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 theassert
macro, and is intended for performing checks at run-time. (As a side note, it could not be namedassert
because that would have conflicted with theassert
macro.)pre
: defines asserts for functions that specify preconditionspost
: 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 performedenforce
: the checks are performed and if one fails then a message is printed and the program terminatesobserve
: the checks are performed and if one fails then a message is printed but the program continues its executionquick-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: