C++ Error Handling
- Description: A note on C++ error handling — exceptions, the standard exception hierarchy,
noexcept, exception safety guarantees, RAII,std::expected, andstd::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 - 2. The Standard Exception Hierarchy
- 3.
noexcept - 4. Exception Safety Guarantees
- 5. RAII for Exception Safety
- 6.
std::expectedandstd::error_code - 7. Decision Guide: Exceptions vs
expectedvs Error Codes - 8.
std::source_locationandstd::stacktrace
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
- Move operations. STL containers like
std::vectoruse move (instead of copy) during reallocation only when moves arenoexcept. Failing to mark movesnoexceptsilently degrades performance. - Optimization. The compiler can omit unwinding tables for
noexceptfunctions. - Self-documentation. Callers know they don't need to handle exceptions from this function.
What should be noexcept
- Destructors (already implicitly
noexceptunless you explicitly say otherwise). - Move constructors and move assignment.
swap.- Functions that genuinely cannot fail (returning a stored value, simple getters).
What should NOT be noexcept
- Anything that can
throw, even indirectly (e.g., calls into user code that may throw). - Functions that allocate (which can throw
std::bad_alloc). - Generally: don't mark
noexceptif 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:
- No guarantee — leaks, corrupts state. Avoid.
- Basic guarantee — invariants preserved, no leaks; observable state may change. Bare minimum.
- Strong guarantee — operation either fully succeeds or has no observable effect (transactional).
- 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:
- Copy-and-swap. Make a copy, swap it in. If the copy throws, the original is untouched.
- RAII for cleanup. Destructors run on stack unwinding (see § 5).
noexceptswap. 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
- Happy path in modern compilers has near-zero exception overhead (the throw cost is paid only when actually thrown).
- Throwing is expensive — typically thousands of nanoseconds. Don't use exceptions for control flow.
expectedanderror_codehave 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:
- Compiler support is still rolling out as of 2025; check your toolchain.
- Capture cost is non-trivial (walks the stack, may symbolicate). Don't capture in hot paths.
- Some platforms require linking a stacktrace backend (e.g.
-lstdc++_libbacktraceon libstdc++).