[Python-ideas] Why is design-by-contracts not widely adopted?

Steven D'Aprano steve at pearwood.info
Sun Sep 30 10:28:45 EDT 2018


On Fri, Sep 28, 2018 at 01:49:01PM +0100, Paul Moore wrote:

> There's clearly a number of trade-offs going on here:
> 
> * Conditions should be short, to avoid clutter
> * Writing helper functions that are *only* used in conditions is more
> code to test or get wrong

This is no different from any other refactoring. If you have a function 
that checks its input:

def spam(arg):
    if condition(arg) and other_condition(arg) or alt_condition(arg):
        raise ValueError

and refactor it to a helper:

def invalid(arg):
    return condition(arg) and other_condition(arg) or alt_condition(arg)

def spam(arg):
    if invalid(arg):
        raise ValueError

how is that a bad thing just because we call it a "precondition" instead 
of calling it "error checking"?

Of course we don't necessarily want the proliferation of masses and 
masses of tiny helper functions, but nor should we fear them. Helpers 
should carry their own weight, and if they do, we should use them. 
Whether they are used for contracts or not makes zero difference.


> * Sometimes it's just plain hard to express a verbal constraint in code

Indeed.

People seem to be arguing against some strawman of "Design By Contract 
is a magic bullet that solves every imaginable problem". Of course it 
doesn't. Some constraints are too hard to specify as code. Okay, then 
don't do that.

DbC isn't "all or nothing". If you can't write a contract for something, 
don't. You still get value from the contracts you do write.


[...]
> But given that *all* the examples I've seen of contracts have this
> issue (difficult to read expressions) I suspect the problem is
> inherent.

Are you specifically talking about *Python* examples? Or contracts in 
general?

I don't know Eiffel very well, but I find this easy to read and 
understand (almost as easy as Python). The trickiest thing is the 
implicit "self".

put (x: ELEMENT; key: STRING) is
		-- Insert x so that it will be retrievable through key.
	require
		count <= capacity
		not key.empty
	do
		... Some insertion algorithm ...
	ensure
		has (x)
		item (key) = x 
		count = old count + 1
	end

https://www.eiffel.com/values/design-by-contract/introduction/


Here are a couple of examples from Cobra:

    def fraction( numer as int, denom as int) as float
        require
            numer > 0
            denom <> 0
        body
          ...

    def bumpState( incr as int) as int
        require
            incr > 0
        ensure 
            result >= incr
            .state = old.state + incr 
        body
          .state += incr
          return .state


http://cobra-language.com/trac/cobra/wiki/Contracts


If you find them difficult to read, I don't know what to say :-)



> Another thing that I haven't yet seen clearly explained. How do these
> contracts get *run*? Are they checked on every call to the function,

Yes, that's the point of them. In development they're always on. Every 
time you run your dev code, it tests itself.


> even in production code? 

That's up to you, but typical practice is to check pre-conditions (your 
input) but not post-conditions (your output) in production.


> Is there a means to turn them off? What's the
> runtime overhead of a "turned off" contract (even if it's just an
> if-test per condition, that can still add up)?


Other languages may offer different options, but in Eiffel, contracts 
checking can be set to:

    no:        assertions have no run-time effect.
    require:   monitor preconditions only, on routine entry.
    ensure:    preconditions on entry, postconditions on exit.
    invariant: same as ensure, plus class invariant on both entry
               and exit for qualified calls.
    all:       same as invariant, plus check instructions, 
               loop invariants and loop variants.

You can set the checking level globally, or class-by-class. The default 
is to check only preconditions. That is, for methods to validate their 
inputs. Quoting from the Eiffel docs:

    When releasing the final version of a system, it is usually 
    appropriate to turn off assertion monitoring, or bring it down
    to the ``require`` level. The exact policy depends on the 
    circumstances; it is a trade off between efficiency considerations, 
    the potential cost of mistakes, and how much the developers and 
    quality assurance team trust the product. When developing the 
    software, however, you should always assume -- to avoid loosening 
    your guard -- that in the end monitoring will be turned off. 

https://www.eiffel.org/doc/eiffel/ET-_Design_by_Contract_%28tm%29%2C_Assertions_and_Exceptions


The intention for Python would be similar:

- we ought to be able disable contract checking globally;

- and preferrably on a case-by-case basis;

- a disabled contact ought to be like a disabled assertion, 
  that is, completely gone with no runtime effect at all;

- but due to the nature of Python's execution model, there will
  probably be some (small) overhead at the time the function is
  created, but not when the function is called.

Of course the overhead will depend on the implementation.


> And what happens if a
> contract fails - is it an exception/traceback (which is often
> unacceptable in production code such as services)?

What happens when any piece of error checking code fails and raises an 
exception?

In the case of Python, a failed contract would be an exception. What you 
do with the exception is up to you. The *intention* is that a failed 
contract is a bug, so what you *ought to do* is fix the bug. But you 
could catch it, retry the operation, restart the service, or whatever.

That's worth repeating: *contracts aren't for testing end-user input* 
but for checking internal program state. A failed contract ought to be 
considered a bug. Any *expected error state* (such as a missing file, or 
bad user input, or a network outage) shouldn't be treated as a contract.


-- 
Steve


More information about the Python-ideas mailing list