Copy/Swap Idiom in C++
C++ automatically gives every class six special member functions. For classes that own a resource (raw memory, file handles, OS objects, …) the defaults are rarely correct, so you eventually write your own “Big Five” (ctor, dtor, copy/move ctor, copy/move assignment). When you reach the copy-assignment operator you hit three hard problems:
- self-assignment (
a = a
) - resource leaks if the left side already owns something
- partial updates when new allocations throw
The community solution that ticks all boxes is the Copy/Swap Idiom. Below is a deep dive into how and why it works, how it interacts with move semantics, what guarantees it gives, and the (few) cases where you may skip it.
The classical, broken, operator=
#
struct Buffer {
std::size_t n;
int* data; // owns a heap array
Buffer(std::size_t n) : n{n}, data{new int[n]} {}
~Buffer() { delete[] data; }
// ⚠️ naïve copy-assignment
Buffer& operator=(const Buffer& rhs) {
delete[] data; // leak-prevention ✔
n = rhs.n; // copy members
data = new int[n]; // allocate. may throw!
std::copy(rhs.data, rhs.data+n, data);
return *this;
}
};
Issues#
- Self assignment –
delete[] data
obliterates the very data we are about to copy. - Exception safety –
new
can throwstd::bad_alloc
; the object is then left with anullptr
→ UB on destructor.
Enter Copy/Swap#
High-level plan#
- Make a copy of the right-hand side (may throw, but that’s OK).
- swap the current object with that copy (noexcept).
- Let the temporary (now holding old state) go out of scope and destroy itself.
If step 1 throws, *this
is untouched → strong guarantee: either the assignment succeeds completely or nothing happens.
Implementation#
import <algorithm> // std::swap
struct Buffer {
std::size_t n = 0;
int* data = nullptr;
Buffer() = default;
Buffer(std::size_t n) : n{n}, data{new int[n]} {}
~Buffer() { delete[] data; }
Buffer(const Buffer& rhs) // deep copy ctor
: Buffer(rhs.n) // delegate
{ std::copy(rhs.data, rhs.data+n, data); }
// Step 0: provide a non-throwing swap
void swap(Buffer& other) noexcept {
using std::swap; // enable ADL
swap(n, other.n);
swap(data, other.data);
}
// Step 1–3: copy/swap assignment
Buffer& operator=(Buffer rhs) { // rhs taken **by value**
swap(rhs); // now *this owns rhs' data
return *this; // rhs dies, cleaning old data
}
!};
Key points:
- The parameter is by value → copy ctor already performed.
swap
is noexcept; that is critical because throwing inside copy-assignment breaks the basic exception-safety guarantee.- The body is three lines, handles self-assignment naturally, and is easy to prove correct.
How good are the guarantees?#
Guarantee | Why it holds here |
---|---|
Basic – object remains valid | swap is noexcept ; destructors never throw. |
Strong – state unchanged on failure | All potential failures happen before we swap. |
No-throw | Not provided – copying can still fail. Marking operator= noexcept would be a lie. |
Interaction with Move Semantics#
With C++11 the compiler also generates a move-assignment operator (Buffer(Buffer&&)
and operator=(Buffer&&)
) as long as no user-defined copy operations exist . Because we do define them, the implicitly-declared moves disappear. Luckily, Copy/Swap can reuse move operations for free:
Buffer(Buffer&&) noexcept = default; // steal pointer
Buffer& operator=(Buffer&& rhs) noexcept { // move-assign
swap(rhs); // O(1) – just swaps a pointer
return *this;
}
Note that we keep swap
as the single authoritative low-level primitive; both the copy and move assignments forward to it.
Performance Reality Check#
- Copy/Swap allocates exactly once per assignment – same as the naïve but safe.
- It may allocate even for
a = std::move(b)
(because parameter is by value). If this matters, keep the explicit move operator above. - Modern compilers optimise the by-value parameter into a move when the argument is an rvalue, so the cost is negligible in practice.
Pitfalls & Best Practices#
- Write
swap
as a free friend or as a member?
A hidden friend enables ADL and keeps the interface clean. Either way, mark itnoexcept
. - Do not forget
std::swap
inside your overload – calling unqualifiedswap
is the canonical pattern to support composites that themselves specialiseswap
. - Never allocate outside the copy – the moment you touch owning members before swapping you lose the strong guarantee.
- Test the self-assignment path explicitly:
Buffer buf(1024);
buf = buf; // must not leak or corrupt
When not to use Copy/Swap#
- Trivially copyable aggregates – the compiler-generated operator is fine.
- Large fixed buffers where copy + swap’s extra allocation is unacceptable – write a hand-rolled strong-guarantee operator that reuses existing storage.
- PImpl classes – often you only swap a single
unique_ptr
, so a custom move-assignment may already be trivial.