Skip to content
C++
Language
since C++11
Expert

Exception Safety: Guarantees, Copy-and-Swap, and noexcept

The four exception safety guarantees (no-guarantee, basic, strong, nothrow), copy-and-swap idiom, the Cargill Widget problem, PIMPL as the general solution, and noexcept specification.

Every piece of C++ code makes an implicit promise about what happens when an exception is thrown. Breaking that promise β€” even in a function that claims not to use exceptions β€” leaves objects in indeterminate states, leaks resources, and causes cascading failures. Understanding the four exception safety levels, and the idioms that deliver them, separates maintainable library code from fragile application code.


The four levels

No guarantee

If an exception is thrown, anything can happen: resources leak, invariants break, objects become unusable, program state is indeterminate.

cpp
// No guarantee: the second allocation may throw after the first succeeded
struct NaiveOwner {
    T1* a;
    T2* b;
    NaiveOwner() : a(new T1), b(new T2) {}  // if T2 throws, T1 leaks
    ~NaiveOwner() { delete a; delete b; }
};

When new T2 throws, b was never set, but a was already allocated. The destructor runs only if the constructor completes β€” so a leaks. This is the classic resource leak that RAII solves.

Rule: no code should have no guarantee. It takes only one missed cleanup path to cause a leak or corruption.

Basic guarantee

If an exception is thrown, no resources leak and all objects remain in a valid, destructible state β€” but their values may be unpredictable.

cpp
// Basic guarantee via RAII:
struct RaiiedOwner {
    std::unique_ptr<T1> a;
    std::unique_ptr<T2> b;
    RaiiedOwner() : a(std::make_unique<T1>()), b(std::make_unique<T2>()) {}
    // If make_unique<T2> throws: a is destroyed by unique_ptr, no leak.
};

Now the failure is safe: a is cleaned up automatically. But after a failed construction, RaiiedOwner may be in a state with a set and b null β€” callers must be prepared for this.

The basic guarantee is the minimum acceptable standard for all code. Design classes with invariants that remain valid even in the presence of exceptions.

Strong guarantee

If an exception is thrown, the operation leaves the program in exactly the same state it was in before the operation was attempted β€” commit-or-rollback semantics. No side effects are visible on failure.

cpp
// Strong guarantee: transaction-style
void safe_push(std::vector<T>& v, const T& x) {
    // Make a copy (might throw) β€” but v is untouched:
    std::vector<T> temp = v;
    temp.push_back(x);         // might throw β€” but temp is a local copy
    v = std::move(temp);       // swap is nothrow; only this mutates v
}

// Simpler alternative: std::vector::push_back already provides
// the strong guarantee when T's move constructor doesn't throw.

The strong guarantee is valuable for containers and operations where partial updates are unacceptable. It is more expensive than the basic guarantee because it requires making a copy, performing work on the copy, and committing with a nothrow operation.

Nothrow guarantee

The operation cannot throw an exception under any circumstances. This is essential for the machinery that makes the other guarantees work.

Functions that must be nothrow:

  • Destructors: called during stack unwinding; if a destructor throws while another exception is active, std::terminate is called
  • Move constructors and move assignment (when possible): containers like vector use move during reallocation only if the move constructor is noexcept
  • swap: used by copy-and-swap to commit changes atomically
  • Deallocation functions: operator delete cannot throw

noexcept β€” documenting and enforcing the nothrow guarantee

Mark nothrow functions with noexcept. The compiler may generate better code (no exception handling tables for these functions), and callers can rely on the guarantee.

cpp
void swap(MyType& a, MyType& b) noexcept {
    using std::swap;
    swap(a.data_, b.data_);   // pointer swap β€” nothrow
    swap(a.size_, b.size_);   // int swap β€” nothrow
}

MyType(MyType&& other) noexcept
    : data_(std::exchange(other.data_, nullptr))
    , size_(std::exchange(other.size_, 0))
{}

Conditional noexcept propagates the nothrow-ness of contained types:

cpp
template<typename T>
class Container {
    T* data_;
    std::size_t size_;
public:
    Container(Container&& other)
        noexcept(std::is_nothrow_move_constructible_v<T>)
        : data_(other.data_), size_(other.size_)
    { other.data_ = nullptr; other.size_ = 0; }
};

Why std::vector cares: when vector grows its buffer (reallocation), it moves elements from old to new storage. If the move constructor is noexcept, it moves them. If not, it falls back to copying (to maintain the strong guarantee). A non-noexcept move constructor on a vector element can silently cause O(n) copies on every push_back.

