What’s new in C++26: contracts (part 3)

This article continues the series of posts about the new features included in C++26 (as they are accepted). This time I will focus on a single language feature: concepts. This has been under the radar of the standard committee for quite some time and it’s finally being added to the standard.

Contracts define language level assertions for checking preconditions, postconditions, and invariants for functions. Let’s see how they work (however, at the time of writing this article no major compiler has support for them).

Asserting before contracts

Asserting is possible with a macro called assert, available in the <cassert> header. Here is how we can use it to check some conditions:

template <typename T>
class sequence
{
public:
    sequence(size_t capacity) : 
        capacity_(capacity), 
        size_(0)
    {
        data_ = new T[capacity_];
    }

    ~sequence()
    {
        delete[] data_;
    }

    T& operator[](size_t index)
    {
        assert(index < size_);
        return data_[index];
    }

    const T& operator[](size_t index) const
    {
        assert(index < size_);
        return data_[index];
    }

    size_t size() const
    {
        return size_;
    }

    void resize(size_t new_capacity)
    {
        assert(new_capacity > 0);
        if(new_capacity <= 0) return;

        T* new_data = new T[new_capacity];
        size_t min_size = std::min(size_, new_capacity);
        for (size_t i = 0; i < min_size; ++i)
        {
            new_data[i] = data_[i];
        }

        delete[] data_;
        data_ = new_data;
        capacity_ = new_capacity;
        size_ = min_size;
    }

    void clear()
    {
        size_ = 0;
    }

    void push_back(const T& value)
    {
        if(capacity_ == size_)
            resize(capacity_ * 2);

        data_[size_] = value;
        size_++;
    }

    void pop_back()
    {
        if (size_ > 0)
            size_--;
    }

private:
    T*      data_;
    size_t  size_;
    size_t  capacity_;
};

The check assert(index < size_) in operator[] verifies that we don’t call with an index that is beyond the bounds of the sequence. Similarly, assert(new_capacity > 0) in resize() verifies we don’t resize to a zero capacity. However, just the assert is not enough, the program should ensure it does the right thing (such as throwing an exception) if the condition does not hold.

We can turn off this checks by defining another macro called NDEBUG (keep in mind that although assert is a standard macro, NDEBUG is not).

These asserts are checked at runtime. But it’s also possible to define assertions at compile time with the use of static_assert (which is a keyword). Here is an example:

template <typename T>
class sequence
{
    static_assert(std::is_copy_constructible_v<T>, "T must be copyable");
};

int main()
{
    sequence<int> si(3);                      // OK
    sequence<std::unique_ptr<int>> su(3);     // error: T must be copyable
}

If we compile this code, we get a compiler error with the message “T must be copyable”, because std::unique_ptr is not copyable, and therefore cannot be used with our sequence class.

Enter contracts

C++26 defines asserts as a language feature with the help of three new keywords:

  • contract_assert: is the equivalent of the assert macro, and is intended for performing checks at run-time. (As a side note, it could not be named assert because that would have conflicted with the assert macro.)
  • pre: defines asserts for functions that specify preconditions
  • post: defines asserts for functions that specify postconditions

The argument for all these asserts is an expression that evaluates to a bool.

contract_assert can be used in function bodies, while pre and post apply to function declarators. A function can have any number of pre and post asserts and they can be specified in any order. These two can be applied to:

  • functions (both member and non-member)
  • function templates
  • lambda expressions
  • coroutines

On the other hand, pre and post cannot be applied to:

  • functions that are defaulted (=default)
  • functions that are deleted (=delete)
  • virtual functions

Here is how we can re-write the previous operator[] to use contract_assert:

T& operator[](size_t index)
{
   contract_assert(index < size_);
   return data_[index];
}

However, for this particular case, the index being smaller than the size of the container is a precondition. Threfore, the best way to perform the assert is using the pre keyword:

T& operator[](size_t index)
   pre (index < size_)
{
   return data_[index];
}

Similarly, we can define post asserts in the same manner:

void clear()
   post (size_ == 0)
{
   size_ = 0;
}

As previously mentioned, we can specify both pre- and post-conditions on the same function:

void resize(size_t new_capacity)
   pre (new_capacity > 0)
   post (size_ <= capacity_)
{
   // ...
}

In the case of the post assert, you can refer directly the object returned by the function, using the syntax shown below (notice that the name of the variable in the post assert can be anything that is a valid identifier):

sequence<int> make_range(int const from, int const to)
    pre (from < to)
    post(r: r.size() == to - from + 1))
    post(r: r[0] == from)
    post(r: r[r.size() - 1] == to)
{
    sequence<int> s(to - from + 1);
    for (int i = from; i <= to; ++i)
    {
        s.push_back(i);
    }
    return s;
}

Evaluation of asserts

Just like the assert macro can be undefined with another macro, so do contracts provide several possible evaluation semantics. These could be specified with a compiler flag. For instance, Clang and GCC provide one called -fcontract-semantic. The possible semantics are:

  • ignore: no check is performed
  • enforce: the checks are performed and if one fails then a message is printed and the program terminates
  • observe: the checks are performed and if one fails then a message is printed but the program continues its execution
  • quick-enforce: the checks are performed and if one fails the program terminates immediately without printing a message and without doing anything else

Note that whether termination happens with a call to std::terminate or std::abort is implementation defined.

Assertion violation handler

When an assertion check fails a function called handle_contract_violation is called. This has the following signature:

void handle_contract_violation( std::contracts::contract_violation );

A default implementation is provided but a user-defined replacement could be supplied. Note, however, that the standard does not guarantee this; it’s implementation-defined whether this handler can be replaced with a user-defined one or not.

Having a user-defined implementation is helpful in order to define your own strategy for handling the assertion failure. The std::contracts::contract_violation parameter of this function provides information about the failure (including location and evaluation semantic).

When the handler returns normally, the behavior of the program depends on the evaluation semantic:

  • for observe, the program continues normally
  • for enforce, the program terminates

Further readings

To learn more about these topic see:

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.