The most over-engineered assertion library
void zoog(std::vector<int>& vec) {
assert(vec.size() > 10, "vector doesn't have enough items");
}
const char* path = "/home/foobar";
FILE* f = VERIFY(fopen(path, "r") != nullptr, "Internal error with foobars", errno, path);
std::optional<float> get_param();
float f = *assert(get_param());
- Philosophy
- Methodology
- Considerations
- Features
- Documentation
- How To Use This Library
- Comparison With Other Languages
Fundamentally the role of assertions is to verify assumptions made in software and identify violations close to their sources. Assertion tooling should prioritize providing as much information and context to the developer as possible to allow for speedy triage. Unfortunately, existing language and library tooling provides very limited triage information.
For example with stdlib asserts, when assert(n <= 12);
fails we may get no information about why
(i.e., the value of n
) and we don't get stack trace. Ideally an assertion failure should provide
enough diagnostic information that we don't have to rerun in a debugger to pinpoint the problem.
This library is an exploration looking at how much helpful information and functionality can be packed into assertions while also providing a quick and easy interface for the developer.
Different types of assumptions call for different handling. This library has a tiered assertion system:
- For core assumptions that must always be true under normal operation use
ASSERT
- For convenient assumptions, e.g. not worrying about an edge case for the time being, use
VERIFY
- For quick development/sanity checks, use
CHECK
Name | When to Use | Effect |
---|---|---|
ASSERT / assert |
Core assumptions | Checked in debug, assumed in release |
VERIFY |
Convenient assumptions | Checked always |
CHECK |
Sanity checks | Checked in debug, does nothing in release |
Rationale for CHECK
: Sometimes it's problematic to assume expressions, e.g. a call to
std::unordered_map::at
call can't be optimized away even if the result is unused.
VERIFY
and CHECK
calls may specified to be nonfatal. If marked nonfatal CHECK
/VERIFY
will
simply log a failure message but not abort or throw an exception.
Automatic expression decomposition requires a lot of template metaprogramming shenanigans. This adds a lot of work at the callsite just to setup an assertion expression. These calls are swiftly inlined in an optimized build, but it is a consideration for unoptimized builds.
All the TMP work required to setup and process assertions is a consideration for build speeds, there will be an impact on build speeds from using this library. This is the price we pay for nice things. As stated previously, this library is a proof of concept. Doing this better might require language support.
As far as runtime performance goes, the impact at callsites is very minimal under -Og
or higher,
potentially even less than a stdlib assert.
A lot of work is required to process assertion failures once they happen but failures are the coldest path in a binary, I'm not concerned with performance in the assertion processor as long as it's not noticeably slow.
Here are some of the most notable features I'd like to highlight:
The most important feature this library supports is automatic expression decomposition. No need for
ASSERT_LT
or other such hassle, assert(vec.size() > 10);
is automatically understood, as
showcased above.
Values involved in assert expressions are displayed. Redundant diagnostics like 7 => 7
are
avoided.
assert(vec.size() > 7);
Only the full assert expression is able to be extracted from a macro call. Showing which parts of the expression correspond to what values requires some basic expression parsing. C++ grammar is ambiguous but most expressions can be disambiguated.
Asserts, checks, and verifies support optional diagnostic messages as well as arbitrary other diagnostic messages.
FILE* f = VERIFY(fopen(path, "r") != nullptr, "Internal error with foobars", errno, path);
Special handling is provided for errno
, and strerror is automatically called.
A lot of work has been put into generating pretty stack traces and formatting them as nicely as possible.
One feature worth noting is that instead of always printing full paths, only the minimum number of directories needed to differentiate paths are printed.
Another feature worth pointing out is that the stack traces will fold deep recursion traces:
Because expressions are already being automatically decomposed, signed-unsigned comparisons are
automatically done with sign safety (same mechanism as C++20 std::cmp_equal
, std::cmp_less
,
...):
assert(18446744073709551606ULL == -10);
The assertion handler applies syntax highlighting wherever appropriate, as seen in all the screenshots above. This is to help enhance readability.
A lot of care is given to printing values as effectively as possible: Strings, characters, numbers,
should all be printed as you'd expect. If a user defined type overloads operator<<(std::ostream& o, const S& s)
, that overload will be called. Otherwise it a default message will be printed.
Assertion values are printed in hex or binary as well as decimal if hex/binary are used on either side of an assertion expression:
assert(get_mask() == 0b00001101);
The library provides a set of macros which effectively function as such:
decltype(auto) assert(<expression>, [optional assertion message],
[optional extra diagnostics, ...], fatal?);
decltype(auto) ASSERT(<expression>, [optional assertion message],
[optional extra diagnostics, ...], fatal?);
decltype(auto) VERIFY(<expression>, [optional assertion message],
[optional extra diagnostics, ...], fatal?);
void CHECK(<expression>, [optional assertion message],
[optional extra diagnostics, ...], fatal?);
The macros are all caps to conform with macro hygiene practice - "check" and "verify" they're likely
to conflict with other identifiers. assert
is provided for compatibility with assert.h
code and
it is an identifier that will not conflict.
The <expression>
is automatically decomposed so diagnostic information can be provided. The
resultant type must be convertible to boolean.
The operation between left and right hand sides of the top-level operation in the expression tree is evaluated by a functor.
If the operation is a comparison (==
, !=
, <
, <=
, >
, >=
) and the operands are integers,
the comparison is automatically done with sign safety.
Note: Short circuiting does not occur for a top level operation of &&
or ||
.
An optional assertion message may be provided. If the first argument following <expression>
is any
string type it will be used as the message (if you want the first parameter, which happens to be a
string, to be an extra diagnostic value instead simply pass an empty string first, i.e.
assert(foo, "", str);
).
An arbitrary number of extra diagnostic values may be provided. These are displayed below the expression diagnostics if a check fails.
There is special handling when errno
is provided: The value of strerror
is displayed
automatically.
ASSERT::FATAL
and ASSERT::NONFATAL
may be passed in any position in a call. By default asserts,
verifies, and checks are fatal. If nonfatal, failure will simply be logged but abort isn't called
and exceptions aren't raised.
To facilitate ease of integration into code, ASSERT
and VERIFY
return a value from the assert
expression. The returned value is the following:
- If there is no top-level binary operation (e.g. as in
assert(foo());
orassert(false);
) in the<expression>
, the value of the expression is simply returned. - Otherwise if the top-level binary operation is
==
,!=
,<
,<=
,>
,>=
,&&
,||
, or or any assignment or compound assignment then the value of the left-hand operand is returned. - Otherwise if the top-level binary operation is
&
,|
,^
,<<
,>>
, or any binary operator with precedence above bitshift then value of the whole expression is returned.
If the value from <expression>
selected to be returned is an lvalue, the type of the
ASSERT
/VERIFY
call will be an lvalue reference. If the value from <expression>
is an rvalue
then the type of the call will be an rvalue.
CHECK
does not return anything as its expression is not evaluated at all under -DNDEBUG
.
After the assertion handler processes the failure and prints diagnostic information it will invoke an assert failure action. These may be overridden by the user on a per-TU basis, the default behaviors are:
Name | Failure |
---|---|
ASSERT / assert |
stdout and stderr are flushed, abort() is called |
VERIFY |
assert_detail::verification_failure is thrown |
CHECK |
assert_detail::check_failure is thrown |
-DNCOLOR
Turns off colors / syntax highlighting-DNDEBUG
Disables assertion checks for release (assertion conditions are assumed for the optimizer's benefit)-DASSERT_NO_LOWERCASE
Disablesassert
alias forASSERT
Custom failure actions: These are called when an assertion fails after diagnostic messages are
printed. Set these macros to the name of the failure action function, signature should be void()
.
-DASSERT_FAIL=...
Default action: Flushesstdout
andstderr
then callsabort()
.-DVERIFY_FAIL=...
Default action:assert_detail::verification_failure
is thrown.-DCHECK_FAIL=...
Default action:assert_detail::check_failure
is thrown.
This library targets >=C++17 and supports gcc and clang on windows and linux. Note: The library does rely on some compiler extensions / compiler specific features. It supports at least GCC >= 8 and Clang >= 9. The library is no longer single header due to compile times.
- Run
make
to compile static and shared libraries - Copy the static or shared library where you want it.
- Copy
include/assert.hpp
where you want it. - Add a
-I
path if needed, add a-L
path if needed, link with the library (-lassert
)- For the shared library you may need to add a path to your
LD_LIBRARY_PATH
environment variable. - If static linking, additionally link with dbghelp (
-ldbghelp
) on windows or lib dl (-ldl
) on linux.
- For the shared library you may need to add a path to your
Even when standard libraries provide constructs like assert_eq
they don't always do a good job of
providing helpful diagnostics. E.g. Rust where the left and right values are displayed but not the
expressions themselves:
fn main() {
let count = 4;
assert_eq!(count, 2);
}
thread 'main' panicked at 'assertion failed: `(left == right)`
left: `4`,
right: `2`', /app/example.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
This is not as helpful as it could be.
Functionality other languages / their standard libraries provide:
C/C++ | Rust | C# | Java | Python | JavaScript | This Library | |
---|---|---|---|---|---|---|---|
Expression string | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | ✔️ |
Location | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
Backtrace | ❌ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
Assertion message | ❌ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
Extra diagnostics | ❌ | ❌* | ❌* | ❌ | ❌* | ❌* | ✔️ |
Binary specializations | ❌ | ✔️ | ❌ | ❌ | ❌ | ✔️ | ✔️ |
Automatic expression decomposition | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✔️ |
*
: Possible through string formatting but that is sub-ideal.
Extras:
C/C++ | Rust | C# | Java | Python | JavaScript | This Library | |
---|---|---|---|---|---|---|---|
Syntax Highlighting | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✔️ |
Non-Fatal Assertions | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✔️ |
Format Consistency | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✔️ |
Expression strings and expression values everywhere | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✔️ |
Safe signed-unsigned comparison | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✔️ |
Return values from the assert to allow asserts to be integrated into expressions inline | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✔️ |