cpp
// This triggers copies instead of moves on vector reallocation:
class SlowWidget {
    std::vector<int> data_;
public:
    // compiler generates a non-noexcept move constructor
    // because std::vector's move constructor is noexcept but
    // the compiler doesn't always infer this automatically
    SlowWidget(SlowWidget&&) = default;  // check: is this noexcept?
};

// Verify:
static_assert(std::is_nothrow_move_constructible_v<SlowWidget>,
              "move constructor must be noexcept for vector efficiency");

Copy-and-swap: the canonical strong guarantee

The copy-and-swap idiom achieves the strong guarantee for assignment operators by separating the risky work (copy) from the commit (swap):

cpp
class Buffer {
    uint8_t* data_;
    std::size_t size_;

public:
    Buffer(std::size_t n) : data_(new uint8_t[n]), size_(n) {}

    ~Buffer() { delete[] data_; }

    // Copy constructor: may throw (allocation)
    Buffer(const Buffer& other)
        : data_(new uint8_t[other.size_])
        , size_(other.size_)
    { std::copy_n(other.data_, size_, data_); }

    // nothrow swap: only pointer/integer swaps
    void swap(Buffer& other) noexcept {
        std::swap(data_, other.data_);
        std::swap(size_, other.size_);
    }

    // Copy assignment with STRONG guarantee:
    // Pass by value triggers copy constructor β†’ if that throws, *this is unchanged
    Buffer& operator=(Buffer other) noexcept {  // 'other' is already constructed
        swap(other);    // nothrow commit
        return *this;   // 'other' (holding old data) is destroyed here
    }

    // Move assignment (nothrow):
    Buffer& operator=(Buffer&& other) noexcept {
        swap(other);
        return *this;
    }
};

The idiom:

  1. Accept by value β€” copy constructor runs; if it throws, the exception propagates before *this is touched
  2. Swap β€” nothrow pointer exchange; this is the commit
  3. Old data is destroyed in the temporary's destructor β€” also nothrow

Trade-off: copy-and-swap always allocates even when assigning to self (a = a) or when the destination has enough capacity. For types where this matters (large buffers), a hand-rolled assignment that reuses capacity may be preferable β€” at the cost of code complexity and a weaker guarantee.


The Cargill Widget problem: why strong guarantee requires structural change

Tom Cargill's classic challenge: can you make this assignment strongly exception-safe without changing the class?

cpp
class Widget {
    T1 t1_;
    T2 t2_;
public:
    Widget& operator=(const Widget& other) {
        t1_ = other.t1_;   // might throw β€” t1_ is modified
        t2_ = other.t2_;   // might throw β€” t1_ is already changed, can't undo
        return *this;
    }
};

Answer: no, not without changing the class. If t1_ assignment succeeds but t2_ throws, *this is in a "half-assigned" state. Rolling back t1_ requires T1 to provide its own nothrow rollback facility β€” which it typically doesn't.

The fix: PIMPL + copy-and-swap. Moving members behind a single pointer enables a nothrow swap:

cpp
class Widget {
    struct Impl { T1 t1; T2 t2; };
    std::unique_ptr<Impl> pimpl_;

public:
    explicit Widget() : pimpl_(std::make_unique<Impl>()) {}
    ~Widget() = default;

    void swap(Widget& other) noexcept {
        std::swap(pimpl_, other.pimpl_);   // pointer swap β€” nothrow
    }

    // Strong guarantee: copy constructs a new Impl, then swaps
    Widget& operator=(Widget other) noexcept {
        swap(other);
        return *this;
    }

    Widget(const Widget& other)
        : pimpl_(std::make_unique<Impl>(*other.pimpl_))
    {}
};

Now swap is a single pointer exchange β€” unconditionally nothrow. The copy-and-swap idiom delivers the strong guarantee on top.


Operations that must be nothrow

OperationWhy nothrow is required
DestructorCalled during unwinding; second exception β†’ std::terminate
swapUsed as the commit in copy-and-swap
Move constructor (ideally)std::vector copies instead of moves if not noexcept
Move assignment (ideally)Same; also used by std::sort, std::rotate
operator deleteDeallocation must never throw
std::mutex::unlockReleasing a lock must never throw

All of these should be marked noexcept explicitly.


Exception safety in containers

