- 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;
}
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;
void g() noexcept(false);
void h() noexcept(condition);
Why mark noexcept
- Move ops — STL containers (
std::vector) use move (not copy) during realloc only when moves are noexcept. Forgetting silently degrades perf.
- Optimization — compiler can omit unwinding tables.
- Self-documentation — callers know no exception handling needed.
What should be noexcept
- Destructors (implicitly
noexcept).
- Move constructors + move assignment.
swap.
- Genuinely-cannot-fail functions (returning stored value, simple getters).
What should NOT be noexcept
- Anything that can throw, even indirectly (user code).
- Allocating functions (can throw
std::bad_alloc).
- 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)))) {
}
static_assert(noexcept(std::declval<std::string>().size()));
- Double
noexcept pattern (noexcept(noexcept(...))) — how generic code propagates noexcept-ness of inner expressions.
4. Exception Safety Guarantees
- 4 levels, increasing strictness:
- No guarantee — leaks, corrupts state. Avoid.
- Basic — invariants preserved, no leaks; observable state may change. Bare minimum.
- Strong — fully succeeds or no observable effect (transactional).
- Nothrow — cannot throw. Required for dtors, swap, (where possible) move.
- stdlib gives ≥ basic, often strong. Common idioms:
- Copy-and-swap — make copy, swap in. Copy throw → original untouched.
- RAII for cleanup — dtors fire on stack unwinding (see § 5).
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);
std::ifstream file(path);
auto buf = std::make_unique<char[]>(N);
if (file.fail()) throw std::runtime_error("open failed");
}
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.
- Happy path — modern compilers, near-zero exception overhead (throw cost paid only when thrown).
- Throwing is expensive — ~thousands of ns. Don't use for control flow.
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");
}
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";
}
void middle() { deep(); }
void shallow() { middle(); }
int main() {
shallow();
}
| 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:
- Compiler support still rolling out as of 2025 — check your toolchain.
- Capture cost non-trivial — walks stack, may symbolicate. Skip in hot paths.
- Backend linking — some platforms need it (e.g.
-lstdc++_libbacktrace on libstdc++).