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

  • Built-in error propagation. throw interrupts execution, walks up stack destroying objects (running dtors), until matching catch.
#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 const reference (const std::exception&).
  • By value → slices derived type (drops dynamic part) and wastes a copy.

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

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 → derive from 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 — function will not throw. Throwing from a noexcept fn → std::terminate 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 noexcept

  1. Move ops — STL containers (std::vector) use move (not copy) during realloc only when moves are noexcept. Forgetting silently degrades perf.
  2. Optimization — compiler can omit unwinding tables.
  3. Self-documentation — callers know no exception handling needed.

What should be noexcept

  1. Destructors (implicitly noexcept).
  2. Move constructors + move assignment.
  3. swap.
  4. Genuinely-cannot-fail functions (returning stored value, simple getters).

What should NOT be noexcept

  1. Anything that can throw, even indirectly (user code).
  2. Allocating functions (can throw std::bad_alloc).
  3. When in doubt — don't. Adding later is easy; removing is ABI-breaking.

3.2 The noexcept Operator

  • noexcept(expr) — compile-time bool, true iff expr is known 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
  • Double noexcept pattern (noexcept(noexcept(...))) — how generic code propagates noexcept-ness of inner expressions.

4. Exception Safety Guarantees

  • 4 levels, increasing strictness:
  1. No guarantee — leaks, corrupts state. Avoid.
  2. Basic — invariants preserved, no leaks; observable state may change. Bare minimum.
  3. Strong — fully succeeds or no observable effect (transactional).
  4. Nothrow — cannot throw. Required for dtors, swap, (where possible) move.
  • stdlib gives ≥ basic, often strong. Common idioms:
  1. Copy-and-swap — make copy, swap in. Copy throw → original untouched.
  2. RAII for cleanup — dtors fire on stack unwinding (see § 5).
  3. noexcept swap — building block of strong-guarantee operations.

5. RAII for Exception Safety

  • Exceptions work safely only when every resource is RAII-wrapped.
  • Stack unwinding fires dtors of in-scope objects → locks, files, memory cleaned up even on 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

6. std::expected and std::error_code

  • Not all errors need exceptions. 2 alternatives.

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

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

std::error_code

  • Lightweight, copyable error value. Used by stdlib 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/network APIs offer 2 overloads: one throws std::system_error, one takes std::error_code&. 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 caller will write try { f(); } catch(...) { /* log and continue */ } → return expected, don't throw. If failure means program can't continue → throw.

Performance

  1. Happy path — modern compilers, near-zero exception overhead (throw cost paid only when thrown).
  2. Throwing is expensive — ~thousands of ns. Don't use for control flow.
  3. expected / error_code — predictable cost (compare + branch), no call site disruption.

8. std::source_location and std::stacktrace

  • When something fails, you want where. Modern C++ standardizes context capture.

8.1 std::source_location (C++20)

  • std::source_location (<source_location>) — captures file, line, column, function name.
  • Unlike __FILE__ / __LINE__ macros — integrates cleanly; as a default arg, captures caller's location.
#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
}
  • std::source_location::current() evaluated at the call site (not inside log) → default arg captures where log was called from. Replaces LOG(...) macro pattern with type-safe equivalent.
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 in logging APIs, contract assertions, exception constructors.

8.2 std::stacktrace (C++23)

  • std::stacktrace (<stacktrace>) — captures current call stack as sequence of stacktrace_entry.
#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
    //  ...
}
Method Returns
.description() Function name and details
.source_file() Source file path
.source_line() Line number
  • Pattern: capture stacktrace in custom exception, include 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 still rolling out as of 2025 — check your toolchain.
  2. Capture cost non-trivial — walks stack, may symbolicate. Skip in hot paths.
  3. Backend linking — some platforms need it (e.g. -lstdc++_libbacktrace on libstdc++).