Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, you separated an object’s lifetime from the storage it occupies. That distinction matters again when an operation fails halfway through: even if every pointer is valid at the start, an exception can leave ownership or object state inconsistent.
In this final lesson of the ownership module, you’ll design operations that remain safe when allocation or copying throws. The practical goal is to state what callers may rely on after failure, then arrange resource acquisition and state changes to meet that promise. Allow about 40 minutes, including the selected video passages and reading.
Specify the promise before changing the code
Exception safety is a property of an operation, not merely a claim that its destructor cleans up. The two guarantees you will use most often are:
- Basic guarantee: If the operation throws, no resources leak and the object’s invariants still hold. Its contents may have changed.
- Strong guarantee: If the operation throws, it also has no observable effect on the state covered by the promise. The operation either succeeds or leaves that state as it was.
A no-throw guarantee means no exception escapes. It is useful for cleanup and for the final step of a strong-guarantee operation, but it is not something to declare merely because failure seems unlikely.
Watch Klaus Iglberger’s CppCon talk Back to Basics: Exceptions for the distinction between valid-but-changed state and an unchanged state. Notice the qualification about sockets: some external effects cannot simply be undone.
Back to Basics: Exceptions - Klaus Iglberger - CppCon 2020
Klaus Iglberger’s Back to Basics: Exceptions (CppCon) gives a concise account of the guarantees and their limits.
Watch the guarantees. Focus on what each promise says about state after an exception, particularly why a successful receive from a socket cannot necessarily be rolled back.
The scope of a guarantee matters. “This buffer is unchanged if append throws” is precise. “Nothing in the program changed” would also include logging, consumed input, file writes, and other effects the buffer cannot control. Before implementing an operation, identify its invariant, its possible failure points, and exactly which state its guarantee covers.
Why cleanup alone is not enough
Suppose a class owns a dynamic array and an assignment operator releases the old array before allocating its replacement:
delete[] data_;
data_ = new int[new_size]; // Allocation can throw.
size_ = new_size;
If new throws, assignment to data_ never happens. The member still contains the address of freed storage, so even destroying the object can cause another invalid access. This provides neither the basic nor the strong guarantee. Setting data_ to nullptr immediately after deletion might restore a usable invariant, but it would not restore the old contents: that would be a possible route to the basic guarantee, not the strong one.
Read the contrasting assignments in CERT Secure Coding’s ERR56-CPP. Guarantee exception safety. The example uses an int array, making allocation the important failure point; with elements whose copying can throw, newly acquired storage must also have an owner throughout the copy.
ERR56-CPP. Guarantee exception safety | CERT Secure Coding
CERT Secure Coding’s assignment example makes the difference between “release first” and “prepare first” concrete.
In “Noncompliant Code Example (No Exception Safety),” start with the failure analysis, then inspect the assignment operator through the end of its code block. Continue to “Compliant Solution (Strong Exception Safety)”: read the repair rationale and its code block, stopping before “Risk Assessment.” Track which array the object owns if allocation fails.
The general repair is to acquire and prepare replacement state while the old state is intact. But that is only half the proof. The eventual change from old to new must not itself throw after partially modifying the object.
A basic-guarantee append
Consider a small collection that owns strings and caches their total byte count. Its invariant is that total_bytes_ equals the sum of the stored string lengths. The input below is an independent batch, not a reference into the collection itself.
#include <cstddef>
#include <limits>
#include <stdexcept>
#include <string>
#include <vector>
class RecordBatch {
std::vector<std::string> records_;
std::size_t total_bytes_ = 0;
public:
void append_basic(const std::vector<std::string>& incoming) {
for (const auto& record : incoming) {
if (record.size() >
std::numeric_limits<std::size_t>::max() - total_bytes_) {
throw std::length_error("byte count overflow");
}
records_.push_back(record); // May allocate or copy.
total_bytes_ += record.size(); // Cannot throw.
}
}
};
Imagine the third push_back throws. The first two records have already been appended, so the operation is not strong. It is basic: std::vector manages its storage, the failed insertion of a std::string leaves this vector valid without an inserted third record, and the count changes only after an insertion succeeds. The object can still be used and destroyed.
This illustrates why update order is part of the guarantee. Incrementing total_bytes_ before push_back would break the invariant if insertion threw. RAII would still release memory, but it would not repair the incorrect count. No leak is necessary for the basic guarantee; it is not sufficient.
Turn preparation into a transaction
To give the whole append the strong guarantee, perform potentially throwing work on a temporary that owns its own storage. Only then replace the live state:
#include <utility>
// Added to RecordBatch:
void append_strong(const std::vector<std::string>& incoming) {
auto staged = records_; // May allocate or copy.
auto staged_bytes = total_bytes_;
for (const auto& record : incoming) {
if (record.size() >
std::numeric_limits<std::size_t>::max() - staged_bytes) {
throw std::length_error("byte count overflow");
}
staged.push_back(record); // May allocate or copy.
staged_bytes += record.size();
}
records_.swap(staged); // No-throw here: default allocator.
total_bytes_ = staged_bytes; // No-throw integer assignment.
} // staged destroys the old records.
If copying records_, checking the count, or appending to staged throws, the temporary cleans itself up. Neither live member has changed. Once preparation finishes, std::vector<std::string>::swap with its default allocator is non-throwing, as is assigning the byte count. There is no throwing work left between beginning the commit and returning. The old records end up in staged and are destroyed when it goes out of scope.
The proof relies on those specific operations, not on a belief that swaps are universally safe. A custom resource-owning type must establish that its own swap or move-based commit truly cannot throw. Likewise, this function promises to preserve the RecordBatch state; it does not promise to reverse unrelated external effects. The brief interval between swapping the vector and assigning the count is not exposed by this function, which invokes no callback there; concurrent access without synchronization would be a separate error.
Watch the selected portions of Iglberger’s talk to see the same progression from broken ownership, through RAII and a basic guarantee, to a temporary followed by a non-throwing commit. The final passage explains why noexcept must be a verified promise rather than a hopeful annotation.
Back to Basics: Exceptions - Klaus Iglberger - CppCon 2020
Return to Iglberger’s Back to Basics: Exceptions (CppCon) for a resource-owning assignment example and the role of a non-throwing commit.
In the Widget case study, watch the failure point, then the repairs. Look for the change from manually managing a pointer to preparing a complete temporary before touching the target. Finish with no-throw operations; pay particular attention to what happens if an exception escapes a function declared noexcept.
Choose the guarantee the workload needs
The strong version copies all existing records before adding the new ones. That is straightforward to reason about, but its additional allocations and work may be unacceptable in a latency-sensitive path. The basic version performs incremental work and may be the better contract when callers can tolerate a successfully appended prefix. The important decision is to document that possibility, not accidentally offer it while callers assume all-or-nothing behavior.
For a different owning type, a strong implementation might prepare only the replacement portion rather than copy everything. The same proof obligations remain: every acquired resource has an owner while failure is possible; the existing state stays untouched during preparation; and the final commit consists only of operations known not to throw. If you cannot meet the last obligation—or an external action cannot be undone—state a narrower or basic guarantee instead.
You can audit such code at each potentially throwing line by asking two questions: Who owns every resource right now? and What state would the caller observe if execution stopped here? Those questions are useful even in code that reports failures by return values rather than exceptions.
Conclusion
The basic guarantee preserves invariants and resources after failure, while permitting partial progress. The strong guarantee adds an unchanged-state promise for the state it covers. RAII makes failure-path cleanup reliable; preparing new state separately and committing it with genuinely non-throwing operations makes rollback possible without writing fragile rollback code.
This closes the ownership and lifetime module. Next, you’ll apply these habits while writing constrained C++20 templates, where a clear type contract makes generic code easier to use and diagnose.