Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

icontract fails to restore class invariants at method exit #247

Closed
geryogam opened this issue Jun 5, 2022 · 4 comments
Closed

icontract fails to restore class invariants at method exit #247

geryogam opened this issue Jun 5, 2022 · 4 comments

Comments

@geryogam
Copy link

geryogam commented Jun 5, 2022

icontract fails to restore class invariants at method exit:

import icontract

@icontract.invariant(lambda self: self.x > 0)
class A:
    def __init__(self):
        self.x = 1
    def f(self):
        self.x = -1
        raise ValueError

a = A()
assert a.x > 0  # ok
try:
    a.f()
except ValueError:
    pass
assert a.x > 0  # AssertionError

This is contrary to what Bertrand Meyer prescribes in his 1992 seminal article Applying “Design by Contract”:

Any exception handling, whether for resumption or for organized panic, should restore the invariant.

Class invariants are the minimal guarantees in case of failure.

@mristin
Copy link
Collaborator

mristin commented Jun 5, 2022

Hi @maggyero ,
Thanks for reporting! Since icontract checks the invariants only before and after the methods, there is no way to my knowledge how the invariant can be checked after an exception has been caught.

While I can't implement the feature, I'll describe the issue in the docs. Please let me know if you have an idea how to implement this.

@geryogam
Copy link
Author

geryogam commented Jun 5, 2022

Hi @mristin,

I used assert statements only to make it explicit what properties I expected to hold at those points in the program; they are not meant to be checked by icontract at those points.

In order to properly deal with failures to fulfil a method postcondition, icontract should

  1. Catch any exception raised in a method call (here a.f()).
  2. Restore the class invariant (here self.x > 0).
  3. Re-raise the exception (here ValueError).

In paragraph ‘Dealing with abnormal situations’, Meyer explains that exception handling method:

(2) Perhaps, however, we have lost the war altogether. No new strategy is available. Then the routine should put back the objects in a consistent state, give up on the contract, and report failure to the caller. This is called organized panic.

In paragraph ‘A disciplined exception-handling mechanism’, Meyer explains that to achieve it, Eiffel uses a rescue clause:

To specify how a routine should behave after an exception, the author of an Eiffel routine may include a “rescue” clause, which expresses the alternate behavior of the routine (and is similar to clauses that occur in human contracts, to allow for exceptional, unplanned circumstances). When a routine includes a rescue clause, any exception occurring during the routine’s execution interrupts the execution of the body (the Do clause) and starts execution of the rescue clause. The clause contains zero or more instructions, one of which may be a Retry. The execution terminates in either of two ways:

  • If the rescue clause terminates without executing a Retry, the routine fails. It reports failure to its caller by triggering a new exception. This is the organized panic case.
  • If the rescue clause executes a Retry, the body of the routine (Do clause) is executed again.

This illuminates the difference between the body (the Do clause) and the rescue clause:

  • The body must implement the contract, or ensure the postcondition. For consistency, it must also abide by the general law of the land—preserve the invariant. Its job is made a bit easier by the assumption that the invariant will hold initially, guaranteeing that the routine will find objects in a consistent state.
  • In contrast, the rescue clause may not make any such assumption: it has no precondition, since an exception may occur at any time. Its reward is a less demanding task. All that it is required to do on exit is to restore the invariant. Ensuring the postcondition—the contract—is not its job.

A rescue clause is needed to specify how to restore the invariant (here self.x > 0). So icontract would need something like this:

@icontract.invariant(lambda self: self.x > 0)
class A:
    def __init__(self):
        self.x = 1
    @icontract.rescue(lambda self: setattr(self, 'x', 1))  # performs self.x = 1 in case of exceptions
    def f(self):
        self.x = -1
        raise ValueError

I think the rescue clause could also be specified at the class level if it does not need to be method specific:

@icontract.invariant(lambda self: self.x > 0)
@icontract.rescue(lambda self: setattr(self, 'x', 1))  # performs self.x = 1 in case of exceptions
class A:
    def __init__(self):
        self.x = 1
    def f(self):
        self.x = -1
        raise ValueError

The only way to avoid introducing the rescue clause to restore class invariants is perhaps to restore self to its method-entry state since this operation can be applied to any class. But in paragraph ‘Further sources’, Meyer explains that it would be inefficient:

Another view of exceptions can be found in Cristian. Eiffel’s notion of a rescue clause bears some resemblance to Randell’s recovery blocks, but the spirit and aims are different. Recovery blocks as defined by Randell are alternate implementations of the original goal of a routine, to be used when the initial implementation fails to achieve this goal. In contrast, a rescue clause does not attempt to carry on the routine’s official business; it simply patches things up by bringing the object to a stable state. Any retry attempt uses the original implementation again. Also, recovery blocks require that the initial system state be restored before an alternate implementation is tried after a failure. This appears impossible to implement in any practical environment for which efficiency is of any concern. Eiffel’s rescue clauses do not require any such preservation of the state; the only rule is that the rescue clause must restore the class invariant and, if resumption is attempted, the routine precondition.

@mristin
Copy link
Collaborator

mristin commented Jun 5, 2022

Hi @maggyero ,
Thanks for the explanations! Actually, I think a method-specific and class-spefic rescue clauses would do the trick as you suggested.

I would opt that the rescue decorator accepts the self and exception as arguments. The exception needs to contain enough information so that the rescue decorator knows how to fix the instance self, and supplying this information in the exception is the responsibility of the class, not the icontract library. Moreover, the rescue clause would not return anything, and would fix the self in place. As a best practice, you would write:

def _some_rescue_function(self: "A", exception: Exception) -> None:
    ...

@icontract.rescue(_some_rescue_function)
class A:
    ...

or

def _another_rescue_function(self: "A", exception: Exception) -> None:
    ...

class A:
    @icontract.rescue(_another_rescue_function)
    def some_method(self, ...) -> ...:
        ...

I foresee quite some complex logic there, so I wouldn't go with the simple lambdas. IMO, this will quickly lead to developing a parallel domain-specific language which is unnecessarily contrived. I'd rather stick with plain Python functions instead.

Alternatively, the rescue instance method can also be supplied:

class A:
    def _some_rescue_function(self, exception: Exception) -> None:
        ...

icontract.rescue(A._some_rescue_function)(A)

This is unfortunately not possible with a decorator, but need to come after the class definition.

What do you think?

What about inheritance? Would the rescue functions be stacked?

Just out of curiosity: what is the scenario where you need this feature? I have never encountered it in practice myself as my exceptions are most often simply panics, but this is just my code style, of course.

@mristin
Copy link
Collaborator

mristin commented Sep 15, 2023

(I'm closing the issue due to inactivity.)

@mristin mristin closed this as completed Sep 15, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants