Skip to content
C++
Idiom
since C++98
Intermediate

Copy-and-Swap Idiom

Implement exception-safe assignment by taking the argument by value, swapping internals with *this, and letting the old state destroy itself on scope exit.

Copy-and-Swap Idiomsince C++98

An assignment operator pattern that delegates copying to the copy constructor, swaps the copy's internals with *this, and lets the copy destroy the old state on scope exit β€” achieving the strong exception guarantee with no explicit self-assignment check.

Overview

Correct assignment operators for resource-owning classes are harder to write than they appear. Naive implementations delete old memory before allocating new memory, creating a dangling pointer if allocation throws. They also require an explicit self-assignment check, which guards a case that almost never occurs in practice while leaving the real hazard β€” exception safety β€” unaddressed.

Copy-and-swap eliminates both problems with one insight: take the argument by value. The compiler generates the copy (or, since C++11, the move) at the call site, before the function body executes. Inside the function body, only a noexcept swap remains. If the copy throws, operator= is never entered and *this is untouched β€” that is the strong exception guarantee. Self-assignment produces a copy-then-swap of identical data, which is safe and requires no branch.

Syntax

The idiom rests on three mandatory components:

  1. A correct copy constructor β€” allocates and deep-copies owned resources; may throw.
  2. A noexcept swap β€” exchanges all data members with another instance without throwing; noexcept is required, not cosmetic.
  3. A by-value assignment operator β€” takes ownership of the already-constructed copy, swaps internals, and returns.
cpp
class Buffer {
    char*  data_;
    size_t size_;

public:
    // 1. Copy constructor β€” may throw
    Buffer(const Buffer& other)
        : data_(new char[other.size_])
        , size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    // 2. noexcept swap β€” C++11 noexcept specifier
    void swap(Buffer& other) noexcept {
        using std::swap;
        swap(data_, other.data_);
        swap(size_, other.size_);
    }

    // ADL hook: code written as `using std::swap; swap(a, b)` finds this
    friend void swap(Buffer& a, Buffer& b) noexcept { a.swap(b); }

    // 3. By-value assignment β€” copy (or move) already happened at call site
    Buffer& operator=(Buffer other) noexcept {
        swap(other);   // exchange guts; 'other' now holds old data
        return *this;  // 'other' destroyed here, old data cleaned up
    }

    ~Buffer() { delete[] data_; }
};

The noexcept specifier on swap (introduced in C++11) matters to the STL: std::vector queries std::is_nothrow_move_constructible at compile time to decide whether to move or copy elements during reallocation. A throwing swap disqualifies your type from that optimisation.

Examples

Why the naive approach is broken

cpp
// Three distinct bugs in one function
Buffer& operator=(const Buffer& other) {
    delete[] data_;                 // BUG: if new throws next line, data_ is dangling
    data_ = new char[other.size_];  // may throw
    size_ = other.size_;
    std::copy(other.data_, other.data_ + size_, data_);
    return *this;
}

// Self-check fixes self-assignment correctness but not exception safety
Buffer& operator=(const Buffer& other) {
    if (this == &other) return *this;  // guards against a = a, not exceptions
    delete[] data_;                    // still dangling if new throws
    data_ = new char[other.size_];
    // ...
}

The self-assignment guard optimises for a case that almost never happens while leaving the actual failure mode β€” allocation throwing after delete[] β€” completely unhandled.

Exception-safety walkthrough

cpp
// dst = src β€” call-site sequence:
//
//   Buffer tmp(src);   // copy ctor, may throw
//                      // if throws: operator= never called, dst is intact βœ“
//   dst.operator=(std::move(tmp));
//     dst.swap(tmp);   // noexcept, cannot throw βœ“
//                      // tmp now holds dst's old data
//   ~Buffer() on tmp   // old data cleaned up βœ“

Move-aware unified assignment (C++11)

Since C++11, the same operator=(Buffer other) signature handles both copy-assign and move-assign. When passed an lvalue, the compiler selects the copy constructor to build other; when passed an rvalue, it selects the move constructor. The swap is identical either way.

cpp
class Buffer {
    char*  data_ = nullptr;  // in-class member initializer β€” C++11
    size_t size_ = 0;

public:
    Buffer() = default;
    ~Buffer() { delete[] data_; }

    // Copy constructor β€” may throw
    Buffer(const Buffer& other)
        : data_(new char[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    // Move constructor β€” noexcept, C++11
    // std::exchange reads and zeroes in one step β€” C++14
    Buffer(Buffer&& other) noexcept
        : data_(std::exchange(other.data_, nullptr))
        , size_(std::exchange(other.size_, 0)) {}

    // Unified assignment: handles copy-assign AND move-assign
    Buffer& operator=(Buffer other) noexcept {
        swap(other);
        return *this;
    }

    void swap(Buffer& other) noexcept {
        using std::swap;
        swap(data_, other.data_);
        swap(size_, other.size_);
    }

    friend void swap(Buffer& a, Buffer& b) noexcept { a.swap(b); }
};

Buffer a = make_buffer(1024);
Buffer b;

b = a;             // copy ctor builds 'other' β†’ swap β†’ copy-assign
b = std::move(a);  // move ctor builds 'other' β†’ swap β†’ move-assign

std::exchange (C++14) is the idiomatic way to zero-out moved-from members atomically. Before C++14, assign then zero explicitly: data_ = other.data_; other.data_ = nullptr;.

Rule of Five checklist (C++11)

cpp
class Proper {
public:
    ~Proper();                           // owns a resource β€” must be defined
    Proper(const Proper&);               // deep copy
    Proper(Proper&&) noexcept;           // noexcept steal β€” C++11
    Proper& operator=(Proper) noexcept;  // unified: covers both copy- and move-assign
    // move-assign is covered by operator=(Proper&&) β†’ move ctor β†’ swap
};

If you define any one of destructor, copy constructor, copy assignment, move constructor, or move assignment, define or = delete all five (C++11 Rule of Five). The unified by-value assignment covers two of the five with a single function.

Best Practices

Make swap unconditionally noexcept. If any member's swap is not noexcept, your composite swap cannot be noexcept either. Audit member types, especially any pre-C++11 custom types that lack noexcept annotations.

Always provide a free friend swap. Generic code written to the STL convention uses using std::swap; swap(a, b) β€” ADL finds your friend. A member-only swap is invisible to such code, so callers silently fall back to std::swap's copy-based default.

Prefer = default when all members are RAII types. If every data member already has correct copy and move semantics β€” std::unique_ptr, std::vector, std::string β€” compiler-generated special members are correct, noexcept-qualified where appropriate, and simpler than hand-written copy-and-swap. Reserve the idiom for classes managing raw resources directly.

Mark the assignment operator noexcept explicitly. If your swap is noexcept and the parameter is built from a noexcept move, the operator can be noexcept. Say so β€” it documents the guarantee and enables callers to rely on it.

Common Pitfalls

Performance penalty on move-assign with the unified signature. operator=(Buffer other) requires constructing other via the move constructor, then a swap, then destruction of other (which now holds the old state). For types with expensive destructors β€” deep trees, arena-backed containers β€” this is observable overhead compared to a bespoke move-assign that simply swaps members:

cpp
// Separate move-assign avoids intermediate destruction cost
Buffer& operator=(Buffer&& other) noexcept {
    if (this != &other) swap(other);  // other is now responsible for old data
    return *this;
}

Provide both if profiling shows the difference matters; otherwise the unified form is cleaner.

Self-assignment creates an unnecessary copy. buf = buf constructs a full copy of buf, swaps, then destroys it. Correct, but wasteful. For types assigned to themselves in hot paths (unusual), a cheap identity check at entry remains valid as a fast-path β€” just not as a correctness requirement:

cpp
Buffer& operator=(Buffer other) noexcept {
    if (this != &other) swap(other);  // optional fast-path, not a correctness guard
    return *this;
}

Missing noexcept cascades silently. A swap that calls any potentially-throwing operation cannot be noexcept. The compiler will silently drop the specifier if the body can throw (with noexcept(false) deduction), or if you omit it, STL containers will fall back to copying your type on reallocation.

Applying the idiom where = default suffices. Copy-and-swap targets classes with raw resource ownership. Wrapping std::unique_ptr<T[]> members in copy-and-swap adds indirection and a hand-maintained invariant where = default on the copy constructor and move operations would be both correct and zero-overhead.

C++11: Unified vs. Separate Assignment

The by-value operator=(T other) signature is the "unified" form: one overload handles both copy-assign (lvalue argument β†’ copy constructor builds other) and move-assign (rvalue argument β†’ move constructor builds other). This is clean and correct, but it makes a trade-off that C++11 lets you avoid.

The cost of the unified form for move-assignment

When the caller writes c1 = std::move(c2), the unified form:

  1. Calls the move constructor to build the other parameter β€” cheap (pointer steal).
  2. Calls swap β€” cheap (pointer exchanges).
  3. Calls the destructor on other, which now holds c1's old data β€” potentially expensive.

The destructor runs at the close of operator='s scope, not at the point of the swap. For types with large or costly destructors (deep trees, arena-backed containers, reference-counted graphs), this deferred cleanup matters. Scott Meyers documented this as a known pessimization: the old resources linger inside the moved-from parameter until operator= returns, temporarily inflating peak memory use.

A minimal single-pointer example makes the cost concrete. Swap-based move-assign needs three pointer assignments (the three steps inside std::swap), while a direct implementation needs only two:

cpp
// Unified form: 3 pointer ops inside swap + deferred destructor
Buffer& operator=(Buffer other) noexcept { swap(other); return *this; }

// Separate move-assign: 2 pointer ops + immediate release
Buffer& operator=(Buffer&& other) noexcept {
    if (this != &other) {
        delete[] data_;           // old data freed immediately
        data_ = other.data_;
        size_ = other.size_;
        other.data_ = nullptr;
        other.size_ = 0;
    }
    return *this;
}

The unified form is not wrong β€” it is correct and still noexcept β€” but for types where move operations exist precisely to be cheap, the deferred release can be a surprise.

Separate copy-and-swap plus move-and-swap

When you want both correctness and performance, write the copy-assign with the idiom (strong exception guarantee) and the move-assign separately (immediate release, noexcept):

cpp
class Buffer {
    char*  data_ = nullptr;
    size_t size_ = 0;

public:
    Buffer() = default;
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer& other)
        : data_(new char[other.size_]), size_(other.size_) {
        std::copy(other.data_, other.data_ + size_, data_);
    }

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

    void swap(Buffer& other) noexcept {
        using std::swap;
        swap(data_, other.data_);
        swap(size_, other.size_);
    }
    friend void swap(Buffer& a, Buffer& b) noexcept { a.swap(b); }

    // Copy-assign: copy-and-swap β€” strong exception guarantee
    Buffer& operator=(const Buffer& other) {
        Buffer tmp(other);   // may throw; *this untouched if it does
        swap(tmp);           // noexcept
        return *this;
    }

    // Move-assign: direct steal β€” noexcept, immediate release
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) swap(other);
        return *this;
    }
};

This version uses swap for move-assign rather than a raw pointer steal, which is simpler and still noexcept. The key difference from the unified form is that copy-assign is now const Buffer& (no superfluous copy for lvalues already constructed) and move-assign is a distinct overload with its own signature.

The trade-off table:

FormCopy-assignMove-assignOverloads
Unified operator=(T)Strong guaranteeStrong guarantee (deferred release)1
Separate copy+moveStrong guaranteeImmediate release, noexcept2

noexcept and the By-Value Parameter

A recurring question: can operator=(T other) noexcept be legitimately marked noexcept when the copy constructor can throw?

Yes β€” and the reason is a language rule about where parameter construction occurs. In C++, arguments to a function are evaluated and constructed at the call site, before the function body begins. The noexcept specifier governs only what happens inside the function body. If the copy constructor throws while building other, the exception propagates from the call site, not from inside operator=, so the noexcept contract is not violated.

In practice this means:

cpp
// Correct: body only calls swap (noexcept), so this function cannot throw
Buffer& operator=(Buffer other) noexcept {
    swap(other);
    return *this;
}

// Call site β€” copy ctor may throw here, before operator= is entered
buf1 = buf2;   // if copy ctor throws, it propagates to the caller, not from operator=

The is_nothrow_copy_assignable<T> trait will reflect this correctly: it returns false for a type whose copy constructor can throw, even though operator= itself is noexcept. This is the right answer β€” a copy-assignment that might throw (at the call site) is not nothrow copy-assignable.

For move-assign via the unified form, if the move constructor is noexcept, then is_nothrow_move_assignable<T> returns true. This is what std::vector checks during reallocation.

Ensuring swap is noexcept. The noexcept on operator= is only valid if swap is noexcept. Use noexcept on member swap, and audit every member type. A single non-noexcept member swap silently poisons the whole chain:

cpp
void swap(Buffer& other) noexcept {  // explicit, not inferred
    using std::swap;
    swap(data_, other.data_);   // pointer swap β€” always noexcept
    swap(size_, other.size_);   // size_t swap β€” always noexcept
}

If any member's swap could throw, the noexcept specifier on your swap will cause the compiler to emit an implicit noexcept(false) or, with an explicit specifier, call std::terminate if a throw occurs. Always verify with static_assert:

cpp
static_assert(std::is_nothrow_swappable_v<Buffer>);  // C++17

Interaction with the Rule of Zero

The Rule of Zero (C++ Core Guidelines C.20) says: if a class can avoid defining any of the five special members, it should. When every data member is itself a well-behaved RAII type β€” std::unique_ptr, std::shared_ptr, std::vector, std::string β€” the compiler generates copy, move, and destruction automatically, and they are correct and noexcept-qualified where appropriate.

Copy-and-swap applies only when you have raw resource ownership that no existing RAII wrapper covers β€” typically when writing a custom container, allocator-aware type, or FFI wrapper.

cpp
// Rule of Zero β€” no copy-and-swap needed
class Config {
    std::string name_;
    std::vector<int> values_;
    std::unique_ptr<Logger> logger_;
    // Compiler generates: copy ctor, copy-assign, move ctor, move-assign, dtor
    // All are correct, exception-safe, and noexcept where possible
};

// Rule of Five with copy-and-swap β€” raw resource, no RAII wrapper available
class SocketHandle {
    int fd_ = -1;
public:
    explicit SocketHandle(int fd) : fd_(fd) { }
    ~SocketHandle() { if (fd_ >= 0) ::close(fd_); }

    SocketHandle(const SocketHandle&) = delete;  // sockets are not copyable
    SocketHandle& operator=(const SocketHandle&) = delete;

    SocketHandle(SocketHandle&& other) noexcept
        : fd_(std::exchange(other.fd_, -1)) { }

    SocketHandle& operator=(SocketHandle&& other) noexcept {
        if (this != &other) {
            if (fd_ >= 0) ::close(fd_);
            fd_ = std::exchange(other.fd_, -1);
        }
        return *this;
    }
    // No copy-and-swap here: the type is move-only, so the pattern doesn't apply
};

A useful heuristic: if you find yourself writing new/delete, malloc/free, or a C API handle lifecycle inside a class, that class needs the Rule of Five. Everything else should aim for Rule of Zero, and copy-and-swap becomes irrelevant.

Wrapping raw resources in a dedicated handle class (sometimes called the "rule of zero via rule of five in one place") confines the copy-and-swap complexity to a single small class, letting every class that uses the handle stay at Rule of Zero.

Copy-and-Swap with Move Semantics

The unified assignment operator is a deliberate trade-off, not the always-correct choice. When the move constructor is cheap (pointer steal) but the destructor is expensive (deep tree teardown, arena release, reference-count decrement), the deferred destruction inside the unified form inflates peak resource use and adds a hidden cost per move-assign call.

The practical decision rule: use the unified form when the type is small, the destructor is trivial or near-trivial, and you want the fewest lines of code. Reach for separate overloads when profiling shows the deferred destructor is measurable, or when the type's move contract explicitly promises O(1) move-assign.

cpp
// C++11
// Guideline: measure before splitting. Split form shown for types where it matters.
class HeavyTree {
    Node* root_ = nullptr;

public:
    HeavyTree() = default;
    ~HeavyTree() { destroy_recursive(root_); }  // O(n) β€” expensive

    HeavyTree(const HeavyTree& other) : root_(clone_recursive(other.root_)) { }
    HeavyTree(HeavyTree&& other) noexcept : root_(std::exchange(other.root_, nullptr)) { }

    void swap(HeavyTree& other) noexcept { std::swap(root_, other.root_); }
    friend void swap(HeavyTree& a, HeavyTree& b) noexcept { a.swap(b); }

    // Copy-assign: copy-and-swap keeps the strong exception guarantee
    HeavyTree& operator=(const HeavyTree& other) {
        HeavyTree tmp(other);   // may throw; *this untouched
        swap(tmp);              // noexcept
        return *this;           // tmp's destructor runs old data here β€” O(n) but acceptable
    }

    // Move-assign: direct steal releases old data immediately, not deferred
    HeavyTree& operator=(HeavyTree&& other) noexcept {
        if (this != &other) {
            destroy_recursive(root_);               // free old data now
            root_ = std::exchange(other.root_, nullptr);
        }
        return *this;
    }
};

In C++20, operator=(this HeavyTree self) (explicit object parameter) is not yet the conventional form for copy-and-swap β€” the by-value parameter remains idiomatic. C++20 does, however, allow [[nodiscard]] on assignment operators for types where ignoring the result is always a mistake, which pairs cleanly with the unified form.

Copy-and-Swap and noexcept: Impact on std::vector Reallocation

std::vector decides at compile time whether to move or copy your elements during reallocation, and the unified assignment operator's noexcept status is part of that decision. When std::vector<T> grows its buffer, it must transfer elements from the old storage to the new one. If an exception fires mid-transfer, the old buffer must still be intact so the original vector is unmodified. A move that can throw makes this impossible to guarantee, so the standard library falls back to copying.

The trait that governs this is std::is_nothrow_move_constructible<T>, not is_nothrow_move_assignable. But the assignment operator's noexcept-ness affects code that inserts into associative containers, performs std::swap, or uses std::rotate β€” all of which assign through iterators.

cpp
// C++17
#include <type_traits>
#include <vector>

struct Good {
    Good(Good&&) noexcept;           // move ctor is noexcept
    Good& operator=(Good) noexcept;  // unified assign: body only calls noexcept swap
    void swap(Good&) noexcept;
};

struct Risky {
    Risky(const Risky&);             // only copy ctor; no move ctor
    Risky& operator=(Risky other);   // body is noexcept but no specifier declared
};

static_assert(std::is_nothrow_move_constructible_v<Good>);   // true  β†’ vector moves
static_assert(!std::is_nothrow_move_constructible_v<Risky>); // false β†’ vector copies

// Confirm the assignment operator itself is also treated correctly
static_assert(std::is_nothrow_move_assignable_v<Good>);      // true

The practical implication: if your type will live inside std::vector and you care about reallocation performance, the move constructor must be noexcept. The assignment operator's noexcept status is a secondary concern for reallocation but matters for algorithms. Mark both explicitly and verify with static_assert.

