C++ Module 13 – Error handling
Assertions, preconditions and [[assume]]
Check your own assumptions with assert and static_assert, validate preconditions at API boundaries, and see what a false [[assume]] does to C++ results.
What you will learn
- Check internal assumptions with assert and static_assert
- Validate preconditions at API boundaries in every build
- Explain why [[assume]] is dangerous, with the code it changes
Before you start
What you’ll learn · Before you start
What you will learn
- Check internal assumptions with assert and static_assert
- Validate preconditions at API boundaries in every build
- Explain why [[assume]] is dangerous, with the code it changes
Before you start
On this page
mark_at(marks, 3) asks a vector of three marks for a fourth, and an assert stops the program before the read,
with exit status 134. assertion: A check of something the programmer believes must be true, such as assert(i < v.size()): a false one prints the condition, the file and the line, then stops the program. assert disappears from builds that define NDEBUG. state what a programmer believes must be true at a
point in the code, and stop the program when it is not. assert(condition) checks while the program runs and
disappears completely from builds that define NDEBUG: A macro that most release builds define. Where it is defined before <cassert> is included, assert(anything) expands to ((void)0), so neither the check nor any work inside its condition is left in the program., as most release builds do;
static_assert(condition) checks while the program compiles and costs nothing at run time.
precondition: What a function requires from its callers before it is called, such as an index inside the array. A public function checks its preconditions in every build, because code it does not control can break them. are what a function requires from its callers, such as an index inside the array. Check preconditions at public boundaries, where code you do not control can break them, in every build, by throwing or returning an error, and use assertions for the internal assumptions that only a bug in your own code can break. [[assume]]: A C++23 attribute, [[assume(condition)]], that tells the optimiser to treat a condition as true without checking it. If the condition is false while the program runs, the behaviour is undefined, and nothing reports it., new in C++23, tells the optimiser to treat a condition as true without checking it at all.
A failed assert names its condition and stops the program
mark_at asserts that the index is inside the vector. The string after && is always true, so it changes nothing
but the message:
#include <cassert>
#include <cstddef>
#include <print>
#include <vector>
double mark_at(const std::vector<double>& marks, std::size_t i) {
assert(i < marks.size() && "mark_at: index out of range");
return marks[i];
}
int main() {
std::vector<double> marks{72.5, 64.0, 91.5};
std::println("{}", mark_at(marks, 2));
std::println("{}", mark_at(marks, 3));
} from session import run
# A failed assert with both compilers: on macOS both use the C library's assert, so the message is the same.
run("""
clang++ -std=c++23 -Wall -Wextra bad_index.cpp -o bad_index && ./bad_index
g++-16 -std=c++23 -Wall -Wextra bad_index.cpp -o bad_index-gcc && ./bad_index-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra bad_index.cpp -o bad_index && ./bad_index 91.5 Assertion failed: (i < marks.size() && "mark_at: index out of range"), function mark_at, file bad_index.cpp, line 7. Abort trap: 6 [exit status 134] $ g++-16 -std=c++23 -Wall -Wextra bad_index.cpp -o bad_index-gcc && ./bad_index-gcc 91.5 Assertion failed: (i < marks.size() && "mark_at: index out of range"), function mark_at, file bad_index.cpp, line 7. Abort trap: 6 [exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 assert_session.py
Step through the two calls, the good index first and then the one past the end:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- mark_at(marks, 2). main asks for the mark at index 2. The vector holds three marks, so i < marks.size() is 2 < 3, true: the assert does nothing.
- 91.5. mark_at returns marks[2], and main prints 91.5.
- mark_at(marks, 3). Now main asks for index 3, one past the last mark.
- The belief breaks. 3 < 3 is false, so the assert fails before marks[3] is ever read. The string after && is always true; it only makes the message say what the check is for.
- The message. assert prints the condition as written, the function, the file and the line: Assertion failed: (i < marks.size() && "mark_at: index out of range"), function mark_at, file bad_index.cpp, line 7.
- std::abort. Then it calls std::abort, which raises SIGABRT, signal 6: the shell prints Abort trap: 6 and reports exit status 134. The bad value never travels further.
The first call passes and prints 91.5. The second fails, and assert prints the condition as written, the
function, the file and the line, then calls std::abort, so the exit status is 134, 128 plus the number of
SIGABRT. The two compilers print the same message because on macOS both use the C library’s assert. A failed
assertion means a bug: the program stops at the line where its belief broke, before a wrong value travels further.
Checkpoint
Check: What a failed assert does
NDEBUG removes an assert, and the work inside it
load_settings fills a map and reports whether it could. Calling it inside assert looks like a careful check:
#include <cassert>
#include <map>
#include <print>
#include <string>
// Fills `out` and reports whether it could.
bool load_settings(std::map<std::string, int>& out) {
out = {{"port", 8080}, {"retries", 3}, {"workers", 8}};
return true;
}
int main() {
std::map<std::string, int> settings;
assert(load_settings(settings)); // the call is inside the assert, so NDEBUG removes it
std::println("settings loaded: {}", settings.size());
} from session import run
# The same program as a debug build and as a release build with NDEBUG, with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra vanishing.cpp -o vanishing && ./vanishing
clang++ -std=c++23 -Wall -Wextra -DNDEBUG vanishing.cpp -o vanishing-release && ./vanishing-release
g++-16 -std=c++23 -Wall -Wextra vanishing.cpp -o vanishing-gcc && ./vanishing-gcc
g++-16 -std=c++23 -Wall -Wextra -DNDEBUG vanishing.cpp -o vanishing-gcc-release && ./vanishing-gcc-release
""") Output
$ clang++ -std=c++23 -Wall -Wextra vanishing.cpp -o vanishing && ./vanishing settings loaded: 3 $ clang++ -std=c++23 -Wall -Wextra -DNDEBUG vanishing.cpp -o vanishing-release && ./vanishing-release settings loaded: 0 $ g++-16 -std=c++23 -Wall -Wextra vanishing.cpp -o vanishing-gcc && ./vanishing-gcc settings loaded: 3 $ g++-16 -std=c++23 -Wall -Wextra -DNDEBUG vanishing.cpp -o vanishing-gcc-release && ./vanishing-gcc-release settings loaded: 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vanishing_session.py
Step through the line in a debug build and in a build with NDEBUG:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One line, two builds. Both builds compile the same main: an empty map, then assert(load_settings(settings)), then a line that prints the size of the map.
- Debug: the call runs. Without NDEBUG, assert evaluates its condition. load_settings runs, fills the map with port, retries and workers, and returns true, so the assert passes.
- settings loaded: 3. The debug builds of both compilers print settings loaded: 3.
- NDEBUG: the line is gone. With NDEBUG defined where <cassert> is included, assert(anything) becomes ((void)0). Its argument is never evaluated, so the call to load_settings vanishes with it.
- settings loaded: 0. The map stays empty, and both release builds print settings loaded: 0, without an error or a warning.
- The fix. Do the work on its own line and assert on the result: bool loaded = load_settings(settings); assert(loaded);, with [[maybe_unused]] on loaded so that release builds do not warn.
The debug builds load 3 settings; the release builds load none. When NDEBUG is defined where <cassert> is
included, assert(anything) becomes ((void)0), so its argument is never evaluated, and the call vanished with it.
Both compilers built the release programs without a warning, even with -Wall -Wextra, so nothing in the build output
points at the missing call.
Work inside an assert
assert(load_settings(settings)); loads nothing in a release build. Do the work on its own line and assert on its
result: bool loaded = load_settings(settings); assert(loaded);, with [[maybe_unused]] on loaded so that
release builds do not warn about a variable they never read.
Checkpoint
Check: NDEBUG and work inside an assert
Checks while compiling cost nothing at run time
A message header must be exactly 8 bytes to match a wire format, and static_assert makes the compiler check it:
#include <cstdint>
// The header of a message on the wire: exactly 8 bytes, in this order.
struct Header {
std::uint16_t type;
std::uint32_t length;
std::uint16_t flags;
};
static_assert(sizeof(Header) == 8, "Header must match the 8-byte wire format");
int main() {} from session import run
# A static_assert that fails: the program does not compile with either compiler.
run("""
clang++ -std=c++23 -c wire.cpp -o wire.o
g++-16 -std=c++23 -c wire.cpp -o wire-gcc.o
""") Output
$ clang++ -std=c++23 -c wire.cpp -o wire.o
wire.cpp:10:15: error: static assertion failed due to requirement 'sizeof(Header) == 8': Header must match the 8-byte wire format
10 | static_assert(sizeof(Header) == 8, "Header must match the 8-byte wire format");
| ^~~~~~~~~~~~~~~~~~~
wire.cpp:10:30: note: expression evaluates to '12 == 8'
10 | static_assert(sizeof(Header) == 8, "Header must match the 8-byte wire format");
| ~~~~~~~~~~~~~~~^~~~
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -c wire.cpp -o wire-gcc.o
wire.cpp:10:30: error: static assertion failed: Header must match the 8-byte wire format
10 | static_assert(sizeof(Header) == 8, "Header must match the 8-byte wire format");
| ~~~~~~~~~~~~~~~^~~~
• the comparison reduces to '(12 == 8)'
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 static_assert_session.py
Neither compiler builds the program. Both show the values they compared, 12 against 8: the std::uint32_t member
must start at a multiple of 4, so 2 bytes of padding follow type, and 2 more follow flags, as the lesson on
memory layout, alignment and padding explains. Step through the
bytes, as declared and with length moved to the front:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- type. type is a std::uint16_t, two bytes, so it takes bytes 0 and 1.
- Padding. length is a std::uint32_t, which must start at a multiple of 4. The next free byte is 2, so 2 bytes of padding follow type.
- length. length takes bytes 4 to 7.
- flags. flags, another std::uint16_t, takes bytes 8 and 9.
- 2 more bytes. 2 more bytes of padding follow flags, so the struct takes 12 bytes. static_assert(sizeof(Header) == 8) is false, and both compilers stop the build, showing 12 == 8.
- length first. With length moved to the front, every member starts at a multiple of its own size without any padding: bytes 0 to 3, 4 and 5, 6 and 7. The header is 8 bytes and the static_assert passes.
Moving length to the front makes the header 8 bytes. Rule P.5 of the C++ Core Guidelines prefers compile-time
checks like this one to run-time checks wherever the condition is known while compiling.
Checkpoint
Check: static_assert while compiling
Who can break a condition decides how to check it
Four kinds of check differ in when, if ever, they run:
When each kind of check runs
Text description of the diagram
The diagram is a column of four boxes, from the earliest check to none at all.
- static_assert(condition) is checked while the program compiles: if the condition is false, the program does not build.
- assert(condition) is checked while the program runs, but only in debug builds: defining NDEBUG removes it, the condition included.
- A check at an API boundary, such as an if that throws or returns an error, or a CHECK macro, runs in every build.
- [[assume(condition)]] is never checked: the optimiser believes the condition, and if it is false the behaviour is undefined. This box has a dashed border.
Preconditions of a public function belong to the third row, because its callers include code you do not control,
such as a file a user wrote. The row’s CHECK is a macro that stops the program with a message in every build, an
assert that nothing removes. The function’s own internal helpers can rely on assertions, because a broken condition
there is a bug in the same code. The standard library follows the same split: its
hardened standard library: A standard library that checks the preconditions of calls such as operator[] and front() while the program runs, and stops it when one fails, as libc++'s hardening modes and libstdc++ with _GLIBCXX_ASSERTIONS do. C++26 makes such hardened implementations part of the standard. More in “std::vector: the container to reach for first” check its own preconditions, such as the index given to
operator[], in release builds too.
[[assume]] belongs to no row of checks at all. The optimiser treats its condition as a fact and may remove code that would only matter if the condition were false; if the condition is false at run time, the behaviour is undefined behaviour: What a program does when it breaks a rule for which the C++ standard sets no requirements, such as reading past the end of an array. The compiler may assume it never happens, so the result can change with the compiler and the optimisation level. More in “Object lifetime and undefined behaviour in C++”, and nothing reports it.
Key idea
Ask who can break the condition: what users and other code supply is checked in every build, your own internal
assumptions get an assert, and [[assume]] checks nothing at all.
Checkpoint
Check: Who can break a condition, and the check it gets
Practise: put each check where it belongs
Nine questions on the four kinds of check, the idea you have answered least well first, each choice explained.
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
What [[assume]] changes in the generated code, and what a false one does Pro: opens with a Pro, Premium or Ultimate pass
An assumption is a promise to the optimiser, and the clearest way to see what it buys and what it risks is to read the instructions the compiler generates. Here one function is compiled with and without an assumption by both compilers, then called with a value that breaks it.
In this part:
- Two halving functions in arm64 assembly, with and without the assumption
- A false assumption, reproduced: three answers to one call
- When an assumption is worth its risk, and how to keep it honest
1 diagram · 2 runnable examples (C++ (halve.cpp), C++ (halve_main.cpp), Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A precondition policy for every build, with an exercise Pro: opens with a Pro, Premium or Ultimate pass
Choosing between an assertion, a check that stays in release builds and an error a caller can handle depends on who can break the condition. This part gives that choice as a policy, a macro for checks that must never disappear, and an exercise on a small matrix type.
In this part:
- Who checks what: a policy for public and internal code
- A check that stays in release builds
- Your turn: checks for a matrix type
4 runnable examples (C header, C++, Python) · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher · Warm-up
What is the difference between assert and static_assert?
static_assert(condition, message) is checked by the compiler: the condition must be a constant expression, a false one stops the build with the message, and nothing of it remains in the program, so it costs nothing at run time. It suits facts about types and constants, such as sizeof(Header) == 8. assert(condition), from <cassert>, is checked while the program runs: a false condition prints the condition, the file and the line, then calls std::abort. It checks run-time values, such as an index, and it disappears entirely when NDEBUG is defined, as in most release builds, so its condition must have no side effects. A good answer adds that both document assumptions for the reader, and that neither is meant for input from users, which needs a check that stays in every build.
-
Fresher to mid
A crash happens only in release builds. In the code you find assert(queue.pop(job)); and assert(init_logging());. Explain the bug, how you would find every such case in a large code base, and how you would stop new ones from appearing.
-
Mid to senior
Your image library's public Image::pixel(x, y) receives coordinates that callers compute, some from user files. Should it assert, throw, return an error or use [[assume]]? Defend a policy, including what the library's own filters do in their inner loops.
-
Mid to senior
A pull request adds [[assume(width % 16 == 0)]] to a hot loop of an image filter and claims a 20 % speed-up. What do you check before you approve it, and what would make you reject it?
Key takeaways
- Assertions check your own beliefs:
assertin debug builds,static_assertwhile compiling, at no run-time cost. NDEBUGremoves anassertand everything inside it, so never put work in its condition.- Check preconditions at public boundaries in every build, and report them to the caller as errors.
- [[assume]] is a promise, never a check: when it is false, the behaviour is undefined.
Terms in this lesson
- [[assume]]
- A C++23 attribute, [[assume(condition)]], that tells the optimiser to treat a condition as true without checking it. If the condition is false while the program runs, the behaviour is undefined, and nothing reports it.
- assertion
- A check of something the programmer believes must be true, such as assert(i < v.size()): a false one prints the condition, the file and the line, then stops the program. assert disappears from builds that define NDEBUG.
- NDEBUG
- A macro that most release builds define. Where it is defined before <cassert> is included, assert(anything) expands to ((void)0), so neither the check nor any work inside its condition is left in the program.
- precondition
- What a function requires from its callers before it is called, such as an index inside the array. A public function checks its preconditions in every build, because code it does not control can break them.
References
- Working Draft, Programming Languages: C++ (N4950): [assertions], [dcl.attr.assume], [dcl.pre] (static_assert) (ISO/IEC JTC1/SC22/WG21)
- P1774R8: Portable assumptions (ISO/IEC JTC1/SC22/WG21)
- Working Draft, Programming Languages: C++ (N5050), the C++26 draft with contract assertions (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: I.5, state preconditions (if any) (Standard C++ Foundation)
- C++ Core Guidelines: I.6, prefer Expects() for expressing preconditions (Standard C++ Foundation)
- C++ Core Guidelines: P.5, prefer compile-time checking to run-time checking (Standard C++ Foundation)
- C++ Support in Clang ([[assume]], P1774R8) (LLVM Project)
- C++ Standards Support in GCC ([[assume]], P1774R8) (GNU Project)
- libc++ hardening modes (LLVM Project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress