C++ / Intermediate / Lesson 11
Exceptions and state guarantees
Use exceptions for documented failure paths, let RAII clean up, and design operations so failed work cannot silently corrupt an object.
On this page
Lesson goals
- Throw meaningful standard exception types and catch by const reference.
- Distinguish basic, strong, and non-throwing guarantees.
- Apply prepare-then-commit and avoid throwing destructors.
Failure is part of the interface
A function can fail because input violates a domain rule, an allocation fails, or an external operation cannot complete. Decide which failures are ordinary results and which use exceptions. A missing optional record often fits optional; an invariant-breaking construction request may fit an exception. std::expected is C++23 and is not used as a C++20 facility here. State whether an operation can throw and what remains unchanged if it does. Exceptions propagate control to a matching handler while automatic objects in exited scopes are destroyed.
Report a precise domain failure
Throw an exception object by value and catch standard exception families by const reference. Catching by value can slice a derived exception and copy unnecessarily. Put specific handlers before general ones. In the first example a negative batch count is rejected before multiplication, and the handler prints the message supplied by our own code. This output does not depend on implementation-specific wording from a library exception. The small positive domain is also bounded to avoid signed overflow. Successful calculations return normally without involving the handler.
#include <iostream>
#include <stdexcept>
int units(int batches) {
if (batches < 0 || batches > 1000) {
throw std::invalid_argument("batches out of range");
}
return batches * 5;
}
int main() {
try { std::cout << units(-1) << "\n"; }
catch (const std::invalid_argument& error) {
std::cout << error.what() << "\n";
}
}
batches out of range
The invalid request throws before producing an integer. The matching handler prints the exact custom message.
Name what failure preserves
The basic guarantee preserves invariants and prevents resource leaks, although values may change. The strong guarantee makes failure leave the observable state unchanged. A non-throwing guarantee promises no exception escapes; it does not mean an operation cannot report failure through another channel. These guarantees are properties of a complete operation, not merely of one statement. An operation that changes a balance and then appends to a possibly allocating log can fail after the balance changed. RAII protects resource cleanup but does not automatically roll back business state.
Do risky work before changing the owner
Prepare a candidate in local owned storage, validate it, then commit through a non-throwing operation. The second example validates a candidate title before swapping it with the current title. std::string with its standard allocator supports the non-throwing swap used here. Copying the candidate argument may fail before entry, but that also leaves current unchanged. In a real aggregate, a sequence of swaps is only a strong transaction if every commit step cannot fail and no observer can see partial state. External network or file effects need a separate recovery design.
#include <iostream>
#include <stdexcept>
#include <string>
void replace_title(std::string& current, std::string candidate) {
if (candidate.empty()) { throw std::invalid_argument("empty title"); }
current.swap(candidate);
}
int main() {
std::string title{"Draft"};
try { replace_title(title, ""); }
catch (const std::invalid_argument&) { std::cout << "rejected\n"; }
std::cout << title << "\n";
replace_title(title, "Final");
std::cout << title << "\n";
}
rejected
Draft
Final
The empty candidate leaves Draft untouched. The accepted candidate commits Final and the old title is cleaned up locally.
Handle failures where recovery is meaningful
Catch an exception where you can retry, translate it into a documented boundary result, or add useful context and rethrow with throw;. A catch-all that silently continues can hide broken assumptions. Destructors should release resources without propagating failure, particularly during unwinding. If explicit close or commit can fail, expose that operation separately before destruction. Exception tests should inspect both the error category and the state afterward. Also exercise allocation-sensitive paths when practical; catching std::exception everywhere is not a substitute for knowing which operation owns the recovery policy.
Common errors and fixes
A derived exception loses its specific identity.
- Why it happens
- It was caught and copied as a base value.
- How to fix it
- Catch by const reference and order handlers specifically.
- Prevent it next time
- Avoid value catches for polymorphic exceptions.
A failed operation leaves half-updated state.
- Why it happens
- Mutation occurred before later throwing work.
- How to fix it
- Prepare and validate locally, then commit without throwing.
- Prevent it next time
- Specify and test the intended exception guarantee.
Cleanup unexpectedly terminates the process.
- Why it happens
- A destructor allowed an exception to escape during unwinding.
- How to fix it
- Keep destruction non-throwing and expose fallible completion separately.
- Prevent it next time
- Audit destructor dependencies and failure reporting.
Check your understanding
1. Add tests for units at zero, one thousand, negative one, and one thousand one. State results and which requests must throw.
Show worked solution
units(0) returns zero and units(1000) returns five thousand. The two out-of-range requests throw invalid_argument with our message. The maximum multiplication is safely representable on conforming int implementations. A test should fail if an invalid request returns normally, rather than merely printing an unexpected result.
2. A method decrements stock and then pushes a record into a vector. If push_back throws, can the method claim the strong guarantee? Propose a correction.
Show worked solution
No: the stock already changed. Prepare a candidate complete state, including the log addition, in local owned objects. Once all throwing work succeeds, replace the old state using a proven non-throwing commit. Alternatively document only the basic guarantee if that matches the application, but do not label partial updates transactional.
Key takeaways
- State the failure channel and state guarantee.
- RAII cleans resources; transactions preserve business state.
- Catch by reference and keep destructors non-throwing.