C++ Error Handling


  • Description: A note on C++ error handling — exceptions, the standard exception hierarchy, noexcept, exception safety guarantees, RAII, std::expected, and std::error_code
  • My Notion Note ID: K2A-B1-7
  • Created: 2018-09-22
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Exceptions: try / catch / throw

Exceptions are C++'s built-in error propagation mechanism. A throw interrupts execution, walks back up the stack destroying objects (running destructors), until a matching catch is found.

#include <stdexcept>
#include <iostream>

double divide(double a, double b) {
    if (b == 0) {
        throw std::runtime_error("division by zero");
    }
    return a / b;
}

int main() {
    try {
        double r = divide(10, 0);
    } catch (const std::runtime_error& e) {
        std::cerr << "error: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cerr << "fallback: " << e.what() << "\n";
    } catch (...) {
        std::cerr << "unknown exception\n";
    }
}

Catch by reference

Always catch exceptions by const reference (const std::exception&). Catching by value slices derived exception types (drops their dynamic part) and is wasteful.

throw; vs throw e;

catch (const std::exception& e) {
    log(e.what());
    throw;       // rethrows the original exception (preserves dynamic type)
    // throw e;  // BUG: copies as std::exception, slicing the derived type
}

2. The Standard Exception Hierarchy

The standard library throws exceptions derived from std::exception. The hierarchy:

std::exception
├── std::logic_error          — programmer errors (preconditions violated)
│   ├── std::invalid_argument
│   ├── std::domain_error
│   ├── std::length_error
│   └── std::out_of_range     — e.g. std::vector::at out of range
├── std::runtime_error        — errors detectable only at runtime
│   ├── std::range_error
│   ├── std::overflow_error
│   ├── std::underflow_error
│   └── std::system_error     — wraps an std::error_code
├── std::bad_alloc            — operator new failed
├── std::bad_cast             — dynamic_cast on a reference
├── std::bad_typeid
├── std::bad_optional_access  — operator* / value() on empty optional
├── std::bad_variant_access   — wrong type in variant
└── std::bad_any_cast

Custom exceptions should derive from a relevant base, typically std::runtime_error or std::logic_error:

class FileNotFound : public std::runtime_error {
public:
    explicit FileNotFound(const std::string& path)
        : std::runtime_error("file not found: " + path), path_(path) {}
    const std::string& path() const { return path_; }
private:
    std::string path_;
};

3. noexcept

3.1 The noexcept Specifier

noexcept declares that a function will not throw. If a noexcept function does throw, std::terminate is called immediately — no unwinding.

void f() noexcept;                  // never throws
void g() noexcept(false);           // may throw (default)
void h() noexcept(condition);       // throws iff condition is false at compile time

Why mark functions noexcept

  1. Move operations. STL containers like std::vector use move (instead of copy) during reallocation only when moves are noexcept. Failing to mark moves noexcept silently degrades performance.
  2. Optimization. The compiler can omit unwinding tables for noexcept functions.
  3. Self-documentation. Callers know they don't need to handle exceptions from this function.

What should be noexcept

  1. Destructors (already implicitly noexcept unless you explicitly say otherwise).
  2. Move constructors and move assignment.
  3. swap.
  4. Functions that genuinely cannot fail (returning a stored value, simple getters).

What should NOT be noexcept

  1. Anything that can throw, even indirectly (e.g., calls into user code that may throw).
  2. Functions that allocate (which can throw std::bad_alloc).
  3. Generally: don't mark noexcept if you're not sure — adding it later is easy, removing it is a breaking ABI change.

3.2 The noexcept Operator

noexcept(expr) is a compile-time boolean that's true when expr is known to be non-throwing.

template <typename T>
void process(T&& x) noexcept(noexcept(T(std::move(x)))) {
    // noexcept iff the move constructor is noexcept
}

static_assert(noexcept(std::declval<std::string>().size()));  // true

The "double noexcept" pattern (noexcept(noexcept(...))) is how generic code propagates the noexcept-ness of an inner expression.


4. Exception Safety Guarantees

There are four levels of exception safety, in increasing strictness:

  1. No guarantee — leaks, corrupts state. Avoid.
  2. Basic guarantee — invariants preserved, no leaks; observable state may change. Bare minimum.
  3. Strong guarantee — operation either fully succeeds or has no observable effect (transactional).
  4. Nothrow guarantee — operation cannot throw. The strongest; required for destructors, swap, and (where possible) move.

The standard library generally provides at least the basic guarantee, often the strong guarantee. Common idioms for getting these guarantees:

  1. Copy-and-swap. Make a copy, swap it in. If the copy throws, the original is untouched.
  2. RAII for cleanup. Destructors run on stack unwinding (see § 5).
  3. noexcept swap. A swap that doesn't throw is the building block of strong-guarantee operations.

5. RAII for Exception Safety

