Lesson 16 • Advanced Track
Exception Safety
By the end of this lesson you'll be able to throw and catch exceptions correctly, let RAII clean up automatically when something fails, and choose the right safety guarantee — basic, strong, or no-throw — for every function you write.
What You'll Learn
- Throw, try, and catch exceptions the idiomatic way
- Use std::exception and std::runtime_error correctly
- Understand stack unwinding and why RAII makes code leak-free
- Name the three guarantees: basic, strong, and no-throw
- Provide the strong guarantee with the copy-and-swap idiom
- Mark functions noexcept and know when it actually matters
💡 Real-World Analogy
Think of a function call as a person climbing a ladder, one rung per call. A throw is shouting "abort!" and sliding back down. As you pass each rung on the way down, you must put back anything you borrowed there — that automatic clean-up is stack unwinding, and the destructors are the "put it back" step. The three guarantees describe how tidy you leave the room when you bail out: basic = nothing dropped on the floor (no leaks); strong = the room looks exactly as it did before you entered (all-or-nothing); no-throw = you promise you will never need to abort at all.
1. Throw, Try, and Catch
An exception is an error object that travels up the call stack until something handles it. You throw it to signal "I can't continue", wrap risky code in a try block, and handle the problem in a catch. Standard error types live in <stdexcept>: std::runtime_error for problems found at run time and std::invalid_argument for bad inputs — both derive from std::exception, whose .what() method returns your message. Read this worked example, then run it.
Worked example: throw / try / catch
Read every comment, run it, and check the output matches.
#include <iostream>
#include <stdexcept> // std::runtime_error, std::invalid_argument
using namespace std;
// Withdraw money — throw if the request is impossible.
double withdraw(double balance, double amount) {
if (amount <= 0)
// throw STOPS the function and hands an error object up the stack.
throw invalid_argument("Amount must be positive");
if (amount > balance)
throw runtime_error("Insufficient funds");
return balance - amount; // normal (happy) pat
...Notice how the line after the failing call was skipped — a throw jumps straight to the matching catch. Now your turn: fill in the three blanks to write your own throw, try, and catch.
🎯 Your turn: divide safely
Fill in the ___ blanks, then check your output against the expected lines.
#include <iostream>
#include <stdexcept>
using namespace std;
int safeDivide(int a, int b) {
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// 1) If b is 0, throw a runtime_error with a message.
if (b == 0)
___; // 👉 throw runtime_error("Cannot divide by zero")
return a / b;
}
int main() {
// 2) Wrap the risky call in a try block.
___ { // 👉 the keyword that starts a guarded block
cout
...2. Stack Unwinding & Why RAII Wins
When a throw fires, C++ unwinds the stack: it walks back up through every function it was inside and destroys each local object along the way, running its destructor. This is the secret to exception safety — if your resources (memory, files, locks, connections) are owned by local objects, they are guaranteed to be released, even on the error path. That pattern is RAII (Resource Acquisition Is Initialisation): tie a resource's lifetime to an object's lifetime, and you never have to remember to clean up manually.
Worked example: RAII cleans up during unwinding
Watch the destructor run before the catch — no leak, no manual cleanup.
#include <iostream>
#include <memory> // std::unique_ptr
#include <stdexcept>
using namespace std;
struct Connection {
Connection() { cout << " Connection OPENED" << endl; }
~Connection() { cout << " Connection CLOSED" << endl; } // runs no matter what
void send() { throw runtime_error("network dropped"); }
};
void doWork() {
// RAII: the resource lives in a local object. When doWork() exits —
// normally OR by a thrown exception — the destructor runs automaticall
...3. The Three Exception-Safety Guarantees
Every function offers one of three promises about what happens when it throws. The basic guarantee says: no leaks, the object stays usable, but its state may have partly changed. The strong guarantee says: all-or-nothing — if it throws, nothing changed. The no-throw guarantee says: this can never fail, so you mark it noexcept. This example shows all three on one class.
Worked example: basic, strong, and no-throw
One class demonstrating all three guarantees side by side.
#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;
class Account {
vector<string> history; // a record of every action
public:
// BASIC guarantee: no leaks, object stays valid — but state MAY change.
void logBasic(const string& note) {
if (note.empty())
throw invalid_argument("empty note");
history.push_back(note); // if this throws, no leak; vector still valid
}
// STRONG guarantee: all-or-nothing. On throw, nothi
...4. The Copy-and-Swap Idiom (Strong Guarantee)
The classic recipe for the strong guarantee is copy-and-swap: do all the risky work on a copy, and only when it has fully succeeded, swap the copy into place. Because std::swap on standard containers is noexcept (it just exchanges internal pointers), the commit step can't fail — so either everything works or your original is untouched. Your turn: complete the two blanks to make addAll strong.
🎯 Your turn: make it all-or-nothing
Copy first, mutate the copy, then swap to commit. Fill in the blanks.
#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;
class Playlist {
vector<string> songs;
public:
// Add several songs as ALL-OR-NOTHING (the strong guarantee).
void addAll(const vector<string>& newSongs) {
// 🎯 YOUR TURN — make this strong with copy-and-swap.
// 1) Make a COPY of songs to do the risky work on.
vector<string> temp = ___; // 👉 copy the current songs vector
for (const auto& s : newSongs) {
...5. noexcept and Why It Matters
noexcept is a promise to the compiler that a function never throws. Break that promise and the program calls std::terminate immediately — so only mark things that truly can't fail (moves, swaps, simple getters). The biggest payoff is performance: std::vector will only move its elements when it grows if their move constructor is noexcept; otherwise it must copy them to preserve its own strong guarantee.
Common mistake: forgetting noexcept on a move constructor silently makes vector fall back to copying — a quiet performance killer for types with expensive copies.
Worked example: noexcept moves
See how noexcept lets vector move (cheap) instead of copy on regrowth.
#include <iostream>
#include <vector>
#include <utility>
using namespace std;
class Buffer {
int* data;
size_t sz;
public:
Buffer(size_t n) : data(new int[n]{}), sz(n) {}
~Buffer() { delete[] data; } // RAII: frees memory
// noexcept move ctor — vector PREFERS this when it grows. Without
// noexcept the vector must COPY instead (to keep the strong guarantee).
Buffer(Buffer&& other) noexcept
: data(other.data), sz(other.sz) {
other.dat
...Common Errors (and the fix)
- Leaking on throw (raw
newwith no RAII):int* p = new int[n]; risky();leaks ifrisky()throws, because thedelete[]never runs. Fix: own the resource withstd::unique_ptr/std::vectorso unwinding frees it for you. - Catching by value (slicing):
catch (std::exception e)copies and slices the object — you lose the derived type and often the real message. Fix: alwayscatch (const std::exception& e). - Throwing from a destructor: destructors are
noexceptby default, so a thrown exception callsstd::terminateand crashes. Fix: wrap failable cleanup in atry/catchinside the destructor and handle it there. - Swallowing exceptions: an empty
catch (...) { }hides real bugs and leaves the program in an unknown state. Fix: handle it, log it, or rethrow withthrow;— never silently ignore it. terminate called after throwing an instance of 'std::runtime_error': an exception escapedmainwith no matchingcatch. Fix: add atry/catch (const std::exception& e)around the call that can throw.
📋 Quick Reference — the three guarantees
| Guarantee | Promise on throw | How you provide it |
|---|---|---|
| Basic | No leaks; object still valid, but state may have changed | RAII (own resources in objects) |
| Strong | All-or-nothing; state is exactly as before the call | Copy-and-swap |
| No-throw | Never throws at all | noexcept (moves, swap, getters) |
Aim for the strongest guarantee you can afford. The strong guarantee costs a copy, so use it for critical operations and the basic guarantee on hot paths.
Frequently Asked Questions
Q: Why catch by const reference (const std::exception&) instead of by value?
Catching by value copies the exception, and if you catch the base type std::exception by value you slice off the derived part — losing the real message and type. Catching by const reference keeps the full object and avoids the copy, so it is the standard idiom.
Q: What is stack unwinding?
When an exception is thrown, C++ walks back up the call stack looking for a matching catch, destroying every fully-constructed local object on the way. Those destructors are what free your resources — which is exactly why RAII makes code exception-safe automatically.
Q: What is the difference between the basic and strong guarantee?
The basic guarantee promises no leaks and a valid object, but the state may have partially changed. The strong guarantee promises all-or-nothing: if the operation throws, the object is exactly as it was before the call. Copy-and-swap is the usual way to provide the strong guarantee.
Q: Should I ever throw from a destructor?
No. Destructors are implicitly noexcept since C++11, so an exception escaping one calls std::terminate and crashes the program — and during stack unwinding a second exception is undefined behaviour. Do any failable cleanup inside the destructor in a try/catch and swallow it there.
Q: Why does noexcept matter for performance?
std::vector only moves your objects when it reallocates if the move constructor is noexcept; otherwise it copies them to preserve the strong guarantee. Marking moves noexcept can turn an expensive copy of every element into a cheap pointer steal.
Mini-Challenge: a Strong-Guarantee Cart
No blanks this time — just a brief and an outline. Make addItems all-or-nothing with copy-and-swap and mark total as noexcept. Run it and confirm the rejected batch leaves the cart unchanged.
🎯 Mini-Challenge: build a strong-guarantee cart
Provide the strong guarantee with copy-and-swap, then add a noexcept total().
#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;
class Cart {
vector<double> prices;
public:
// 🎯 MINI-CHALLENGE: a strong-guarantee "checkout"
// 1. addItems(items): give the STRONG guarantee using copy-and-swap.
// - copy prices into a temp vector
// - if any price is negative, throw invalid_argument("bad price")
// - otherwise push it onto the temp
// - swap(prices, temp) only at the very end
// 2. total(): m
...Pro Tips
- 💡 Let RAII do the cleanup. If every resource is owned by an object, you almost never need a
try/catchjust to free things. - 💡 Catch by
const&, throw by value. Throw a temporary, catch a reference to the base type — it avoids slicing and copies. - 💡 Mark moves and swaps
noexcept. It unlocks the fast path in standard containers and signals intent. - 💡 Pick the cheapest guarantee that's correct. Strong for critical updates, basic everywhere the copy would hurt.
🎉 Lesson Complete
- ✅
throwraises an error object;tryguards risky code;catchhandles it - ✅ Standard errors (
std::runtime_error,std::invalid_argument) derive fromstd::exception— catch byconst& - ✅ A throw unwinds the stack, and RAII destructors free resources automatically — no leaks
- ✅ Three guarantees: basic (no leaks), strong (all-or-nothing), no-throw (
noexcept) - ✅ Copy-and-swap gives the strong guarantee;
noexceptmoves keep containers fast - ✅ Next lesson: Design Patterns — reusable solutions to common C++ design problems
Sign up for free to track which lessons you've completed and get learning reminders.