Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Three Guarantees

The vocabulary in this chapter was largely codified by David Abrahams in the late 1990s, in a series of papers and standards-committee documents that became the basis for how the C++ Standard Library specifies exception behavior. Abrahams’s contribution was not the idea that operations might be exception-safe to varying degrees — that was already in the air — but the insistence that there are exactly three useful contractual levels, distinct enough that confusing them produces wrong code, and that every operation in a serious library should declare which level it provides.

The three levels, named:

  1. The no-throw guarantee: the operation will not throw, full stop.
  2. The basic guarantee: if the operation throws, no resources are leaked, all invariants are preserved, but the visible state of objects may have changed in unspecified ways.
  3. The strong guarantee: if the operation throws, the visible state is exactly as it was before the operation began. Either it did the thing entirely, or it did nothing.

There is a fourth level worth naming explicitly even though Abrahams did not formalize it:

  1. No guarantee (sometimes called no exception safety): if the operation throws, anything may have happened. Resources may have leaked, invariants may be broken, the program may be in a state from which no further operation is meaningful. Code that provides no guarantee is broken code. This used to be common; it is still common; nobody likes to admit it.

Most of the rest of this book is about how to recognize each level, how to upgrade between them, and what they cost.

The no-throw guarantee

This is the easiest to define and the easiest to lie about.

A no-throw operation will not, under any circumstances, throw an exception. Not just “won’t throw normally” — won’t throw at all. Calls to it can be made unconditionally; their failure mode, if any, must be communicated by some other channel (return value, side effect, abort).

In C++, no-throw operations are the building blocks of everything else. If you cannot rely on at least some operations to not throw, you cannot recover from an exception, because the recovery code itself might throw. Specifically:

  • Destructors must not throw. The standard does not literally forbid it (until C++11, which made it the default), but throwing from a destructor during stack unwinding from another exception calls std::terminate and ends the program.
  • swap operations on standard-library types are required to not throw. This is structurally important, as we’ll see in chapter 4: copy-and-swap depends on it.
  • Move constructors and move assignment, if marked noexcept, allow standard containers to use them in operations that need the strong guarantee. If they aren’t noexcept, containers fall back to copies, which is slower but exception-safer. This is the famous std::vector reallocation behavior.
  • Primitive-type assignment, integer arithmetic on built-ins, pointer assignment, and similar elemental operations.

Examples of operations that look no-throw but are not, in real C++:

// LOOKS no-throw. ISN'T.
int compute(int x) {
    return x * 2;  // can't throw...
}

// ...except this:
struct Counter {
    int n;
    Counter(int n_) : n(n_) {}
};

int compute(int x) {
    return Counter(x * 2).n;  // Counter's ctor is implicitly noexcept(false)
                              // unless declared otherwise.
}

The second compute does not throw in practice, but the type system does not know that. If you use it in a context that requires noexcept (move operations, certain container operations), the compiler will reject it or fall back to a slower path. In modern C++, the noexcept keyword on a function is the contract:

int compute(int x) noexcept { return x * 2; }

If a noexcept function tries to throw, the runtime calls std::terminate. This is a deliberate, blunt design: noexcept is a load-bearing property in the type system, used by container code to choose between code paths, and a function that lies about it must be punished severely enough that the lie cannot survive testing.

The thing to internalize about the no-throw guarantee is that it is contagious in one direction: a no-throw operation can only call other no-throw operations. As soon as you call something that can throw, you can throw, regardless of whether the throw is “likely.” The contract is binary.

In Java, this guarantee is harder to state because almost everything can throw RuntimeException. The closest analog is “won’t throw a checked exception,” which is partial. Python, Ruby, and JavaScript don’t really have a meaningful no-throw concept; in Rust, panic-free is approximately the same idea, with the same contagion property. Go’s analog is “won’t panic,” which most code does not bother to verify.

The basic guarantee

The basic guarantee says: if an operation throws, no resources are leaked and all invariants are preserved, but the observable state of the objects involved may have changed in unspecified ways.

The two clauses do different work. “No resources are leaked” is about memory, file handles, locks, network connections — the things RAII (chapter 3) directly addresses. “Invariants are preserved” is the harder part. An invariant is anything that must always be true about an object: a vector’s size() <= capacity(), a hash table’s bucket count being a power of two, a binary tree’s order property, an account’s balance being non-negative. Invariants are properties the type promises to maintain, and the basic guarantee says throwing an exception cannot leave the type in a state that violates them.

