K2A-B1-7
C++ Error Handling
Created 2018-09-22Updated 2026-02-28programming-language-stack / c_or_cpp / language
- 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
- Built-in error propagation.
throwinterrupts execution, walks up stack destroying objects (running dtors), until matchingcatch.
#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
constreference (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_errororstd::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 anoexceptfn ->std::terminateimmediately, 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
- Move ops — STL containers (
std::vector) use move (not copy) during realloc only when moves arenoexcept. 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,trueiffexpris 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
noexceptpattern (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).
noexceptswap — 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
try { ... } catch(...) { cleanup; throw; }= sign cleanup belongs in a destructor.- See K2A-B1-6 Memory Management § 4.
6. std::expected and std::error_code
- Not all errors need exceptions. 2 alternatives.
std::expected<T, E> (C++23)
- "Value or error". See C++ Vocabulary Types § 7.
- For local failure that callers handle inline.
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 takesstd::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 */ }-> returnexpected, don't throw. If failure means program can't continue -> throw.
Performance
- 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"); // captures worker() as the function name
}
std::source_location::current()evaluated at the call site (not insidelog) -> default arg captures wherelogwas called from. ReplacesLOG(...)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 ofstacktrace_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:
- 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++_libbacktraceon libstdc++).