C++ Part I · Module 22 · Error Handling and Diagnostics
Choosing an Error Strategy
This module's frozen description calls failure a design input, and that framing is the reason this chapter comes first. Most arguments about error handling are conducted as though there were one question — exceptions or return codes — with two camps and a winner. There are three questions, because there are three genuinely different kinds of failure, and each has a mechanism that fits it. A failure can be exceptional: rare, caused by the environment, and usually something the immediate caller cannot do anything about. It can be expected: a lookup that misses, a parse that does not match, an end of input — a normal outcome that happens often and that the caller is expected to handle on the spot. Or it can be a programming error: a precondition the caller was required to satisfy and did not, which is a bug in the program rather than a condition in the world. Sorting a failure into one of those three decides the mechanism almost mechanically, and getting the sort wrong is what produces the two recognisable pathologies — an exception thrown for a routine miss, and a status code returned for a catastrophe that nobody checks. The chapter measures the cost of each mechanism on both the succeeding and the failing path, and the numbers make the decision concrete: the mechanisms are indistinguishable when nothing fails, and separated by four orders of magnitude when something does.
Intermediate18 min readC++Part IFundamentalsError Handling
Part I · Module 22 · Chapter 22.1 · Choosing an Error Strategy
1. Three Kinds of Failure
The frozen purpose names them, and the distinction is about whose fault it is and how often it happens.
Exceptional. The environment did something. A device did not respond, a file that existed yesterday is gone, an allocation failed, a configuration file is malformed. It is rare, it is not the caller's fault, and the immediate caller usually cannot fix it — the handling belongs somewhere further up, where there is enough context to retry, fall back or report.
Expected. A normal outcome that happens to be the unsuccessful one. A key is not in the map. A line does not parse. The input has ended. The user has not configured this field. These are frequent, they are not errors in any meaningful sense, and the caller is the right place to handle them because the caller knows what to do next.
A programming error. A precondition was violated. An index was out of range, a width was greater than the register, a pointer that must not be null was null, an invariant inside your own class does not hold. The program is wrong. No run-time handling can be correct, because the code that would do the handling is part of the program whose assumptions have already been shown to be false.
The whole chapter is the claim that these three want different mechanisms, and that most error-handling arguments are two people each describing a different one of them.
2. The Mechanism for Each
Exceptional → throw. The failure is rare, so the cost of throwing does not matter. The caller cannot handle it, so propagating past them automatically is exactly right. And it cannot be ignored: an unhandled exception terminates the program loudly, where an unchecked return code produces wrong answers quietly.
Expected → the return value. std::optional when absence needs no explanation (Chapter 21.5), a status enum plus an out-value, or a result type carrying either a value or a reason (Chapter 22.7). The caller is forced to look at it in order to get the value, which is the point.
A programming error → assert, and fail immediately. Chapter 22.8's subject. Not an exception, because there is nothing to recover to; not a return code, because the caller cannot meaningfully respond to "you called me wrongly".
That mapping is the chapter's answer. The rest of it is the evidence.
3. The Cost When Nothing Fails
The usual objection to exceptions is that they cost something everywhere. Measured, on 200,000 successful calls through each mechanism:
throw (never thrown) 0.34 ns/call [0.33-0.41]
code 0.37 ns/call [0.35-0.44]
optional 0.38 ns/call [0.35-0.81]Indistinguishable. Medians over seven whole-process runs, with ranges that overlap across their whole width. The throw version's median is in fact the lowest of the three, which is noise rather than a finding — but it does rule out the claim that a try block costs anything per call on the path that does not throw.
The mechanism is worth knowing. On every platform this curriculum targets, exception handling is implemented with side tables: the compiler emits a static table mapping instruction ranges to cleanup actions, and entering a try block generates no instructions at all. There is no flag to set, no handler to push, nothing to check. That is why the measurement comes out flat, and it is why the approach is called zero-cost — meaning zero cost when nothing throws.
Chapter 22.6 §3 measures where the cost actually lands, which is in the size of the caller of a function that might throw, and even there it is tens of bytes.
A note on the instrument, because the first version of this measurement was worthless. With the failing id written as a literal 0xDEAD, the optimiser proved the branch outcome and folded the entire loop to a constant — reporting 0.00 ns/call for the code and optional versions and a ratio of 17,280,398×. Reading the ids through a volatile pointer fixed it. A ratio with a denominator the compiler deleted is not a measurement.
4. The Cost When It Fails
throw (always thrown) 3362 ns/call [2687-5013]
code 0.30 ns/call
optional 0.30 ns/callRoughly eleven thousand times, with the ratio's median at ~11,000 over seven runs and a range of 9,027 to 16,357. Four orders of magnitude.
A throw does real work: allocate the exception object on a special heap, copy the thrown object into it, walk the side tables frame by frame, run every destructor on the way, type-match against each catch clause in order, and finally transfer control. Three thousand nanoseconds is roughly three microseconds — which at a gigahertz is a few thousand cycles.
So the two measurements together give the rule, and it is not a matter of preference:
- A failure that happens rarely costs nothing until it happens, and then costs microseconds — which is irrelevant if it happens once per run.
- A failure that happens often costs microseconds every time, which at a thousand occurrences a second is three milliseconds of pure overhead for a condition that is not an error.
Three microseconds is also fast on a human scale. An exception per failed network request is fine. An exception per missing map key, inside a loop over a million keys, is three seconds.
5. The Two Pathologies
Both common mistakes are a mis-sort in §1, and both have a recognisable shape.
An exception for an expected failure. The symptom is a try/catch in a loop body, or a caller that catches immediately and does something ordinary:
for (const auto& name : names) {
try { total += rf.lookup(name); }
catch (const NotFound&) { /* skip */ } // this is control flow
}The catch is not handling an exceptional condition; it is implementing "if absent, skip". Chapter 21.5's std::optional says that directly, and §4's measurement is the cost of not doing so. The tell is that the handler is adjacent to the call and does something routine.
A return code for an exceptional failure. The symptom is a call whose result is ignored:
device.reset(); // returns a status nobody looked at
device.configure(cfg); // and so does thisNothing stops that compiling. [[nodiscard]] helps, and is not the default. The failure then surfaces much later as a wrong answer with no connection to its cause — which is the opposite of what you want from a catastrophe, and the specific reason this curriculum keeps measuring silent-wrong-answer defects.
The tell for the second is a function whose failure the caller cannot sensibly handle but is nevertheless required to check.
6. Where assert Fits, and Where It Does Not
A programming error is the third category and it is not an error-handling problem at all — it is a bug. Chapter 22.8 covers the mechanics; the strategic point belongs here.
std::uint32_t fieldWidth(unsigned pos, unsigned width)
{
assert(pos < 32 && "bit position must be below 32");
assert(width >= 1 && width <= 32 - pos && "field must fit in the register");
return width;
}The test that sorts it: can this condition occur in a program that has no bugs? A file that may not exist: yes, so not an assert. A device that may not respond: yes, so not an assert. An index beyond the end of a container the caller just sized: no — if that happens, something is already wrong, and continuing will produce a wrong answer rather than a diagnosable failure.
And the measured reason assert is not an error-handling mechanism:
BUILD: NDEBUG not defined -- asserts are ACTIVE
after `assert(advance());` the side effect ran 1 time(s)
BUILD: NDEBUG defined -- asserts are COMPILED OUT
after `assert(advance());` the side effect ran 0 time(s)An assert is not there in a release build. So it cannot be the thing that validates untrusted input, checks a file's contents, or guards against a hardware timeout — those conditions still exist when NDEBUG is defined and the check does not.
7. Choosing Per Interface, Not Per Codebase
The frozen purpose says per interface, and that word is doing work. A single program will correctly use all three mechanisms, and the unit of decision is one function's contract.
std::vector itself demonstrates this exactly, with three accessors offering three strategies for the same operation:
v[i]— a programming error. No check in a release build; an out-of-range index is undefined behaviour. The contract is "you must pass a valid index".v.at(i)— an exceptional failure. Throwsstd::out_of_range. The contract is "I will check, and tell you loudly".v.size()then your own test — an expected condition, handled by the caller.
One container, one operation, three contracts, chosen by what the caller knows. That is the model to copy, and it is why "we use exceptions" or "we use error codes" as a codebase-wide policy is the wrong granularity.
Two consistency rules do belong at the codebase level, though: a given function should pick one mechanism and keep it, and functions at the same layer should agree, so a caller does not have to remember which of two sibling functions throws.
8. The Awkward Constraints
Four situations where the mechanism is partly chosen for you, and they are worth knowing before the decision feels free.
A constructor cannot return a value. If construction can fail, the options are to throw, to have a two-phase init() that returns a status, or to make the constructor private behind a static factory returning std::optional<T>. Throwing is usually right, because a half-constructed object is exactly what RAII exists to prevent — Chapter 22.4 §2.
A destructor must not throw. Chapter 22.6 §5 measures what happens when one does. So cleanup that can fail — closing a file, flushing a buffer — needs an explicit close() that reports, with the destructor as a silent best-effort fallback.
A C API boundary cannot carry an exception. Anything called from C, from a callback registered with a C library, or across a shared-library boundary with a C ABI must catch everything and convert to a code. A catch (...) at that boundary is correct and is one of the few places it is.
noexcept means terminate, not propagate. Chapter 22.6 §4's measurement: the exception does not escape, the process does. So marking something noexcept is a decision about failure strategy, not just an annotation.
9. The Decision Table
| the failure is… | frequency | can the immediate caller handle it? | mechanism | cost when it happens |
|---|---|---|---|---|
| a device not responding | rare | no | throw | ~3,400 ns (measured) |
| an allocation failing | rare | no | throw (bad_alloc) | ~3,400 ns |
| a config file malformed | once | maybe | throw, or a result with a reason | ~3,400 ns |
| a key not in a map | often | yes | std::optional | ~0.3 ns (measured) |
| a line that does not parse | often | yes | optional or a result type | ~0.3 ns |
| end of input | every run | yes | a status or a sentinel iterator | ~0.3 ns |
| an index out of range | never, if correct | no — it is a bug | assert | compiled out in release (measured) |
| a broken class invariant | never, if correct | no | assert | compiled out |
| a failure crossing a C ABI | varies | — | catch and convert to a code | — |
10. What the Rest of the Module Does
Having sorted the failure, the mechanisms get a chapter each.
Chapter 22.2 is throw, try and catch properly — including why you catch by const reference, measured.
Chapters 22.3 and 22.4 are the machinery that makes throwing safe: unwinding, and why RAII is not a separate idea from exceptions but the same design. Chapter 22.4 measures a manual-cleanup function leaking.
Chapter 22.5 is the four guarantees, and the question which one does my function provide? — with a measurement of a broken invariant that only appears on the failure path.
Chapter 22.6 is noexcept, where the promise is enforced by std::terminate.
Chapter 22.7 is the expected-failure half: error codes, status-plus-value, and result types.
Chapter 22.8 is assert and what it must never be used for.
Chapter 22.9 is what you need in the log when a failure happens four hours into a run and you were not watching.
11. What to Actually Do
- Sort the failure first: exceptional, expected, or a programming error. The mechanism follows.
- Throw for the rare and unhandleable. Measured free until it happens.
- Return a value for the frequent and handleable.
std::optional, a status, or a result type. assertfor a violated precondition, and never for anything that can happen in a correct program.- Decide per interface, not per codebase —
std::vectoroffers all three for one operation. - Keep one function's mechanism stable, and keep siblings at the same layer consistent.
- Mark a status-returning function
[[nodiscard]]so it cannot be silently ignored. - Throw from a constructor rather than inventing a two-phase
init(). - Never throw from a destructor; give it an explicit
close()that reports. - Catch everything at a C ABI boundary and convert to a code.
- Suspect a
tryin a loop body — that is usually an expected failure wearing an exception. - Suspect an ignored return value — that is usually an exceptional failure wearing a code.
12. Mistakes Worth Making Once
An exception per missing key, in a loop. Measured ~3,400 ns each. At a million keys that is 3.4 seconds of nothing.
A status code for something nobody can handle, ignored at the call site, surfacing later as a wrong answer.
assert on a file's existence. Compiled out in release, so the check is not there when it matters.
An assert containing a side effect. Measured 1 call in debug, 0 in release — the expression vanishes.
A two-phase init() because "constructors cannot fail", producing an object that is valid-but-not-ready and a second state nobody checks.
Throwing out of a destructor. Chapter 22.6 §5 measures the terminate.
Letting an exception cross a C callback boundary. Undefined behaviour at best.
Benchmarking a failure path whose outcome the optimiser can prove. My own first attempt reported 0.00 ns/call and a ratio of 17 million.
13. Interview Relevance
The frozen curriculum marks this chapter High, and it is a good design question because the expected answer is a framework rather than a side.
"Exceptions or error codes?" The answer that stands out refuses the dichotomy: three kinds of failure, three mechanisms, decided per interface. Then the measurements — indistinguishable on the happy path, ~11,000× apart on the failure path — so the deciding variable is frequency.
"Do exceptions slow down code that does not throw?" Measured no: 0.34 against 0.37 ns/call with overlapping ranges, because entry to a try block emits no instructions on a table-based implementation.
"What does throwing cost?" About 3,400 ns here — allocation, unwinding, destructor calls, and a type match per handler.
"When would you use assert instead?" For a condition that cannot occur in a correct program. And the reason it is not an error-handling mechanism: it is compiled out under NDEBUG — measured, the side effect ran 0 times.
"How does std::vector handle an out-of-range index?" Three ways — operator[] undefined, at() throws, size() lets you check — which is the per-interface principle in one class.
"How do you report a failure from a constructor?" Throw. The alternative is a two-phase init and an object with a second validity state.
14. What You Should Be Able to Say Now
- The three kinds of failure, and that the mechanism follows from the sort.
- That on the succeeding path throw, code and optional measured 0.34 / 0.37 / 0.38 ns/call with overlapping ranges — indistinguishable.
- Why: table-based exception handling emits no instructions on entry to a
try. - That on the failing path a throw cost ~3,362 ns against ~0.30, a ratio of about 11,000×.
- That the deciding variable is therefore frequency, a property of the failure.
- That
assertis compiled out underNDEBUG— measured by a side effect running 1 time then 0 — so it cannot guard anything that happens in a correct program. - That the decision is per interface, with
std::vectoroffering all three for one operation. - The four constrained situations: constructors, destructors, C boundaries, and
noexcept. - That a benchmark whose failure branch the optimiser can prove measures nothing.
15. Exercises
Exercise 1 — sort ten failures. Take ten failure conditions from your own code and sort each into one of the three categories, with a one-line reason. Note any you cannot decide, and say what extra information would settle it.
Exercise 2 — reproduce the costs. Build §3 and §4's benchmark. First with the failing id as a literal, and confirm you get an absurd ratio; then with the volatile read, and report your medians and ranges.
Exercise 3 — the loop pathology. Find or write a try/catch inside a loop. Convert it to std::optional and measure both at 10,000 failures.
Exercise 4 — the ignored code. Write a function returning a status, call it without checking, and confirm it compiles. Add [[nodiscard]] and read the warning.
Exercise 5 — assert in release. Put a side effect in an assert, build with and without NDEBUG, and record the counts. Then explain in two sentences why that disqualifies assert for input validation.
Exercise 6 — the three accessors. Write a small container offering all three contracts for element access, document each, and say which you would make the default.
Exercise 7 — the constructor. Write a type whose construction can fail, three ways: throwing, two-phase init, and a static factory returning std::optional. Compare the call sites.
Exercise 8 — the boundary. Write a C-callable wrapper around a throwing C++ function that converts every exception to an error code, and enumerate what it must catch.
16. Summary
- Three kinds of failure: exceptional (rare, environmental, unhandleable here), expected (frequent, normal, handleable here), and a programming error (a bug).
- Three mechanisms: throw, the return value, and
assert. - Measured on the succeeding path: throw 0.34, code 0.37, optional 0.38 ns/call, ranges overlapping — indistinguishable, because entry to a
tryblock emits no instructions. - Measured on the failing path: throw ~3,362 ns against ~0.30 — ratio median ~11,000× (9,027–16,357). Four orders of magnitude.
- So the deciding variable is frequency, which is a property of the failure rather than of taste.
- Measured:
assertis compiled out underNDEBUG— a side effect inside one ran 1 time in debug and 0 in release — so it can never guard a condition that occurs in a correct program. - Decide per interface.
std::vectoroffersoperator[](undefined),at()(throws) andsize()(check yourself) for one operation. - The two pathologies are a
tryin a loop body and an ignored return value — each a mis-sort of §1. - Four constrained cases: constructors cannot return, destructors must not throw, C boundaries cannot carry an exception, and
noexceptmeans terminate. - A benchmark whose failure branch is constant-foldable measures nothing — mine reported a ratio of 17 million before it was fixed.
The strategy is settled; the mechanism needs explaining. Chapter 22.2 is throw, try and catch — the standard exception hierarchy, how to derive your own types from it, and the one syntactic detail that silently discards information: catching by value rather than by const reference, which the chapter measures turning a TimeoutError into a plain DeviceError and losing the data that made it diagnosable, with no warning from any flag clang offers.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
