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 const reference. Catching by value slices the derived part off the exception.
  • Order catch clauses 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 noexcept on moves it relies on.

Exception safety guarantees

GuaranteePromiseTypical technique
No-throwThe operation never throwsOnly swap and trivial operations; marked noexcept
StrongCommit or roll back: the object is unchanged on failureCopy-then-swap, or build a new value and assign
BasicNo leaks and everything is valid, but the value may have changedRAII members so unwinding cleans up
NoneNo promise at allA 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::optional for "maybe there is a value", never for "an error occurred" — it carries no reason.
  • std::expected for 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 empty optional throws std::bad_optional_access; value_or supplies 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.

Classes, constructors and RAII C++20 and C++23 in practice

Last refreshed 2026-09-18.