Error Handling with Exceptions Summary
Review and test your understanding of all exception handling concepts covered in this chapter.
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.
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
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.
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.
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.
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 |
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.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Error Handling with Exceptions Summary - Quiz
Test your understanding of the lesson.
Practice Exercises
Exception-Safe File Processor
Build an exception-safe file processing system that demonstrates try-catch blocks, custom exception classes, exception hierarchies, RAII, and noexcept specifications.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!