C++ / a working model

79 / 163   ·   C++11   ·   12 min

Exception Safety: Guarantees, noexcept, and Commit Points

Keep this sentence

Exception safety is about what remains of an object after failure, not whether the code contains a catch. Use RAII to retain resources, prepare-then-commit to achieve the strong guarantee, and let noexcept describe only operations that truly do not propagate exceptions; copy-and-swap is a strategy with costs and preconditions, not a universal answer.

In this lesson
  1. Write the contract for the failure path first
  2. RAII cleans up resources; it does not automatically roll back values
  3. noexcept is a boundary promise, not an exception suppressor
  4. The applicability bounds of copy-and-swap
  5. Example
  6. Exercise

Write the contract for the failure path first

The basic guarantee requires no resource leaks after failure and that object invariants still hold, but values may change. The strong guarantee requires that failure does not change the promised observable state, as if the operation never committed. The no-throw guarantee means failure will not be reported through an exception; it does not mean every business request must succeed. Returning an error code can still fail. With no guarantee, the caller cannot even reliably continue using the object.

These guarantees belong to specific operations, not a uniform label for an entire class. The invariant of Scores below is that all scores are non-negative. replace completes validation in a temporary vector and swaps only on success; any validation exception or allocation failure during preparation will not change the old values. Its strong guarantee applies only to object state; it does not claim to undo external logs, network requests, or already-completed file writes.

RAII cleans up resources; it does not automatically roll back values

When an exception propagates to a matching handler, stack unwinding destroys fully constructed automatic objects along the path in reverse order. vector, smart pointers, and lock guards bind cleanup responsibility to lifetime, avoiding handwritten release at every exit. If a non-delegating constructor throws, the complete object's destructor is not executed, but already-constructed members and bases are cleaned up. If the target of a delegating constructor has already completed successfully and the delegating constructor body then throws, that object's destructor is called.

Yet mutating a member and then throwing does not automatically satisfy the strong guarantee even if there is no leak. The reliable approach is to prepare a candidate state first, then commit with a non-throwing operation. The example below uses vector swap with the default allocator; custom-allocator cases require checking propagation traits and allocator equality. Do not assume that an arbitrary container swap is safe.

noexcept is a boundary promise, not an exception suppressor

Once an exception attempts to cross a noexcept function boundary, std::terminate is called instead of returning a default value. A function may throw and catch internally as long as the exception does not escape. Destructors and cleanup operations should avoid throwing outward; a destructor that propagates an exception during stack unwinding also terminates the program. Handle exceptions typically by catching by const reference; rethrow with throw; to preserve the original exception.

A trustworthy noexcept move constructor makes it easier for containers to maintain the strong guarantee on reallocation; if move may throw and copy is available, containers often choose copy. Concrete guarantees still require reading the corresponding operation and the element type's conditions. Do not casually add noexcept to a replace that performs allocation; that turns a recoverable failure into process termination.

The applicability bounds of copy-and-swap

Copy assignment can first construct Scores temporary(other);, then commit with a non-throwing swap. If the copy fails, the original value is unchanged, and self-assignment is naturally safe; the example below retains this process for observation. After the swap, the temporary holds the old resources and releases them on function exit, so the commit point is clear.

The cost is that the temporary copy may allocate extra and lose the chance to reuse original capacity. For multiple members that each already manage resources, consider the Rule of Zero first. Customizing assignment here also suppresses generation of implicit move members, so you cannot casually claim it has efficient move semantics. Save as exceptions.cpp, compile with g++ -std=c++11 -Wall -Wextra -pedantic exceptions.cpp -o exceptions, then run ./exceptions.

Pitfalls

  • Catching every exception and silently continuing does not restore invariants; handle exceptions where you can recover, add context, or establish an error boundary.
  • The strong guarantee depends on the final commit operation not failing; if swap can still throw after a successful copy, you cannot claim transactional behavior merely from the name copy-and-swap.

Run an example

Minimum C++11 · complete program · Download .cpp

#include <cassert>
#include <initializer_list>
#include <iostream>
#include <stdexcept>
#include <utility>
#include <vector>

class Scores {
    std::vector<int> values_;
public:
    explicit Scores(std::initializer_list<int> values) {
        replace(std::vector<int>(values));
    }
    Scores(const Scores&) = default;

    void swap(Scores& other) noexcept {
        values_.swap(other.values_);
    }
    Scores& operator=(const Scores& other) {
        Scores temporary(other);
        swap(temporary);
        return *this;
    }
    void replace(std::vector<int> candidate) {
        for (int value : candidate) {
            if (value < 0) {
                throw std::invalid_argument("negative score");
            }
        }
        values_.swap(candidate);
    }
    const std::vector<int>& values() const noexcept {
        return values_;
    }
};

int main() {
    Scores scores{10, 20};
    const auto before = scores.values();
    bool rejected = false;
    try {
        scores.replace({30, -1});
    } catch (const std::invalid_argument&) {
        rejected = true;
    }
    assert(rejected);
    assert(scores.values() == before);

    Scores replacement{40, 50};
    scores = replacement;
    assert(scores.values() == replacement.values());
    const Scores& same = scores;
    scores = same;
    assert((scores.values() == std::vector<int>{40, 50}));
    scores.replace({60});
    assert((scores.values() == std::vector<int>{60}));
    static_assert(noexcept(scores.swap(replacement)),
                  "commit must not throw");
    std::cout << scores.values().front() << '\n';
}

Compile locally

g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread tooling-exceptions.cpp -o example && ./example

Expected result

60

CHECK YOUR UNDERSTANDING

Close the answer. Explain it.

If replace first executes values_ = std::move(candidate) and then checks whether values_ contains negatives, which guarantee remains? Write the order that restores the original contract, and explain what the existing assertions verify.

Show a reference answer

Throwing only after negatives have already entered the member both changes the original value and breaks the invariant that all scores are non-negative, so even the basic guarantee is absent for this class. The correct order is to check every element of the local candidate and execute values_.swap(candidate) only after all pass. In the example, rejected confirms that failure actually occurred, and values() == before confirms that the original value is retained after failure; successful replacement and self-assignment also have assertions. It does not simulate memory-allocation failure; that path's guarantee comes from preparing the copy before commit, with temporary resources cleaned up by RAII.

Check the sources

Drafts and official chapters change. The version mark is only the example’s minimum.

Back to the catalog