What This Chapter Built Up To

Every function you wrote before this chapter had exactly one way out: run to the end and hand a value back to whoever called it. Exception handling adds a second exit. A function that detects a problem it cannot solve locally can abandon the call, throw an object describing the problem, and let the runtime search upwards for a handler willing to take an object of that type. Detecting the error and deciding what to do about it become two separate pieces of code, which is precisely what return codes cannot give you: with return codes, every intermediate caller has to inspect, forward, and pollute its own signature with failure information it does not care about.

Everything in this chapter is a consequence of that single idea. Here is what each lesson contributed.

Lesson The rule it added
Exceptions Terminology The shared vocabulary: throw, try, catch, handler, unwinding, rethrow
Why Exception Handling is Necessary Exception handling decouples error handling from the typical flow of control, removing the bookkeeping that return codes force on every caller
Try-Catch Exception Handling throw raises, try watches, catch handles, and a caught exception counts as handled by default
How Exceptions Propagate Control jumps to the nearest enclosing try block, and the stack unwinds to that point, destroying locals in reverse order of construction
Handling Unexpected Exceptions With no matching handler anywhere, the program calls std::terminate; catch (...) is the last line of defence
Class-Based Exception Hierarchies Any type can be thrown, classes included; a handler for a base class reference also catches derived types
Propagating Exceptions Up the Call Stack A bare throw; continues the current exception intact; throw err; slices it
Exception Handling in Constructors Function try blocks are the only way to catch a throw from a member initializer list
Exception Safety Considerations Never throw from a destructor, and remember that exceptions are not free
Documenting Exception Guarantees noexcept marks a function as no-throw / no-fail
Conditional Move Optimization std::move_if_noexcept moves only when moving cannot break the strong exception guarantee

The Whole Mechanism in One Program

A seed vault records how many seeds from each sample tray sprouted. Bad paperwork is exactly the sort of thing a low-level calculation can detect but cannot fix, so it throws.

#include <exception>
#include <iostream>
#include <string>
#include <string_view>

class VaultError : public std::exception
{
private:
    std::string m_detail{};

public:
    explicit VaultError(std::string_view detail)
        : m_detail{ detail }
    {
    }

    const char* what() const noexcept override { return m_detail.c_str(); }
};

class ViabilityError : public VaultError
{
public:
    explicit ViabilityError(std::string_view detail)
        : VaultError{ detail }
    {
    }
};

double germinationRate(int sprouted, int tested)
{
    if (tested <= 0)
        throw VaultError{ "no seeds were sown on this tray" };

    if (sprouted > tested)
        throw ViabilityError{ "more sprouts counted than seeds sown" };

    return (100.0 * sprouted) / tested;
}

void checkLot(std::string_view lotCode, int sprouted, int tested)
{
    try
    {
        double rate{ germinationRate(sprouted, tested) };
        std::cout << lotCode << ": " << rate << "% viable\n";
    }
    catch (const ViabilityError& err)
    {
        std::cout << lotCode << ": recount needed (" << err.what() << ")\n";
    }
    catch (const std::exception& err)
    {
        std::cout << lotCode << ": rejected (" << err.what() << ")\n";
    }
}

int main()
{
    checkLot("PV-118", 87, 100);
    checkLot("PV-119", 12, 0);
    checkLot("PV-120", 30, 25);

    return 0;
}

Output:

PV-118: 87% viable
PV-119: rejected (no seeds were sown on this tray)
PV-120: recount needed (more sprouts counted than seeds sown)

Four chapter rules are visible at once here. germinationRate reports failure without returning a sentinel value, so its return type stays double. ViabilityError derives from VaultError, which derives from std::exception, so both custom types are reachable through a single std::exception handler and both carry a message through what(). The two handlers are ordered most-derived first, so the third lot lands in the specific handler rather than the general one. And the assignment to rate is inside the try block, because a throw skips the rest of the block: nothing after the throwing call in that block ever runs.

Key Concept
All exceptions thrown by the standard library derive from std::exception, declared in the <exception> header. Catching const std::exception& therefore catches every standard library exception, and what() tells you which one arrived.

What the Runtime Does With a Thrown Object

The search for a handler is not a scan of your source file. It walks the call stack outward from the throw, and it stops at the first handler whose type matches.

Situation at the throw point What happens
The enclosing try block has a matching catch The stack unwinds to that try block and control resumes at the top of the catch
The enclosing try block has no matching catch The search continues in the next enclosing try block, in this function or a caller
The current function has no try block at all The function is popped off the stack and the search continues in its caller
The stack runs out with no match std::terminate is called and the program dies with an unhandled exception error
A handler is found and returns normally The exception is considered handled and execution continues after the catch block