STL containers have documented guarantees. Understanding them prevents surprise failures:

cpp
std::vector<T> v;

// push_back:
// - strong guarantee if T is CopyInsertable and !noexcept(T::T(T&&))
// - otherwise basic guarantee
v.push_back(x);

// insert in the middle:
// - basic guarantee (elements may have been shifted, then insertion fails)
v.insert(v.begin() + 2, x);

// std::map::operator[]:
// - strong guarantee (inserts a default-constructed value; if that throws, map unchanged)
m["key"] = value;

// std::list::splice:
// - nothrow (only pointer manipulation, no allocation)
list1.splice(list1.begin(), list2);

Practical checklist

cpp
Writing a class that manages a resource?
  β†’ Use unique_ptr / RAII β€” basic guarantee for free

Writing an assignment operator?
  β†’ Use copy-and-swap for strong guarantee
  β†’ Or PIMPL + copy-and-swap for classes with multiple members

Writing a swap()?
  β†’ Must be noexcept β€” document it, verify it

Writing a move constructor/assignment?
  β†’ Mark noexcept if possible
  β†’ If move can throw, vector falls back to copy

Writing a destructor?
  β†’ Must be noexcept (default is noexcept; never let it throw)
  β†’ Catch and swallow exceptions inside destructors

Documenting a function?
  β†’ State which guarantee it provides
  β†’ "Basic guarantee: object remains valid after exception"
  β†’ "Strong guarantee: no observable change on failure"

Quick reference: what guarantee does this provide?

CodeGuaranteeWhy
unique_ptr<T1> a; unique_ptr<T2> b; constructorBasicBoth cleaned up; but object may be half-init
v.push_back(x) with noexcept moveStrongvector uses move during reallocation; old buffer kept as rollback
v.push_back(x) without noexcept moveStrong (copies)vector copies elements; more expensive but still strong
operator=(Widget other) (copy-and-swap)StrongCopy may throw before *this is touched; swap is nothrow
Destructor throwingNoneIf during unwinding: std::terminate
noexcept function calling throwing functionTerminatesUncaught exception in noexcept context calls std::terminate

noexcept and move semantics: std::move_if_noexcept

std::vector refuses to use your move constructor during reallocation unless it is marked noexcept. When a vector grows its buffer it must move (or copy) every existing element into the new allocation. If a move constructor can throw and the vector is halfway through relocation, there is no safe rollback β€” the old buffer is partially destroyed. To preserve the strong guarantee, the standard mandates using the copy constructor instead, even though that is O(n) more expensive.

The mechanism is std::move_if_noexcept, which returns an rvalue reference only when the move constructor is noexcept, and a const lvalue reference otherwise:

cpp
#include <utility>   // std::move_if_noexcept

template<typename T>
void relocate(T* dest, T* src) {
    // Moves if T's move constructor is noexcept; copies otherwise.
    // This is exactly what std::vector uses internally during reallocation.
    new (dest) T(std::move_if_noexcept(*src));
}

// Example: a type whose move can throw
struct RiskyMove {
    std::vector<int> data;
    // std::vector's move is noexcept, but suppose we add a throwing log:
    RiskyMove(RiskyMove&& other) : data(std::move(other.data)) {
        log("moved");   // hypothetically may throw
    }
};
// std::vector<RiskyMove> will COPY elements on reallocation β€” O(n) overhead.
// Fix: mark the move constructor noexcept if the body truly cannot throw.

struct SafeMove {
    std::vector<int> data;
    SafeMove(SafeMove&&) noexcept = default;   // inherits noexcept from vector
};
// std::vector<SafeMove> moves elements β€” O(1) per element.
static_assert(std::is_nothrow_move_constructible_v<SafeMove>);

The lesson generalises beyond std::vector: std::deque, std::unordered_map, and any generic container that relocates elements will choose the same path. A missing noexcept on a move constructor is a silent O(n) performance regression waiting to be discovered under load.


Exception-neutral code

Exception-neutral code does not handle exceptions itself, but it never breaks its own invariants when an exception propagates through it. This is a distinct concept from providing the basic or strong guarantee: neutral code simply lets exceptions pass up the call stack while ensuring that every object it manages is left in a valid state.

Generic code β€” templates, adaptors, higher-order functions β€” must almost always be exception-neutral. A generic transform cannot know what exception a user-supplied functor might throw; it must let those exceptions propagate while guaranteeing no resource leak:

