The C++ Textbook

Part 6 · Correctness

Exceptions

Reporting failure across call boundaries, and what unwinding costs.

By the end of this chapter you can

  • Throw and catch by the right types
  • Explain what the strong exception guarantee requires
  • Write a function that is exception-safe by construction

A function that cannot do what it was asked has to tell someone. When the caller can handle it, returning a value is usually right — Chapter 6.2 covers that. When the caller cannot, and the code that can handle it is several frames up, that is what exceptions are for.

Throwing and catching

Failure that travels
#include <iostream>
#include <stdexcept>
#include <string>

int parse_percentage(const std::string& text) {
    const int value = std::stoi(text);              // throws on bad input
    if (value < 0 || value > 100) {
        throw std::out_of_range("percentage must be 0-100, got " + std::to_string(value));
    }
    return value;
}

int main() {
    for (const std::string& input : {"50", "150", "abc"}) {
        try {
            std::cout << input << " -> " << parse_percentage(input) << '\n';
        } catch (const std::out_of_range& e) {
            std::cout << input << " -> out of range: " << e.what() << '\n';
        } catch (const std::exception& e) {
            std::cout << input << " -> " << e.what() << '\n';
        }
    }
}

Three rules that cover most of what you need:

  • Throw by value, catch by const reference. Catching by value slices a derived exception to its base (Chapter 3.7), losing the type and the message.
  • Order handlers most-derived first. They are tried in order, so a catch (const std::exception&) placed first would swallow everything below it.
  • Derive from std::exception. The standard hierarchy gives every caller a what() and one type they can catch to mean “something went wrong”.

The standard types worth knowing: std::runtime_error for failures detectable only at run time, std::logic_error for programming mistakes, std::out_of_range and std::invalid_argument for their obvious cases, and std::bad_alloc when allocation fails.

What unwinding does

When an exception is thrown, control leaves every frame between the throw and the handler, destroying every automatic object on the way. That is stack unwinding, and it is what makes RAII and exceptions work together:

Destructors run on the way out
#include <iostream>
#include <stdexcept>
#include <string>

struct Noisy {
    std::string name;
    explicit Noisy(std::string n) : name(std::move(n)) { std::cout << "  + " << name << '\n'; }
    ~Noisy() { std::cout << "  - " << name << '\n'; }
};

void inner() {
    Noisy n{"inner"};
    throw std::runtime_error("failed deep down");
}

void middle() {
    Noisy n{"middle"};
    inner();
    std::cout << "  never reached\n";
}

