Intuition What this page is
The parent note gave you the ladder: basic → strong → no-throw . Here we drill: every shape of situation a piece of code can be in when an exception fires, and how to classify or fix it. Think of it as a checklist — after this page, no thrown exception should surprise you.
Just like an angle formula has quadrants where the naive version fails, exception safety has "cells" — recurring situations that each demand a different answer. The figure below is our board : each cell is a square, coloured by which tool fixes it, exactly the way you'd chart the four quadrants of an angle.
The scenario board. Each square is one cell (C1–C9); its colour says which tool applies.
Definition A word we'll reuse:
acquisition
Acquiring a resource means taking ownership of something the program must later release — allocating memory with new, opening a file, locking a mutex. The dangerous window is between "acquired" and "released": if an exception fires inside it, someone must still release the resource. Cell C2 below is about a second acquisition throwing while the first is still un-released.
Here is the full board in words:
Cell
Situation
The core question
C1
Single resource, may throw after acquire
Does cleanup still happen?
C2
Two resources, the second acquisition (allocation) throws
Is the first one leaked?
C3
Multi-step visible mutation, later step throws
Are earlier steps rolled back?
C4
Copy-and-swap upgrade of C3
Can we make it strong ?
C5
The "zero / degenerate" input (empty, self, no-op)
Does the safe path survive trivial input?
C6
Move that throws mid-transfer
Is strong lost? Copy vs move.
C7
noexcept that lies and throws
What actually happens at runtime?
C8
Real-world word problem (bank transfer)
Which guarantee does the business need?
C9
Exam twist: which guarantee, and why not stronger?
Reason about the whole, not the parts.
Below, each example is tagged with the cell(s) it covers. Together they fill the whole board.
Worked example C1 — one resource, work may throw
void draw () {
auto pen = std :: make_unique < Pen >(); // acquire
pen-> stroke (); // may throw
}
Which guarantee does draw give, and why?
Forecast: Guess now — basic, strong, or no-throw? Does anything leak if stroke() throws?
Steps:
make_unique<Pen>() allocates and hands ownership to pen, a stack object.
Why this step? The unique_ptr on the stack is the anchor — its lifetime is tied to the scope, not to reaching the last line. See Smart pointers — unique_ptr, shared_ptr .
stroke() runs and throws.
Why this step? We are testing the failure path — the only path that matters for safety.
The stack unwinds; ~unique_ptr runs and deletes the Pen.
Why this step? Stack unwinding and destructors guarantees destructors of fully-constructed locals run. No leak.
Answer: Basic guarantee (via RAII and resource management ). Not strong, because draw has no visible state to roll back anyway — but if it did partial visible work, that would persist.
Verify: Count new vs delete. One allocation, exactly one deletion during unwinding → net zero live objects. No leak. ✓
Worked example C2 — the classic leak trap
void bad () {
Widget * a = new Widget (); // #1 acquisition ok
Widget * b = new Widget (); // #2 acquisition THROWS bad_alloc
delete a; delete b; // never reached
}
What is leaked when line #2 throws?
Forecast: How many Widgets leak — zero, one, or two?
Steps:
new Widget() for a succeeds — one live raw pointer, no owner. (This is the first acquisition — memory now owned by nobody but the raw pointer.)
Why this step? A raw new has no destructor attached to a scope; nothing will free it automatically.
new Widget() for b throws std::bad_alloc.
Why this step? The exception fires before b is assigned, so b never exists.
Unwinding runs destructors of stack objects only . a is a raw pointer (a value), not an owning object — its destructor does nothing.
Why this step? Raw pointers have trivial destructors; the pointee is not touched.
Answer: Exactly one Widget (the one a points at) leaks. This function has no guarantee .
Fix (back to basic):
void good () {
auto a = std :: make_unique < Widget >();
auto b = std :: make_unique < Widget >(); // if this throws, ~a frees a
}
Verify: In good, a is fully constructed before b's allocation; when b throws, ~a runs → 1 alloc, 1 free → 0 leaks. ✓
Worked example C3 — later step throws, earlier persists
void append_and_log ( std :: vector < int > & v , int x ) {
v. push_back (x); // (A) mutates visible state
log_to_file (x); // (B) may throw
}
Which guarantee?
Forecast: push_back alone is strong. Does composing it with a throwing log keep it strong?
Steps:
push_back(x) succeeds → v now has one extra element, visible to the caller.
Why this step? This is a committed change to a caller-visible object.
log_to_file(x) throws.
Why this step? Testing the failure path after a commit already happened.
Unwinding leaves v with the pushed element — no rollback exists.
Why this step? Nobody saved the old v; there is nothing to restore it to.
Answer: Basic only. v is valid and leak-free, but the push persisted → not "no effect", so not strong. The whole is only as strong as your rollback across all steps.
Verify: Before: v = [1,2]. Call with x=3, log throws. After: v = [1,2,3] → length grew from 2 to 3, so effect ≠ none. ✓
Worked example C4 — copy-and-swap to the rescue
void append_and_log_strong ( std :: vector < int > & v , int x ) {
std ::vector <int> tmp = v; // 1. copy (may throw → v safe)
tmp. push_back (x); // 2. work on copy (may throw)
log_to_file (x); // 3. may throw → v still untouched
v. swap (tmp); // 4. noexcept commit
}
Which guarantee now?
Forecast: Which single line is the "point of no return"?
Steps:
Copy v into tmp. If the copy throws, v is untouched.
Why this step? We move all throwing work onto a throwaway object. See Copy-and-swap idiom .
2–3. push_back and log_to_file may throw, but touch only tmp (log has no effect on v).
Why this step? As long as failures cannot alter v, v keeps its old value = rollback for free.
swap exchanges the internal pointers of v and tmp — this is noexcept.
Why this step? Move semantics and noexcept : a pointer swap can't allocate, so it can't throw → the commit is the one atomic, non-throwing instant.
Answer: Strong. v changes only at line 4, which cannot throw → commit-or-rollback.
Verify: If step 2 or 3 throws: v still equals its input (say [1,2]), unchanged. If nothing throws: v = [1,2,3]. Two clean outcomes, no in-between. ✓
Buffer type used here
Buffer is a small hand-rolled container that owns a heap array of ints through a raw pointer data and a size n — the same class the parent note uses. It has: a copy constructor (allocates a fresh array and copies the elements — may throw ), a noexcept swap friend that exchanges the data pointers and n, and a destructor that frees data. We use it here because its assignment is the textbook stage for copy-and-swap.
Worked example C5 — empty vector, self-assignment, no-op
Take the strong function from Example 4 and this copy-and-swap assignment for Buffer:
Buffer & operator = ( Buffer rhs ) { // rhs = a copy (pass-by-value)
swap ( * this , rhs);
return * this ; // ~rhs frees the OLD data
}
Do the trivial inputs — empty container, self-assignment b = b, zero elements — break anything?
Forecast: Self-assignment breaks naive assignment operators. Does copy-and-swap survive it?
Steps:
Empty container: the copy constructor copies zero elements (never throws), swap commits.
Why this step? The safe path has no special case for size 0 — it just works, because copying an empty array is a no-op that cannot fail.
Self-assignment b = b: rhs is a separate copy of b made by argument passing, before the body runs.
Why this step? Because we copy first, *this and rhs are distinct objects — swapping them is harmless.
After swap, rhs holds b's old buffer and its destructor frees it once — no double-free.
Why this step? Ownership moved cleanly into rhs; exactly one destructor releases the old memory.
Answer: All degenerate inputs are safe for free — this is a headline benefit of copy-and-swap: self-assignment safety without an explicit if (this == &rhs) check.
Verify: b = b where b = [7]: copy makes rhs = [7], swap gives *this = [7] and rhs = [7] (old), ~rhs frees once → b still [7], one free, zero double-frees. ✓
Worked example C6 — why vector copies instead of moves
A std::vector<T> with 3 elements reallocates to a bigger buffer. Transferring element #2 via move throws.
Forecast: If moves can throw, is the strong guarantee kept or lost? What does the library do to avoid this?
The figure below shows both buffers stacked; read it top-to-bottom.
Reallocation with a throwing move: the old buffer is half-gutted, so nothing intact remains to roll back to.
Steps:
Vector allocates the new, larger buffer (see std::vector reallocation strategy ).
Why this step? Growth needs contiguous memory; the old buffer stays alive for now.
It transfers elements one by one. If moving , element #0 and #1 are now gutted in the old buffer (moved-from). Element #2's move throws.
Why this step? A move steals from the source, so the old buffer is already damaged — look at the greyed cells and the pink X in the figure.
Rollback is now impossible: the old buffer has half-empty (moved-from) slots. Strong guarantee lost.
Why this step? You cannot restore what a move already stole.
The fix: if T's move constructor is not noexcept, the library copies instead (std::move_if_noexcept). Copy leaves the source intact → if #2's copy throws, the old buffer is untouched → strong preserved.
Why this step? Copy is slower but non-destructive; safety wins over speed here.
Answer: Move that can throw → strong lost , so the library copies. Mark your move constructor noexcept (only if it truly can't throw) to unlock the fast move path.
Verify (logic): noexcept(move) == true → library moves (fast). noexcept(move) == false → library copies (safe). The decision is exactly move_if_noexcept. ✓
Worked example C7 — the fatal lie
void release () noexcept {
throw std :: runtime_error ( "oops" ); // promised not to!
}
What happens at runtime?
Forecast: Does the exception propagate to the caller like normal? Or something worse?
Steps:
The function is declared noexcept, a promise to the compiler and runtime.
Why this step? The runtime relies on this promise — e.g. vector's move path assumes moves marked noexcept won't throw.
A throw actually escapes the noexcept boundary.
Why this step? We deliberately break the contract to see the consequence.
The runtime calls std::terminate() immediately — no stack unwinding , destructors of outer frames do not run.
Why this step? See noexcept specifier and std::terminate : violating noexcept is unrecoverable by design.
Answer: The program dies instantly via std::terminate. Worse than a normal exception, because cleanup is skipped. Rule: mark noexcept only for operations you can truly guarantee — swaps, moves, deallocation, destructors.
Verify: noexcept violation → std::terminate (not a catchable exception). No catch block can intercept it. ✓
Money and Account types used below
Money is a simple value type holding a whole number of cents (an integer count). Account stores one Money balance. The operations we rely on:
debit(amt) subtracts amt from an account's balance by plain integer arithmetic — cheap and no-throw for in-range values. Why this matters: the subtraction itself cannot fail, so it is not the source of danger — but it is a committed visible change once done.
credit(amt) adds amt to a balance, but here it may throw (imagine an overflow check, or a downstream ledger/network write inside credit). Why this matters: this is the one throwing step in the whole example, so it is the exact point our failure-path reasoning must target.
Copying an Account is a plain value copy that keeps the invariant "balance is a valid integer". Why this matters: because copying never corrupts state, we can safely stage the whole transfer on copies (this is what makes copy-and-swap applicable).
Worked example C8 — bank transfer
Transfer $100 from account A to account B:
void transfer ( Account & A , Account & B , Money amt ) {
A. debit (amt); // (1) subtract from A
B. credit (amt); // (2) add to B — may throw (network, overflow…)
}
Balances start: A = $500, B = $200, amt = $100.
Forecast: If credit throws, how much money exists in the system afterward?
Steps:
A.debit(100) → A = $400. Committed visible change.
Why this step? The debit is real and immediate; the caller can already observe A = $400.
B.credit(100) throws.
Why this step? This is the failure path we must analyse — the second visible step fails after the first committed.
No rollback exists → A = $400, B = $200. $100 vanished into thin air.
Why this step? Nobody saved A's old balance, so the debit persists while the credit never happened → the business invariant "total money conserved" is broken. Basic (no leak, valid objects) is not enough here.
Business requirement: money conservation demands the strong guarantee. Rewrite with commit-or-rollback:
void transfer_strong ( Account & A , Account & B , Money amt ) {
Account tmpA = A, tmpB = B; // copies (may throw → originals safe)
tmpA. debit (amt);
tmpB. credit (amt); // may throw → originals untouched
swap (A, tmpA); swap (B, tmpB); // noexcept commit (real code: one atomic step)
}
Why this step? Every throwing action (credit, and even the copies) touches only tmpA/tmpB; A and B change only at the noexcept swaps → commit-or-rollback.
Answer: Basic is a bug here; the domain needs strong . Total before = $700, total after (success) = $700, total after (throw) = $700. Money is conserved either way.
Verify: Success: A=400, B=300 → sum 700. Throw: A=500, B=200 → sum 700. Naive version on throw: A=400, B=200 → sum 600 ≠ 700 (the bug). ✓
Worked example C9 — "which guarantee, and why not stronger?"
template < class T >
void safe_replace ( std :: vector < T > & v , std :: size_t i , T value ) {
v. at (i) = std :: move (value); // (1) bounds-checked, then assign
}
T's move-assignment is not noexcept. State: which guarantee, and justify why it isn't stronger.
Forecast: at() throws on a bad index before any change. But what about the assignment itself?
Steps:
v.at(i) throws std::out_of_range if i is out of bounds — this happens before touching any element.
Why this step? An out-of-range throw leaves v completely unchanged → that path alone is strong.
If i is valid, v[i] = std::move(value) runs T's move-assignment, which may throw mid-assignment.
Why this step? We must analyse the other reachable path: a valid index. Since T's move-assign is not noexcept, it can throw partway, leaving v[i] in a moved-from / partially-updated state — this is the path that caps the guarantee.
That partially-updated v[i] is valid & destructible (no leak) but its value is unspecified → basic , not strong.
Why this step? We composed a strong path (bad index) with a basic path (throwing assign); the weakest wins.
Answer: Basic guarantee overall. Not stronger because a throwing move-assignment cannot be rolled back in place — the element v[i] may already be half-overwritten with no saved copy to restore. To make it strong: assign to a copy of the element then swap it in, or require a noexcept move-assignment for T.
Verify (logic): whole-function guarantee = min(guarantee of each reachable path) = min(strong, basic) = basic. Bad-index path: v unchanged (strong). Valid-index throwing path: v[i] unspecified but valid (basic). The minimum is basic . ✓
Recall Which cell demands which tool?
C1/C2 → RAII / Smart pointers — unique_ptr, shared_ptr . C3→C4 → Copy-and-swap idiom . C6 → noexcept moves + std::vector reallocation strategy . C7 → honest noexcept. C8 → strong for domain invariants. C9 → whole = weakest path.
Mnemonic Fill the whole board
"Leak, Rollback, Never-lie." Leaks (C1/C2) → RAII. Rollback (C3/C4/C8) → copy-and-swap. Never-lie (C6/C7) → truthful noexcept. C5 & C9 are the trap inputs — always test empty, self, and the mixed-strength composition.
Why does the bank transfer need strong, not basic? Because basic keeps objects valid but lets the debit persist while the credit fails — money is not conserved; the domain invariant is broken.
In Example 2, exactly how many Widgets leak and why? One — a's raw pointer has no destructor, so unwinding never frees it after b's new throws.
Why is copy-and-swap self-assignment-safe automatically? The right-hand copy is made by pass-by-value before the body runs, so *this and rhs are always distinct objects.
What does a std::vector do when T's move constructor is not noexcept? It copies elements during reallocation (move_if_noexcept), because a throwing move would gut the source and lose the strong guarantee.
Whole-function guarantee of a multi-path function equals what? The weakest guarantee among all reachable paths.