Chapter 20: Error Handling and Robustness
Programs fail in many ways; robust code expects this and guides control flow cleanly. C++ offers exceptions for exceptional situations, status objects such as error_code, and value-oriented flow with optional and expected. This chapter explains these tools, shows how to combine them with RAII for safety, and gives design guidance so failures stay contained and understandable.
Exceptions
Exceptions transfer control from the point of failure to a matching handler. Construction unwinds stack frames; destructors run for all fully constructed objects, which is why RAII makes code resilient. Use exceptions for cases that are not part of the normal path, such as contract violations or resource exhaustion that you cannot handle locally.
Throwing and propagating
Throw an exception object with throw. The runtime finds the nearest matching catch; if none is found, the program calls std::terminate(). Prefer throwing by value and catching by reference to avoid slicing.
#include <stdexcept>
int parse_int(const std::string& s) {
if (s.empty()) throw std::invalid_argument{"empty string"};
// … perform parsing or throw on bad format
return 42;
}
int value = 0;
try {
value = parse_int("17");
} catch (const std::invalid_argument& e) {
// handle bad input
}
Catch specificity and ordering
Place more specific handlers before general ones. Catching ... handles anything; use this sparingly and rethrow with throw after logging if you cannot recover locally.
try {
// …
} catch (const std::out_of_range& e) {
// handle bounds issues
} catch (const std::exception& e) {
// fallback for standard exceptions
} catch (...) {
// last resort; log and rethrow
throw;
}
RAII and unwinding safety
During unwinding, local objects are destroyed in reverse order of construction. Ensure destructors never throw; if they must report errors, store state for later queries or use non-throwing callbacks.
struct Guard {
~Guard() noexcept { /* release resource; never throw */ }
};
void work() {
Guard g;
// any throw between here and the end still releases via ~Guard
}
noexcept and exception safety guarantees
noexcept expresses that a function will not throw. This enables optimizations and is required for some library moves. Exception safety describes what your function promises when an exception occurs: basic, strong, or no-throw. State these promises in documentation and test them.
The meaning of noexcept
A function marked noexcept must not emit exceptions; if it does, the program calls std::terminate(). Use conditional noexcept to mirror the guarantees of operations you call.
template<class T>
void swap_safe(T& a, T& b) noexcept(noexcept(std::swap(a, b))) {
std::swap(a, b);
}
The three standard guarantees
These guarantees guide design for strong robustness. Choose the weakest guarantee that still fits the use case; stronger guarantees often need extra copies or staging buffers.
| Guarantee | Promise | Technique | Example |
| Basic | Invariants hold; no leaks; state may change | RAII; commit-or-rollback for critical parts | Push back that may grow capacity |
| Strong | All-or-nothing; if throw then no observable change | Copy-and-swap; build-then-commit | Transaction-like updates |
| No-throw | Operation will not throw | noexcept moves; preallocation | Destructors and move swaps |
noexcept when possible. Containers choose move over copy during reallocation only if the move cannot throw; this improves performance.
Providing strong guarantees
Stage new state off to the side; commit with non-throwing operations. If commit can fail, you do not have the strong guarantee, so revise the plan or document the weaker promise.
struct Bag {
std::vector<int> xs;
void append_all(const std::vector<int>& more) {
auto tmp = xs; // strong guarantee staging
tmp.insert(tmp.end(), more.begin(), more.end());
xs.swap(tmp); // commit is no-throw
}
};
Error handling
std::error_code is a lightweight status object with a numeric value and a category pointer. std::error_condition groups codes into portable meanings such as errc::permission_denied. std::system_error is an exception that carries an error_code. Use codes for expected failures on hot paths; use exceptions for rare failures or when you need stack unwinding.
Working with error_code
APIs can report into an out-parameter of type std::error_code&. Callers test and branch without throwing.
#include <system_error>
#include <fstream>
bool read_file(const std::string& path, std::string& out, std::error_code& ec) {
std::ifstream f(path);
if (!f) { ec = std::make_error_code(std::errc::no_such_file_or_directory); return false; }
out.assign(std::istreambuf_iterator<char>(f), {});
ec.clear();
return true;
}
std::string text;
std::error_code ec;
if (!read_file("cfg.txt", text, ec)) {
// handle using ec.value() and ec.category()
}
Mapping to conditions
Categories map to conditions so portable code can branch on common meanings. This separates platform details from logic that decides how to react.
using std::errc;
std::error_code ec = std::make_error_code(errc::permission_denied);
if (ec == std::make_error_condition(errc::permission_denied)) {
// request elevated rights or skip
}
error_code so logs tell a clear story.
Throwing system_error with codes
When policy favors exceptions, wrap an error_code inside std::system_error. The handler can still inspect the code while enjoying stack unwinding and RAII cleanup.
void open_or_throw(const std::string& path) {
// imagine a failing open that produced ec
std::error_code ec = std::make_error_code(std::errc::permission_denied);
throw std::system_error{ec, "open failed: " + path};
}
Monadic flow
Value-centric error handling keeps control flow explicit. std::optional<T> carries either a value or no value; this fits lookups and parses where no further context is needed. std::expected<T, E> (C++23) carries either a value or an error type; this fits pipelines that need per-step error information without exceptions.
Using optional for absence
Return optional when failure has no details to report. Callers check has_value() or use boolean tests; then either use the value or choose a fallback.
#include <optional>
std::optional<int> to_int(const std::string& s) {
// … parse; return {} on failure
return 17;
}
auto oi = to_int("17");
int v = oi.value_or(0);
Composing steps with expected
expected supports expressive flows. Use and_then to continue on success and or_else to handle errors. The error type can be error_code or a bespoke struct with context.
#include <expected>
#include <system_error>
using R = std::expected<int, std::error_code>;
R read_config_value() {
// … return std::unexpected(make_error_code(std::errc::io_error)) on failure
return 7;
}
R double_value(int x) { return x * 2; }
R pipeline() {
return read_config_value()
.and_then(double_value);
}
auto r = pipeline();
if (r) {
// use *r
} else {
// inspect r.error()
}
expected moves or copies values as they pass through steps. Keep values small or store them via smart pointers to avoid heavy copies when needed.
When to choose value flow over exceptions
Prefer optional or expected when failure is common and part of normal control flow, such as cache misses or user cancellation. Prefer exceptions when failure is rare and you want to separate the hot path from error plumbing. Mixing is valid; confine strategies at boundaries and translate once.
Designing APIs for failure
APIs should make failure behavior obvious. Choose one primary error strategy per layer; document it clearly. Preserve invariants at all times; never leak. Make common success paths simple and branch-free; make failure paths descriptive and cheap to check.
Pick a clear strategy per boundary
Libraries that cross process or thread boundaries often prefer status returns for predictability. In-process libraries for business logic often prefer exceptions for clarity. Translate at the edge and keep the interior consistent.
Attach context to errors
Error messages without context waste time. Include operation, parameters, and environment hints. Use small structs for expected<T,E> errors so callers can act programmatically.
struct IoError {
std::error_code ec;
std::string path;
};
using RFile = std::expected<std::string, IoError>;
Keep destructors and swaps non-throwing
Destructors should be noexcept. Swaps and moves should be conditionally noexcept so containers can optimize. If a destructor must detect an error, record it for later inspection rather than throwing during unwinding.
struct Channel {
~Channel() noexcept { /* flush best effort; record status if needed */ }
Channel(Channel&&) noexcept = default;
Channel& operator=(Channel&&) noexcept = default;
};
Document guarantees and test them
State the exception safety level for each public operation. Write tests that force throws from collaborators to verify you kept the promise. Inject failures with test doubles or fault points.
// Pseudocode for a fault injection test
// arrange collaborator to throw during write …
// call operation and assert the object's observable state is unchanged
Choose low-friction defaults
Make the easy path safe by default. Use make_unique and make_shared. Prefer string_view for read-only parameters. Avoid surprise allocations in error paths. Keep handler code short, local, and consistent across the codebase.