libassert
The most over-engineered C++ assertion library
Table of Contents:
- 30-Second Overview
- In-Depth Library Documentation
30-Second Overview
Some of the awesome things the library does:
void zoog(const std::map<std::string, int>& map) {
assert(map.contains("foo"), "expected key not found", map);
}
assert(vec.size() > min_items(), "vector doesn't have enough items", vec);
const char* path = "/home/foobar/baz";
FILE* f = VERIFY(fopen(path, "r") != nullptr, "Internal error with foobars", errno, path);
std::optional<float> get_param();
float f = *assert(get_param());
Library philosophy: Provide as much helpful diagnostic info as possible.
Types of assertions:
DEBUG_ASSERT
This is the analog for <cassert>: checked in debug and does nothing in releaseASSERT
Checked in debug but still evaluated and returned (more below) in release- Of course, if the expression's value isn't used or doesn't have any side effects it should be eliminated by the optimizer
ASSUME
Checks core assumptions, preconditions, and postconditions in debug and provides hints to the optimizer in releaseVERIFY
Checks the condition in both debug and release
Returning a value:
Assertions return a value so they can be integrated seamlessly into code:
==
,!=
,<
,<=
,>
,>=
,&&
, and||
expressions return the left-hand operand- E.g.
VERIFY(fopen(path, "r") != nullptr)
returns the pointer fromfopen
- E.g.
- Otherwise the full expression is returned
- E.g.
ASSERT(5)
andASSERT(4 | 1)
return 5
- E.g.
Prefer lowecase assert
?
Me too, you can enable the lowercase debug_assert
and assert
aliases with -DASSERT_LOWERCASE
.
Using the library:
This library is for C++17 and newer.
Build the library with cmake. Both static and dynamic linking are supported. More details below.
In-Depth Library Documentation
Philosophy
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.
Methodology
Different types of assumptions call for different handling and different behavior. This library implements a tiered assertion system:
Name | When to Use | Effect |
---|---|---|
DEBUG_ASSERT |
Core assumptions that potentially can't be optimized away (e.g. calls to std::unordered_map::at ) |
Checked in debug, no codegen in release |
ASSERT |
Core assumptions | Checked in debug, still evaluated and returned in release (usually elided by the optimizer if appropriate) |
ASSUME |
Assumptions that can serve as hints to the optimizer | Checked in debug, if(!expr) { __builtin_unreachable(); } in release |
VERIFY |
Checks that are good to have even in release | Checked in both debug and release builds |
All assertions except DEBUG_ASSERT
return a value from the assertion expression so checks can be seamlessly integrated
into a program's structure. Because an expression's result may still be relevant even in a release build the expression
still has to be valid and evaluated even in a release build. Of course, if the result is unused and produces no side
effects it will be optimized away (at the very least during LTO). But consequently all side effects are preserved unlike
traditional asserts. This is the motivation for DEBUG_ASSERT
, where it's undesirable for side effects to be preserved.
ASSUME
marks the fail path as unreachable in release, potentially providing helpful information to the optimizer. This
isn't the default behavior for all assertions because the immediate consequence of this is that assertion failure in
-DNDEBUG
can lead to UB and it's better to make this very explicit.
All of these assertions can be marked as nonfatal by passing ASSERTION::FATAL
which prevents aborting or throwing in
the default assertion failure handler. Except ASSUME
which aborts regardless of being marked fatal. This can all be
customized in a custom assertion failure handler.
Considerations
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.
Features
Here are some of the most notable features I'd like to highlight:
Automatic Expression Decomposition
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.
Expression Diagnostics
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.
Extra Diagnostics
All assertions in this library 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.
Note: Extra diagnostics are only evaluated in the failure path of an assertion.
Stack Traces
A lot of work has been put into generating pretty stack traces and formatting them as nicely as possible. Cpptrace is used to provide portable and zero-configuration-required stack traces. Optional configurations can be found on the library's page.
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:
Automatic Safe Comparisons
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);
Syntax Highlighting
The assertion handler applies syntax highlighting wherever appropriate, as seen in all the screenshots above. This is to help enhance readability.
Object Printing
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);
Documentation
All assertion functions are macros. Here are some pseudo-declarations for interfacing with them:
void DEBUG_ASSERT(<expression>, [optional assertion message],
[optional extra diagnostics, ...], fatal?);
decltype(auto) ASSERT(<expression>, [optional assertion message],
[optional extra diagnostics, ...], fatal?);
decltype(auto) ASSUME(<expression>, [optional assertion message],
[optional extra diagnostics, ...], fatal?);
decltype(auto) VERIFY(<expression>, [optional assertion message],
[optional extra diagnostics, ...], fatal?);
-DASSERT_LOWERCASE
can be used to enable the debug_assert
and assert
aliases for DEBUG_ASSERT
and ASSERT
. See:
Replacing <cassert>.
Parameters
expression
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: Boolean logical operators (&&
and ||
) are not decomposed by default due to short
circuiting.
assertion message
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);
).
extra diagnostics
An arbitrary number of extra diagnostic values may be provided. These are displayed below the expression diagnostics if a check fails.
Note: Extra diagnostics are only evaluated in the failure path of an assertion.
There is special handling when errno
is provided: The value of strerror
is displayed
automatically.
fatal?
ASSERT::FATAL
and ASSERT::NONFATAL
may be passed in any position in a call. By default all
assertions, are fatal. If nonfatal, failure will simply be logged but abort isn't called
and exceptions aren't raised.
Return value
To facilitate ease of integration into code, all asserts except DEBUG_ASSERT
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.
I.e., assert(foo() > 2);
returns the computed result from foo()
and assert(x & y);
returns the
computed result of x & y
;
If the value from <expression>
selected to be returned is an lvalue, the type of the
assertion call will be an lvalue reference. If the value from <expression>
is an rvalue
then the type of the call will be an rvalue.
Failure
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 |
---|---|
DEBUG_ASSERT |
abort() is called in debug, nothing happens in release |
ASSERT |
abort() is called in debug, nothing happens in release |
ASSUME |
abort() is called in debug, fail path is marked unreachable in release |
VERIFY |
libassert::verification_failure is thrown |
Configuration
The following can be used to set application-wide settings:
libassert::config::set_color_output(bool)
Enables or disables colored assertion messages on TTY outputs by the default assertion handler.libassert::config::set_rgb_output(bool)
Enables or disables 24-bit rgb ansi color sequences in assertion diagnostics. Default is true. This is here for terminal compatibility, in the relatively rare case your terminal does not support rgb sequences.
These are both thread-safe, not that it should ever matter.
The following configurations can be applied on a per-TU basis:
-DNDEBUG
Disables assertion checks for release (assertion conditions are assumed for the optimizer's benefit)-DASSERT_DECOMPOSE_BINARY_LOGICAL
Enables expression decomposition of&&
and||
. Note: This disables short-circuiting in assert expressions. (If you really need short-circuiting in a condition in this mode, an extra set of parentheses can be used:assert((ptr && ptr->foo));
.)-DASSERT_LOWERCASE
Enablesassert
alias forASSERT
-DNO_ASSERT_RELEASE_EVAL
MakesASSERT
behave asDEBUG_ASSERT
and traditional <cassert>-DASSERT_FAIL=fn
Allows a custom failure handler to be provided-DASSERT_USE_MAGIC_ENUM
use the MagicEnum library to print better diagnostics for enum classes
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 is expected to be
void custom_fail(libassert::assert_type, ASSERTION, const libassert::assertion_printer&)
.
The printer
is used to allow the failure handler to format to a desired width. It accepts zero and
negative widths to indicate the message should be printed to work on any width (i.e., no pretty
columns in the output). type
is the type of the assertion and fatal
indicates whether an
assertion is fatal. A typical implementation looks like:
void custom_fail(libassert::assert_type type, ASSERTION fatal, const libassert::assertion_printer& printer) {
std::string message = printer(libassert::utility::terminal_width(STDERR_FILENO));
if(isatty(STDERR_FILENO)) {
std::cerr<<message<<std::endl;
} else {
std::cerr<<libassert::utility::strip_colors(message)<<std::endl;
}
using libassert::assert_type;
switch(type) {
case assert_type::debug_assertion:
case assert_type::assertion:
if(fatal == ASSERTION::FATAL) {
case assert_type::assumption: // switch->if->case, cursed!
abort();
}
break;
case assert_type::verification:
if(fatal == ASSERTION::FATAL) {
throw verification_failure();
}
break;
default:
std::cerr<<"ERROR: Unknown assertion type encountered during assertion failure handling. Aborting."<<std::endl;
abort();
}
}
Debug Stringification
Stringification resolution uses the following precedence:
- Library customization point
- Primitives and strings
operator<<(ostream&, ...)
overload- Container-like object (has a size and iterators) with printable contents
- Tuple-like objects
- Enum values
If nothing matches the stringifier displays <instance of Type>
.
User-Defined Types
User defined types custom stringification can be provided with either a ``operator<<(ostream&, ...)` overload or through a library customization point.
Customization point: ADL-resolved std::string stringify(const T& p, libassert::detail::literal_format)
. I.e., declare
a function with this signature for your type in the namespace of your type T
.
libassert::detail::literal_format
is an enum class containing character, dec, hex, octal, binary, none
, it's used for
formatting of ints and floats but it's part of all stringify signatures, you can ignore it. It also helps the library's
customization point not interfere with any other functions named stringify
.
Example:
struct S {
int x;
debug_print_customization(int x) : x(x) {}
friend std::ostream& operator<<(std::ostream& stream, const S&) {
return stream<<"this won't be called if the customization point is used";
};
std::string stringify(const S& s, libassert::detail::literal_format) {
return "this will be used for the stringification";
}
Utilities
The following utilities are made public in libassert::utility::
, as they are immensely useful:
std::string strip_colors(const std::string& str)
Strips ansi sequences from a stringstd::string replace_rgb(std::string str)
Replaces 24-bit rgb ansi color sequences with traditional color sequencesint terminal_width(int fd)
Returns the width of the TTY referenced by the given file descriptor, or 0 on errorstd::string stacktrace(int width)
Generates a stack trace, formatted for the given width (0 for a width-independent formatting)std::string_view type_name<T>()
Produces a type name for typeT
std::string pretty_type_name<T>()
Produces a prettified type name for typeT
std::string stringify(const T& t)
Prints a debug stringification oft
Namespace synopsis
// Macros:
#define DEBUG_ASSERT(...) ...
#define ASSERT(...) ...
#define ASSUME(...) ...
#define VERIFY(...) ...
#ifdef ASSERT_LOWERCASE
#define debug_assert(...) ...
#define assert(...) ...
#endif
namespace libassert {
// Core functionality:
enum class ASSERTION { NONFATAL, FATAL };
class assertion_printer {
public:
[[nodiscard]] std::string operator()(int width) const;
// returns filename, line number, pretty function, message
[[nodiscard]] std::tuple<const char*, int, std::string, const char*> get_assertion_info() const;
};
struct verification_failure : std::exception {
[[nodiscard]] const char* what() const noexcept override;
};
// Other functionality:
enum class assert_type { debug_assertion, assertion, assumption, verification };
namespace utility {
[[nodiscard]] std::string strip_colors(const std::string& str);
[[nodiscard]] std::string replace_rgb(std::string str);
[[nodiscard]] int terminal_width(int fd);
[[nodiscard]] std::string stacktrace(int width);
[[nodiscard]] std::string_view type_name<T>() noexcept;
[[nodiscard]] std::string pretty_type_name<T>();
[[nodiscard]] std::string stringify(const T&);
};
namespace config {
void set_color_output(bool);
}
namespace detail { /* internals */ }
}
using libassert::ASSERTION;
This library defines macros of the form LIBASSERT_*
. In macro expansion variables of the form
libassert_*
are created. The user shouldn't define variables of this form to prevent shadowing
issues.
How To Use This Library
This library targets >=C++17 and supports all major compilers and all major platforms (linux, macos, windows, and mingw).
Note: The library does rely on some compiler extensions and compiler specific features so it is not
compatible with -pedantic
.
FetchContent
A) With CMake Follow the below example to use this library with CMake's FetchContent
module.
project(my_project)
include(FetchContent)
FetchContent_Declare(
libassert
GIT_REPOSITORY "https://github.com/jeremy-rifkin/libassert"
GIT_TAG "v1.1"
)
FetchContent_MakeAvailable(assert)
add_executable(my_executable main.cpp)
target_link_libraries(my_executable PRIVATE assert)
You should then be able to use the library in your code like this:
#include <assert.hpp>
int main() {
DEBUG_ASSERT(1 == 2); // prints a stack trace and aborts
}
B) Manual Build
- 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
3. Use
- Setup include / library paths appropriately
- Link appropriately
- Debug symbols will be needed for good stack traces
Special notes for generating debug symbols:
Note: MSVC may require /Z7 for generating debug symbols.
Compiler | Linux | Windows |
---|---|---|
GCC | - | - |
Clang | - | .pdb needed, pass -g to the linker |
MSVC | N/A | .pdb needed, pass /DEBUG to the linker |
Replacing <cassert>
This library is not a drop-in replacement for <cassert>
out of the box but it can be with two settings:
-DASSERT_LOWERCASE
and -DNO_ASSERT_RELEASE_EVAL
. Alternatively, just change all current asserts in a codebase to
DEBUG_ASSERT
(or debug_assert
in conjunction with -DASSERT_LOWERCASE
).
One thing to be aware: Overriding cassert's assert
is technically not allowed by the standard, but this
should not be an issue for any sane compiler.
Comparison With Other Languages
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 | β | β |