C++ Part I · Module 22 · Error Handling and Diagnostics
Error Codes and Result-Returning Interfaces
Chapter 22.1 divided failures into three kinds and six chapters have been about one of them. This chapter is the expected failure: the lookup that misses, the line that does not parse, the input that has ended, the field that was never configured. These are not errors in any useful sense — they are normal outcomes that happen to be the unsuccessful one, they happen often, and the caller is the right place to handle them because the caller knows what to do next. The mechanism is the return value, and the chapter's job is to choose among the four shapes it can take and to say when each is right. std::optional when absence needs no explanation. A status enum when the caller must distinguish several reasons. A status plus a value when both are needed. And a result type carrying either a value or a reason, which is what C++23's std::expected finally standardised and what every codebase wrote by hand before it. The case for all of them rests on Chapter 22.1's measurement, and it is worth restating plainly because it is the entire argument: on the path that succeeds, throwing and returning cost the same, and on the path that fails, throwing costs about eleven thousand times more. For a failure that happens once a run that is irrelevant. For one that happens a thousand times a second it is the difference between a mechanism and a performance bug.
Intermediate18 min readC++Part IFundamentalsError Handling
Part I · Module 22 · Chapter 22.7 · Error Codes and Result-Returning Interfaces
1. The Case, Measured
Chapter 22.1 §3 and §4 measured three mechanisms on 200,000 calls each. The figures are the reason this chapter exists, so they are repeated here rather than referenced.
A. the HAPPY path: 200000 successful calls
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
B. the FAILURE path: 200000 failing calls
throw (always thrown) 3362 ns/call [2687-5013]
code 0.30 ns/call
optional 0.30 ns/call
-> ratio median ~11000x (9027-16357)Two conclusions, and they are not symmetrical.
On the path that succeeds, returning a failure costs nothing over throwing one. So there is no efficiency argument for throwing. The try block is free, and so is the status check — both are sub-nanosecond and within each other's noise.
On the path that fails, throwing costs four orders of magnitude more. About 3.4 microseconds against 0.3 nanoseconds.
So the decision is Chapter 22.1 §4's: how often does this fail? A device that does not respond once per run can cost microseconds. A map lookup that misses a thousand times a second costs 3.4 milliseconds a second of pure overhead for a condition that is not an error at all.
That is the whole case. Everything below is about which shape of return value to use.
2. std::optional — Absence With No Explanation
Chapter 21.5 is the chapter; this is where it belongs in the taxonomy.
std::optional<std::uint32_t> lookup(std::string_view name) const;Use it when there is exactly one way to fail and the caller does not need to be told which. A key is not in the map. A field was not configured. A parse found no match.
What it costs, from Chapter 21.5 §4: the value plus a padded flag — 8 bytes for a uint32_t, measured — and zero heap allocations, because the value is stored inline.
What it buys over the obvious alternative is Chapter 21.5 §2's measurement, which is worth repeating because it is a correctness argument and not a style one. A sentinel value steals one value from the range, and a register map containing a register that legitimately holds 0xFFFFFFFF made the sentinel version return the same answer for a real value and for a missing register. std::optional reported has_value() == 1 with that value, keeping the two distinct.
The limit: it carries no reason. If the caller needs to know why, §3.
3. A Status Enum — Several Reasons, No Value
enum class Status { Ok, NotFound, Malformed, Truncated, PermissionDenied };
[[nodiscard]] Status parseLine(std::string_view line);Use it when the operation produces no value and the caller must distinguish outcomes. The enum class matters — a plain enum converts to int and participates in arithmetic, which Chapter 9.9 §4's measurement covers.
Three rules make the difference between a usable status and the pathology Chapter 22.1 §5 named.
[[nodiscard]], always. Without it the return value can be ignored silently, which is the entire failure mode of this mechanism:
device.reset(); // returns a status nobody looked atWith it, that line warns. It is one attribute and it converts the mechanism's main weakness into a build diagnostic.
Ok first and zero. Not for any truth-value reason — do not write if (!status) — but because it is the conventional position and makes the enum's default state the successful one.
A name per reason, not per call site. Five statuses that a caller can switch on usefully, not thirty that are really log messages.
4. Status Plus Value
When there is both an outcome to report and a value to return, there are four arrangements and they are not equal.
The output parameter. The C approach, and Chapter 21.5 §3 measured its four costs: an uninitialised local at the call site, an ignorable return value, no use in an expression, and a signature that reads backwards.
Status lookup(std::string_view name, std::uint32_t& out); // avoidA struct with both. Honest and readable, and it is what the standard library now does:
struct LookupResult { Status status; std::uint32_t value; };
LookupResult lookup(std::string_view name);std::from_chars returns exactly this shape — a pointer and an errc — and it is a deliberate move away from the output-parameter style. Chapter 21.4 §5's structured bindings make it read well:
auto [status, value] = lookup("CTRL");
if (status == Status::Ok) use(value);std::pair<T, Status>. Works, and Chapter 21.4 §9's argument applies: .first and .second say nothing, and a reader has to look up which is which. Prefer the named struct.
std::optional<T> plus a separate status accessor. The std::istream approach — the value comes back, and fail() tells you about it. Workable for a stateful object, wrong for a pure function, because the two facts can be read in the wrong order.
The one real weakness of the struct form is that the caller can read value without checking status. Nothing prevents it. §5 is the shape that does.
5. A Result Type — A Value or a Reason
The structural fix is a type that holds a value or an error, never both, so the value is unreachable without acknowledging the alternative.
In C++23 this is standard:
std::expected<std::uint32_t, Status> lookup(std::string_view name);auto r = lookup("CTRL");
if (r) use(*r);
else report(r.error());Before C++23 the usual implementation is std::variant<T, E>, which Chapter 21.6 measured: 24 bytes for a three-alternative variant, zero heap allocations by guarantee, and — the relevant property — Chapter 21.6 §4's result that a std::visit with an overload-set visitor makes handling every alternative a compile error rather than a warning.
That is what a result type buys over the struct in §4: the error case cannot be forgotten, because there is no way to reach the value without going through something that distinguishes them.
Two honest caveats.
C++23 is not everywhere yet. This curriculum's compiler accepts -std=c++23 (Chapter 21.8 §1 checked), but a codebase targeting C++17 will be hand-rolling it or using a third-party implementation.
The error type needs designing. std::expected<T, Status> is fine; std::expected<T, std::string> allocates on every failure and turns an expected outcome back into an expensive one.
6. std::error_code, and the Thing It Solves
The standard library has one more mechanism, and it exists for a specific problem: an error that must cross a boundary between subsystems that do not share an enum.
std::error_code ec;
auto sz = std::filesystem::file_size(path, ec);
if (ec) report(ec.message());std::error_code is an integer plus a pointer to a category — so two subsystems can each define their own codes without colliding, and a caller can compare against a portable std::errc condition rather than a platform number.
It is the right tool for library code at an OS boundary, and it is over-engineered for anything inside one program. The filesystem library offers both forms of every operation — throwing and error_code — which is itself Chapter 22.1 §7's per-interface principle: the same operation, two contracts, chosen by what the caller knows.
std::system_error is the exception that wraps an error_code, which is how you convert from this mechanism to the throwing one at a layer boundary.
7. Composition Is the Real Weakness
The honest disadvantage of returning failures, and it is the strongest argument the other side has.
// with exceptions: the happy path is the only path written
auto cfg = loadConfig(path);
auto dev = openDevice(cfg.id);
auto caps = queryCaps(dev);
return summarise(caps);// with returned failures, pre-C++23
auto cfg = loadConfig(path);
if (!cfg) return std::nullopt;
auto dev = openDevice(cfg->id);
if (!dev) return std::nullopt;
auto caps = queryCaps(*dev);
if (!caps) return std::nullopt;
return summarise(*caps);Every step grows a check, and the structure of the computation is buried in them. Three steps is tolerable; ten is not, and that is where codebases drift back to exceptions or invent a macro.
Two responses are worth knowing.
C++23's monadic operations on std::expected and std::optional collapse it:
return loadConfig(path)
.and_then([](auto cfg) { return openDevice(cfg.id); })
.and_then([](auto dev) { return queryCaps(dev); })
.transform([](auto caps){ return summarise(caps); });The checks are gone and the failure propagates automatically, which is exactly what the exception version gets from unwinding.
Or convert at a layer boundary. Return failures within a subsystem where they are frequent and the composition is shallow, and throw at the boundary where a caller cannot do anything local anyway. That is the pragmatic arrangement most large codebases converge on, and it is consistent with Chapter 22.1 §7's per-interface rule.
8. Choosing the Shape
| the situation | use | why |
|---|---|---|
| one way to fail, no reason needed | std::optional<T> | 8 bytes, no allocation (21.5 §4), no sentinel collision (21.5 §2) |
| several reasons, no value | enum class Status + [[nodiscard]] | the caller can switch; the attribute stops it being ignored |
| a reason and a value | a named struct | what std::from_chars does; reads well with structured bindings |
| a value or a reason, forced | std::expected<T,E> (C++23), else variant<T,E> | the value is unreachable without handling the error |
| crossing a subsystem or OS boundary | std::error_code | categories prevent collisions; std::errc is portable |
| rare, and the caller cannot act locally | throw | measured free until it happens (22.1 §3) |
| a violated precondition | assert | not a failure at all — Chapter 22.8 |
Two anti-patterns to name explicitly:
A sentinel from the value's own range. Measured colliding with real data.
An output parameter plus an ignorable status. Chapter 22.1 §5's second pathology, and the measured reason the failure surfaces far from its cause.
9. Where This Leaves the Module's Taxonomy
Chapter 22.1 sorted failures into three kinds and promised a mechanism for each. All three are now covered.
Exceptional → throw. Chapters 22.2 through 22.6: the mechanism, the unwinding, RAII, the guarantees, and noexcept.
Expected → the return value. This chapter.
A programming error → assert. Chapter 22.8, next.
The thing worth noticing is how much of the module went to the first category. That is not because it is the most common — Chapter 22.1 §1 noted that expected failures are the frequent ones — but because it is the one with machinery. Returning a value needs no unwinding, no guarantees and no noexcept; it needs you to pick a shape and to not let the caller ignore it.
10. What to Actually Do
- Return expected failures; throw exceptional ones. Measured ~11,000× on the failing path, indistinguishable on the succeeding one.
[[nodiscard]]on every status-returning function. It is the one-line fix for the mechanism's only real weakness.std::optional<T>when absence needs no explanation — 8 bytes, no allocation.- Never a sentinel from the value's own range. Measured colliding with real data.
enum class, not a plain enum, for a status.- A named struct over
std::pairfor status-plus-value, and read it with structured bindings. std::expected<T, E>where you have C++23, so the value cannot be reached without handling the error.- Keep the error type cheap.
std::expected<T, std::string>allocates on every failure. - Avoid output parameters. Four measured costs, no benefit.
std::error_codeonly at a subsystem or OS boundary.- Convert at layer boundaries: return inside, throw outward.
- Use C++23's monadic operations if composition depth is the problem.
11. Mistakes Worth Making Once
A status nobody checks. Compiles, runs, and the failure surfaces far from its cause. [[nodiscard]] is the fix.
A sentinel the data later contains. Measured: two situations, one answer, no diagnostic.
Throwing for a lookup miss in a hot loop. 3.4 µs each; at a million misses, 3.4 seconds.
std::expected<T, std::string>. An allocation on every expected failure.
An output parameter left uninitialised and read on the failure path.
std::pair<Status, T>. .first and .second mean nothing to the next reader.
Ten chained operations each with its own check. The structure disappears; this is the real cost, and C++23's monads or a layer boundary is the answer.
A plain enum for a status. It converts to int and takes part in arithmetic.
12. Interview Relevance
The frozen curriculum marks this chapter High, and the measurement is what makes the answer concrete.
"When would you return an error instead of throwing?" When the failure is expected — frequent, and handleable by the immediate caller. The strong answer gives both measurements: indistinguishable on the succeeding path, ~11,000× apart on the failing one, so the deciding variable is frequency.
"What is wrong with an error code?" It can be ignored. [[nodiscard]] is the fix, and it is not the default.
"std::optional or std::expected?" optional when there is one way to fail and no reason to report; expected when the caller needs the reason. And expected makes the value unreachable without handling the error.
"Why not a sentinel value?" It steals a value from the range and collides as soon as the data contains it — measured on a register legitimately holding 0xFFFFFFFF.
"What is the disadvantage of returning failures?" Composition. Ten steps become ten checks. C++23's monadic operations fix it; before that, convert at a layer boundary.
"What is std::error_code for?" Errors crossing subsystem or OS boundaries, where categories prevent code collisions and std::errc gives portable comparisons.
13. What You Should Be Able to Say Now
- That on the succeeding path throw, status and optional measured 0.34 / 0.37 / 0.38 ns/call — indistinguishable.
- That on the failing path the ratio was about 11,000× (9,027–16,357).
- That the deciding variable is therefore frequency, and the arithmetic: 3.4 ms per second at a thousand failures a second.
- The four shapes —
optional, status enum, named struct, result type — and when each is right. - That
std::optionalis 8 bytes with no allocation, and that a sentinel collides with real data. - That
[[nodiscard]]is the one-line fix for the mechanism's only real weakness. - That a result type makes the error case unreachable-around, where a struct does not.
- That
std::error_codeis for subsystem and OS boundaries. - That composition is the honest disadvantage, and that C++23's monadic operations or a layer boundary is the answer.
14. Exercises
Exercise 1 — reproduce both paths. Build Chapter 22.1's harness and confirm the two tables, with the volatile read in place. Then remove it and observe the folded result.
Exercise 2 — the arithmetic. For failure rates of 1 per run, 1 per second, 1,000 per second and 1,000,000 per second, compute the annual cost of throwing at 3.4 µs. State where you would switch mechanisms.
Exercise 3 — [[nodiscard]]. Write a status-returning function, ignore its result, and confirm it compiles. Add the attribute and read the warning. Then find out whether your build treats it as an error.
Exercise 4 — four shapes. Implement the same lookup four ways — optional, status plus out-param, named struct, and variant<T,Status>. Compare the call sites for readability and for whether the error can be skipped.
Exercise 5 — composition. Chain six fallible operations with explicit checks, then with std::expected and and_then if you have C++23. Count the lines and the branches.
Exercise 6 — the expensive error type. Measure expected<int, Status> against expected<int, std::string> on 100,000 failures, counting allocations.
Exercise 7 — the boundary. Design a subsystem that returns failures internally and throws at its public interface. Write the conversion layer and say what it does with an unknown status.
Exercise 8 — error_code. Use std::filesystem::file_size in both its throwing and error_code forms on a missing file, and compare what each gives you.
15. Summary
- An expected failure goes in the return value. Measured on the succeeding path: throw 0.34, status 0.37, optional 0.38 ns/call — indistinguishable.
- Measured on the failing path: ~11,000× (median over 7 runs, range 9,027–16,357). Throwing an expected failure is a performance bug.
- The deciding variable is frequency, not taste: 3.4 µs per throw is 3.4 ms/s at a thousand failures a second.
- Four shapes:
std::optional<T>for unexplained absence;enum class Statusfor several reasons; a named struct for status-plus-value, asstd::from_charsdoes; and a result type (std::expectedin C++23,variant<T,E>before) when the error must not be skippable. - Measured:
std::optional<uint32_t>is 8 bytes with 0 allocations, and a sentinel collides with real data — a register legitimately holding0xFFFFFFFFwas indistinguishable from absent. [[nodiscard]]is the one-line fix for the only real weakness of a returned status.- Avoid output parameters — four measured costs and no benefit.
std::error_codeis for subsystem and OS boundaries, where categories prevent collisions.- Composition is the honest disadvantage: ten steps become ten checks. C++23's monadic operations fix it; otherwise return inside a subsystem and throw at its boundary.
- All three of Chapter 22.1's categories are now covered — and the exceptional one took five chapters because it is the one with machinery, not because it is the common one.
Two of the three categories are mechanisms for handling a failure. The third is not a failure at all. Chapter 22.8 is assert: stating an assumption so that a violation stops the program at the point of the mistake rather than producing a wrong answer somewhere downstream. The chapter measures the one thing assert must never contain — and the measurement is that the expression inside it ran once in a debug build and zero times in a release build, which is simultaneously the feature and the reason it can never be the thing guarding real input.
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.