Unwinding is the part with visible consequences. As each function is popped, its local objects are destroyed in reverse order of construction, so every destructor along the path still runs.

#include <iostream>
#include <string>
#include <string_view>

class DrawerLock
{
private:
    std::string m_label{};

public:
    explicit DrawerLock(std::string_view label)
        : m_label{ label }
    {
        std::cout << "open " << m_label << '\n';
    }

    ~DrawerLock()
    {
        std::cout << "seal " << m_label << '\n';
    }
};

void pullSample()
{
    DrawerLock inner{ "drawer C4" };
    std::cout << "reading probe\n";

    throw 41;
}

void auditShelf()
{
    DrawerLock outer{ "shelf C" };
    pullSample();
    std::cout << "never reached\n";
}

int main()
{
    try
    {
        auditShelf();
    }
    catch (...)
    {
        std::cout << "audit abandoned, vault left sealed\n";
    }

    return 0;
}

Output:

open shelf C
open drawer C4
reading probe
seal drawer C4
seal shelf C
audit abandoned, vault left sealed

Two more chapter rules show up here. The thrown object is a plain int, which is legal: any type can be thrown, and classes are simply the useful choice because they can carry structured information. And catch (...), the catch-all handler, matches it without naming a type. The catch-all cannot inspect what it caught, so it earns its place as a safety net at the top of main rather than as a general-purpose handler.

Notice which line never printed. "never reached" sits after the throwing call in auditShelf, and unwinding skips it entirely, while both destructors still ran. That combination is what makes RAII and exceptions work together: cleanup written into a destructor happens whether the function exits normally or by throwing.

When Nothing Matches

Remove the handler and the failure mode changes completely.

#include <exception>
#include <iostream>
#include <string>
#include <string_view>

class VaultError : public std::exception
{
private:
    std::string m_detail{};

public:
    explicit VaultError(std::string_view detail)
        : m_detail{ detail }
    {
    }

    const char* what() const noexcept override { return m_detail.c_str(); }
};

void sealVault()
{
    throw VaultError{ "door sensor never reported closed" };
}

int main()
{
    sealVault();

    return 0;
}

This program produces nothing on standard output. It writes the following to standard error and is then killed by SIGABRT, so its exit status is 134 rather than 0:

terminate called after throwing an instance of 'VaultError'
  what():  door sensor never reported closed
Warning
An unhandled exception is not a recoverable state. std::terminate ends the process immediately, and the standard does not require the stack to be unwound first, so destructors you were counting on may never run. Give any thread that can throw an outermost handler.

Handler Matching Rules

Handler Catches
catch (const ViabilityError& err) ViabilityError and anything derived from it
catch (const VaultError& err) VaultError and every type derived from it, including ViabilityError
catch (const std::exception& err) Every standard library exception and every custom type derived from std::exception
catch (int value) Thrown int values only; there is no implicit conversion from long or double
catch (...) Anything at all, with no access to the object

Handlers are tried in written order, not by best fit, so a base class handler placed above a derived one will swallow everything and the derived handler becomes dead code.

Best Practice
Catch by const reference and order handlers from most derived to least derived, with any catch (...) last. Catching by value copies the exception object and slices away the derived part.

Passing an Exception Onward

A handler that only does part of the job (logging, releasing something, adding context) should not consume the exception. There are three distinct ways to leave a catch block, and they are not interchangeable.

Written in a catch block Effect
throw; Continues the exception already in flight, with its dynamic type and contents untouched
throw err; Copies the object through the static type of err, slicing off anything derived
throw OtherError{ ... }; Starts a brand new exception, which the enclosing try block sees rather than this catch block

The third row is worth restating: an exception thrown inside a catch block is not caught by the catch blocks attached to that same try. Those handlers are no longer active, so the new exception propagates outward as if the try block itself had thrown it.

The next program runs both rethrow styles side by side. logThenSlice is written incorrectly on purpose, to show what slicing costs you: its throw err; line is the bug.

#include <exception>
#include <iostream>
#include <string>
#include <string_view>

class VaultError : public std::exception
{
private:
    std::string m_detail{};

public:
    explicit VaultError(std::string_view detail)
        : m_detail{ detail }
    {
    }

    const char* what() const noexcept override { return m_detail.c_str(); }

    virtual std::string_view tier() const { return "vault"; }
};

class ViabilityError : public VaultError
{
public:
    explicit ViabilityError(std::string_view detail)
        : VaultError{ detail }
    {
    }

    std::string_view tier() const override { return "viability"; }
};

