contracts.ruby icon indicating copy to clipboard operation
contracts.ruby copied to clipboard

extend Contracts for raised and thrown objects

Open md-work opened this issue 8 years ago • 2 comments

Thanks for this really great gem!

It helps a lot checking the types of objects being send up and down the stack trough method calls and returns. Sadly there's still no way to check the type of objects being send trough the stack by the Ruby raise and throw commands.

I'm thinking about something similar to throws Exception in Java. (sure this isn't Java and the checking can only be done dynamically, not statically)

public void example() throws Exception {
    throw new Exception();
}

What do you think about adding such an feature to Contracts?

  • Would it technically be possible?
  • What could be a nice and backward compatible syntax?

See also: #193

md-work avatar Aug 23 '17 17:08 md-work

Hi @md-work, this is a neat idea. Based on the comments in https://github.com/egonSchiele/contracts.ruby/issues/193, would you like to add a contract that says "if an exception is raised, catch it and raise a contract exception"? Or would you want to annotate methods saying "this can throw an exception"?

egonSchiele avatar Aug 27 '17 18:08 egonSchiele

Hi @egonSchiele I'm talking about "this can throw an exception".

Examples:

Contract String => String raises Errno::ENOENT
def read_file(filename)
    # This raises Errno::ENOENT if the filename doesn't exist.
    # In this case everything's fine the Contract should let the
    # exception pass (or re-raise it without modification).

    # But in case the user doesn't has permissions to read the file,
    # this raises Errno::EACCES.
    # In this case the Contract should catch the exception and raise an
    # own exception instead (e.g. a RaiseContractError).

    return File.read(filename).to_s
end


Contract Integer, Integer => Integer raises Contracts::None
def add(num_a, num_b)
    # This should never raise an exception.
    return num_a + num_b
end


# This method usually never raises an exception. Just in one very rare
# situation, about which the programmer forgot when he wrote the
# contract.
# So if the method gets 23 it raises :illuminati and the Contract
# should catch that and raise an own exception instead.
Contract Integer => Integer raises Contracts::None
def some_fun(number)
    if number = 23
        raise :illuminati
    else
        return number
    end
end


# Same with a fixed contract.
Contract Integer => Integer raises :illuminati
def some_fun(number)
    if number = 23
        raise :illuminati
    else
        return number
    end
end


# Same for throw-catch like for raise-rescue.
Contract Integer => Integer throws :illuminati
def some_fun(number)
    if number = 23
        throw :illuminati
    else
        return number
    end
end


# And for both, raise-rescue and throw-catch.
Contract Integer => Integer throws :illuminati raises :answer
def some_fun(number)
    if number = 23
        raise :illuminati
    elsif number = 42
        throw :answer
    else
        return number
    end
end

Some notes

  • Remember: In Ruby you can't just raise or throw objects from exception classes, but also every other object.
  • In case a Contract is violated by a throw, it should raise an ThrowContractError. The ThrowContractError should be raised, because throw is for normal control flow and not for errors.
  • Unchecked exceptions: There should be a whitelist of raised objects/classes, which must not be checked and don't trigger a RaiseContractError, because those exceptions can just happen everywhere. E.g. objects of the classes ArgumentError, NoMemoryError and ZeroDivisionError.
    • There should be the possibility to modify the whitelist and add more objects/classes to it.
    • This is only for raise, not for throw.
    • Optionally there could be a whitelist for throw, but it should be empty by default.
    • Java has a similar whitelist for. In Java everything that inherits from RuntimeError is an unchecked exception and doesn't have to be declared at the method header. https://docs.oracle.com/javase/8/docs/api/java/lang/RuntimeException.html
    • Objects of classes inheriting from whitelisted classes should be unchecked too.
    • Here's a list of Rubys standard exception classes: https://ruby-doc.org/core-2.2.0/Exception.html
    • Optionally there could be the possibility to set blacklists of objects/classes for raise and throw. By default those blacklists should be empty, but if they get set only blacklisted objects/classes should be checked.
  • Optionally there should be global raises_none and throws_none settings. If those get set, every method with a Contract but without a Contract ... throws ... should be considered to throw nothing. (except whitelisted objects/classes)
  • In case the raises/throws part of a Contract is violated, the RaiseContractError/ThrowContractError should reference the original raised/thrown object, so the developer can look up what originally happened.

md-work avatar Aug 28 '17 09:08 md-work