5.2.29 · D2C++ Programming

Visual walkthrough — Exception safety — basic, strong, no-throw guarantees

2,219 words10 min readBack to topic

This is the picture-first companion to the parent topic. Read that for the definitions; read this to see the machinery.


Step 0 — The vocabulary, drawn before we use it

Before any derivation, three plain-English ideas, each pinned to a picture below.

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

We will follow one concrete operation the whole way: a class Buffer that owns a heap array, and we append some numbers to it. Appending needs a bigger array, so it must allocate and copy — the classic place an exception hides.


Step 1 — The naive in-place version, and the tripwire inside it

void append_naive(const int* src, std::size_t k) {
    int* bigger = new int[n + k];        // (1) allocate — may throw bad_alloc
    std::copy(data, data + n, bigger);   // (2) copy old — a copy may throw
    std::copy(src,  src  + k, bigger+n); // (3) copy new — may throw
    delete[] data;                       // (4) free old
    data = bigger; n += k;               // (5) commit pointers
}

Here data is the label pointing at our current array, n is how many numbers it holds, src is the incoming numbers and k is how many.

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

Step 2 — Move the risky work onto a stand-in copy

void append_strong(const int* src, std::size_t k) {
    Buffer tmp(*this);            // (A) copy — may throw, but only tmp is at stake
    tmp.grow_in_place(src, k);    // (B) risky work — happens on tmp
    // ... commit comes next ...
}

tmp is our stand-in — a second jar filled with the same contents as *this.

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

Step 3 — The commit must be a move that cannot throw

    swap(*this, tmp);   // (C) commit — noexcept, cannot throw
}                       // (D) ~tmp runs, freeing the OLD array
friend void swap(Buffer& a, Buffer& b) noexcept {
    std::swap(a.data, b.data);   // trade the two pointers
    std::swap(a.n,    b.n);      // trade the two sizes
}

In the figure, watch the two labels cross over. There is no instant in this crossover where a throw could leave us half-committed — the exchange is a sequence of trivial assignments, and if you froze the program between them the object is still fully valid (worst case it points at the new array with an old n for a nanosecond, but no exception can occur to freeze it there).

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

Step 4 — The complete timeline: every failure point covered

Throw at *this (visible) tmp (stand-in) Verdict
(A) copy untouched (old) half-built → auto-freed by ~tmp no effect, no leak ✅
(B) grow untouched (old) partly grown → auto-freed by ~tmp no effect, no leak ✅
(C) swap cannot throw (noexcept) impossible ✅
(D) destroy new state, committed freeing old array (dtor never throws) already succeeded ✅
Figure — Exception safety — basic, strong, no-throw guarantees

Step 5 — The degenerate cases (never skip these)

Case k = 0 (append nothing). The copy in (A) still runs, grow_in_place copies zero new numbers, swap commits an identical-valued buffer. No throw source is removed, no throw source added — still strong. Cost is a wasted copy; correctness is untouched.

Case self-append (src points inside data). Because (A) copies first, tmp holds an independent snapshot of the old numbers before any growing happens. So reading from src while writing into tmp never reads a clobbered value — the copy-first design makes self-reference safe for free, the same way copy-and-swap assignment is self-assignment-safe (see Copy-and-swap idiom).

Case the type's move throws. If we tried to move elements instead of copy them during growth and that move could throw, then a throw mid-growth would gut the source — and for a container like std::vector that means the strong guarantee is lost. This is exactly why the library uses std::move_if_noexcept: it copies unless the move is marked noexcept (see Move semantics and noexcept and std::vector reallocation strategy).

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

The one-picture summary

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

The whole idea in one frame: a throw-zone (copy + grow, drawn on the stand-in jar) followed by a safe-zone commit (the label swap). Every tripwire lives left of the swap; the swap itself is a wall no tripwire can cross. Therefore the visible object is either fully old or fully new — never in between.

Recall Feynman retelling — explain the walkthrough with no jargon

You have to renovate a jar without ruining the one on your shelf.

The clumsy way (Step 1): you tip the shelf-jar out onto the table and start pouring in the new stuff. If your phone rings (a throw) mid-pour, you've got a mess on the table that nobody cleans up (a leak) and a shelf-jar that's half-empty (broken).

The safe way: first make a second jar that's a perfect copy of the shelf one (Step 2). Do all the messy pouring into that second jar. Phone rings? Only the second jar is messy — and because it's a "self-cleaning" jar, it tidies itself the moment you drop it (RAII). The shelf-jar was never touched.

When the second jar is finally perfect, you don't pour it back — pouring could spill. Instead you just swap the two labels (Step 3): the shelf label now names the new jar, and the old jar gets the spare label and is thrown out. Swapping labels can never spill, so this last step is a wall no interruption can cross.

Check every possible ring of the phone (Step 4): before the swap it only hurts the spare jar; the swap can't be interrupted; after the swap you're already done. And the awkward cases (Step 5) — pouring nothing, pouring from the jar into itself, or using a "pour" that itself might spill — all behave, because we copied first and only trusted a move when it promised never to spill. That is copy-and-swap, and that is the strong guarantee.

Line (3) leaks — the fresh new[] block sits in a local raw pointer with no destructor to free it
correct
Swap exchanges only the data pointers and n sizes — trivial assignments, no allocation, so nothing can throw
correct
Because the visible object changes only at the swap, and the swap is noexcept — before it, throws hit only tmp
correct
Because tmp is an independent snapshot taken before growth, reads from src never see clobbered data
correct