Here is transfer_to from chapter 1, modified to provide the basic guarantee:

void Account::transfer_to(Account& other, int amount) {
    balance_ -= amount;
    try {
        log_transfer(amount, &other);
        other.balance_ += amount;
    } catch (...) {
        balance_ += amount;  // restore
        throw;
    }
}

This is better. If log_transfer throws, we restore balance_ and re-throw. The invariant “money is conserved across the two accounts” is preserved — we end with the same balances we started with. But notice what we still don’t have: if balance_ += amount succeeds, but then a hypothetical operation between it and the end of the function throws, we have a partial state visible. (In this exact code there’s no such operation, but in real systems there often is.)

The basic guarantee is achievable for most code, most of the time, with reasonable discipline. RAII handles the leak side; thoughtful ordering handles the invariant side. This is the level the C++ Standard Library generally provides for operations that aren’t trivially no-throw, and it is the realistic floor for production code.

A subtlety: the basic guarantee allows the visible state to change. If vector::push_back throws because the new element’s copy constructor threw, the standard says the vector is left in a “valid state” — but the standard does not promise the size or contents are unchanged. (Different implementations make different choices here; libstdc++ and libc++ generally leave the vector unchanged for the throw-during-copy case, but this is not portable contract.) The user has to either query and re-establish state explicitly, or use a strong-guarantee primitive instead.

The strong guarantee

The strong guarantee says: the operation either succeeds completely, or it throws and the visible state is bit-for-bit indistinguishable from what it was before the operation started.

This is the transactional guarantee: commit-or-rollback at the level of a single operation. It is the closest thing C++ provides to a database transaction’s atomicity property, and the analogy is exact.

Here is an account transfer with the strong guarantee, written awkwardly to make the structure visible:

void Account::transfer_to(Account& other, int amount) {
    // Phase 1: do everything that might throw, on a side copy.
    int new_self_balance = balance_ - amount;
    int new_other_balance = other.balance_ + amount;
    log_transfer(amount, &other);  // might throw — fine, no state changed yet

    // Phase 2: commit. These operations must not throw.
    balance_ = new_self_balance;
    other.balance_ = new_other_balance;
}

This works because int assignment is noexcept. Once the throwing operation (log_transfer) is past, the rest of the function is no-throw, so we can guarantee that either we never touched balance_ and other.balance_, or we touched both successfully.

The pattern generalizes: do the work that might throw on a side copy first, then swap or assign the results into place using only no-throw operations. This is the heart of copy-and-swap, of pimpl swapping, of two-phase commit. Chapter 4 develops it in detail.

The strong guarantee is the most expensive level. It typically requires extra copies, or a careful split of the operation into “preparation” and “commit” phases, and many operations cannot be cheaply written this way. The C++ Standard Library only provides the strong guarantee for a subset of operations, and where it does, the documentation generally calls it out. (For example, std::vector::push_back provides it if the element’s copy/move is exception-safe; std::map::insert provides it.)

The honest reality of the strong guarantee in production: most code does not need it, the basic guarantee is enough, and trying to provide the strong guarantee where the basic one suffices is a real source of complexity and slowness. The places where the strong guarantee actually matters are usually places where some external observer (a user, a database, another service, the file system) might see the half-completed state and act on it. Inside a single object, in a single thread, between two member-function calls — basic is generally fine.

Examples of code that claims one and provides another

This is the part that working engineers most need to internalize, because in real codebases the gap between claimed and provided guarantee is enormous.

Example 1: vector::push_back, almost-but-not-quite strong

template<class T>
void vector<T>::push_back(const T& v) {
    if (size_ == capacity_) reallocate(capacity_ * 2);
    new (data_ + size_) T(v);
    ++size_;
}

Where can this throw?

  • reallocate allocates memory, which can throw bad_alloc. If it does, we haven’t touched anything observable yet. Fine.
  • T(v) (copy construction of the new element). If this throws, size_ has not been incremented, so we haven’t observably added an element. The new memory we allocated is gone (well, leaked, in this snippet — fix that with RAII).
  • After the ++size_, nothing. We’re done.