cpp
// Exception-neutral generic algorithm:
template<typename InputIt, typename OutputIt, typename UnaryOp>
OutputIt safe_transform(InputIt first, InputIt last,
                        OutputIt out, UnaryOp op) {
    // If op() throws, the output range is partially written β€”
    // that is the caller's problem. Our invariant is: no leak,
    // iterators left consistent.
    for (; first != last; ++first, ++out) {
        *out = op(*first);   // may throw β€” we do not catch it
    }
    return out;
}

// Exception-neutral node insertion in a linked list:
template<typename T>
void LinkedList<T>::push_front(const T& value) {
    // Construct node first (may throw) β€” list is untouched:
    auto node = std::make_unique<Node>(value);   // allocation + copy may throw
    // Only modify the list after all risky work is done:
    node->next = head_;
    head_ = node.release();   // nothrow pointer assignment β€” this is the commit
}

The push_front implementation above is exception-neutral and simultaneously provides the strong guarantee: if Node construction throws, the list is completely unchanged. Neutral code often achieves the strong guarantee for free precisely because it defers all mutations until after the risky work completes.


Transaction-based design: strong guarantee without copy-and-swap

When copying the entire object is too expensive, you can achieve the strong guarantee by staging mutations in temporary local state and committing with a nothrow operation. This is the "transaction" pattern: build the new state from scratch in a local variable, verify it, then install it atomically.

cpp
class ConfigRegistry {
    std::map<std::string, std::string> entries_;

public:
    // Strong guarantee WITHOUT copying the whole map:
    void update_batch(std::span<const std::pair<std::string,std::string>> updates) {
        // Stage: accumulate all changes in a local copy of ONLY the affected keys
        std::map<std::string, std::string> staged;
        for (auto& [key, value] : updates) {
            // Validate each entry (may throw on bad input)
            validate(key, value);
            staged[key] = value;   // may throw (allocation) β€” entries_ untouched
        }
        // Commit: merge staged changes β€” each insert/assign here is individually
        // fallible, but map::insert_or_assign provides the basic guarantee per-key.
        // For true strong guarantee on the commit step, swap the whole map:
        auto new_entries = entries_;          // copy (may throw) β€” entries_ still intact
        for (auto& [k, v] : staged)
            new_entries.insert_or_assign(k, v);  // may throw
        entries_ = std::move(new_entries);    // nothrow (map move is noexcept)
    }

private:
    static void validate(const std::string& key, const std::string& value) {
        if (key.empty()) throw std::invalid_argument("empty key");
        if (value.size() > 4096) throw std::length_error("value too long");
    }
};

The key insight: std::map::operator= with an rvalue is noexcept on most implementations, making the final assignment the nothrow commit. Every allocation and validation happens in locals that are cleanly destroyed if an exception fires, leaving entries_ unmodified.


Annotating APIs with exception expectations

Document which functions may throw and which are noexcept as part of the API contract, not as an afterthought. In public headers, noexcept is part of the function type since C++17 β€” callers can query it with noexcept(expr), and changing it later is an ABI break.

cpp
// Public API with explicit exception annotations:
class FileStream {
public:
    // May throw std::system_error on I/O failure:
    explicit FileStream(std::filesystem::path path);

    // Nothrow β€” always safe to call in destructors and finally-blocks:
    void close() noexcept;
    ~FileStream() noexcept;

    // May throw std::ios_base::failure:
    void write(std::span<const std::byte> data);

    // Nothrow: returns false on failure instead of throwing:
    [[nodiscard]] bool try_write(std::span<const std::byte> data) noexcept;

    // Conditionally noexcept β€” propagates T's moveability:
    template<typename T>
    void write_object(T&& obj)
        noexcept(noexcept(serialize(std::forward<T>(obj))));
};

// At a call site, callers can verify statically:
static_assert(noexcept(std::declval<FileStream>().close()),
              "FileStream::close must be noexcept for use in destructors");

A consistent convention β€” for example, every method that returns a bool is noexcept, every method that can fail by throwing follows the "may throw X" documentation pattern β€” dramatically reduces the cognitive load on callers writing exception-safe code against your API.


std::terminate β€” when it fires, what it does, how to intercept it

std::terminate ends the program immediately without unwinding the stack. Destructors do not run, finally blocks (from other languages) don't execute, file buffers may not be flushed. Understanding exactly when it fires is essential for writing robust programs.

std::terminate is called in these situations:

