C++ / ERRORS, EXCEPTIONS, AND UNDEFINED BEHAVIOUR
Assertions for stating what must hold
Use assert and static_assert to state conditions your code assumes, and tell broken assumptions apart from runtime errors that need real handling.
What you will learn
- State a function's preconditions as assert at the top of the body, before any work
- Keep assertion conditions free of side effects, since NDEBUG deletes them entirely
- Append && "reason" to a condition so the abort message names the rule that broke
- Put type, size, and layout assumptions in static_assert so they fail at compile time
Understanding Assertions for stating what must hold
An assertion is a claim you already believe is true, written in a form the running program can check. assert(n > 0) does not handle the case where n is zero; it says that reaching this point with n equal to zero is impossible, and that if it happens something earlier in the program is broken. That is why a failed assert calls std::abort instead of throwing: a violated assumption means your model of the program is wrong, and code written under a false assumption cannot be trusted to clean up or recover. Input that a user or a file can legitimately get wrong is not an assertion's job.
Mechanically, assert is a macro from <cassert>, and which definition you get depends on whether NDEBUG was defined the last time that header was included. In an NDEBUG build it expands to a do-nothing expression such as ((void)0) and the argument is never evaluated, which is the whole reason assertion conditions must be pure: anything you compute inside one vanishes from the shipped program. Because a macro also has the argument's source text, the idiom assert(i < size && "index out of range") works — the string literal is a non-null pointer and therefore true, so the condition's meaning is unchanged, but the text shows up in the diagnostic. The preprocessor splits macro arguments at top-level commas, so assert(std::is_same<int, long>::value) is a compile error until you wrap it in a second pair of parentheses.
static_assert is the compile-time counterpart: its condition must be a constant expression, it is checked while the code is compiled, and NDEBUG cannot switch it off. Use it for facts about types, sizes, and configuration — this capacity is a power of two, this accumulator is at least 64 bits — because a failure there costs a compiler message instead of a debugging session. For objects, the highest-value runtime assertion is an invariant: a private predicate spelling out what makes the object valid, asserted on entry and exit of every mutating operation. That turns a corrupted object into a failure at the line that corrupted it, instead of a mystery a thousand lines later where the bad value is finally read.
Assertions are cheap to add and cheapest to add early, while the assumption is still fresh in your head and can be stated in one line.
<cassert>
<cstddef>
<iostream>
<limits>
<vector>
// Preconditions: 0 < n <= data.size(), and the first n values are non-negative.
double mean_of_first(const std::vector<int>& data, std::size_t n) {
assert(n > 0 && "the mean of zero values is undefined");
assert(n <= data.size() && "n must not exceed the number of values");
long long sum = 0;
for (std::size_t i = 0; i < n; ++i) {
assert(data[i] >= 0 && "this function assumes non-negative values");
sum += data[i];
}
return static_cast<double>(sum) / static_cast<double>(n);
}
int main() {
static_assert(std::numeric_limits<long long>::digits >= 63,
"the accumulator must hold at least 63 value bits");
const std::vector<int> v{4, 8, 15, 16, 23, 42};
std::cout << mean_of_first(v, 3) << '\n';
std::cout << mean_of_first(v, v.size()) << '\n';
// A count that arrives from outside the program is checked, not asserted.
const std::size_t requested = 99;
if (requested == 0 || requested > v.size()) {
std::cout << "rejected: only " << v.size() << " values available\n";
} else {
std::cout << mean_of_first(v, requested) << '\n';
}
}
An assertion states a condition the code assumes is always true, so a failing assertion means a bug to fix, not a situation to handle.
Worked examples
What NDEBUG does to your checks
Shows that in an NDEBUG build the assertion's condition is not evaluated at all, so both the check and any side effect inside it disappear.
NDEBUG// stands in for compiling this file with -DNDEBUG
<cassert>
<iostream>
int calls = 0;
bool record_call() {
++calls;
return true;
}
int main() {
assert(record_call());
std::cout << "record_call ran " << calls << " time(s)\n";
int width = -1;
assert(width > 0 && "width must be positive");
std::cout << "width is still " << width << '\n';
}
Example explained
Line 1#define NDEBUG before <cassert> selects the definition that expands to ((void)0), so the argument is never evaluated.
Line 2record_call() is therefore never entered and the counter stays 0: work written inside an assertion exists only in debug builds.
Line 3The plainly false condition width > 0 is not checked either, so execution continues with an invalid width.
Line 4Delete the first line and the second assertion aborts the program instead of printing.
Asserting a class invariant on entry and exit
Encodes the rule that makes a Ratio object valid as a private predicate and checks it around every mutation.
<cassert>
<iostream>
<numeric>
class Ratio {
public:
Ratio(int n, int d) : num_(n), den_(d) {
assert(d != 0 && "denominator must not be zero");
reduce();
assert(invariant());
}
void scale(int k) {
assert(invariant()); // we arrived in a valid state
assert(k != 0 && "scaling by zero is not supported here");
num_ *= k;
reduce();
assert(invariant()); // we are leaving in a valid state
}
void print() const { std::cout << num_ << '/' << den_ << '\n'; }
private:
bool invariant() const { return den_ > 0 && std::gcd(num_, den_) == 1; }
void reduce() {
if (den_ < 0) { num_ = -num_; den_ = -den_; }
const int g = std::gcd(num_, den_);
if (g != 0) { num_ /= g; den_ /= g; }
}
int num_;
int den_;
};
int main() {
Ratio r(6, -8);
r.print();
r.scale(4);
r.print();
}
Example explained
Line 1The constructor asserts d != 0 before running arithmetic that divides by the denominator.
Line 2invariant() names the rule in one place: positive denominator, numerator and denominator sharing no factor.
Line 3scale() asserts it twice, which localises blame — an entry failure means an earlier operation left the object broken, an exit failure means scale() did.
Line 4std::gcd works on absolute values, so gcd(-3, 4) is 1 and the reduced -3/4 satisfies the invariant.
static_assert on a template's assumption
A ring buffer replaces % N with bit masking, and states the requirement that makes the two equivalent so a bad capacity cannot compile.
<cstddef>
<iostream>
template <typename T, std::size_t N>
class Ring {
static_assert(N > 0, "capacity must be non-zero");
static_assert((N & (N - 1)) == 0, "capacity must be a power of two");
public:
void push(T value) { data_[write_++ & (N - 1)] = value; }
T at(std::size_t i) const { return data_[i & (N - 1)]; }
private:
T data_[N]{};
std::size_t write_ = 0;
};
int main() {
Ring<int, 4> r;
for (int i = 1; i <= 6; ++i) r.push(i);
for (std::size_t i = 0; i < 4; ++i) {
if (i) std::cout << ' ';
std::cout << r.at(i);
}
std::cout << '\n';
// Ring<int, 6> r2; // error: capacity must be a power of two
}
Example explained
Line 1i & (N - 1) equals i % N only when N is a power of two, and the second static_assert states exactly that dependency.
Line 2The checks run when Ring<int, 4> is instantiated, so Ring<int, 6> is a compiler error rather than a silent wrong answer.
Line 3Six pushes into four slots overwrite positions 0 and 1 with 5 and 6, which is the printed line.
Line 4Nothing here costs runtime work, and no -DNDEBUG build can remove these checks.
Important notes
NDEBUG is re-examined at every #include <cassert>, so a stray #define NDEBUG in a header disables assertions in everything included after it; set it on the compiler command line instead.
A failed assert calls std::abort: the message text and exit status are implementation-defined, destructors do not run, and no catch block can intercept it.
Common mistakes
Using assert to validate user input, file contents, or arguments from other people's code: with -DNDEBUG the check is gone and the bad value flows on into undefined behaviour.
Hiding work in the condition, as in assert(queue.pop() == expected): the pop only happens in debug builds, so the release build silently does something different.
Typing assert(x = compute()) instead of assert(x == compute()): the assignment makes the condition true for any non-zero result, so it never fires and x is quietly overwritten in debug builds only.
Try it yourself
Change, predict, then run
Write a fixed-capacity IntStack with push and pop, assert that pop is only called when the stack is non-empty, and assert a private invariant (size between 0 and capacity) on entry and exit of both operations. Then add #define NDEBUG at the top of the file and confirm a correct sequence of calls prints the same result.
Open the C++ workspaceCheck your understanding
A library validates a caller-supplied index with assert(i < size) and is shipped compiled with -DNDEBUG. What actually happens when a caller passes an out-of-range index?
- The assertion still runs but throws std::logic_error, so the caller can catch it
- The code fails to compile, because assert requires NDEBUG to be undefined
- The check is compiled out, so the out-of-range index goes straight into an out-of-bounds access
- The check still runs and reports the failure, but lets the program continue
Show answer
With NDEBUG defined, assert expands to a do-nothing expression, so the condition is never evaluated and the bad index reaches the memory access as undefined behaviour. Option 1 is tempting because a broken precondition feels like a logic error, but assert never throws anything: enabled it aborts, disabled it does nothing, which is exactly why caller-supplied values need a real check rather than an assertion.