So this snippet is almost strong. The hole: if reallocate succeeded — moved or copied old elements into the new buffer, freed the old buffer — and then the new-element construction throws, we’ve already irreversibly changed the underlying buffer pointer. The visible vector still has the same elements, but a moves-during-reallocation vector implementation has executed those moves, which might have side effects on the source elements.

The standard’s resolution: if T’s move constructor is noexcept, the implementation is allowed to move during reallocation; otherwise it must copy. With copies, if the new-element copy then throws, we can free the new buffer and keep using the old one — strong guarantee. With moves, the implementation has already moved-from the old elements, can’t get them back, and must commit to the new buffer — basic guarantee.

This is a real, documented, intentional trade-off in the standard. The lesson is that “what guarantee does this provide” can depend on properties of the type parameter. There is no shortcut to actually thinking it through.

Example 2: assignment operator, the classic basic-claiming-strong

class Image {
    char* data_;
    size_t size_;
public:
    Image& operator=(const Image& other) {
        delete[] data_;                    // (1)
        data_ = new char[other.size_];     // (2) might throw bad_alloc
        size_ = other.size_;               // (3)
        std::memcpy(data_, other.data_, size_);
        return *this;
    }
};

If new char[other.size_] at (2) throws, what state is the object in?

data_ has been deleted but is now a dangling pointer. size_ is unchanged. The destructor will eventually delete[] data_, which is undefined behavior. We have provided no guarantee. This code is broken.

The basic guarantee fix: assign to a local first, then swap.

Image& operator=(const Image& other) {
    char* new_data = new char[other.size_];           // might throw, fine
    std::memcpy(new_data, other.data_, other.size_);  // might throw, fine
    delete[] data_;
    data_ = new_data;
    size_ = other.size_;
    return *this;
}

This provides the strong guarantee, because all the throwing operations happen before any state mutation. (We could go further with copy-and-swap, but this is enough.)

Example 3: Java assignment looking strong, providing basic

class Cache {
    private Map<String, byte[]> entries = new HashMap<>();
    private long totalBytes = 0;

    public void put(String key, byte[] value) {
        byte[] old = entries.put(key, value);
        if (old != null) totalBytes -= old.length;
        totalBytes += value.length;
    }
}

If entries.put throws (it can: HashMap can resize, allocation can fail), totalBytes has not been touched yet. Good. If value.length throws… it can’t, length is a final int. Good.

But what if entries.put succeeds, then we update totalBytes, then much later something else in the call chain throws? The Cache is now in a state where entries and totalBytes are consistent. So this is, somewhat surprisingly, fine — the basic guarantee, possibly the strong guarantee depending on what put does on resize-failure. Java’s HashMap is documented to be in a valid state on OutOfMemoryError, with no formal guarantee about what got inserted; you would need to check by reading the source.

The point is that even this trivial code requires you to read the documentation of every called function to know what guarantee you’re providing. There is no shortcut.

The cost gradient

GuaranteeTypical costWhen you need it
No-throwFree, but constrains the implementation.Building blocks for everything else. Destructors. Move ops. swap.
BasicRAII discipline; thoughtful ordering.Default for most production code.
StrongExtra copy, or two-phase commit pattern.When external observers might see partial state. Transactional code.

A useful rule of thumb: aim for the basic guarantee everywhere, the strong guarantee at API boundaries that mutate observable state, and the no-throw guarantee for the small set of primitives you build the strong guarantee out of. Everything else is over- or under-engineering.

The next chapter is about the discipline that makes the basic guarantee mostly mechanical: RAII. The chapter after that is about the patterns that buy you the strong guarantee on top of it.

Further reading

  • David Abrahams, “Exception-Safety in Generic Components,” Generic Programming: Proceedings of a Dagstuhl Seminar, 2000. The foundational paper. Find it online; it is free and short.
  • Herb Sutter, Exceptional C++ (1999), items 8–19. Long out, never out of date.
  • Bjarne Stroustrup, The C++ Programming Language, 4th ed., Appendix E (“Standard-Library Exception Safety”). The closest thing to an official statement of guarantees by the language’s designer.
  • Andrei Alexandrescu, “Generic: Change the Way You Write Exception-Safe Code — Forever,” C/C++ Users Journal, 2003. Introduces ScopeGuard, which we’ll meet in chapter 4.