Exercises — Exception safety — basic, strong, no-throw guarantees
A quick reminder of the ladder we are grading against:

Every exercise's answer is one of these four rungs, or a code fix that moves you up the ladder. Keep this picture in your head: basic = no leak but values may change; strong = all-or-nothing; no-throw = can't fail.
Level 1 — Recognition
Goal: given a small function, name the guarantee it provides. No code writing yet — just read and classify.
Recall Solution 1.1
Answer: the basic (in fact effectively no-effect) guarantee via RAII.
The int is owned by a unique_ptr. If mayThrow() throws, the stack unwinds and ~unique_ptr runs, freeing the int. No leak. Since g has no other observable side effect and any allocated resource is cleaned up, the visible program state is unchanged — but the guarantee we can promise for a general body here is the basic one: valid, destructible, no leak. Cleanup is tied to scope exit, not to reaching the last line — that is the whole point of RAII.
Recall Solution 1.2
- No-throw. Exchanging pointers touches no allocation and cannot throw. (This is why swap is the commit step of copy-and-swap.)
- Strong.
push_backeither successfully grows and inserts, or throws and leaves the vector exactly as before. - No-throw. A well-behaved destructor never throws (see Stack unwinding and destructors).
- Basic. The log write is a side effect that persists even if the later step throws — there is no rollback across both steps, so the whole is only basic.
Level 2 — Application
Goal: apply RAII, copy-and-swap, and noexcept to fix code or reach a target guarantee.
Recall Solution 2.1
Replace the raw owning pointer with a unique_ptr:
void fixed() {
auto w = std::make_unique<Widget>();
w->configure(); // may throw — ~unique_ptr still frees w
} // basic guarantee, no leakWhy: if configure() throws, the manual delete w; in the original is skipped → leak. The smart pointer's destructor runs during unwinding regardless of where control leaves the function.
Recall Solution 2.2
void addTagStrong(std::vector<std::string>& tags, std::string t) {
std::vector<std::string> tmp = tags; // copy (may throw → tags safe)
tmp.push_back(std::move(t)); // work on the copy
validate(tmp); // may throw → tags still untouched
tags.swap(tmp); // noexcept commit
}Why: in the original, (A) succeeds and then (B) can throw, leaving the tag already pushed → only basic. In the fix, every throwing step touches only tmp. The visible tags changes only at swap, which is noexcept. So either everything commits or tags is exactly as before → strong.
Level 3 — Analysis
Goal: reason about why a subtle case fails, spot the exact line that breaks the guarantee.
Recall Solution 3.1
Two problems, both after the intended commit point.
- Line (Y) is a throwing operation placed after the state change. If
notify()throws,cfghas already been updated → the operation had an effect → not strong. - Line (X) uses move-assign, which is the commit. For this to be a true no-throw commit,
Config's move assignment must benoexcept. If it can throw, the commit itself can fail mid-swap → the strong guarantee is lost. Fix: make the commit anoexceptswap and put nothing throwing after it:
void updateStrong(Config& cfg, Settings s) {
Config tmp = cfg;
tmp.apply(s); // all throwing work here
notify_prepare(tmp); // any throwing prep also on tmp
swap(cfg, tmp); // noexcept — the last line
}Recall Solution 3.2
Yes — push_back preserves the strong guarantee, but at a cost. Because T's move ctor may throw, the library applies the std::move_if_noexcept rule: it copies the elements into the new buffer instead of moving them. See std::vector reallocation strategy.
Why: if it moved and a move threw halfway, the old buffer would already be partly gutted → impossible to roll back → strong lost. Copying leaves every source element intact, so if a copy throws the library can discard the new buffer and keep the old one untouched. The price is a slower copy instead of a cheap move.
Level 4 — Synthesis
Goal: build new correct code that composes the tools.
Recall Solution 4.1
Buffer& Buffer::operator=(Buffer rhs) { // rhs = a copy, made by the caller
swap(*this, rhs); // noexcept commit
return *this; // ~rhs frees the old data
}Why it works:
- The (possibly throwing) copy happens during argument passing, before the body starts. If it throws,
*thiswas never touched → strong. - The body is only a
noexceptswap → the commit cannot fail. - Self-assignment safe for free: even
b = bcopies intorhsfirst, then swaps*thiswith that independent copy — no aliasing hazard, no specialif (this == &rhs)check needed.
Recall Solution 4.2
Do all mutation on staged copies, commit with a no-throw swap of both accounts at once:
void transfer(Account& A, Account& B, Money amount) { // STRONG
Account tmpA = A; // copies (may throw → A,B untouched)
Account tmpB = B;
tmpA.debit(amount); // may throw (insufficient funds, etc.)
tmpB.credit(amount); // may throw
using std::swap; // commit both, no-throw:
swap(A, tmpA); // noexcept
swap(B, tmpB); // noexcept
}Why: all validation and arithmetic run on tmpA/tmpB. If any of those throws, the real A and B were never modified → no effect. Both commits are noexcept swaps, and once the first swap runs the second cannot fail, so we never leave the pair half-transferred. (Both swaps together form the atomic commit.)
Level 5 — Mastery
Goal: reason like a library author about noexcept, std::terminate, and design trade-offs.
Recall Solution 5.1
The allocation throws std::bad_alloc. Because the function is declared noexcept, the runtime does not propagate the exception — instead it calls std::terminate(), which by default aborts the program with no stack unwinding (see noexcept specifier and std::terminate). Destructors of locals do not run; the program dies instantly.
Why: noexcept is a promise. If a noexcept function lets an exception escape, the language treats that as an unrecoverable contract violation.
Fix: a real swap must exchange existing handles/pointers and allocate nothing:
friend void swap(T& a, T& b) noexcept {
using std::swap;
swap(a.ptr_, b.ptr_); // exchange pointers, no allocation
swap(a.n_, b.n_);
}Only declare noexcept what you can truly guarantee never throws.
Recall Solution 5.2
With a lying noexcept move, std::vector will now move elements during reallocation (per std::move_if_noexcept, which trusts your declaration). If a move then throws, the escape from a noexcept function calls std::terminate() → program death.
Contrast: left honestly not noexcept, the vector would have copied instead, keeping the strong guarantee and merely running slower. So lying converts a recoverable, strong-guaranteed slowdown into a fatal crash. The "optimisation" trades correctness for speed and loses correctness catastrophically.
Recall Solution 5.3
(a) ≈ (b) > (c).
- (a)
noexceptpointer swap — cannot throw, allocates nothing: gold standard commit. - (b)
noexceptmove-assign — also cannot throw provided the noexcept is honest; equally valid as a commit, and often what copy-and-swap compiles to. Slightly more to verify (the whole move must be no-throw). - (c) throwing copy-assign — not a valid commit at all. If the copy-assign throws mid-write, the target may be partly modified → strong guarantee lost. A commit step must be no-throw; (c) fails that requirement.
Recall Feynman recap — the whole ladder in one breath
Basic = RAII cleans up, nothing leaks, but values may have changed. Strong = do the messy work on a copy, then swap it in at the last no-throw instant, so it's all-or-nothing. No-throw = so simple it can't fail, and you prove it with noexcept — but if you lie, the program dies. Tool ladder: RAII → copy-and-swap → noexcept.