11 Exceptions
So far, when something goes wrong in your programs you have printed an error message and returned early. That works when the error happens in main(), but what about a function buried three or four calls deep? You would have to thread error codes back through every function in the chain, and every caller would have to check the return value — tedious and easy to get wrong. C++ provides exceptions, a mechanism that lets a function signal an error and lets code much higher up in the call stack handle it. C++23 also introduces std::expected, which gives you a way to return either a value or an error without the overhead of exceptions. In this chapter you will learn how to throw and catch exceptions, how the stack unwinds when an exception is thrown, when to use noexcept, and how std::expected offers a lightweight alternative.
11.1 Throwing Exceptions
When a function encounters a situation it cannot handle, it throws an exception using the throw keyword:
#include <stdexcept>
#include <string>
int parse_track(const std::string &s) {
int n = std::stoi(s);
if (n < 1) {
throw std::out_of_range("track number must be positive");
}
return n;
}When throw executes, the function stops immediately. It does not return a value — control leaves the function and travels up the call stack looking for something that can handle the error.
The <stdexcept> header provides several standard exception types, all derived from std::exception:
| Type | When to use |
|---|---|
std::runtime_error | general errors detected at runtime |
std::out_of_range | a value is outside an acceptable range |
std::invalid_argument | an argument does not make sense |
std::logic_error | a bug in the program’s logic |
std::overflow_error | arithmetic overflow |
All of them take a std::string message in their constructor and provide a what() member function that returns it.
The hierarchy splits in two: std::logic_error and std::runtime_error both derive from std::exception, and the more specific types in turn derive from one of those two. std::out_of_range and std::invalid_argument derive from std::logic_error; std::overflow_error derives from std::runtime_error. This matters for catching: a catch (const std::logic_error &) block will catch a std::out_of_range but not a std::overflow_error.
11.2 Catching Exceptions
To handle an exception, wrap the code that might throw in a try block and follow it with one or more catch blocks:
#include <iostream>
#include <stdexcept>
#include <string>
int parse_track(const std::string &s) {
int n = std::stoi(s);
if (n < 1) {
throw std::out_of_range("track number must be positive");
}
return n;
}
int main() {
try {
int track = parse_track("0");
std::cout << "Track: " << track << std::endl;
} catch (const std::out_of_range &e) {
std::cout << "Error: " << e.what() << std::endl;
}
return 0;
}Output:
Error: track number must be positiveWhen parse_track throws std::out_of_range, the rest of the try block is skipped and the matching catch block runs. After the catch block finishes, execution continues normally after it — the program does not crash.
11.2.1 Multiple Catch Blocks
You can have multiple catch blocks to handle different exception types. The handlers are tried in order and the first one that matches wins:
#include <iostream>
#include <stdexcept>
#include <string>
int parse_volume(const std::string &s) {
int v = std::stoi(s);
if (v < 0 || v > 11) {
throw std::out_of_range("volume must be 0-11");
}
return v;
}
int main() {
try {
int v = parse_volume("abc");
std::cout << "Volume: " << v << std::endl;
} catch (const std::out_of_range &e) {
std::cout << "Out of range: " << e.what() << std::endl;
} catch (const std::invalid_argument &e) {
std::cout << "Bad input: " << e.what() << std::endl;
}
return 0;
}Output:
Bad input: stoiHere std::stoi("abc") throws std::invalid_argument, so the second catch block handles it. If you passed "99" instead, parse_volume would throw std::out_of_range and the first catch would handle it.
Trap: When you have several catch blocks for related exception types, more-derived types must come before their base classes. The first catch whose type matches wins, and every standard exception derives (eventually) from std::exception — so a catch (const std::exception&) placed first will swallow std::out_of_range, std::invalid_argument, and every other library exception, and the more-specific blocks below it will never run. Order from most specific to least specific.
11.2.2 Catching Everything
You can catch any exception with catch (...):
try {
risky_function();
} catch (const std::exception &e) {
std::cout << "Known error: " << e.what() << std::endl;
} catch (...) {
std::cout << "Unknown error" << std::endl;
}The catch (...) block is a last resort — it catches any exception, including types that do not derive from std::exception. Always catch specific types first and use catch (...) only as a safety net.
Tip: Always catch exceptions by const reference (const std::exception &e). Catching by value makes a copy and can slice off information from derived exception types.
11.3 Stack Unwinding
When an exception is thrown, C++ unwinds the stack — it walks back up the call chain, destroying local variables in each function along the way, until it finds a matching catch block. This means destructors run automatically, which is critical for cleaning up resources.
#include <iostream>
#include <stdexcept>
#include <string>
struct Song {
std::string title;
Song(const std::string &t) : title(t) {
std::cout << " created: " << title << std::endl;
}
~Song() {
std::cout << " destroyed: " << title << std::endl;
}
};
void deep_function() {
Song s("The Freshmen");
throw std::runtime_error("something went wrong");
}
void middle_function() {
Song s("Save Tonight");
deep_function();
}
int main() {
try {
middle_function();
} catch (const std::runtime_error &e) {
std::cout << "Caught: " << e.what() << std::endl;
}
return 0;
}Output:
created: Save Tonight
created: The Freshmen
destroyed: The Freshmen
destroyed: Save Tonight
Caught: something went wrongEven though no function returned normally, both Song destructors ran. deep_function’s Song (“The Freshmen”) is destroyed first (most recent), then middle_function’s (“Save Tonight”). This automatic cleanup during stack unwinding is why destructors are so important — and why you should manage resources through objects rather than raw new/delete.
Trap: If a destructor throws an exception during stack unwinding (while another exception is already in flight), the program calls std::terminate() and crashes. Never throw from a destructor.
11.4 noexcept
The noexcept keyword promises the compiler that a function will not throw any exceptions:
int add(int a, int b) noexcept {
return a + b;
}If a noexcept function does throw (for example, by calling a function that throws), the program calls std::terminate() immediately — no catch blocks run, the stack is not required to be unwound, it is just a crash.
noexcept is not just documentation — the compiler uses it to generate more efficient code. Standard library containers like std::vector check whether your move operations are noexcept before deciding whether to move or copy during reallocation. If your move constructor is noexcept, the vector moves elements (fast). If it is not, the vector falls back to copying (slow) because a failed move would leave the container in a broken state.
Tip: Mark functions noexcept when you are certain they will not throw. This is especially important for move constructors, move assignment operators, and destructors.
Trap: The compiler does not verify that a noexcept function actually avoids throwing. If you mark a function noexcept but it calls something that throws, you get std::terminate() at runtime with no warning at compile time.
11.5 std::expected
Exceptions are powerful, but they are not always the right tool. They are best for truly exceptional situations — file not found, out of memory, network failure. For errors that are a normal part of a function’s contract (like parsing invalid user input), the overhead of exception handling can be unnecessary.
C++23 introduces std::expected<T, E> in the <expected> header. It holds either a value of type T (the success case) or an error of type E (the failure case) — but never both.
std::expected<T, E>You return the value normally for success, and wrap the error in std::unexpected for failure:
#include <expected>
#include <iostream>
#include <string>
std::expected<int, std::string> parse_track(const std::string &s) {
try {
int n = std::stoi(s);
if (n < 1) {
return std::unexpected("track must be positive");
}
return n;
} catch (...) {
return std::unexpected("not a number");
}
}
int main() {
auto result = parse_track("5");
if (result) {
std::cout << "Track: " << *result << std::endl;
}
auto error = parse_track("abc");
if (!error) {
std::cout << "Error: " << error.error() << std::endl;
}
return 0;
}Output:
Track: 5
Error: not a numberUse *result or result.value() to get the value, and result.error() to get the error. The boolean check (if (result)) tells you whether it holds a value or an error.
Trap: *result and result.value() are not interchangeable. *result assumes the expected holds a value; calling it on an error is undefined behavior. result.value() checks first and throws std::bad_expected_access if there is no value. Use *result only after you have confirmed the boolean check; reach for value() when you want the throw-on-empty safety net.
11.5.1 Exceptions vs std::expected
When should you use which?
| Exceptions | std::expected | |
|---|---|---|
| Best for | rare, truly exceptional failures | expected, routine failures |
| Error path | unwinds the stack | returns normally |
| Caller must check? | no — propagates automatically | yes — must inspect the return value |
| Performance | zero cost when no exception is thrown; expensive when thrown | small constant cost (size of the return type) |
A good rule of thumb: if the caller is likely to handle the error immediately, use std::expected. If the error should propagate up several layers, use exceptions.
Tip: std::expected makes error handling explicit and visible in the return type. This is especially useful for functions where failure is a normal outcome, like parsing user input or looking up a key in a map.
11.6 Try It: Exception Starter
Here is a program that exercises throwing, catching, stack unwinding, and std::expected. Type it in, compile it, and experiment:
#include <expected>
#include <iostream>
#include <stdexcept>
#include <string>
struct Gate {
std::string name;
Gate(const std::string &n) : name(n) {
std::cout << " open " << name << std::endl;
}
~Gate() {
std::cout << " close " << name << std::endl;
}
};
int parse_percent(const std::string &s) {
int n = std::stoi(s); // throws std::invalid_argument on junk
if (n < 0 || n > 100) {
throw std::out_of_range("percent must be 0-100");
}
return n;
}
int checked_parse(const std::string &s) {
Gate g("parser"); // destroyed even if an exception flies past
return parse_percent(s);
}
std::expected<int, std::string> parse_quiet(const std::string &s) {
try {
return parse_percent(s);
} catch (const std::exception &e) {
return std::unexpected(e.what());
}
}
int main() {
const std::string inputs[] = {"42", "999", "abc"};
for (const std::string &input : inputs) {
try {
int p = checked_parse(input);
std::cout << input << " -> " << p << std::endl;
} catch (const std::out_of_range &e) {
std::cout << input << " -> out of range: "
<< e.what() << std::endl;
} catch (const std::invalid_argument &e) {
std::cout << input << " -> bad input: "
<< e.what() << std::endl;
}
}
auto r = parse_quiet("abc");
if (!r) {
std::cout << "expected says: " << r.error() << std::endl;
}
return 0;
}Output:
open parser
close parser
42 -> 42
open parser
close parser
999 -> out of range: percent must be 0-100
open parser
close parser
abc -> bad input: stoi
expected says: stoiNotice that close parser prints for every input, even the two that throw — that is stack unwinding running the Gate destructor.
Some things to try:
- Move the
catch (const std::invalid_argument &)block above thecatch (const std::out_of_range &)block. Does the output change? Why not? (Hint: check the exception hierarchy — neither type derives from the other.) - Now replace the first
catchblock withcatch (const std::exception &e)and watch it swallow both error kinds. - Add a second
Gateinsideparse_percentand predict the order of theopen/closelines before running. - Mark
checked_parseasnoexceptand run it again. What happens when the exception tries to escape? - Change
parse_quiet("abc")toparse_quiet("55")and add anif (r)branch that prints*r.
11.7 Key Points
- Use
throwto signal an error andtry/catchto handle it. - The standard exception types in
<stdexcept>cover most common error categories. - Always catch exceptions by
constreference. - Stack unwinding destroys local variables automatically when an exception propagates — this is why resource management through objects (RAII) matters.
- Never throw from a destructor.
noexceptpromises a function will not throw; violating the promise callsstd::terminate().- Mark move constructors, move assignment operators, and destructors
noexcept. std::expected<T, E>(C++23) returns either a value or an error without using exceptions.- Use exceptions for rare failures that should propagate; use
std::expectedfor routine errors the caller handles immediately.
11.8 Exercises
What does the following program print?
#include <iostream> #include <stdexcept> void step3() { throw std::runtime_error("oops"); } void step2() { step3(); } void step1() { step2(); } int main() { try { step1(); std::cout << "A" << std::endl; } catch (const std::runtime_error &e) { std::cout << "B: " << e.what() << std::endl; } std::cout << "C" << std::endl; return 0; }What is wrong with this code?
try { int n = std::stoi(input); } catch (const std::out_of_range &e) { std::cout << "out of range" << std::endl; } catch (const std::exception &e) { std::cout << "error" << std::endl; } catch (const std::invalid_argument &e) { std::cout << "bad input" << std::endl; }Why should you always catch exceptions by
constreference rather than by value?What does the following program print?
#include <iostream> #include <stdexcept> #include <string> struct Amp { std::string name; Amp(const std::string &n) : name(n) { std::cout << name << " on" << std::endl; } ~Amp() { std::cout << name << " off" << std::endl; } }; void soundcheck() { Amp a("Marshall"); Amp b("Fender"); throw std::runtime_error("feedback!"); } int main() { try { soundcheck(); } catch (...) { std::cout << "handled" << std::endl; } return 0; }Will this code compile? If so, what happens when
play()is called?void load(const std::string &file) { throw std::runtime_error("file not found: " + file); } void play() noexcept { load("track01.wav"); }What is the output of this program?
#include <expected> #include <iostream> #include <string> std::expected<int, std::string> divide(int a, int b) { if (b == 0) { return std::unexpected("division by zero"); } return a / b; } int main() { auto r1 = divide(10, 3); auto r2 = divide(10, 0); if (r1) std::cout << *r1 << std::endl; if (!r2) std::cout << r2.error() << std::endl; return 0; }When would you use
std::expectedinstead of throwing an exception? Give an example scenario for each.How many destructors run before the
catchblock executes?struct Song { std::string title; Song(const std::string &t) : title(t) {} ~Song() { std::cout << "destroyed: " << title << std::endl; } }; void inner() { Song a("Torn"); Song b("Vogue"); throw std::runtime_error("oops"); } void outer() { Song c("Iris"); inner(); } int main() { try { outer(); } catch (...) { std::cout << "caught" << std::endl; } return 0; }Write a function with the following signature:
std::expected<double, std::string> safe_sqrt(double x);If
xis negative, return an error message. Otherwise, return the square root. Test it inmain()with both a positive and a negative value.Where is the bug?
#include <iostream> #include <stdexcept> int main() { try { throw std::out_of_range("nope"); } catch (const std::exception &e) { std::cout << "exception: " << e.what() << "\n"; } catch (const std::out_of_range &e) { std::cout << "out_of_range: " << e.what() << "\n"; } return 0; }Catch handlers are tried in source order, top to bottom. Why is the second
catchblock effectively dead code? How would you reorder the handlers so thatout_of_rangeis caught specifically andstd::exceptiononly acts as a safety net?Write a program that defines a function
int parse_age(const std::string &s);that converts
sto an integer usingstd::stoiand then returns it. Throwstd::invalid_argument("not a number")ifstd::stoiitself throwsstd::invalid_argument, and throwstd::out_of_range("age must be 0..150")if the parsed number is outside the range[0, 150]. Inmain, callparse_ageon three inputs —"42","abc", and"-1"— insidetry/catchblocks that catch each of the two exception types separately and print a different message for each one.