void logThenRethrow()
{
    try
    {
        throw ViabilityError{ "more sprouts counted than seeds sown" };
    }
    catch (const VaultError& err)
    {
        std::cout << "logged a " << err.tier() << " fault\n";
        throw;
    }
}

void logThenSlice()
{
    try
    {
        throw ViabilityError{ "more sprouts counted than seeds sown" };
    }
    catch (const VaultError& err)
    {
        std::cout << "logged a " << err.tier() << " fault\n";
        throw err;
    }
}

int main()
{
    try
    {
        logThenRethrow();
    }
    catch (const VaultError& err)
    {
        std::cout << "supervisor received a " << err.tier() << " fault\n";
    }

    try
    {
        logThenSlice();
    }
    catch (const VaultError& err)
    {
        std::cout << "supervisor received a " << err.tier() << " fault\n";
    }

    return 0;
}

Output:

logged a viability fault
supervisor received a viability fault
logged a viability fault
supervisor received a vault fault

Both functions logged the same thing, because inside the catch block err still refers to the real ViabilityError. Only the outer handler tells them apart. throw; delivered a ViabilityError; throw err; copy-constructed a fresh VaultError from the base part of the reference and threw that instead. Nothing warns you, and the type information is gone for good.

Warning
Object slicing here is silent. The program still compiles, still runs, and still reports an error, but the outer layer of your code can no longer tell which kind of failure occurred. Use a bare throw; whenever you are passing the same exception onward.

The Three Places Exceptions Behave Differently

Most code can throw freely. Three contexts cannot.

Context Rule Reason
Destructors Never throw A destructor running during unwinding of another exception leaves two exceptions in flight, and the program is terminated
Member initializer lists An ordinary try block in the constructor body cannot reach them The initializer list runs before the body is entered
noexcept functions A throw that escapes calls std::terminate The specification is a promise the compiler relies on, not a filter

The second row is what function try blocks exist for. The try keyword goes before the member initializer list, so the handler covers both the initialization and the body.

#include <exception>
#include <iostream>
#include <string>
#include <string_view>

class VaultError : public std::exception
{
private:
    std::string m_detail{};

public:
    explicit VaultError(std::string_view detail)
        : m_detail{ detail }
    {
    }

    const char* what() const noexcept override { return m_detail.c_str(); }
};

class StorageUnit
{
private:
    int m_capacity{};

public:
    explicit StorageUnit(int capacity)
        : m_capacity{ capacity }
    {
        if (m_capacity < 1)
            throw VaultError{ "a storage unit needs at least one drawer" };
    }

    int capacity() const { return m_capacity; }
};

class ChillRoom : public StorageUnit
{
public:
    explicit ChillRoom(int capacity)
    try : StorageUnit{ capacity }
    {
        std::cout << "chill room ready with " << StorageUnit::capacity() << " drawers\n";
    }
    catch (const VaultError& err)
    {
        std::cout << "constructor handler saw: " << err.what() << '\n';
    }
};

int main()
{
    ChillRoom stocked{ 12 };

    try
    {
        ChillRoom empty{ 0 };
    }
    catch (const VaultError& err)
    {
        std::cout << "caller saw: " << err.what() << '\n';
    }

    return 0;
}

Output:

chill room ready with 12 drawers
constructor handler saw: a storage unit needs at least one drawer
caller saw: a storage unit needs at least one drawer

The handler ran, printed its line, and the exception still reached main. That is not an oversight in the code: a constructor's function try block cannot swallow the exception, because the object was never fully built and there is nothing valid to hand back. If the handler does not throw something of its own, the original exception is rethrown automatically when the handler ends. That narrow behaviour is why function try blocks are used almost exclusively on derived class constructors, to log or translate a failure coming out of base class or member initialization.

Promising Not To Throw

noexcept is a declaration about behaviour, and the compiler and standard library both act on it.

Form Meaning
void f() noexcept f is no-throw / no-fail; if an exception escapes it anyway, the program terminates
void f() f is potentially throwing, which is the default for ordinary functions
noexcept(expr) An operator, not a specifier, that yields true or false at compile time without evaluating expr

The payoff appears whenever code must relocate an object and cannot afford a half-finished result. A move constructor that throws part way through has already gutted the source object, so there is nothing left to roll back to. std::move_if_noexcept refuses to take that risk: it returns an rvalue only when the move constructor is noexcept, and otherwise returns an lvalue so a copy is made instead.

#include <iostream>
#include <string>
#include <string_view>
#include <utility>

class Tray
{
private:
    std::string m_lotCode{};

public:
    explicit Tray(std::string_view lotCode)
        : m_lotCode{ lotCode }
    {
    }

