5.2.29 · D4C++ Programming

Exercises — Exception safety — basic, strong, no-throw guarantees

2,653 words12 min readBack to topic

A quick reminder of the ladder we are grading against:

Figure — Exception safety — basic, strong, no-throw guarantees

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
  1. No-throw. Exchanging pointers touches no allocation and cannot throw. (This is why swap is the commit step of copy-and-swap.)
  2. Strong. push_back either successfully grows and inserts, or throws and leaves the vector exactly as before.
  3. No-throw. A well-behaved destructor never throws (see Stack unwinding and destructors).
  4. 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 leak

Why: 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, cfg has 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 be noexcept. If it can throw, the commit itself can fail mid-swap → the strong guarantee is lost. Fix: make the commit a noexcept swap 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, *this was never touched → strong.
  • The body is only a noexcept swap → the commit cannot fail.
  • Self-assignment safe for free: even b = b copies into rhs first, then swaps *this with that independent copy — no aliasing hazard, no special if (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) noexcept pointer swap — cannot throw, allocates nothing: gold standard commit.
  • (b) noexcept move-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-assignnot 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.


Active Recall

Why must the commit step of copy-and-swap be noexcept?
So the atomic all-or-nothing property holds — if the commit could throw, you could be stranded in a half-committed, unrecoverable state.
What does std::vector do during reallocation when the element's move ctor is not noexcept?
It copies instead of moving (std::move_if_noexcept), so a throw can't gut the old buffer, preserving the strong guarantee.
What happens if a noexcept function lets an exception escape?
The runtime calls std::terminate(), which aborts with no stack unwinding — destructors of locals do not run.
Why is operator=(Buffer rhs) self-assignment-safe for free?
The copy is made during argument passing before the body, so even b = b swaps *this with an independent copy — no aliasing hazard.