C++ Part I · Module 22 · Error Handling and Diagnostics
Exception Safety Guarantees
Chapter 22.4 made cleanup reliable, which answers the question of whether a function leaks. It does not answer the harder one, and the harder one is what this chapter is for: after a failure, what state is the object in? A function can release every resource perfectly and still leave the data it was modifying half-changed — a counter incremented for an element that was never added, a cache key registered for an entry that does not exist, two members that are supposed to agree and no longer do. Nothing leaked. Nothing crashed. The object is simply wrong, and it will stay wrong until something reads it. The standard library describes this with four named levels, and the frozen purpose asks for two things: the ability to classify an operation, and the discipline of stating which level your own functions provide. The chapter measures all four on one operation — inserting into a small class with an invariant — and the result has the shape this module keeps finding. On the path where the insertion succeeds, the version with no guarantee at all is indistinguishable from the version with the strongest one. On the path where it fails, one of them leaves the invariant broken. The chapter then measures what the strongest guarantee costs, because it is not free and the usual advice to always provide it is wrong.
Advanced19 min readC++Part IFundamentalsError Handling
Part I · Module 22 · Chapter 22.5 · Exception Safety Guarantees
1. The Four Levels
Every function that can throw provides exactly one of these, whether or not its author knows which.
No-throw. The operation cannot fail. Marked noexcept, and Chapter 22.6 is what that promise means and what breaking it costs. Destructors, swap, a sane move, and most observers.
Strong. The operation either completes fully or has no effect at all. Commit-or-rollback: if it throws, the object is exactly as it was. std::vector::push_back provides this, conditionally.
Basic. If the operation throws, nothing leaks and every invariant still holds — but the object's contents may have changed in an unspecified way. It is still a valid object you can use or destroy. Most of the library, and most of what you should write.
None. After a failure, anything may be true, including a broken invariant. Not a level you choose; a level you end up at by not thinking about it.
The ordering is a hierarchy: no-throw implies strong implies basic. A function offering the strong guarantee also offers the basic one.
2. The Measurement
A class with an invariant, and an insertion written three ways.
struct Config {
std::vector<Fragile> names;
int count = 0; // INVARIANT: count == names.size()
void addNone(const Fragile& f) {
++count; // bumped first
names.push_back(f); // if this throws, count is wrong
}
void addBasic(const Fragile& f) {
names.push_back(f); // if this throws, nothing changed
++count; // only bumped on success
}
void addStrong(const Fragile& f) {
Config tmp = *this; // all the throwing work happens here
tmp.names.push_back(f);
++tmp.count;
swapWith(tmp); // and this cannot throw
}
void swapWith(Config& o) noexcept { names.swap(o.names); std::swap(count, o.count); }
};Fragile's copy constructor throws on demand. With the copy made to fail:
A. the same insertion, four ways, with the copy made to fail
no guarantee threw=yes count=2 names=1 invariant *** BROKEN ***
basic threw=yes count=1 names=1 invariant HELD
strong threw=yes count=1 names=1 invariant HELDcount=2 and names=1. The object now claims to hold two names and holds one. Nothing leaked — std::vector cleaned up after itself perfectly, and Chapter 22.4's RAII did its job. The resource management is flawless and the object is wrong.
Everything downstream of that object is now also wrong: a loop over count reads past the end, a size check passes when it should not, a serialised copy records a count that does not match its payload.
And then the half that matters:
B. and with no failure, all three are identical
no guarantee threw=no count=2 names=2 invariant HELD
basic threw=no count=2 names=2 invariant HELD
strong threw=no count=2 names=2 invariant HELD
-> which is why this defect never shows up in a passing test.Identical. addNone and addStrong produce the same object, with the same contents, in the same state, whenever nothing fails. There is no test of the success path that distinguishes them.
3. The Difference Between addNone and addBasic
Two lines, swapped. That is the entire fix, and it is worth being explicit about the principle because it generalises further than this example.
++count; names.push_back(f); // no guarantee
names.push_back(f); ++count; // basicDo the part that can throw first; make the part that cannot throw last. push_back can throw — it allocates. ++count cannot. Ordering them so the fallible operation happens before any state is modified means a failure leaves nothing modified.
That is the cheapest exception-safety technique there is: it costs nothing, it is a reordering rather than an addition, and it upgrades a function from no guarantee to the basic one. Most functions that currently provide no guarantee can be fixed this way.
The general form: perform all the operations that can fail, then commit with operations that cannot. §4 is what to do when that is not possible because there are several fallible steps.
4. Copy and Swap, and What It Costs
When a function must modify several things and any of them can fail, reordering is not enough — you cannot make four fallible steps atomic by arranging them. The strong guarantee then needs the copy-and-swap idiom:
void addStrong(const Fragile& f) {
Config tmp = *this; // 1. copy
tmp.names.push_back(f); // 2. do all the fallible work on the copy
++tmp.count;
swapWith(tmp); // 3. commit with a no-throw swap
}If any step throws, tmp is destroyed and *this was never touched. The commit is a noexcept swap, which is why Chapter 22.6 lists swap among the operations that must be no-throw: the strong guarantee is built on a no-throw commit, and without one there is nothing to build it from.
The cost:
D. what the strong guarantee cost
on a 1000-element Config: addBasic made 1 copies, addStrong made 1002One copy against 1,002. The strong version duplicated the entire object — a thousand elements — to insert one. It is O(n) where the basic version is O(1) amortised, and at ten thousand elements it would be 10,002.
So the advice to "always provide the strong guarantee" is wrong, and the trade is explicit: the strong guarantee costs a full copy of the object's state, every call, on the path that succeeds as well as the one that fails. For a small object that is nothing. For a large one, inserting in a loop, it turns a linear algorithm quadratic.
A note on the instrument: the copy counter was initially incremented only when the failure flag was set, so section D reported 0 copies for both versions — measuring nothing. Counting unconditionally fixed it. A counter gated on the condition you are not currently testing is a counter that reads zero.
5. The No-Throw Guarantee Is the Foundation
swapWith is noexcept and that is not decoration. Every strong-guarantee implementation needs a commit step that cannot fail, because a commit that can fail leaves you half-committed and back where you started.
Which operations can realistically be no-throw:
swapof two objects whose members swap without allocating — pointers, integers, and containers whoseswapis a pointer exchange.std::vector::swapisnoexcept.- A move of a type that owns its data through a pointer. Chapter 13.8 §7 is the argument, and Chapter 19.3 §3 measured the library acting on it.
- Destructors, always.
- Integer and pointer assignment.
And the one that is not: copy is almost never no-throw, because it allocates.
That is why the idiom is copy-and-swap rather than copy-and-assign. The assignment would be the fallible step at the worst possible moment.
6. What the Library Provides
Knowing these saves re-deriving them, and the pattern in them is instructive.
| operation | guarantee | why |
|---|---|---|
vector::push_back | strong, if the element's move is noexcept | reallocation can move safely, or it copies |
vector::push_back | basic, if the move can throw | a half-moved buffer cannot be restored |
vector::insert (middle) | basic | elements are shifted in place |
vector::erase | no-throw, if the element's move/copy is | shifting down cannot fail |
vector::swap | no-throw | exchanges three pointers |
vector::reserve | strong | allocate, then move, then commit |
map::insert | strong | the node is built before anything relinks |
map::erase | no-throw | relinking cannot fail |
sort | basic | a throwing comparator leaves the range permuted |
copy / transform | basic | partial output is already written |
make_unique | strong | either you get the object or nothing happened |
The push_back row is the one worth internalising, because the guarantee depends on your type. Chapter 19.3 §3 measured the library choosing to move when the move was noexcept and copy otherwise, and the reason is exactly this: with a throwing move it cannot offer the strong guarantee while moving, so it copies instead and pays for it. Your noexcept is what lets the library be both fast and strong.
7. Which One Should You Provide?
The frozen purpose asks you to state which one, and the honest default is the basic guarantee.
Aim for basic, always. Nothing leaks, every invariant holds. §3's reordering gets you there for free in most functions, and Chapter 22.4's RAII gets you the no-leak half automatically.
Provide strong where the caller genuinely needs to retry, and where the object is small enough that the copy does not matter. A configuration object, a transaction, a single-element insertion into a modest container.
Provide no-throw for destructors, swap, and moves — Chapter 22.6's list — because other people's strong guarantees are built on yours.
Accept "none" for nothing. It is not a trade-off; it is an oversight. §3's two-line reordering is almost always available.
And then the part that is a discipline rather than a technique: write it down. A comment on the function, or better, a // exception: basic convention the codebase shares. The level is not visible in the signature — noexcept is the only part of it the type system records — so an unstated guarantee is a guarantee the caller has to guess at, and they will guess "strong" because that is what they want.
8. Testing the Failure Path
§2's block B is the problem: the success path cannot distinguish the levels. So the failure path has to be tested deliberately, and there is a standard technique.
Make a dependency throw on demand, as Fragile does — a counter and a threshold, so the nth operation fails:
struct Fragile {
Fragile(const Fragile& o) : s(o.s) {
++copies;
if (throwAfter >= 0 && copies > throwAfter) throw std::runtime_error("copy failed");
}
};Then sweep the threshold. Fail on the first operation, the second, the third, and after each one assert the invariant. That is a real test of the basic guarantee, because it exercises every point at which the function could be interrupted.
And a throwing allocator does the same for anything that allocates, which is how the standard library's own test suites verify these guarantees.
The cost of not doing this is §2 block B: a test suite that is green while the class has no guarantee at all.
9. The Four Levels Table
| level | if it throws | built on | example | cost |
|---|---|---|---|---|
| no-throw | cannot throw | — | destructors, swap, a noexcept move | none |
| strong | no effect at all | a no-throw commit | map::insert, make_unique | a full copy — 1,002 vs 1 (measured) |
| basic | no leak, invariants hold, contents unspecified | RAII + ordering | sort, vector::insert | free — a reordering (measured) |
| none | anything, including a broken invariant | — | addNone (measured count=2 names=1) | — |
| what distinguishes them | success path | failure path |
|---|---|---|
| none vs basic vs strong | identical (measured) | only here (measured) |
10. What to Actually Do
- Aim for the basic guarantee in everything you write. Nothing leaks, invariants hold.
- Reorder: fallible work first, then commit with operations that cannot fail. Measured free, and it is the whole fix for most functions.
- Use copy-and-swap for the strong guarantee — and only where the caller retries and the object is small.
- Do not provide the strong guarantee by default. Measured 1,002 copies against 1.
- Make
swap, moves and destructorsnoexcept. Other people's strong guarantees are built on yours. - Mark the level in a comment on every non-trivial member function. The type system records only
noexcept. - Test the failure path with a throw-on-demand dependency, sweeping the threshold.
- Assert the invariant after a caught failure in those tests, not just the absence of a leak.
- Check
push_back's guarantee for your own types — it is strong only if your move isnoexcept. - Treat "no guarantee" as a defect, not a choice.
11. Mistakes Worth Making Once
Incrementing a counter before the operation that fills the thing it counts. Measured count=2 names=1, nothing leaked, all tests green.
Believing RAII gives you exception safety. It gives you no leaks. The invariant is a separate question, and this chapter is it.
Providing the strong guarantee everywhere. Measured 1,002 copies for a one-element insertion.
A commit step that can throw. Then there is no strong guarantee, only a different half-state.
Testing only the success path. Measured: it cannot distinguish any of the three levels.
A copy counter gated on the failure flag. My own first instrument reported 0 copies for both versions.
Assuming vector::push_back is always strong. It is strong only if the element's move is noexcept; otherwise basic.
Leaving the level unstated. The caller assumes strong.
12. Interview Relevance
The frozen curriculum marks this chapter High, and the four levels are a standard question whose interesting part is the follow-up.
"Name the exception safety guarantees." No-throw, strong, basic, none — and they form a hierarchy. Being able to say what each means about the object's state rather than just naming them is the distinction.
"What does the basic guarantee actually promise?" No leaks and every invariant intact; contents unspecified. It does not promise the operation had no effect.
"How do you implement the strong guarantee?" Copy-and-swap, with a noexcept commit. And the cost: measured 1,002 copies against 1 on a 1,000-element object, so it is O(n).
"Is vector::push_back strong?" Conditionally — strong if the element's move is noexcept, basic otherwise. That conditionality is the best answer available to this question.
"How would you test exception safety?" A dependency that throws on the nth call, with the threshold swept, asserting the invariant after each caught failure. And the reason: measured, the success path cannot distinguish any level.
"Should every function provide the strong guarantee?" No — measured cost. Basic everywhere, strong where the caller retries.
13. What You Should Be Able to Say Now
- The four levels, what each promises about the object's state, and that they are a hierarchy.
- That
addNoneleftcount=2withnames=1on the failure path, with nothing leaked. - That all three versions were identical on the success path — so no success-path test distinguishes them.
- That the fix from none to basic is a reordering — fallible work first, no-throw commit last — and costs nothing.
- That copy-and-swap gives the strong guarantee and measured 1,002 copies against 1 on a 1,000-element object.
- That the strong guarantee requires a no-throw commit, which is why
swapand moves must benoexcept. - That
vector::push_backis strong only if your element's move isnoexcept— Chapter 19.3 §3's measurement from the other side. - How to test the failure path: throw on the nth operation, sweep the threshold, assert the invariant.
- That the level must be written down, because only
noexceptis in the type system.
14. Exercises
Exercise 1 — reproduce it. Build §2's three versions and confirm both blocks. Then write a success-path unit test and verify it passes for all three.
Exercise 2 — the reordering. Take three functions from your own code that modify two members and determine each one's level. Fix any that offer none by reordering.
Exercise 3 — sweep the threshold. Instrument Fragile to fail on the nth copy and sweep n from 1 to 5, asserting the invariant after each. Report which versions survive every threshold.
Exercise 4 — the cost curve. Measure addBasic against addStrong for object sizes 10, 100, 1,000 and 10,000. Plot the copies and confirm O(1) against O(n).
Exercise 5 — a quadratic safety net. Insert 1,000 elements one at a time using addStrong and count the total copies. Compare with addBasic.
Exercise 6 — the conditional guarantee. Build a type with a noexcept move and one without, push_back each into a vector past its capacity, and count copies and moves. Relate it to the guarantee each gets.
Exercise 7 — a failing commit. Make swapWith able to throw and show that addStrong no longer provides the strong guarantee.
Exercise 8 — state the levels. Document the guarantee on every public member of one of your own classes. Note any you could not determine, and what that says about the class.
15. Summary
- Four levels: no-throw, strong, basic, none — a hierarchy, and every function provides one whether or not its author knows which.
- Measured:
addNoneleftcount=2withnames=1on the failure path — a broken invariant with nothing leaked, because RAII solved a different problem. - Measured: all three versions are identical on the success path (
count=2 names=2), so no success-path test distinguishes them. - The fix from none to basic is a reordering — fallible work first, no-throw commit last — and it is free.
- Measured: the strong guarantee cost 1,002 copies against 1 on a 1,000-element object. It is O(n) against O(1) amortised, so "always provide it" is wrong.
- The strong guarantee is built on a no-throw commit, which is why
swap, moves and destructors must benoexcept. vector::push_backis strong only if the element's move isnoexcept— otherwise basic, and the library copies instead.- Test the failure path with a throw-on-demand dependency and a swept threshold, asserting the invariant each time.
- State the level in a comment. Only
noexceptis recorded by the type system, and callers assume strong. - A copy counter gated on the condition you are not testing reads zero — my own first instrument did.
Three chapters have now depended on the same promise: that certain operations cannot fail. Chapter 22.3's unwinding needs destructors not to throw, Chapter 22.4's guards need the same, and this chapter's strong guarantee needs a commit step that cannot fail. Chapter 22.6 is that promise — how to make it, what the compiler does with it, and what happens when it is broken. The answer to the last is not an exception the caller can catch: the chapter measures a throw escaping a noexcept function bypassing a perfectly good handler and aborting the process, and measures where the compiler's benefit actually lands, which turns out not to be where the folklore says it is.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
