C++ / ERRORS, EXCEPTIONS, AND UNDEFINED BEHAVIOUR
Exception safety and the guarantees functions offer
Classify and implement the nothrow, strong, and basic exception-safety guarantees, and restructure functions so a throw cannot leave broken invariants.
What you will learn
- Name the guarantee a function offers: nothrow, strong, basic, or none
- Reorder a function so all throwing work happens before the first observable change
- Commit changes with nothrow steps only: swap, noexcept moves, scalar assignment
- Recognise when two strong operations still add up to a weak function
Understanding Exception safety and the guarantees functions offer
Exception safety is not about whether a function throws; it is about what the caller may assume once the exception has been caught. Four levels are worth naming: nothrow, where an exception never escapes; strong, where a failed call leaves every observable value exactly as it was; basic, where the object is still valid, still destructible, and every invariant still holds although values may have changed; and no guarantee at all. The line between basic and nothing is invariants, not memory: add_naive below leaks nothing, yet it leaves names one element longer than codes, so every later function that relies on those sizes matching is now wrong.
The way to earn a stronger guarantee is to split the function into a phase that can throw and a phase that cannot, and to run them in that order. Validation, allocation, and copies belong to the first phase and should work on scratch objects; the commit phase may use only operations that cannot fail, such as swap, moves of types whose move is noexcept, and assignment of scalars and pointers. That is the whole reason copy-and-swap works, and the reason add_strong calls reserve before push_back: capacity is not part of the observable value, so paying for the allocation early turns the two appends into steps that cannot throw.
Guarantees do not compose by themselves. A function built from two strong operations on two different objects is not strong: if the first one commits and the second throws, there is nothing left to roll back, which is what move_bad shows in the second example. The strong guarantee also has a price, usually one extra copy of the data, so for large containers it is a legitimate decision to offer only the basic guarantee and say so next to the declaration, exactly as the standard library does for range insertion.
<iostream>
<stdexcept>
<string>
<utility>
<vector>
void check_code(const std::string& code) {
if (code.size() != 3)
throw std::invalid_argument("code must have 3 characters");
}
struct Registry {
std::vector<std::string> names;
std::vector<std::string> codes; // invariant: same size as names
// No guarantee: nothing leaks, but a throw leaves the two vectors
// at different sizes, so the class invariant is broken.
void add_naive(const std::string& name, const std::string& code) {
names.push_back(name);
check_code(code);
codes.push_back(code);
}
// Strong guarantee: every step that can throw runs first.
void add_strong(std::string name, std::string code) {
check_code(code); // throws before anything changes
names.reserve(names.size() + 1); // may throw, only capacity moves
codes.reserve(codes.size() + 1); // may throw, values still intact
names.push_back(std::move(name)); // capacity is there, move is noexcept
codes.push_back(std::move(code)); // so neither append can throw
}
bool consistent() const { return names.size() == codes.size(); }
};
int main() {
Registry a, b;
a.add_strong("alpha", "AAA");
b.add_strong("alpha", "AAA");
try {
a.add_naive("beta", "TOOLONG");
} catch (const std::invalid_argument& e) {
std::cout << "add_naive threw: " << e.what() << "\n";
}
std::cout << std::boolalpha
<< "a: names=" << a.names.size() << " codes=" << a.codes.size()
<< " consistent=" << a.consistent() << "\n";
try {
b.add_strong("beta", "TOOLONG");
} catch (const std::invalid_argument& e) {
std::cout << "add_strong threw: " << e.what() << "\n";
}
std::cout << "b: names=" << b.names.size() << " codes=" << b.codes.size()
<< " consistent=" << b.consistent() << "\n";
}
An exception-safety guarantee is a promise about the state the caller is left with, and you earn the strong one by finishing every operation that can throw before changing anything observable.
Worked examples
Copy-and-swap versus clear-and-refill
Shows the difference between the basic guarantee (object usable, old data lost) and the strong guarantee (old data intact) in the same assignment task.
<iostream>
<stdexcept>
<string>
<utility>
<vector>
struct Payload {
std::string tag;
static int copies_left; // -1 means "never fail"
explicit Payload(std::string t) : tag(std::move(t)) {}
Payload(const Payload& other) : tag(other.tag) {
if (copies_left == 0)
throw std::runtime_error("copy of " + other.tag + " failed");
if (copies_left > 0) --copies_left;
}
};
int Payload::copies_left = -1;
struct Record {
std::vector<Payload> items;
// Basic: the object stays valid, but the old contents are gone.
void assign_basic(const std::vector<Payload>& src) {
items.clear();
for (const Payload& p : src) items.push_back(p);
}
// Strong: build the whole copy, then commit with a nothrow swap.
void assign_strong(const std::vector<Payload>& src) {
std::vector<Payload> fresh;
fresh.reserve(src.size());
for (const Payload& p : src) fresh.push_back(p);
items.swap(fresh);
}
};
void show(const char* label, const Record& r) {
std::cout << label << " holds " << r.items.size() << ":";
for (const Payload& p : r.items) std::cout << " " << p.tag;
std::cout << "\n";
}
int main() {
std::vector<Payload> start;
start.reserve(3);
start.push_back(Payload("a"));
start.push_back(Payload("b"));
start.push_back(Payload("c"));
std::vector<Payload> next;
next.reserve(2);
next.push_back(Payload("x"));
next.push_back(Payload("y"));
Record one, two;
one.assign_strong(start);
two.assign_strong(start);
Payload::copies_left = 1; // let the second copy fail
try { one.assign_basic(next); }
catch (const std::exception& e) { std::cout << "assign_basic: " << e.what() << "\n"; }
show("one", one);
Payload::copies_left = 1;
try { two.assign_strong(next); }
catch (const std::exception& e) { std::cout << "assign_strong: " << e.what() << "\n"; }
show("two", two);
}
Example explained
Line 1copies_left makes the copy of "y" fail on demand, standing in for a real allocation failure buried inside a copy constructor.
Line 2assign_basic calls clear() first, so a, b and c are already destroyed when the throw happens: the Record is usable but its old value is unrecoverable.
Line 3assign_strong fills the local vector fresh and only then calls items.swap(fresh), which exchanges pointers and cannot throw.
Line 4When the copy throws, fresh is destroyed while the stack unwinds, which is why two still holds a b c.
Ordering a two-object update
Demonstrates that two safe operations on different objects only add up to a strong function when the throwing one runs first.
<algorithm>
<cstddef>
<iostream>
<stdexcept>
<utility>
<vector>
struct Table {
std::vector<int> keys;
std::size_t limit;
Table(std::size_t cap, std::vector<int> initial)
: keys(std::move(initial)), limit(cap) { keys.reserve(cap); }
// Strong: the check precedes the only mutation, and the reserved
// capacity means push_back cannot allocate.
void insert(int k) {
if (keys.size() == limit) throw std::runtime_error("table full");
keys.push_back(k);
}
// Nothrow: erasing ints only moves ints.
void erase(int k) {
auto it = std::find(keys.begin(), keys.end(), k);
if (it != keys.end()) keys.erase(it);
}
};
void move_bad(Table& from, Table& to, int k) {
from.erase(k); // commits before the risky step
to.insert(k); // throws: k is now in neither table
}
void move_good(Table& from, Table& to, int k) {
to.insert(k); // the throwing step comes first
from.erase(k); // nothrow commit
}
void show(const char* label, const Table& t) {
std::cout << label << " = {";
for (std::size_t i = 0; i < t.keys.size(); ++i)
std::cout << (i ? "," : "") << t.keys[i];
std::cout << "}\n";
}
int main() {
Table a(4, {1, 7}), b(2, {2, 3}); // b already sits at its limit
try { move_bad(a, b, 7); }
catch (const std::exception& e) { std::cout << "move_bad: " << e.what() << "\n"; }
show("a", a);
show("b", b);
Table c(4, {1, 7}), d(2, {2, 3});
try { move_good(c, d, 7); }
catch (const std::exception& e) { std::cout << "move_good: " << e.what() << "\n"; }
show("c", c);
show("d", d);
}
Example explained
Line 1insert tests the limit before touching keys, so it either throws with no effect or succeeds: on its own it is strong.
Line 2move_bad erases from a first, so when insert throws the key 7 exists in neither table and there is no rollback code to run.
Line 3move_good performs the same two calls in the opposite order, so the failure leaves c and d byte for byte as they were.
Line 4Both functions failed with the same message; the guarantee is the only thing that differs, which is why it belongs in the documentation.
Important notes
The strong guarantee is not always affordable or even available: inserting a range into a std::vector is only basic, and the library documents that instead of copying the whole container first.
reserve(size() + 1) before every append keeps the commit nothrow but can reallocate on each call; when two members must change together, storing them as one vector of structs replaces the two-step commit with a single strong operation.
Common mistakes
Reading a caught exception as proof that nothing happened: after a basic-guarantee call a container may already have grown or lost its contents, so resuming with stale assumptions corrupts state further.
Writing assignment as clear-then-refill (or delete-then-new): if the refill throws, the previous value is gone for good, while copy-and-swap would have cost one temporary.
Assuming a function is strong because each call inside it is strong: the first commit is irreversible, so a later throw leaves a half-applied update, as move_bad shows.
Try it yourself
Change, predict, then run
Add Registry::replace_all(std::vector<std::string> new_names, std::vector<std::string> new_codes) that checks both sizes and every code first and then commits with two swaps. Call it with one four-character code and print names.size(), codes.size() and consistent() to confirm the registry is untouched.
Open the C++ workspaceCheck your understanding
A function calls insert on one container, which offers the strong guarantee, and then erase on another container, which cannot throw. What guarantee does the function as a whole offer?
- Basic, because chaining any two container operations weakens the result to basic
- Nothrow, because the only step that can throw already offers the strong guarantee
- Strong, because the step that can throw runs before anything is modified and the commit cannot fail
- None, because a throwing insert may leave its container in an unspecified state
Show answer
The order is what makes it strong: if insert throws, nothing has changed yet, and once insert succeeds the remaining erase cannot fail, so the caller sees either the finished update or the original state. The first option is tempting because composition often does weaken the guarantee, but only when a commit happens before a step that can throw, as in move_bad where the erase runs first and the lost key cannot be recovered. The second option confuses 'strong' with 'cannot throw': a strong function can still propagate the exception.