Your Library
Chapter 4 · Error HandlingLesson 4: Error Handling
Contents

4.1

Lesson 4: Error Handling

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.

C++
#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:

C++
#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.

C++
#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;:

C++
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:

StrategyAppropriate whenExample
Error value or codeFailure is an ordinary outcome and the immediate caller can act on itA lookup may not find a key
ExceptionThe operation cannot complete and recovery belongs higher in the call chainParsing malformed input or an invalid argument
TerminationThe program cannot safely continueA 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.

C++
#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:

C++
#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:

C++
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:

C++
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.

MechanismWhen checkedFailure behaviorUse for
assertRuntime, unless disabled with NDEBUGDiagnostic and terminationInternal assumptions
static_assertCompile timeCompilation failsConstant and type properties
ExceptionRuntimePropagates to a handlerFailures callers may handle at a higher level
Error valueRuntimeReturned to callerExpected outcomes handled locally
noexceptException escaping at runtimeCalls std::terminateA 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/catch blocks.
  • Check compile-time properties with static_assert when possible.
  • Use runtime assertions for internal assumptions, not user-triggerable errors.
  • Declare a function noexcept only when its non-throwing behavior is part of its real contract.

Retrieval quiz

CHECK YOUR UNDERSTANDING
A function throws and has no matching handler. Where can a matching handler be found next?

CHECK YOUR UNDERSTANDING
Which situation best fits an assertion rather than normal error handling?

CHECK YOUR UNDERSTANDING
A `noexcept` function calls code that throws, and the exception escapes the function. What happens?

Notes

Error handling

  • Exceptions propagate up the call stack until a matching catch handles them.
  • Catch exception objects by const reference; use throw; 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

  • assert checks runtime assumptions and may be removed when NDEBUG is defined.
  • static_assert checks a constant expression at compile time.
  • noexcept means an exception must not escape; violation calls std::terminate.