C++20 was looking good to have contracts until the C++ Standards Committee meeting in Cologne, Germany of 2019. There, some strong turbulence happened, and the committee ultimately felt that significantly more work was necessary before contracts are ready for Standard C++.
As one of the authors requesting contracts be removed from the C++20 Working Paper, I'd like to offer a consolation library, which emulates how I'd like contracts to behave.
- CMake 3.14 or later
- Conan 1.18 or later (if using Conan)
- A standard library that supports
std::is_constant_evaluated
(e.g. libstdc++11-9 or libc++-9)
You can clone this repository and place the directory cjdb
in your own include
directory.
- Add
https://api.bintray.com/conan/cjdb/cjdb
to your list of remotes. - Add
constexpr-contracts
to your list of required packages.
To link in CMake, you'll need to use find_package(constexpr-contracts REQUIRED)
to import the library.
To link against the library, use target_link_libraries(target PRIVATE cjdb::constexpr-contracts)
.
This library provides three macros:
Contract type | Macro |
---|---|
Pre-condition | CJDB_EXPECTS |
Assertion | CJDB_ASSERT |
Post-condition | CJDB_ENSURES |
As the name of this library implies, you can use all of these macros in constant expressions. Your
contracts are always checked at compile-time. If the contract's predicate isn't satisfied, then the
program won't compile. This is a huge advantage over using <cassert>
or the GSL's Expects
and
Ensures
macros.
When optimisations are diabled and NDEBUG
is not defined as a macro, the contract will check your
predicate at run-time. If the predicate fails, then a diagnostic will be emit, and the program will
crash.
When optimisations are enabled, and NDEBUG
remains undefined, the program will emit a diagnostic
for failed predicates, but the program continues to run, under the assumption that the
predicate was actually true. Your program will be slightly larger than if you hadn't used the
contract, but this might be useful for identifiying cases where users report bugs (in a release
build) that aren't being caught by your assertions.
When optimisations are disabled, but NDEBUG
is defined as a macro, the program will crash when
the predicate fails, but no diagnostic is emitted. Code generation also appears to be significantly
worse.
When both optimisations and NDEBUG
are enabled, the optimiser is allowed to make assumptions that
could improve code generation and no diagnostics are emitted. Toy examples demonstrate better code
generation than when the contract isn't used. See for yourself.
Optimisations enabled | DNDEBUG enabled |
Predicate evaluation | Notes |
---|---|---|---|
Constant expression | |||
N/A | N/A | false |
Program does not compile |
N/A | N/A | true |
Program compiles |
Run-time expression | |||
No | No | false |
A diagnostic is written to stderr at run-time and then the program crashes.
|
No | Yes | false |
The generated code will probably be slightly different than if NDEBUG
hadn't been defined.A diagnostic is written to stderr at run-time and then the program crashes.
|
Yes | No | false |
Optimiser considers predicate as absolute truth, which leads to optimisations that won't
make sense with the given incorrect input. A diagnostic is written to stderr at run-time. The program won't crash, but
it'll probably give incorrect output.
|
Yes | Yes | false |
Optimiser considers predicate as absolute truth, which leads to optimisations that may
result in a faster run-time or a smaller binary. The program won't crash, but it'll probably give incorrect output. |
No | No | true |
The program will take a performance hit (subject to the evaluation of the predicate plus
a check to see that the predicate is true ), but will otherwise behave as if
the predicate didn't exist.
|
No | Yes | true |
The program will take a performance hit (subject to the evaluation of the predicate plus
a check to see that the predicate is true ), but will otherwise behave as if
the predicate didn't exist.
|
Yes | No | true |
The optimiser considers the predicate to mean the absolute truth, and may make
assumptions about code following the contract*. This might lead to optimisations
resulting in a faster run-time or smaller binary. Correct program output is expected, and code generation is expected to be better than without optimisations, but the program will not be as small as when built using -DNDEBUG .
|
Yes | Yes | true |
The optimiser considers the predicate to mean the absolute truth, and might make
aggressive assumptions about code following the contract*. This may lead to
optimisations resulting in a faster run-time, a smaller binary, or both. Correct program output is expected. Performance is subject to the level of optimisation, but this configuration is expected to result in the best code generation. |
*Rudimentary testing has identified that neither GCC nor Clang perform optimisations before the contract.
You should use assertions to indicate when a programming error has occurred in the middle of your
code. For example, the following program performs division operation, and checks that f
never
returns 0
. If f
returns zero, then the programmer has communicated that there's a logic error
somewhere.
// file: example1.cpp
#include <cjdb/contracts.hpp>
#include <concepts>
int main()
{
std::integral auto x = f();
CJDB_ASSERT(x != 0);
std::cout << (5 / x) << '\n';
}
The above program will behave as if the assertion is not present in the event x != 0
. If, however,
x == 0
, the program will print the following message in debug-mode and then crash.
./example.cpp:8: assertion `x != 0` failed in `int main()`
In this case, nothing is different with -O3 -DNDEBUG
, but take a look at what
happens when the compiler can see what we're expecting.
You should use a pre-condition to indicate that the expected input for a function is not met. Although it can't be compiler-enforced, you should only use a pre-condition to check that a parameter doesn't meet its expected input values; everything else should be checked using an assertion. This will help your users determine when they have provided bad parameters, and when their state is the cause of a logic error.
// example2.cpp
#include <cjdb/contracts.hpp>
#include <string_view>
class person {
public:
constexpr explicit person(std::string_view first_name, std::string_view surname, int age)
: first_name_{first_name}
, surname_{surname}
, age_{(CJDB_EXPECTS(age >= 0), age)} // parens are necessary!
{}
private:
std::string first_name_;
std::string surname_;
int age_;
};
auto const ada_lovelace = person{
"Ada",
"Lovelace",
-1
};
In our second example, we're establishing that a person's age must be 0
or greater. We do this
before we initialise age_
, so that we know that the expectation is met before we ever get a chance
to use it. The run-time diagnostic we get is:
./example2.cpp:10: pre-condition `age >= 0` failed in `person::person(std::string_view, std::string_view, int)`
Finally, post-conditions allow us to provide a guarantee that our invariants hold upon exiting a function. These are a bit different in usage: instead of providing just an expression, the intended usage is to place your post-condition in a return-statement. As such, there should only be one post-condition per return-statement in a function.
#include <cjdb/contracts.hpp>
int f()
{
auto result = g();
return CJDB_ENSURES(result >= 0), result;
}
This usage lets us chain post-conditions before our result is returned. As with pre-conditions and assertions, the diagnostic indicates what kind of contract was violated.
Notice that person::person
is constexpr
. constexpr-contracts has a huge advantage over both
assert
and static_assert
, because it can be evaluated at compile-time or at run-time,
depending on the context. If we wanted to constant-initialise ada_lovelace
, then we'd get a
compile-time diagnostic instead. They're fairly difficult to read, so I've put one below to guide
you.
constexpr auto ada_lovelace = person{
^ ~~~~~~~
../include/cjdb/contracts.hpp:57:4: note: subexpression not valid in a constant expression
__builtin_unreachable();
^
../test/person.cpp:8:10: note: in call to 'contract_impl(false, {&"../test/person.cpp:8: pre-condition `age >= 0` failed in `%s`\n"[0], 62}, {&__PRETTY_FUNCTION__[0], 73})'
, age_{(CJDB_EXPECTS(age >= 0), age)}
^
../include/cjdb/contracts.hpp:29:27: note: expanded from macro 'CJDB_EXPECTS'
#define CJDB_EXPECTS(...) CJDB_CONTRACT_IMPL("pre-condition", __VA_ARGS__)
^
../include/cjdb/contracts.hpp:66:4: note: expanded from macro 'CJDB_CONTRACT_IMPL'
::cjdb::contracts_detail::contract_impl(static_cast<bool>(__VA_ARGS__), \
^
../test/person.cpp:16:31: note: in call to 'person({&"Ada"[0], 3}, {&"Lovelace"[0], 8}, -1)'
constexpr auto ada_lovelace = person{
^
That's a lot of error to say that age < 0
! We can fortunately ignore 90% of the above. The only
useful information in the entire diagnostic for our purposes are the first eight lines.
constexpr auto ada_lovelace = person{
^ ~~~~~~~
../include/cjdb/contracts.hpp:57:4: note: subexpression not valid in a constant expression
__builtin_unreachable();
^
../test/person.cpp:8:10: note: in call to 'contract_impl(false, {&"../test/person.cpp:8: pre-condition `age >= 0` failed in `%s`\n"[0], 62}, {&__PRETTY_FUNCTION__[0], 73})'
, age_{(CJDB_EXPECTS(age >= 0), age)}
^
Here, we see that constexpr auto ada_lovelace = person{
is the offending call-site, and that
CJDB_EXPECTS(age >= 0)
is the contract that we broke. Literally everything else is helper
information that the compiler needs to give us, but we don't need.
In file included from ../test/person.cpp:1:
../test/person.cpp:20:1: in ‘constexpr’ expansion of ‘person(std::basic_string_view<char>(((const char*)"Ada")), std::basic_string_view<char>(((const char*)"Lovelace")), -1)’
../test/person.cpp:8:10: in ‘constexpr’ expansion of ‘cjdb::contracts_detail::contract_impl(((int)(((int)age) >= 0)), std::basic_string_view<char>(((const char*)"../test/person.cpp:8: pre-condition `age >= 0` failed in `%s`\012")), std::basic_string_view<char>(((const char*)(& __PRETTY_FUNCTION__))))’
../include/cjdb/contracts.hpp:57:25: error: ‘__builtin_unreachable()’ is not a constant expression
57 | __builtin_unreachable();
| ~~~~~~~~~~~~~~~~~~~~~^~
Sadly, GCC's diagnostic is much more technical, and harder to read. You'll need to look for
cjdb::contracts_detail::contract_impl(((int)(((int)age) >= 0))
.
cjdb::contracts_detail::contract_impl
is an implementation detail, so only look for it in
diagnostics, as it is subject to change. As such, I'm working on a way to provide a much better
diagnostic.
Because we're peering into the definition here, the code is somewhat brittle: any user-facing declaration needs to match the definition right down to parameter names; otherwise the contract won't make much sense to the user.