Chapter 3 gave you the organizational structure — separate compilation, modules, and
namespaces. But every non-trivial program encounters problems: bad input, missing files,
out-of-range indices, memory exhaustion. What should Vector::operator[] do when
called with an index that doesn't exist? What should a constructor do when the requested size
is negative? This chapter introduces C++'s primary error handling mechanism — exceptions — and
the supporting concepts of invariants, assertions, and noexcept. The core idea is
elegant: errors are reported by throwing an object that travels up the call stack
until a matching catch handler is found. This separates error reporting from
error handling cleanly — the alternative (error codes everywhere) clutters every function with
bookkeeping that obscures the main logic.
Why exceptions matter now: every lesson from here on uses them implicitly.
The standard library throws exceptions for out-of-range access (vector::at),
failed allocations (bad_alloc), and many other failures. Understanding the
exception mechanism is essential for writing correct C++ and for reading the standard
library's error contracts.
C++'s major tool against errors is the type system itself. By building types (string, map, thread) and algorithms (sort(), find_if()) that are appropriate for the application domain, we simplify our programming, limit opportunities for mistakes (you are unlikely to try to apply a tree traversal to a dialog box), and increase the compiler's chances of catching errors before the program runs. Higher-level constructs are easier to use correctly and harder to use incorrectly — that is the central thesis of this chapter and the course.
Consider our Vector again. What should happen when operator[] is
called with an out-of-range index?
The writer of Vector doesn't know what the user would like to do in this case.
The user cannot consistently detect the problem before it happens — if they could, the
out-of-range access wouldn't happen in the first place. The solution: the implementer detects
the problem at the point where it occurs and throws an exception
that the caller can catch and handle.
double& Vector::operator[](int i) {
if (!(0 < i && i < size()))
throw out_of_range{"Vector::operator[]"}; // throw an exception
return elem[i];
}
A throw transfers control to a handler for that exception type in some function
that directly or indirectly called the throwing function. The implementation unwinds the call
stack, invoking destructors (§5.2.2) on the way, until it reaches a catch clause
that matches the exception type:
void f(Vector& v) {
try { // start exception watch
compute1(v); // might range-error
Vector v2 = compute2(v); // might range-error
compute3(v2); // might range-error
} catch (const out_of_range& err) { // handle out_of_range
cerr << err.what() << '\n'; // print what went wrong
} // handler ends here
}
The catch (const out_of_range& err) clause catches exceptions of type
out_of_range (standard library, defined in <stdexcept>) by
const reference (to avoid copying the exception object). err.what() returns the
error message string supplied at the throw point.
Key points about exception propagation:
A class invariant is what is assumed to be true about a class object throughout its lifetime.
It is the constructor's job to establish the invariant and every member function's job to
preserve it. Our original
Vector constructor didn't check its argument — asking for
Vector(-27) would cause chaos (negative-size allocation). A proper constructor
throws if the invariant would be violated:
Vector::Vector(int s) {
if (s < 0) throw length_error{"Vector constructor: negative size"};
elem = new double[s];
sz = s;
}
The standard library uses length_error (from <stdexcept>) for
negative-size arguments. If operator new can't find memory, it throws
bad_alloc.
void test(int n) {
try {
Vector v(n);
} catch (std::length_error& err) {
// handle negative size
} catch (std::bad_alloc& err) {
// handle memory exhaustion
}
}
void run() {
test(-27); // throws length_error (negative size)
test(1'000'000'000); // may throw bad_alloc (too large)
test(10); // likely OK
}
Some functions can't complete their task after an exception is thrown; they do local cleanup and rethrow the same exception to let a higher-level handler deal with it:
void test(int n) {
try {
Vector v(n);
} catch (std::length_error&) {
cerr << "test failed: length error\n";
throw; // rethrow (preserves the original exception object)
} catch (std::bad_alloc&) {
cerr << "out of memory — cannot recover\n";
std::terminate(); // this program is not designed to handle this
}
}
Notice the two different strategies for the two catch blocks: rethrow-and-let-higher-handle
for a local problem; immediate termination for a situation the program simply cannot recover
from. Also note the difference between catch (...) (catch anything, by type) vs.
catching specific types — catching by reference (not by value) avoids object slicing and is
the conventional style.
When to throw vs. return an error code vs. terminate — Stroustrup's decision framework from §4.4:
noexcept and a
throw would violate that contract.
Compilers are optimized so that returning a value is much cheaper than throwing the same value as an exception. But for rare error paths, exceptions are often faster than the equivalent chain of error-code tests that would clutter the happy path. The myth that "exceptions are slow" applies to the throw path specifically — which is why it's appropriate for rare failures.
A constructor cannot return an error code to signal that it didn't succeed. The only clean option is to throw an exception; the partially constructed object is destroyed (destructor runs) and the error propagates to the caller. This is precisely why exceptions exist: they give constructors a way to signal failure without requiring the caller to "check" the result — the object simply doesn't exist if the constructor throws.
Every class should have a clear invariant: a condition that is true of every object throughout its lifetime. The constructor establishes the invariant; member functions preserve it. Formulating invariants does three things for us:
For our Vector: the invariant is that elem points to an array of
sz doubles. Every member function (especially operator[]) can assume
this invariant holds and doesn't need to recheck the pointer sanity — only the index bounds
need checking, which is what the out_of_range throw guards.
There are only three ways a function can report that it cannot complete its task: throw an exception, return an error indicator (error code), or terminate the program. Each has legitimate use cases:
noexcept.
Key insight: error codes clutter the happy path. In a function like compute(), every call site would need an if-check for success, and the real computational result gets tangled with error-status bookkeeping. Exceptions keep the happy path clean — the "normal" code just does its work, and the rare failure case throws.
// error-code style (clutters the happy path)
Matrix add(const Matrix& a, const Matrix& b, ErrorCode& err) {
if (a.rows() != b.rows()) { err = DimensionMismatch; return {}; }
if (a.cols() != b.cols()) { err = DimensionMismatch; return {}; }
Matrix res(a.rows(), a.cols());
for (int i = 0; i != a.rows(); ++i)
for (int j = 0; j != a.cols(); ++j)
res[i][j] = a[i][j] + b[i][j];
err = Success;
return res;
}
// exception style (happy path is clean)
Matrix add(const Matrix& a, const Matrix& b) {
if (a.rows() != b.rows() || a.cols() != b.cols())
throw std::invalid_argument{"Dimension mismatch in add()"};
Matrix res(a.rows(), a.cols());
for (int i = 0; i != a.rows(); ++i)
for (int j = 0; j != a.cols(); ++j)
res[i][j] = a[i][j] + b[i][j];
return res; // clean return — no error-code out-parameter
}
The exception version is easier to write, easier to read, and harder to get wrong. The
compiler even checks that you don't accidentally ignore a return value — with error codes,
forgetting to check err is a silent bug. The cost: thrown exceptions are designed
for rare error paths, not for routine control flow.
Exceptions handle run-time errors that are expected but exceptional (rare). Sometimes we want to check assumptions that should always hold — the function's preconditions, a class invariant, or an internal consistency check. For these, C++ provides assertion mechanisms.
#include <cassert>
void f(const char* p) {
assert(p != nullptr); // p must not be null
// ... use p safely ...
}
If assert fails in debug mode, the program terminates with a diagnostic (file,
line, failed condition). In production builds (when NDEBUG is defined), the
assert call is removed entirely — zero runtime cost. This means
assert is for conditions that you believe must always hold, not for
conditions that a user might reasonably trigger (those are exceptions, not asserts).
When an error can be detected at compile time, it's almost always better to do so than to wait for a run-time failure:
static_assert(4 <= sizeof(int), "integers are too small");
// compiler error if int is smaller than 4 bytes on this platform
Static asserts work on any constant expression (§1.6):
constexpr double C = 299792.458; // km/s
constexpr double local_max = 160.0 / (60 * 60); // km/s → 160 km/h
static_assert(local_max < C, "can't go that fast"); // OK — constant expression
One important use of static_assert in generic programming (§8.2, §16.4):
template<typename T>
void process(T value) {
static_assert(std::is_integral_v<T>, "process() requires an integral type");
// ...
}
This gives a clear compile-time error message instead of a cryptic substitution failure when
someone accidentally passes a double to process().
expect() and noexcept
The standard library gives us assert() (debug-only, always terminates on failure)
and static_assert (compile-time only). Both are relatively blunt. A more flexible
scheme uses a template that accepts an action policy: ignore (release build),
throwing (throw exception), or terminating (call
terminate()) — selected at compile time with if constexpr (§7.4.3).
This gives a single point of control for assertion semantics across a codebase:
enum class Error_action { ignore, throwing, terminating };
constexpr Error_action default_Error_action = Error_action::throwing;
enum class Error_code { range_error, length_error };
constexpr const char* error_code_name[] = { "range error", "length error" };
template<Error_action action = default_Error_action, class C>
constexpr void expect(C cond, Error_code x) {
if constexpr (action == Error_action::logging)
if (!cond()) std::cerr << "expect() failure: "
<< int(x) << ' ' << error_code_name[int(x)] << '\n';
if constexpr (action == Error_action::throwing)
if (!cond()) throw x;
if constexpr (action == Error_action::terminating)
if (!cond()) std::terminate();
}
// Usage:
double& Vector::operator[](int i) {
expect([i, this] { return 0 <= i && i < size(); },
Error_code::range_error);
return elem[i];
}
Setting default_Error_action lets a user pick a deployment strategy: throw
(testing), log (staging), terminate (hard real-time). The compile-time
if constexpr means exactly one of the three branches compiles — zero runtime cost
for the two that don't apply.
assert() vs. expect() vs. exceptions — the spectrum:
| Mechanism | When checked | When fails | Cost when enabled | Use case |
|---|---|---|---|---|
assert() |
Run time (debug only) | Terminate | None in release (NDEBUG) |
"This must never happen" — internal invariants |
static_assert() |
Compile time | Compiler error | None | Type constraints, size assumptions, platform properties |
expect() |
Run time (configurable) | Configurable (throw/terminate/log) | One branch compiled | Public API preconditions (range checks, preconditions) |
Exception (throw) |
Run time | Stack unwinding to catch | Near-zero for rare paths | Errors that the caller might reasonably handle |
A function declared noexcept guarantees that it will never throw an exception. If
a noexcept function does throw, std::terminate() is called
immediately — no stack unwinding, no handler lookup. This is a hard constraint, not a
suggestion:
void user(int sz) noexcept {
Vector v(sz);
iota(&v[0], &v[sz], 1); // fill v with 1,2,3,... (§17.3)
}
// If user() somehow throws, std::terminate() is called — no recovery possible.
Thoughtlessly sprinkling noexcept is hazardous:
noexcept function calls a function that throws (expecting the caller to
catch it), the noexcept turns that throw into a fatal terminate().
noexcept compliance) is
complex, error-prone, and often more expensive than the exception mechanism it replaces.
noexcept also enables important compiler optimizations: the compiler can elide
stack-unwinding machinery for functions it knows cannot throw.
Like other powerful language features, noexcept should be applied with
understanding and caution — typically on move constructors, swap functions, and destructors
(which should never fail), and on hot-path functions where the compiler can exploit the
guarantee.
Stroustrup closes the chapter with a subset of the C++ Core Guidelines. All 18 items, with the section where each is introduced:
| # | Guideline | § |
|---|---|---|
| 1 | Throw an exception to indicate that you cannot perform an assigned task | 4.4 |
| 2 | Use exceptions for error handling only; don't use them for ordinary control flow | 4.4 |
| 3 |
Failing to open a file or to reach the end of an iteration are expected events, not
exceptional; use error codes for these (or expected<T,E> where
available).
|
4.4 |
| 4 | Use error codes when an immediate caller is expected to handle the error | 4.4 |
| 5 | Throw an exception for errors expected to percolate up through many function calls | 4.4 |
| 6 | If in doubt, prefer exceptions — their use scales better and doesn't require external tools to verify all errors are handled | 4.4 |
| 7 | Develop an error-handling strategy early in design, not as an afterthought | 4.4 |
| 8 | Use purpose-designed user-defined types as exceptions (not built-in types like int or string) | 4.2 |
| 9 | Don't try to catch every exception in every function — let exceptions propagate to the right level | 4.2 |
| 10 | You don't have to use the standard-library exception hierarchy | 4.3 |
| 11 | Prefer RAII to explicit try-blocks — let destructors handle cleanup automatically | 4.2 |
| 12 | Let a constructor establish an invariant, and throw if it cannot | 4.3 |
| 13 | Design your error-handling strategy around invariants | 4.3 |
| 14 | What can be checked at compile time is usually best checked at compile time | 4.5.2 |
| 15 |
Use an assertion mechanism (assert(), expect()) to provide a
single point of control for failure semantics
|
4.5 |
| 16 |
Concepts (§8.2) are compile-time predicates and therefore often useful in
static_assert messages
|
4.5.2 |
| 17 | If your function may not throw, declare it noexcept |
4.5.3 |
| 18 |
Don't apply noexcept thoughtlessly — understand what the function calls and
whether they can throw
|
4.5.3 |
A function encounters an error that its immediate caller can reasonably handle. Which strategy does Stroustrup recommend?
What happens if a constructor cannot establish its invariant?
Which mechanism checks a condition at compile time and produces a compiler error if it fails?
What happens if a noexcept function throws an exception?
What is a primary use case for static_assert in templates?
error ⇒ mismatch between intended and actual state
exception ⇒ thrown error propagates up the stack ; try/catch
exception object ⇒ any type ; prefer user-defined types (not int)
RAII ⇒ destructors clean up during unwind → no leaks on throw
throw ⇒ rare errors percolating many calls (eg deep call chains)
error code ⇒ expected events ; immediate caller (eg file not found)
constructor failure ⇒ throw ; no return value to signal
if in doubt ⇒ prefer exceptions → scales better
invariant ⇒ always-true property of valid object
constructor ⇒ establishes invariant ; throw if it cannot
violated invariant → undefined behavior
a) assert() ⇒ run-time check ; compiled out with NDEBUG
b) static_assert ⇒ compile-time check ; constexpr condition + message
c) noexcept ⇒ function may not throw ; throw → terminate()
d) expect() ⇒ configurable failure semantics (throw / terminate / log)
Primary source: Stroustrup, B. (2022). A Tour of C++, 3rd ed.,
Chapter 4: "Error Handling." Addison-Wesley.
Reference:
Chapter 1–4 Quick Reference & Glossary —
keep it beside you while you study.
Recommended supplement: cppreference on
exception handling,
terminate(), and
noexcept.
Questions? Ask your agent — your teacher — about anything unclear: how
except differs from except in the standard library, when
noexcept is appropriate for move constructors, or how RAII interacts with
exceptions when the stack unwinds. Follow-ups are expected, not optional.