Error handling and exception safety
Lesson 14 finished the generic-programming movement: iterators decouple an algorithm from its container, and the STL composes from small reusable pieces. But every one of those pieces can fail — a file won't open, an allocation runs out of memory, a parse hits garbage — and a failure has to travel from where it is detected to where it can be handled, often many calls away. C++ gives you two channels for that journey, and they cost wildly different amounts. The one new idea here: C++ does not have an error-handling feature — it has a choice between an out-of-band channel (exceptions) and an in-band one (value-based errors), and the deciding factor is the same cost model we've used all series. The hinge is that the safe channel is only safe because of RAII (Lesson 05): when an exception unwinds the stack, destructors are what release your resources for you.
New capability: choose between the exception channel and the value channel from a cost argument, state the three exception-safety guarantees (no-throw, strong, basic) and which one you are providing, and explain why RAII makes the basic guarantee almost free.
throw/try/catch and stack unwinding — and the worked example where RAII releases a lock mid-throw while a raw resource leaks. (3) The cost model: ~zero on the happy path, expensive on throw, and what that implies. (4) The value channel: std::optional, std::expected (C++23), error codes. (5) The three exception-safety guarantees, and why RAII hands you the basic one for free — then a decision rule.1 · An error is data that has to travel
When a deep helper detects a failure it usually cannot decide what to do about it — only the caller (or the caller's caller) has the context to retry, report, or abort. So an error must travel from the point of detection up to the point of handling, possibly through many intermediate functions that have no opinion on it. There are exactly two ways to move that information up the call stack.
std::optional, std::expected, error codes.thrown. Control leaves the function immediately and the runtime automatically walks back up the stack, skipping every intermediate function, until it finds a matching catch. Intermediate functions need no error-passing code at all — but the failure is invisible in the signature.The trade is stark. The value channel is explicit and cheap to ignore-by-mistake-proofing, but it pollutes every intermediate signature and every call site with plumbing. The exception channel keeps the happy path clean and the intermediate code error-blind, but it makes failure invisible at the call site and — crucially — it tears through those intermediate stack frames. That tearing is where Lesson 05 becomes life-or-death.
2 · Exceptions and stack unwinding
The mechanics: throw expr; constructs an exception object and begins unwinding. Stack unwinding means the runtime walks back up the call stack frame by frame — a stack frame (Lesson 02) being the block of memory holding one function call's locals — and as it leaves each frame it runs the destructors of every local object in that frame, in reverse order of construction, exactly as if the function had returned normally. It keeps unwinding until it reaches a try block whose catch clause matches the thrown type; that handler runs, and execution continues after it. If no handler is found anywhere, std::terminate is called and the program aborts.
The phrase "runs the destructors as it unwinds" is the whole point. A destructor is where RAII (Lesson 05) put your cleanup. So unwinding is cleanup — provided every resource is owned by an object with a destructor. Watch what happens when one isn't:
std::mutex m;
void process(Account& acct, int amount) {
std::lock_guard<std::mutex> guard(m); // RAII: acquires lock; dtor unlocks
FILE* log = std::fopen("audit.txt", "a"); // RAW handle: no destructor
acct.debit(amount); // (A) suppose this throws std::runtime_error
std::fprintf(log, "debited %d\n", amount);
std::fclose(log); // (B) only reached on the happy path
} // guard's dtor runs HERE on every exit
Suppose acct.debit throws at (A). Control leaves process immediately, so line (B) never runs — fclose is skipped. Now trace each resource through the unwind:
The lock_guard survives the exception correctly because its destructor is what unwinding runs — the mutex is released no matter how we leave the function. The raw FILE* leaks because a pointer carries no cleanup. The fix is to make the file owned too — a RAII wrapper, e.g. a custom FileHandle (Lesson 05) or std::fopen wrapped in std::unique_ptr<FILE, decltype(&std::fclose)> — so it, like guard, has a destructor that unwinding will run. The rule that falls out: in code that can throw, every resource must be owned by an object, never held as a bare pointer/handle. That is the same conclusion Lesson 04 reached for early returns; an exception is just an early return you didn't write.
Note the inversion this forces on your instincts. In a language with try/finally, cleanup is something you remember to write at every call. In C++ the cleanup lives once, in the resource type's destructor, and unwinding triggers it automatically — which is why idiomatic C++ has very few try blocks. You catch where you can handle; you rely on RAII everywhere in between.
3 · The cost model: free until thrown
Modern C++ implementations use zero-cost (table-based) exceptions. "Zero-cost" is literal about the happy path: when no exception is thrown, the generated code contains no extra branches, no flag checks, no setup per try block. The compiler instead emits static side tables that map "if an exception is in flight at this instruction, here are the destructors to run and the handler to jump to." Those tables sit in the binary and cost nothing at runtime until something throws. This is the zero-overhead principle (Lesson 00) applied to errors: a program that never throws pays nothing for the feature.
The flip side is the bill on the failure path. When a throw fires, the runtime allocates the exception object (often on the heap), consults those tables, and walks the stack frame by frame running destructors and type-matching handlers. That is orders of magnitude slower than returning a value — a thrown-and-caught exception is commonly in the microsecond range, versus a fraction of a nanosecond for a value return.
4 · The value channel: optional, expected, error codes
When failure is an expected, routine outcome — or when you are in a hot loop, an embedded system, or any context where the throw-path cost or the lack of determinism is unacceptable — you encode the error in the return value. Three tools, in increasing richness:
| Tool | Models | Carries why-it-failed? | Use when |
|---|---|---|---|
std::optional<T> | a T or nothing | No — just present/absent | "maybe absent" is the whole story (a cache miss, a not-found) |
std::expected<T,E> (C++23) | a T or an error E | Yes — the E is the reason | failure is expected and the caller needs to know why |
error code (int / std::error_code) | status returned alongside output | Yes — but easy to ignore | C APIs, ABI boundaries, no-exception builds |
// optional: absence is the only failure mode, no reason needed
std::optional<int> to_int(std::string_view s);
if (auto n = to_int(text)) use(*n); // dereference only when present
// expected (C++23): value OR a typed reason
std::expected<Config, ParseError> load(std::string_view path);
auto r = load("app.cfg");
if (r) apply(*r); // has_value(): the T is in *r
else report(r.error()); // otherwise the E is in r.error()
std::expected is the value channel's answer to exceptions: same "value-or-error" payload, but in-band, allocation-free, and visible in the signature — every caller sees that load can fail and the compiler will not let them silently treat the result as a Config. The cost is the plumbing the exception channel avoided: each intermediate caller must check and forward (C++23's monadic helpers like .and_then shorten this). That's the trade in one line: exceptions move the cost to the throw and the noise out of the call sites; value errors keep the cost flat and put the handling in the call sites.
5 · The three exception-safety guarantees
"Exception-safe" is not one property — it is a ladder of three promises a function can make about what state the program is in if an exception escapes it. You should know which one each function you write provides; it is part of its contract.
swap, and move operations (mark them noexcept). The strongest, and the foundation the other two are built from.vector::push_back on reallocation.)The classic technique for the strong guarantee is copy-and-swap: do all the work that might throw on a temporary copy; only once it has fully succeeded, swap it into place with a noexcept swap. If a throw happens, it happens during the copy — the original is untouched, so you get commit-or-rollback. The swap itself cannot throw, so the commit step is safe.
unique_ptrs, lock_guards, and vectors leaks nothing on throw without you writing any cleanup. The second part (invariants hold) you still owe — but it shrinks to "don't leave a half-updated object inconsistent," because the resource bookkeeping is already handled. This is the deep payoff of the ownership movement (Lessons 05–07): in a codebase that owns every resource through RAII, the basic guarantee is close to the default, and you only do real work to climb from basic to strong. In a codebase full of raw new/delete and bare handles, even the basic guarantee is a constant, manual, error-prone struggle — which is exactly the Lesson 04 pain returning.Common mistakes / failure modes
optional/expected for outcomes you expect; throw only for the genuinely exceptional.new, fopen, or m.lock() with cleanup written after a call that can throw leaks on unwind (§2). Wrap it in an RAII type so its destructor does the release.std::terminate — two exceptions in flight is undefined to handle. Destructors must be noexcept (the no-throw guarantee); they are by default.expected you never check is a silent bug — the in-band channel only works if callers inspect it. Mark such returns [[nodiscard]] so the compiler warns when the result is dropped.Checkpoint exercise
process function from §2 and run it under a thrown exception two ways. (1) As written, with the raw FILE* — build with -fsanitize=address (Lesson 04's tool) and confirm the leak report on the throw path. (2) Now replace the FILE* with auto log = std::unique_ptr<FILE, decltype(&std::fclose)>(std::fopen("audit.txt","a"), &std::fclose); and rerun — the leak vanishes because unwinding now runs the unique_ptr destructor, which calls fclose. Then ask: which exception-safety guarantee does process now provide — basic or strong? (Hint: if debit committed before the throw, has the account observably changed? What would copy-and-swap, or doing the debit last, buy you?)Where this points next
We chose error channels and named the guarantees by reasoning at runtime — "if this throws, what state are we in?" Lesson 16 asks the sharper question: why let it be a runtime error at all? It turns the type system and the compiler into error-handling tools — const-correctness to forbid illegal mutations, strong types and enum class to make illegal states unrepresentable, and constexpr to move work and checks to compile time. The best-handled error is the one the compiler refuses to let you write, and that is the "push bugs left" discipline the next lesson installs.
throw/try/catch) are out-of-band: a throw triggers stack unwinding, which walks back up the stack running every local object's destructor — so they are safe only because RAII (Lesson 05) put your cleanup in those destructors, and a bare pointer or handle held across a throw leaks. Their cost model is zero-overhead made literal: ~free on the happy path (static tables), expensive on the throw — so use them for the genuinely exceptional, never for control flow. The value channel (std::optional for maybe-absent, std::expected<T,E> for value-or-reason in C++23, error codes for ABI/no-exception boundaries) keeps the cost flat and the failure visible in the signature, at the price of plumbing every call site — use it for expected failures and hot or embedded paths. Whatever you throw, know which of the three guarantees you provide — no-throw, strong (commit-or-rollback), or basic (no leaks, invariants hold) — and lean on RAII, which makes the basic guarantee nearly the default by releasing every owned resource for you during the unwind.Interview prompts
- What is stack unwinding, and why does it make RAII the foundation of exception safety? (§2 — unwinding walks back up the call stack running each frame's local destructors before searching for a handler; since RAII puts resource release in destructors, unwinding is the cleanup — so a bare handle held across a throw leaks while an owned one releases automatically.)
- Why are C++ exceptions described as "zero-cost," and what's the catch? (§3 — table-based exceptions add no runtime cost on the path where nothing is thrown; the cost is concentrated in the throw itself, which is orders of magnitude slower than a value return — hence "exceptions for the exceptional, not control flow.")
- State the three exception-safety guarantees and give an operation for each. (§5 — no-throw:
swap/destructors/moves can't throw; strong: commit-or-rollback with no observable change on failure, e.g.vector::push_back; basic: no leaks and invariants hold, the minimum bar.) - Why does RAII make the basic guarantee almost automatic but not the strong one? (§5 — RAII releases every owned resource on unwind, so "no leaks" comes free; "invariants hold" you still owe, but it shrinks to not leaving an object half-updated. Strong needs an explicit technique like copy-and-swap.)
- When would you return
std::expected<T,E>instead of throwing? (§3–4 — when failure is an expected, routine outcome, or in a hot loop / embedded / no-exception context where the throw-path cost or non-determinism is unacceptable; it also makes failure visible in the signature and forces callers to handle it.) - What happens if a destructor throws during unwinding, and what's the rule? (§Common mistakes — two exceptions in flight triggers
std::terminate; therefore destructors must not throw — they should satisfy the no-throw guarantee, and arenoexceptby default.) - Difference between
std::optionalandstd::expected, and when each fits? (§4 —optionalmodels present-or-absent with no reason;expectedmodels value-or-typed-error and carries why it failed — useoptionalwhen absence is the whole story,expectedwhen the caller needs the reason.)