cpp
// 1. An exception escapes a noexcept function:
void must_not_throw() noexcept {
    throw std::runtime_error("oops");  // β†’ std::terminate
}

// 2. An exception is thrown while another is already propagating
//    (a destructor throws during stack unwinding):
struct Bad {
    ~Bad() { throw std::runtime_error("destructor threw"); }  // β†’ std::terminate
};

// 3. A pure virtual function is called (during construction/destruction):
struct Base {
    virtual void f() = 0;
    Base() { f(); }  // calls pure virtual β†’ std::terminate
};

// 4. An exception escapes main() (implementation-defined, often terminate):
// int main() { throw 42; }

// 5. std::thread's thread function throws:
std::thread t([] { throw std::runtime_error("thread threw"); });
t.join();  // β†’ std::terminate (exception escaped the thread function)

You can install a custom terminate handler with std::set_terminate. This is your last chance to log diagnostic information before the process exits:

cpp
#include <exception>
#include <cstdlib>

void my_terminate_handler() noexcept {
    // Attempt to log the current exception:
    if (auto eptr = std::current_exception()) {
        try {
            std::rethrow_exception(eptr);
        } catch (const std::exception& e) {
            std::fprintf(stderr, "FATAL: unhandled exception: %s\n", e.what());
        } catch (...) {
            std::fprintf(stderr, "FATAL: unknown unhandled exception\n");
        }
    } else {
        std::fprintf(stderr, "FATAL: std::terminate called (no active exception)\n");
    }
    std::abort();  // generate a core dump for post-mortem analysis
}

// Install at program startup:
int main() {
    std::set_terminate(my_terminate_handler);
    // ...
}

Note that the terminate handler itself must not throw β€” if it does, std::abort is called unconditionally. The handler must also be noexcept-compatible, though the standard does not require the noexcept specifier on the handler type.


Testing exception safety: the Abrahams injection methodology

Systematically proving exception safety requires injecting a throw at each step of an operation and verifying that class invariants hold afterward. Dave Abrahams formalized this methodology for the SGI STL: every allocation, copy, and construction is a potential throw site, and the class must tolerate a failure at any of them.

The core technique is a "counting allocator" or "throw-after-N" wrapper that throws a std::bad_alloc after exactly N successful calls:

cpp
#include <stdexcept>
#include <cassert>

// A type that throws on the Nth copy:
struct ThrowOnCopy {
    static int copy_count;
    static int throw_at;

    int value;
    explicit ThrowOnCopy(int v = 0) : value(v) {}

    ThrowOnCopy(const ThrowOnCopy& other) : value(other.value) {
        if (++copy_count == throw_at)
            throw std::runtime_error("injected copy failure");
    }

    ThrowOnCopy& operator=(const ThrowOnCopy& other) {
        if (++copy_count == throw_at)
            throw std::runtime_error("injected copy failure");
        value = other.value;
        return *this;
    }
};
int ThrowOnCopy::copy_count = 0;
int ThrowOnCopy::throw_at   = -1;

// Test framework: run op(), injecting a throw at each copy step
template<typename Container, typename Op, typename Invariant>
void assert_strong_guarantee(Container& c, Op op, Invariant check_invariant) {
    // Snapshot the container state before the operation:
    Container before = c;

    for (int n = 1; ; ++n) {
        ThrowOnCopy::copy_count = 0;
        ThrowOnCopy::throw_at   = n;

        try {
            op(c);
            // If op() succeeded without throwing, we're done:
            break;
        } catch (const std::runtime_error&) {
            // Verify strong guarantee: container must equal pre-op state:
            assert(check_invariant(c, before)
                   && "strong guarantee violated: container changed on exception");
            // Restore and try the next injection point:
            c = before;
        }
    }
}

// Usage:
void test_push_back_strong_guarantee() {
    std::vector<ThrowOnCopy> v = { ThrowOnCopy{1}, ThrowOnCopy{2} };

    assert_strong_guarantee(
        v,
        [](auto& vec) { vec.push_back(ThrowOnCopy{99}); },
        [](const auto& vec, const auto& snapshot) {
            return vec.size() == snapshot.size();
        }
    );
}

For production codebases, libraries such as libstdc++'s test suite and Boost's strong_exception_guarantee test helpers automate this pattern. The methodology generalises to any operation: iterate throw sites from 1 to ∞, checking after each injection that either the operation completed or the invariant is intact.