Exceptions only work safely when every owned resource is wrapped in an RAII class. When the stack unwinds, destructors of in-scope objects fire automatically — that's how locks, files, and memory get cleaned up even on the exceptional path.

void process(const std::string& path) {
    std::lock_guard<std::mutex> lock(m);     // released even if we throw
    std::ifstream file(path);                 // closed even if we throw
    auto buf = std::make_unique<char[]>(N);   // freed even if we throw

    if (file.fail()) throw std::runtime_error("open failed");
    // ... work ...
}    // all three resources cleaned up in reverse order, exception or not

If you ever write try { ... } catch(...) { cleanup; throw; }, that's a sign the cleanup should be in a destructor instead. See also K2A-B1-6 Memory Management § 4.


6. std::expected and std::error_code

Not all error reporting needs exceptions. C++ provides two alternatives.

std::expected<T, E> (C++23)

Returns "value or error" — see C++ Vocabulary Types § 7. Suitable when the failure is local and callers should handle it inline.

std::expected<Config, ParseError> load(std::string_view path);

std::error_code

A lightweight, copyable error value used by the standard library for OS / system errors (filesystem, networking).

#include <system_error>
#include <filesystem>

std::error_code ec;
auto sz = std::filesystem::file_size("missing.txt", ec);
if (ec) {
    std::cerr << "error: " << ec.message() << "\n";
}

Most filesystem and network APIs offer two overloads: one that throws std::system_error on error, and one that takes an std::error_code& and reports failure through it. Pick based on caller preference.


7. Decision Guide: Exceptions vs expected vs Error Codes

Failure mode Recommendation
Programmer error (precondition violated) assert / std::logic_error exception
Truly exceptional (OOM, file system corruption) Exception
Expected, frequent failure (parse error, lookup miss, network timeout) std::expected or std::optional
Interop with C / OS APIs std::error_code
Don't care about cause, just success/failure bool return

Rule of thumb: if the caller is overwhelmingly going to write try { f(); } catch(...) { /* log and continue */ }, you should be returning expected, not throwing. Conversely, if a failure here means the program can't continue meaningfully, throw.

Performance considerations

  1. Happy path in modern compilers has near-zero exception overhead (the throw cost is paid only when actually thrown).
  2. Throwing is expensive — typically thousands of nanoseconds. Don't use exceptions for control flow.
  3. expected and error_code have predictable cost (a comparison + branch) and don't disrupt the call site.

8. std::source_location and std::stacktrace

When something goes wrong, you want to know where. Modern C++ provides standard tools for capturing that context.

8.1 std::source_location (C++20)

std::source_location (<source_location>) captures file, line, column, and function name. Unlike the __FILE__ / __LINE__ macros, it integrates cleanly with C++ — most importantly, it captures the caller's location when used as a default argument.

#include <source_location>
#include <iostream>

void log(std::string_view msg,
         std::source_location loc = std::source_location::current()) {
    std::cout << loc.file_name() << ":" << loc.line()
              << " in " << loc.function_name()
              << " — " << msg << "\n";
}

void worker() {
    log("starting");      // captures worker() as the function name
}

Because std::source_location::current() is evaluated at the call site (not inside log), the default argument captures where log was called from. This replaces the older LOG(...) macro pattern with a clean, type-safe equivalent.

Methods on source_location:

Method Returns
.file_name() const char* — source file path
.line() uint_least32_t — line number
.column() uint_least32_t — column number
.function_name() const char* — enclosing function

Use source_location in logging APIs, contract assertions, and exception constructors.

8.2 std::stacktrace (C++23)

std::stacktrace (<stacktrace>) captures the current call stack as a sequence of stacktrace_entry objects.

#include <stacktrace>
#include <iostream>

void deep() {
    auto trace = std::stacktrace::current();
    std::cout << trace << "\n";        // streams a formatted backtrace
}

void middle() { deep(); }
void shallow() { middle(); }

int main() {
    shallow();
    // output (typical):
    //  0# deep() at main.cpp:6
    //  1# middle() at main.cpp:11
    //  2# shallow() at main.cpp:12
    //  3# main at main.cpp:15
    //  ...
}

Each stacktrace_entry exposes:

Method Returns
.description() Function name and details
.source_file() Source file path
.source_line() Line number

A common pattern: capture a stacktrace in your custom exception class and include it in what():

class ServerError : public std::runtime_error {
    std::stacktrace trace_;
public:
    explicit ServerError(const std::string& msg)
        : std::runtime_error(msg), trace_(std::stacktrace::current()) {}
    const std::stacktrace& trace() const noexcept { return trace_; }
};

Caveats:

  1. Compiler support is still rolling out as of 2025; check your toolchain.
  2. Capture cost is non-trivial (walks the stack, may symbolicate). Don't capture in hot paths.
  3. Some platforms require linking a stacktrace backend (e.g. -lstdc++_libbacktrace on libstdc++).