[Python-ideas] Why is design-by-contracts not widely adopted?
Steven D'Aprano
steve at pearwood.info
Sat Sep 29 07:19:25 EDT 2018
On Wed, Sep 26, 2018 at 04:03:16PM +0100, Rhodri James wrote:
> Let's assume that the contracts are meaningful and useful (which I'm
> pretty sure won't be 100% true; some people are bound to assume that
> writing contracts means they don't have to think).
Contracts are a tool. We shouldn't refuse effective tools because some
developers are too DailyWTF-worthy to use them. Why should the rest of
us miss out because of their incompetence? Contracts are not rocket-
science: there's nothing in them that most of us aren't already doing in
an ad-hoc, clumsy manner by embedding the contracts in the docstring,
inside the body of our methods, in external tests etc.
> Assuming that you
> aren't doing some kind of wide-ranging static analysis (which doesn't
> seem to be what we're talking about), all that the contracts have bought
> you is the assurance that *this* invocation of the function with *these*
> parameters giving *this* result is what you expected. It does not say
> anything about the reliability of the function in general.
This is virtually the complete opposite of what contracts give us. What
you are describing is the problem with *unit testing*, not contracts.
Unit tests only tell us that our function works with the specific input
the test uses. In contrast, contracts test the function with *every*
input the function is invoked with.
(Up to the point that you disable checking, of course. Which is under
your control: you decide when you are satisfied that the software is
sufficiently bug-free to risk turning off checking.)
Both are a form of testing, of course. As they say, tests can only
reveal the presence of bugs, they can't prove the absence of bugs. But
correctness checkers are out of scope for this discussion.
> It seems to me that a lot of the DbC philosophy seems to assume that
> functions are complex black-boxes whose behaviours are difficult to
> grasp.
I can't imagine how you draw that conclusion. That's like saying that
unit tests and documentation requires the assumption that functions are
complex and difficult to grasp.
This introduction to DbC shows that contracts work with simple methods:
https://www.eiffel.com/values/design-by-contract/introduction/
and here's an example:
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
Two pre-conditions, and three post-conditions. That's hardly complex.
[Aside: and there's actually a bug in this. What if the key already
exists? But this is from a tutorial, not production code. Cut them some
slack.]
If I were writing this in Python, I'd write something like this:
def put(self, x, key):
"""Insert x so that it will be retrievable through key."""
# Input checks are pre-conditions!
if self.count > capacity:
raise DatabaseFullError
if not key:
raise ValueError
# .. Some insertion algorithm ...
and then stick the post-conditions in a unit test, usually in a
completely different file:
class InsertTests(TestCase):
def test_return_result(self):
db = SomeDatabase()
db.put("value", "key")
self.AssertTrue("value" in db.values())
self.AssertEqual(db["key"], "value")
self.AssertEqual(db.count, 1)
Notice that my unit test is not actually checking at least one of the
post-conditions, but a weaker, more specific version of it. The
post-condition is that the count goes up by one on each insertion. My
test only checks that the count is 1 after inserting into an empty
database.
So what's wrong with the status quo?
- The pre-condition checks are embedded right there in the
method implementation, mixing up the core algorithm with the
associated error checking.
- Which in turn makes it hard to distinguish the checks from
the implementation, and impossible to do so automatically.
- Half of the checks are very far away, in a separate file, assuming
I even remembered or bothered to write the test.
- The post-conditions aren't checked unless I run my test suite, and
then they only check the canned input in the test suite.
- The pre-conditions can't be easily disabled in production.
- No class invariants.
- Inheritance is not handled correctly.
The status quo is all so very ad-hoc and messy. Design By Contract
syntax would allow (not force, allow!) us to add some structure to the
code:
- requirements of the function
- the implementation of the function
- the promise made by the function
Most of us already think about these as three separate things, and
document them as such. Our code should reflect the structure of how we
think about the code.
> In my experience this is very rarely true. Most functions I
> write are fairly short and easily grokked, even if they do complicated
> things. That's part of the skill of breaking a problem down, IMHO; if
> the function is long and horrible-looking, I've already got it wrong and
> no amount of protective scaffolding like DbC is going to help.
That's like saying that if a function is horrible-looking, then there's
no point in writing tests for it.
I'm not saying that contracts are only for horrible functions, but
horrible functions are the ones which probably benefit the most from
specifying exactly what they promise to do, and checking on every
invocation that they live up to that promise.
> >It's the reason why type checking exists,
>
> Except Python doesn't type check so much as try operations and see if
> they work.
Python (the interpreter) does type checking. Any time you get a
TypeError, that's a failed type check. And with type annotations, we can
run a static type checker on our code too, which will catch many of
these failures before we run the code.
Python code sometimes does type checking too, usually with isinstance.
That's following the principle of Fail Fast, rather than waiting for
some arbitrary exception deep inside the body of your function, you
should fail early on bad input.
--
Steve
More information about the Python-ideas
mailing list