Chapter 3 showed how to organize a program into separately compiled parts. This chapter considers what those parts should do when they cannot complete their work: a file is missing, an index is invalid, or a requested object cannot be created. C++ provides exceptions, error values, assertions, and contracts such as noexcept for different situations.
4.1 The type system as a safety net
Types that reflect the problem domain can make invalid operations harder to express. Using a std::chrono::duration for a time interval, for example, is clearer and safer than passing an unlabelled integer whose units everyone must remember. Algorithms constrained to appropriate types also give the compiler more opportunities to reject mistakes before the program runs.
Error handling does not replace good types or design. It is the policy for failures that remain possible even when code is well-structured.
4.2 Exceptions: throw, try, and catch
Suppose a container receives an index outside its valid range. The container can detect that problem at the point of access, but it cannot know what recovery action its caller wants. It can throw an exception and let a caller that understands the context handle it.
#include <stdexcept>
#include <vector>
double& checked_at(std::vector<double>& values, std::size_t index)
{
if (index >= values.size())
throw std::out_of_range{"index is outside the vector"};
return values[index];
}A try block marks code whose exceptions a handler may handle. A matching catch receives the exception; if no handler matches in the current function, it propagates to a caller:
#include <iostream>
#include <stdexcept>
void process(std::vector<double>& values, std::size_t index)
{
try {
std::cout << checked_at(values, index) << '\n';
} catch (const std::out_of_range& error) {
std::cerr << error.what() << '\n';
}
}Catch standard exception objects by const reference to avoid an unnecessary copy and preserve their dynamic type. Catch specific exception types when possible; a catch-all handler (catch (...)) is appropriate only when the code can genuinely handle any exception or must perform cleanup before rethrowing.
While an exception propagates, automatic objects whose lifetimes are exited are destroyed. This stack unwinding is why resource-owning objects should manage cleanup through their destructors—a technique called Resource Acquisition Is Initialization (RAII). RAII is developed further in the next chapter.
Establish invariants during construction
An invariant is a condition that must hold for every valid object throughout its lifetime. A constructor establishes the invariant; member functions preserve it. If construction cannot produce a valid object, it should fail rather than leave a malformed object available to callers.
#include <stdexcept>
#include <vector>
class Buffer {
public:
explicit Buffer(int size)
{
if (size < 0)
throw std::length_error{"Buffer size cannot be negative"};
data.resize(static_cast<std::size_t>(size));
}
std::size_t size() const { return data.size(); }
private:
std::vector<double> data;
};Here the invariant is that the buffer has a non-negative size; the std::vector member manages its own storage. If a constructor throws, the object itself is not created, and already-constructed members are cleaned up automatically.
Standard exceptions such as std::length_error, std::out_of_range, and std::bad_alloc communicate common failure categories. A new expression that cannot allocate storage normally throws std::bad_alloc.
Catching and rethrowing
Different failure types can have different handlers. A function that cannot resolve an error locally can add context and rethrow the current exception with throw;:
try {
load_configuration();
} catch (const std::runtime_error& error) {
std::cerr << "Configuration loading failed: " << error.what() << '\n';
throw;
}Use throw; from within a handler to rethrow the original exception. Writing throw error; instead throws a new exception object and can lose derived-type information when error is a base-class reference.
4.3 Invariants guide error handling
A clear invariant specifies what “valid object” means. It helps define constructor behavior, identifies which checks a member function must perform, and supports resource management. For a vector-like type, an invariant might state that its storage owns exactly size elements and that every valid index is less than size.
If an invariant is violated, the program may no longer meet the type’s contract; later operations can have undefined behavior. Validate external inputs at boundaries and preserve invariants inside the type rather than asking every caller to duplicate the checks.
4.4 Choosing an error-reporting strategy
The common choices are exceptions, error values, and termination. Choose based on how expected the failure is and where it can be handled:
| Strategy | Appropriate when | Example |
|---|---|---|
| Error value or code | Failure is an ordinary outcome and the immediate caller can act on it | A lookup may not find a key |
| Exception | The operation cannot complete and recovery belongs higher in the call chain | Parsing malformed input or an invalid argument |
| Termination | The program cannot safely continue | A violated process-wide fatal condition |
An error code is explicit, but callers must check it. Exceptions keep the ordinary success path uncluttered and can carry failures through several layers that cannot handle them. Throwing is generally intended for failures, not routine loop or branch control flow.
#include <stdexcept>
Matrix add(const Matrix& left, const Matrix& right)
{
if (left.rows() != right.rows() || left.cols() != right.cols())
throw std::invalid_argument{"matrix dimensions must match"};
Matrix result(left.rows(), left.cols());
// Add corresponding elements.
return result;
}Some APIs use types such as std::optional or std::expected (C++23) to make non-exceptional absence or failure part of the return type. These can be useful when failure is expected and callers should handle it locally.
A constructor cannot return an error code. If it cannot establish the object’s invariant, throwing is the usual way to report failure. The partially constructed object is not made available, and fully constructed members are destroyed.
4.5 Assertions and noexcept
Assertions express assumptions that should hold because of the program’s logic. They are not substitutes for validating ordinary user input.
Runtime assertions
assert from <cassert> checks a condition at runtime in builds where NDEBUG is not defined:
#include <cassert>
void use(const int* values)
{
assert(values != nullptr); // an internal assumption
// Use values.
}If the condition is false, the assertion reports the failure and terminates the program. When NDEBUG is defined, assert is removed, so do not put expressions with required side effects inside an assertion. Use normal runtime checks for conditions users can reasonably trigger.
Compile-time assertions
static_assert checks a constant expression during compilation:
static_assert(sizeof(int) >= 2, "int must be at least 2 bytes");It is useful for compile-time assumptions, including properties of types in generic code. A failed assertion produces a compiler diagnostic rather than a runtime failure.
noexcept: a non-throwing contract
A function declared noexcept promises not to let an exception escape:
void release_resources() noexcept
{
// Perform cleanup that is designed not to throw.
}If an exception escapes a noexcept function, the program calls std::terminate; the exception cannot be caught by a caller as an ordinary propagation from that function. Do not add noexcept casually: every operation it calls must be compatible with the promise. It is commonly appropriate for destructors and some move or swap operations when they truly cannot throw.
| Mechanism | When checked | Failure behavior | Use for |
|---|---|---|---|
assert | Runtime, unless disabled with NDEBUG | Diagnostic and termination | Internal assumptions |
static_assert | Compile time | Compilation fails | Constant and type properties |
| Exception | Runtime | Propagates to a handler | Failures callers may handle at a higher level |
| Error value | Runtime | Returned to caller | Expected outcomes handled locally |
noexcept | Exception escaping at runtime | Calls std::terminate | A genuine non-throwing guarantee |
Advice
- Use exceptions to report failures that prevent a function from completing; do not use them for ordinary control flow.
- Use error values when failure is expected and the immediate caller can handle it.
- Establish class invariants in constructors and preserve them in member functions.
- Let exceptions propagate to the level that can make a meaningful recovery decision.
- Prefer RAII to manually repeated cleanup in
try/catchblocks. - Check compile-time properties with
static_assertwhen possible. - Use runtime assertions for internal assumptions, not user-triggerable errors.
- Declare a function
noexceptonly when its non-throwing behavior is part of its real contract.
Retrieval quiz
Notes
Error handling
- Exceptions propagate up the call stack until a matching
catchhandles them. - Catch exception objects by
constreference; usethrow;to rethrow the current exception. - RAII objects clean up their resources when stack unwinding ends their lifetimes.
Invariants and strategy
- An invariant describes what must be true of every valid object.
- Constructors establish invariants; member functions preserve them.
- Use error values for expected failures handled locally; exceptions for failures handled higher up; terminate only when safe recovery is impossible.
Assertions and contracts
assertchecks runtime assumptions and may be removed whenNDEBUGis defined.static_assertchecks a constant expression at compile time.noexceptmeans an exception must not escape; violation callsstd::terminate.