cpp
// C++17
// Defensive static_asserts to catch regressions when members change
static_assert(std::is_nothrow_swappable_v<Buffer>);
static_assert(std::is_nothrow_move_constructible_v<Buffer>);
static_assert(std::is_nothrow_move_assignable_v<Buffer>);

Copy-and-Swap for Types with Non-Swappable Members

Some members cannot be swapped at all, and copy-and-swap breaks down the moment the noexcept swap requirement cannot be met. std::mutex is not copyable, not movable, and not swappable β€” the standard explicitly deletes all of these. std::atomic<T> is not copyable or movable either. Any class that owns one of these by value cannot provide a noexcept swap, which means the copy-and-swap idiom does not apply in its usual form.

cpp
// C++11
// This will NOT compile β€” mutex has no swap
class BadLogger {
    std::mutex mu_;
    std::vector<std::string> log_;
public:
    void swap(BadLogger& other) noexcept {
        using std::swap;
        swap(mu_, other.mu_);   // ERROR: std::mutex::swap is deleted
        swap(log_, other.log_);
    }
};

The canonical solutions are indirection and ownership separation. Wrap the non-swappable member in a std::unique_ptr (which is swappable), or separate the mutable state behind a pointer so the value type swaps only the pointer:

cpp
// C++14
// Pattern 1: heap-allocate the non-swappable member
class SafeLogger {
    struct State {
        std::mutex mu;
        std::vector<std::string> log;
    };
    std::unique_ptr<State> impl_;  // unique_ptr IS noexcept-swappable

public:
    explicit SafeLogger() : impl_(std::make_unique<State>()) { }

    // Copy-and-swap works: we swap the unique_ptr, not the mutex
    SafeLogger(const SafeLogger& other) : impl_(std::make_unique<State>()) {
        std::lock_guard lk(other.impl_->mu);
        impl_->log = other.impl_->log;
    }

    void swap(SafeLogger& other) noexcept {
        std::swap(impl_, other.impl_);  // pointer swap β€” noexcept
    }
    friend void swap(SafeLogger& a, SafeLogger& b) noexcept { a.swap(b); }

    SafeLogger& operator=(SafeLogger other) noexcept {
        swap(other);
        return *this;
    }
};

For std::atomic<T>, the same indirection applies. If the atomic truly must stay by value (e.g., for cache-line placement), copy-and-swap is not viable β€” write a bespoke assignment operator that loads and stores atomically, accepting that it will not provide a strong exception guarantee because atomics do not throw:

cpp
// C++11
// Pattern 2: atomic member β†’ bespoke operator=, no copy-and-swap
class Counter {
    std::atomic<int> value_{0};

public:
    Counter() = default;
    Counter(const Counter& other) : value_(other.value_.load()) { }

    // No swap possible; write explicit assignment using atomic load/store
    Counter& operator=(const Counter& other) {
        if (this != &other)
            value_.store(other.value_.load(std::memory_order_acquire),
                         std::memory_order_release);
        return *this;
    }

    // Move: identical to copy for atomics β€” moving a value is not cheaper than copying it
    Counter(Counter&& other) : value_(other.value_.load()) { }
    Counter& operator=(Counter&& other) { return *this = static_cast<const Counter&>(other); }
};

The key insight is that copy-and-swap is an implementation strategy enabled by noexcept swap, not a universal assignment law. When swap is unavailable or semantically wrong for a member, the goal β€” strong exception guarantee, self-assignment safety β€” must be achieved by other means.

See Also

  • RAII β€” the foundational pattern that makes copy-and-swap's scope-based cleanup automatic
  • Type Erasure β€” another idiom where correct copy and move semantics are critical to get right
  • Move Semantics β€” how rvalue references integrate with the unified assignment operator
  • Exception Handling β€” basic, strong, and no-throw guarantees and when each is required