int main() {
    Noisy n{"outer"};
    try {
        middle();
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}

inner and middle were destroyed in reverse order before the handler ran, and nothing leaked. This is the payoff of Chapter 3.2: put every resource in an object, and the failure path takes care of itself.

Exception safety guarantees

When a function throws, what state is the program left in? There are four answers, and it is worth being able to name which one you are providing.

Guarantee Meaning
Nothrow The function never throws. Destructors, swaps, and moves should be here.
Strong If it throws, nothing changed. The operation either happens completely or not at all.
Basic If it throws, everything is still valid and destructible, but values may have changed.
None Anything may have happened. Avoid.

The basic guarantee is the minimum; anything less means the program cannot safely continue. The strong guarantee is what you want for operations a caller might retry.

Basic versus strong
#include <iostream>
#include <stdexcept>
#include <vector>

struct Account {
    int balance = 100;
    std::vector<std::string> history;

    // Basic: if the history push throws, the balance has already changed.
    void withdraw_basic(int amount) {
        if (amount > balance) throw std::out_of_range("insufficient funds");
        balance -= amount;
        history.push_back("withdrew");        // may throw (allocation)
    }

    // Strong: nothing is modified until every step that can throw has succeeded.
    void withdraw_strong(int amount) {
        if (amount > balance) throw std::out_of_range("insufficient funds");

        auto updated = history;               // may throw — but changes nothing
        updated.push_back("withdrew");

        balance -= amount;                    // now commit, with no throwing left
        history = std::move(updated);
    }
};

int main() {
    Account a;
    try { a.withdraw_strong(500); }
    catch (const std::exception& e) {
        std::cout << "rejected: " << e.what() << '\n';
        std::cout << "balance unchanged: " << a.balance << '\n';
    }

    a.withdraw_strong(30);
    std::cout << "after a successful withdrawal: " << a.balance
              << ", history has " << a.history.size() << " entry\n";
}

The pattern for the strong guarantee is always the same shape: do all the work that can fail into temporaries, then commit with operations that cannot throw. Chapter 3.3’s copy-and-swap is this idea applied to assignment.

Destructors must not throw

If a destructor throws while an exception is already unwinding the stack, the program calls std::terminate — there is no way to handle two exceptions at once.

Destructors are implicitly noexcept
#include <iostream>
#include <utility>

struct Normal {
    ~Normal() {}                        // implicitly noexcept
};

struct Risky {
    ~Risky() noexcept(false) {}         // opting out, deliberately
};

int main() {
    std::cout << std::boolalpha;
    std::cout << "Normal destructor is noexcept: "
              << noexcept(std::declval<Normal&>().~Normal()) << '\n';
    std::cout << "Risky  destructor is noexcept: "
              << noexcept(std::declval<Risky&>().~Risky())
              << "   <- opted out with noexcept(false)\n";
}

Since C++11 destructors are implicitly noexcept, so a throw from one calls terminate immediately rather than propagating. If your destructor does something that can fail — closing a file, flushing a buffer — catch and handle it inside, and offer a separate close() for callers who need to know.

noexcept

noexcept is a promise that a function will not throw. Break it and the program terminates — there is no catching a noexcept violation.

What noexcept is for
#include <iostream>
#include <utility>
#include <vector>

struct Movable {
    std::vector<int> data;
    Movable() : data(100) {}
    Movable(const Movable& o) : data(o.data) { std::cout << "  copy\n"; }
    Movable(Movable&& o) noexcept : data(std::move(o.data)) { std::cout << "  move\n"; }
};

int main() {
    std::vector<Movable> v;
    v.reserve(1);
    v.emplace_back();

    std::cout << "growing the vector:\n";
    v.emplace_back();          // reallocation: moves, because the move is noexcept
}

Mark as noexcept: destructors (implicit), move constructors and move assignment, swap, and simple accessors. Do not mark something noexcept just because it does not throw today — if it might later, you have promised something you will have to break.

When not to use exceptions

Exceptions cost nothing when nothing is thrown — the modern implementation puts all the cost on the throwing path, via tables consulted only during unwinding. Throwing, however, is genuinely slow: hundreds of nanoseconds to microseconds, because unwinding walks the stack and consults those tables.

That gives a clear rule: exceptions are for the exceptional. If a failure is routine — a parse failing on user input, a lookup missing — throwing on every call turns a fast path into a slow one, and forces callers to write try around ordinary control flow. Return std::optional or std::expected instead; Chapter 6.2 covers that trade properly.

What throwing costs
#include <chrono>
#include <iostream>
#include <optional>
#include <stdexcept>

int by_exception(int x) {
    if (x < 0) throw std::runtime_error("negative");
    return x * 2;
}

std::optional<int> by_optional(int x) {
    if (x < 0) return std::nullopt;
    return x * 2;
}

int main() {
    constexpr int rounds = 100'000;
    using clock = std::chrono::steady_clock;
    using ms = std::chrono::milliseconds;

    auto start = clock::now();
    long long a = 0;
    for (int i = 0; i < rounds; ++i) {
        try { a += by_exception(-1); } catch (const std::exception&) { ++a; }
    }
    auto mid = clock::now();

    long long b = 0;
    for (int i = 0; i < rounds; ++i) {
        b += by_optional(-1).value_or(1);
    }
    auto finish = clock::now();

    std::cout << "throwing:  " << std::chrono::duration_cast<ms>(mid - start).count() << " ms\n";
    std::cout << "optional:  " << std::chrono::duration_cast<ms>(finish - mid).count() << " ms\n";
    std::cout << "(sums " << a << ", " << b << ")\n";
}

Note what is not being measured: the success path. Run the same comparison with positive inputs and the difference disappears, because a try block that does not throw costs nothing.

Check yourself

Practice