Exceptions and error handling
Throw by value and catch by reference, know the safety guarantees, and choose between exceptions, optional and expected.
Throw, catch and unwind
#include <stdexcept>
#include <string>
#include <iostream>
class ConfigError : public std::runtime_error {
public:
explicit ConfigError(std::string key, std::string detail)
: std::runtime_error("config: " + key + ": " + detail), key_(std::move(key)) {}
const std::string& key() const noexcept { return key_; }
private:
std::string key_;
};
std::string load(const std::string& key) {
if (key.empty()) throw ConfigError{"key", "must not be empty"};
throw ConfigError{key, "not found"};
}
int main() {
try {
load("");
} catch (const ConfigError& e) { // catch by reference, most derived first
std::cerr << e.what() << '\n';
} catch (const std::exception& e) {
std::cerr << "generic: " << e.what() << '\n';
} catch (...) {
std::cerr << "unknown\n";
throw; // rethrow preserves the original type
}
}- Throw by value, catch by
constreference. Catching by value slices the derived part off the exception. - Order
catchclauses from most derived to least derived; the first match wins and later ones are dead code. - Stack unwinding runs destructors, which is exactly why RAII works with exceptions. A destructor that throws during unwinding calls
std::terminate. - Exceptions are not free when thrown but cost nothing on the non-throwing path in mainstream ABIs. Do not avoid them out of misplaced performance fear.
- A move or swap that throws makes strong guarantees impossible; that is why the standard library requires
noexcepton moves it relies on.
Exception safety guarantees
| Guarantee | Promise | Typical technique |
|---|---|---|
| No-throw | The operation never throws | Only swap and trivial operations; marked noexcept |
| Strong | Commit or roll back: the object is unchanged on failure | Copy-then-swap, or build a new value and assign |
| Basic | No leaks and everything is valid, but the value may have changed | RAII members so unwinding cleans up |
| None | No promise at all | A bug, not a design choice |
#include <vector>
#include <utility>
class Registry {
public:
// strong guarantee: build the new state first, then swap
void add_many(const std::vector<int>& values) {
std::vector<int> next = data_; // may throw; data_ untouched
next.insert(next.end(), values.begin(), values.end());
data_.swap(next); // noexcept, so the commit cannot fail
}
void set_name(std::string n) noexcept(false) { name_.swap(n); }
private:
std::vector<int> data_;
std::string name_;
};
// noexcept is part of the interface: it changes overload resolution
void quick() noexcept;
static_assert(noexcept(quick()));Prefer the copy-and-swap idiom over trying to undo partial work in place. A rollback path is code that only runs when something already went wrong, which makes it the least-tested code in the project.
optional, expected and error codes
#include <optional>
#include <expected> // C++23
#include <string>
#include <charconv>
// absent value is a normal outcome, not an error
std::optional<int> find_index(const std::vector<int>& v, int needle) {
for (std::size_t i = 0; i < v.size(); ++i)
if (v[i] == needle) return static_cast<int>(i);
return std::nullopt;
}
enum class ParseError { empty, not_a_number, out_of_range };
std::expected<int, ParseError> parse_int(std::string_view sv) {
if (sv.empty()) return std::unexpected(ParseError::empty);
int value{};
auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), value);
if (ec == std::errc::result_out_of_range) return std::unexpected(ParseError::out_of_range);
if (ec != std::errc{} || ptr != sv.data() + sv.size())
return std::unexpected(ParseError::not_a_number);
return value; // implicit conversion to the value type
}
int main() {
auto r = parse_int("42");
if (r) std::cout << *r << '\n';
else std::cout << "failed\n";
// monadic chaining, no early-return boilerplate
auto doubled = parse_int("21").transform([](int v) { return v * 2; });
std::cout << doubled.value_or(0) << '\n';
}std::optionalfor "maybe there is a value", never for "an error occurred" — it carries no reason.std::expectedfor a value or a typed error. It is the natural choice in APIs that must not throw, including ones compiled with exceptions disabled.value()on an emptyoptionalthrowsstd::bad_optional_access;value_orsupplies a default instead.- Error codes at a boundary (a C API, a hot loop, an embedded target) with exceptions inside the implementation is a common and reasonable split.
⚠️
Do not mix the two models in one layer. An interface that sometimes throws and sometimes returns an error forces every caller to handle both and guarantees somebody will forget one of them.
FAQ
Are exceptions expensive?
The throw path is expensive (unwinding, allocating the exception object), and the happy path is close to free with table-based unwinding. The real cost of exceptions is code size and the discipline they demand, not steady-state speed.
Should destructors be noexcept?
They are
noexcept by default and should stay that way. If cleanup can fail, catch the error inside the destructor and report it through a logging or status channel rather than by throwing.Related
Classes, constructors and RAII C++20 and C++23 in practice
Last refreshed 2026-09-18.