    Tray(const Tray& src)
        : m_lotCode{ src.m_lotCode }
    {
        std::cout << "Tray copied\n";
    }

    Tray(Tray&& src) noexcept
        : m_lotCode{ std::move(src.m_lotCode) }
    {
        std::cout << "Tray moved\n";
    }
};

class Ledger
{
private:
    std::string m_lotCode{};

public:
    explicit Ledger(std::string_view lotCode)
        : m_lotCode{ lotCode }
    {
    }

    Ledger(const Ledger& src)
        : m_lotCode{ src.m_lotCode }
    {
        std::cout << "Ledger copied\n";
    }

    Ledger(Ledger&& src)
        : m_lotCode{ std::move(src.m_lotCode) }
    {
        std::cout << "Ledger moved\n";
    }
};

int main()
{
    Tray tray{ "PV-118" };
    Ledger ledger{ "PV-118" };

    std::cout << std::boolalpha;
    std::cout << "Tray move is noexcept:   " << noexcept(Tray{ std::move(tray) }) << '\n';
    std::cout << "Ledger move is noexcept: " << noexcept(Ledger{ std::move(ledger) }) << '\n';

    Tray relocatedTray{ std::move_if_noexcept(tray) };
    Ledger relocatedLedger{ std::move_if_noexcept(ledger) };

    return 0;
}

Output:

Tray move is noexcept:   true
Ledger move is noexcept: false
Tray moved
Ledger copied

The two classes are identical except for one keyword, and that keyword decides everything. Note also that the noexcept(...) lines printed no constructor messages: the operator inspects the expression without running it. The copy of Ledger is slower, and that is the point of the trade: paying for a copy buys the strong exception guarantee, where an operation either completes or leaves everything exactly as it was.

Best Practice
Mark move constructors and move assignment operators noexcept when they genuinely cannot throw. Standard library containers check that specification when they reallocate, and an unmarked move means your objects get copied instead.

What Exceptions Cost

Exceptions are not free, and the chapter's caution about them is a design rule rather than a micro-optimization.

Cost Where it lands
Slightly slower code overall Present even on paths that never throw, because the compiler emits unwinding information and cleanup paths
Very high cost per throw The stack search and unwinding are far more expensive than returning a value
Larger binaries Unwinding tables have to be stored
Harder control flow to follow Any call in a try block is a potential jump to a handler, which reviewers must keep in mind
Best Practice
Reserve exceptions for genuinely exceptional circumstances. A user typing letters into a number field is expected, not exceptional, so validate and reprompt instead. Use exceptions where a function cannot possibly produce a sensible result and its caller cannot reasonably be expected to check a flag on every call.

Key Terminology

  • Exception: An object thrown to signal a condition that the current function cannot handle
  • Exception handling: Separating the code that detects an error from the code that responds to it
  • throw statement: The statement that raises an exception and abandons the rest of the enclosing block
  • try block: A block whose contents, including everything they call, are watched for exceptions
  • catch block: A handler attached to a try block that accepts exceptions of one particular type
  • Catch handler: Another name for a catch block
  • Stack unwinding: Popping call frames on the way to a handler, destroying each frame's locals in reverse order of construction
  • std::terminate: The function invoked when an exception finds no handler, ending the process
  • Unhandled exception: An exception that reached the bottom of the stack without matching any catch block
  • Catch-all handler: catch (...), which matches any thrown type but cannot examine it
  • std::exception: The base class of the standard library's exception types, declared in <exception>
  • what(): The member function that returns an exception's descriptive text
  • Rethrow: Continuing an in-flight exception from inside a handler using a bare throw;
  • Object slicing: Losing the derived portion of an object by copying it through a base type, as throw err; does
  • Function try block: A try block placed before a function's body so it also covers the member initializer list
  • noexcept specifier: A declaration that a function is no-throw / no-fail
  • No-throw / no-fail: A guarantee that a function will not let an exception escape
  • std::move_if_noexcept: A cast that yields an rvalue only when the type's move constructor is noexcept, and an lvalue otherwise
  • Strong exception guarantee: A promise that a failed operation leaves the program state unchanged

Looking Forward

You now have the full mechanism: a way to raise a failure, a way to route it to the right handler, guaranteed cleanup along the way, and a vocabulary (noexcept, the strong guarantee) for describing what a function promises when things go wrong. What remains is judgement, and that comes from use. As your programs grow, you will start choosing deliberately between an exception, a std::optional return, an error code, and an assertion, based on whether the caller can act on the failure and how often it is expected to happen. Later chapters lean on the RAII habit you saw during unwinding, and file and stream I/O in particular will give you plenty of operations that can fail in ways worth reporting.