GotW #100: Preconditions, Part 1 (Difficulty: 8/10)

This special Guru of the Week series focuses on contracts. We’ve seen how postconditions are directly related to assertions (see GotWs #97 and #99). So are preconditions, but that in one important way makes them fundamentally different. What is that? And why would having language support benefit us even more for writing preconditions more than for the other two?

JG Question

1. What is a precondition, and how is it related to an assertion? Explain your answer using the following example, which uses a variation of a proposed post-C++20 syntax for preconditions. [1]

// A precondition along the lines proposed in [1]

void f( int min, int max )
    [[pre( min <= max )]]
{
    // ...
}

Guru Questions

2. Rewrite the example in Question 1 to show how to approximate the same effect using assertions in today’s C++. Are there any drawbacks to your solution compared to having language support for preconditions?

3. If a precondition fails, what does that indicate, and who is responsible for fixing the failure? Explain how this makes a precondition fundamentally different from every other kind of contract.

4. Consider this example, expanded from a suggestion by Gábor Horváth:

auto calc( std::vector<int> const&  x ,
           std::floating_point auto y ) -> double
    [[pre( x[0] <= std::sqrt(y) )]] ;

Note that std::floating_point is a C++20 concept.

  • What kinds of preconditions must a caller of calc satisfy that can’t generally be written as testable boolean expressions?
  • What kinds of boolean-testable preconditions are implicit within the explicitly written declaration of calc?
  • Should any of these boolean-testable implicit preconditions also be written explicitly here in this precondition code? Explain.

Notes

[1] G. Dos Reis, J. D. Garcia, J. Lakos, A. Meredith, N. Myers, and B. Stroustrup. “P0542: Support for contract based programming in C++” (WG21 paper, June 2018). Subsequent EWG discussion favored changing “expects” to “pre” and “ensures” to “post,” and to keep it as legal compilable (if unenforced) C++20 for this article I also modified the syntax from : to ( ). That’s not a statement of preference, it’s just so the examples can compile today to make them easier to check.

4 thoughts on “GotW #100: Preconditions, Part 1 (Difficulty: 8/10)

  1. One more comment on 4.
    It is possible that operator[] of vector already has a precondition on the index, so in this case it will be a contract failure instead of undefined behaviour but I still think it’s a good idea to propagate this precondition to previous callers (like our calc function) because this moves the error indication further back in the stack (and in time) and possibly closer to the actual bug and also honors the rule that “if you must fail, fail as quickly as possible and as loud as possible”.

  2. 1. Precondition is a predicate used for checking the validity of the inputs to a certain function when calling it.
    It’s an assertion on the values of the function arguments, their run-time properties and their relationship.
    In case of methods, the *this* argument is also considered an input to the function, so preconditions also check the validity of the relationship between the object members and the other inputs to the function.

    In the code example, the precondition checks the relationship between the values min and max and asserts that the value of min must not exceed the value of max.

    2.
    void f( int min, int max )
    {
    assert(min <= max && "assertion min <= max failed");
    // …
    }

    There are several drawbacks when using assert over contracts:
    – the assert code pollutes the body of the function and can be intertwined with the actual function implementation
    – when changing the function signature the asserts could be easily overlooked an a new opportunity for a check can be missed
    – it can be hard to reason about which assertions are preconditions, which are internal checks and which are postconditions in case of very short functions.

    3. When preconditions fail, this means that the caller site provided invalid arguments to the function and it's the calling code that needs attention, since the problem occurred outside of the function.
    This makes the preconditions different from other kinds of contracts because a failure indicates that the source of the issue is outside of the function and the programmer should seek the bug in the calling site rather than in the function itself.

    4. In the given example code, it is not possible to check the validity of the vector reference 'x', It could be a dangling reference.
    For the same reason we cannot obtain the size of the vector and so we cannot verify that x[0] exists.
    This means that some preconditions will only be useful if certain non-testable preconditions are met before that.

    If we assume that the vector reference is not dangling, then we're implicitly asserting that the size of the vector must be at least 1 and that the value of the floating point is non-negative.
    However, none of these implicit assumptions are actually checked at runtime, so explicit preconditions must be written in order to cover these cases.
    Otherwise, the precondition may lead to undefined behaviour.

  3. 1. A precondition is a boolean expression that must be true at the entry of the function. The behaviour of the function is undefined if that is not the case. It usually involves the arguments of the function (as well as the this pointer), but may also refer to some global context.

    In the example, the function requires that it is called with two integer arguments, the first one (min) being less or equal that the second (max)

    It is the caller’s responsibility to ensure the precondition is met when calling the function.

    Most preconditions are a special case of assertions, in the sense that they obey the same rules (evaluate to bool, must not have side effects, etc.). However, it’s not the case of all, see below.

    2. A simple way to rewrite it is
    void f( int min, int max )
    {
    assert( min 0)]] must not be called with 0. While this probably looks like a buggy assertion that should be fixed, it may make sense for a specific implementation, and thus the caller must honor it.
    * it fails to express correctly the subset of acceptable input, and will lead to undefined behaviour despite the contract being honored by the callee (no precondition violation). Exemple: auto sumOfFirsts(std::vector items) [[pre(!items.empty())]] { return items[0] + items[1]; } will fail for a vector containing a single element. In that case, the precondition is buggy and shall be fixed, but it will not cause a precondition violation.

    preconditions are different from other aspects of contracts because they are the part that involves the caller. Other aspects of DBC involves the callee.

    4.1 All type information is a precondition of the function. So, in this example, we got several :
    * first argument must be a vector of int
    * second argument must be a floating point type.

    These may not be obvious in a compile-time type checked language, so let’s rewrite it this way:
    auto calc( std::any const& x ,
    std::floating_point auto y ) -> double
    [[pre( x.type() == typeid(std::vector) )]] ;

    Which defer the checks from compile time to run-time, but makes it more obvious that it can be counted as a precondition (a statically checked one, the better ones!).

    4.2 An implicit precondition is that the given vector holds at least one element (requirement from operator[]). Another one is that y is positive or 0 (requirement from sqrt). And, lastly, we need that the given floating point number is comparable with an int (but this one ought to be statically checked, so there’s no need to express it).

    4.3 It all depends on whether this implicit precondition affects the rest of your code or not. A contract is part of the specification of the function, and its specification should not, when possible, depends on the specification of another function. For example, our calc function may need that y is positive or zero in its own code, and should not rely on the fact that sqrt also provides this requirement to express it.

    Although it is unlikely that it happens in the std::sqrt case, preconditions of any function may be broaden in the future (this does not break any code). An example of such a possible change would be a function that initially specified that it shall not accept a null pointer as its argument, but due to people not respecting this requirement causing multiple security issues, someone decided to change this and now it accepts a null arguments and just exits with an error (IIRC there has been such changes in the linux kernel). Any function that would express its own requirement by relying the requirement of the called function could then be broken by the change. In turn, it would make the whole contract system fragile, because by broadening a precondition, which is something you have the right to do, you could actually break other functions, which is not something you should do.

    However, there is some benefit the other way round. If my requirement is not “y is positive”, but really “i can call sqrt(y)”, then i should be able to express it this way. In which case, if sqrt’s contract get broadened, i benefit from the improvement without having to change anything in calc.

    Thus, my advice would be to never rely on implicit preconditions for your own requirements. If you have both a requirement that y is positive and a requirement that involves computing sqrt(y), then write both, and don’t count on the fact that sqrt provides a requirement on y to express it.

  4. 1. It’s a condition that must be true for the code following it to have the correct behavior. So it’s an assertion that is made before some code is executed.

    2.

    void die() { __debugbreak(); }
    void die_if(bool condition) { if (condition) die(); }

    void f(int min, int max)
    {
    die_if(min > max);

    }

    Having the precondition as part of the function declaration means less documentation to write? Perhaps the user can choose what to do when preconditions fail (terminate, throw, break to debugger, undefined behavior) and when preconditions are run (debug and / or release builds) by fiddling with compiler settings. Maybe constexpr expressions can be a compile time failure?

    3. If the precondition fails, it means the expression evaluated to false.

    The person who can fix the bug fixes the bug. If the user is wrong, the user fixes their code. If the precondition is wrong, the person who wrote the precondition fixes the code. Unless one of them is off sick, or gets hit by a bus, or has a nervous breakdown and quits. Then someone else does it. Or no one does it and the issues stays in JIRA through 5 major product releases.

    I don’t think any of that is different?

    4.a) None. Due to laziness I’ve now decided that all preconditions are “testable boolean expressions”.

    b) `x` mustn’t be empty. `x` must actually be a vector, and not 3 cases of undefined behavior in a trenchcoat. `y` should probably be above zero and not infinite or NaN.

    c) Maybe?

    We should definitely check the vector size (we always want to avoid undefined behavior). Unless it isn’t undefined behavior because we’ve added a precondition to `std::vector::operator[]`, in which case we don’t need to!

    Calling `sqrt` with a nan or less-than-zero parameter is at least well-defined (if implementation defined). If we’re happy with the behavior (and its effects on our comparison with the first vector element) we may not *technically* need to add any preconditions for `y`.

    But that sounds complicated, and I’m lazy, so:

    [[ pre(!x.empty()) ]]
    [[ pre(std::isfinite(y)) ]]
    [[ pre(y >= 0) ]] // or perhaps > 0, depending on wtf we’re actually doing
    [[ pre(x.front() <= std::sqrt(y)) ]]

    (Assuming we can add multiple preconditions, and they're executed in order).

    But then again, shouldn't operator[]

    —-

    You know… Aren't we supposed to use assertions like variables: as close to the point of use as possible?

    So lets say we change the function and add an explicit check for emptiness:

    double calc(std::vector const& x, std::floating_point auto y)
    [[ pre(std::isfinite(y)) ]]
    [[ pre(y >= 0) ]]
    {
    if (x.empty()) return 0.0;

    // … do something with y
    }

    Now we absolutely have to pass a valid `y`, even though we never need to use it. Or we make the preconditions more complicated and check `x` as well.

    —-

    Ya know, it seems like we’re working very hard to do something like: `if (!undefined_behavior(code)) code();`

    Are we planning to add preconditions to all the standard library functions that do undefined behavior if you use them wrong, and leave it up to the user if they want preconditions or undefined behavior with a compile time flag?

    And perhaps we’ll have a `[[no-preconditions]] calc()` attribute when we want to avoid specific checks that would be slow?

Comments are closed.