When a class owns a resource — heap memory, a file handle, a socket, a GPU buffer, a CAN bus handle — you need to decide what happens when an object is copied, moved, or destroyed. Get any one of those wrong and you get double-frees, leaks, dangling pointers, or silent data corruption.
This page covers the five special member functions you'll define together, plus the surrounding machinery you can't avoid: shallow vs. deep copies, temporaries and elision, the copy-and-swap idiom, and noexcept.
- 1. Setup: a resource-owning class
- 2. Shallow copy vs. deep copy
- 3. Copy constructor
- 4. Copy assignment operator
- 5. Move constructor
- 6. Move assignment operator
- 7. Rule of 0 / Rule of 3 / Rule of 5
- 8. Copy-and-swap idiom
- 9. Temporary objects
- 10.
noexcepton moves — and why it matters
A trajectory buffer owning a heap array. Every special-member-function example below uses this:
class Trajectory {
public:
Trajectory() = default;
explicit Trajectory(std::size_t n)
: size_{n}, data_{new double[n]} {}
~Trajectory() { delete[] data_; }
// copy, copy-assign, move, move-assign — defined in the sections below
std::size_t size() const { return size_; }
double* data() { return data_; }
private:
std::size_t size_ = 0;
double* data_ = nullptr;
};Because Trajectory manually manages data_, the compiler-generated copy/move would do the wrong thing (see §2). We have to write all five.
A shallow copy copies pointer values, not what they point to — two objects end up referring to the same buffer.
src dst (shallow copy)
┌───────────┐ ┌───────────┐
│ size=3 │ │ size=3 │
│ data ────►│ [a][b][c] ◄──│──── data │
└───────────┘ └───────────┘
When either object's destructor runs, it deletes the buffer — and the other object's data_ is dangling. Touching it is undefined behavior. The next destructor frees the same buffer again → double-free, a crash on glibc.
A deep copy allocates a fresh buffer and copies the contents:
src dst (deep copy)
┌───────────┐ ┌───────────┐
│ size=3 │ │ size=3 │
│ data ────►│ [a][b][c] │ data ────►│ [a][b][c]
└───────────┘ └───────────┘
For raw-owning classes you have to write the deep copy yourself. For classes built from std::vector, std::string, std::unique_ptr, etc., the standard types' copy/move already do the right thing — that's the Rule of 0 (§7).
Creates a new object from an existing lvalue.
Trajectory::Trajectory(const Trajectory& other)
: size_{other.size_}, data_{new double[other.size_]}
{
std::copy(other.data_, other.data_ + size_, data_);
}It runs in five canonical situations:
Trajectory a(10);
Trajectory b = a; // 1. copy-initialization
Trajectory c(a); // 2. direct-initialization
void f(Trajectory t); // 3. pass-by-value
f(a); // — copy ctor invoked on parameter
std::vector<Trajectory> v; // 4. container insertion
v.push_back(a); // — copy ctor unless we have a non-throwing move
auto lam = [a]() { /*...*/ };// 5. lambda capture by valueFor return-by-value, copy elision (NRVO/RVO) usually skips the copy — see §9.
Overwrites an already-constructed object with the contents of another.
The straightforward form, with the self-assignment check and resource swap:
Trajectory& Trajectory::operator=(const Trajectory& other) {
if (this == &other) return *this; // self-assignment guard
delete[] data_; // release current resource
size_ = other.size_;
data_ = new double[size_]; // allocate fresh
std::copy(other.data_, other.data_ + size_, data_);
return *this;
}Two problems with this form:
- Not exception-safe. If
new double[size_]throws, you've alreadydelete[]-ed the old buffer. The object is left in a half-destroyed state. - Easy to get wrong. The self-assignment check, the order of operations, and the duplicated allocation logic are all easy to mis-author.
The copy-and-swap idiom (§8) sidesteps both.
Trajectory a(10), b(20);
b = a; // copy assignment — b now mirrors a
a = a; // safe no-op with the guard above
v[1] = v[0]; // copy-assignment between container elementsCreates a new object by stealing resources from an rvalue. The source is left in a valid but unspecified state — usually empty, so its destructor harmlessly frees nothing.
Trajectory::Trajectory(Trajectory&& other) noexcept
: size_{other.size_}, data_{other.data_}
{
other.size_ = 0;
other.data_ = nullptr; // critical — otherwise both destructors free the same buffer
}Trajectory a(10);
Trajectory b = std::move(a); // explicit move; a is now empty
Trajectory c = Trajectory(20); // construction from temporary — usually elided to direct construction
Trajectory d = f(Trajectory(30)); // move into parameter, then back out
v.push_back(Trajectory(40)); // move into containerThe noexcept is not decorative — see §10.
Overwrites an existing object using an rvalue's resources.
Trajectory& Trajectory::operator=(Trajectory&& other) noexcept {
if (this == &other) return *this;
delete[] data_; // release current resource
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
return *this;
}Trajectory a(10), b(20);
b = std::move(a); // b takes a's buffer; a is empty
v[1] = std::move(v[0]); // move between container slots| Rule | What it says | When it applies |
|---|---|---|
| Rule of 0 | Define none of the five and let the compiler generate them. | Your class is built from RAII types (std::vector, std::string, std::unique_ptr, smart pointers) that already handle their own copy/move/destroy. |
| Rule of 3 (pre-C++11) | If you define any of {destructor, copy ctor, copy assign}, define all three. | Older code; manual resource management. |
| Rule of 5 (C++11+) | If you define any of {destructor, copy ctor, copy assign, move ctor, move assign}, define all five. | A class that owns a non-RAII resource (raw pointer, FILE*, mmap handle, socket fd). |
The deeper intuition: the compiler-generated versions are correct when the member-wise copy/move/destroy is correct — and that's the case if every member knows how to copy/move/destroy itself. A raw owning pointer doesn't, so the moment you have one (or a manual destructor), you've broken member-wise correctness and need to provide all five.
Rule of 0 in practice. This trajectory class needs no special members:
class Trajectory {
std::vector<double> data_; // copy/move/destroy already correct
};You get correct copy, move, and destruction for free — and the compiler will be much better at optimizing them than your hand-written versions.
When you must write all five, here's a reference for Trajectory from above:
class Trajectory {
public:
Trajectory() = default;
explicit Trajectory(std::size_t n);
~Trajectory(); // 1. destructor
Trajectory(const Trajectory& other); // 2. copy ctor
Trajectory& operator=(const Trajectory& other); // 3. copy assign
Trajectory(Trajectory&& other) noexcept; // 4. move ctor
Trajectory& operator=(Trajectory&& other) noexcept; // 5. move assign
private:
std::size_t size_ = 0;
double* data_ = nullptr;
};If you want some but not all of these, be explicit:
class NonCopyable {
public:
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
NonCopyable(NonCopyable&&) noexcept = default;
NonCopyable& operator=(NonCopyable&&) noexcept = default;
};See default_constructors_=default_0_delete.md for the = default / = delete mechanics.
A clever rewrite of the copy assignment operator that gets you exception safety, self-assignment correctness, and DRY-ness with the copy constructor — all in three lines.
class Trajectory {
public:
// ... ctors / dtor as before ...
// unified assignment: takes by value, so the parameter is already a copy
Trajectory& operator=(Trajectory other) noexcept { // takes by VALUE
swap(*this, other); // member-wise swap, can't throw
return *this;
} // `other` (holds OLD data) dies here
friend void swap(Trajectory& a, Trajectory& b) noexcept {
using std::swap;
swap(a.size_, b.size_);
swap(a.data_, b.data_);
}
};Why this works:
- Pass-by-value forces a copy of the rhs before the function body runs. If that copy throws (e.g.
bad_alloc), the call site sees the exception and*thisis untouched — strong exception guarantee. - The body just swaps pointers —
noexcept. The old buffer is inotherafter the swap, andother's destructor frees it at function exit. - One implementation handles both copy and move assignment — when called with an rvalue, the parameter is move-constructed (free), then swapped. No duplicated logic.
- Self-assignment is fine: the copy is independent, so swapping doesn't lose anything.
The "traditional" assignment in §4 is shorter to read but harder to get right. Most modern C++ guides recommend copy-and-swap for any non-trivial owning class — and Rule of 0 when you can use it.
A temporary is an unnamed object the compiler creates while evaluating an expression. They show up:
// 1. Function return value
Trajectory make() { return Trajectory(7); } // temporary returned, destroyed at end of statement
auto t = make();
// 2. Implicit conversion
void take(const Trajectory& t);
take(Trajectory(10)); // temporary constructed for the call
// 3. Composite expressions
Complex a(1,2), b(3,4), c(5,6);
Complex sum = a + b + c; // (a+b) produces a temporary; that + c produces anotherBy default each temporary calls a constructor and a destructor — potentially expensive. Modern C++ erases most of that cost through copy elision and move semantics:
- NRVO / RVO. A function returning a value by name (
return t;) or by prvalue (return Trajectory(7);) constructs the result directly in the caller's storage. No copy, no move, no temporary. - Mandatory elision (C++17). Initializing from a prvalue is guaranteed elision:
Trajectory t = Trajectory(7);is exactly one constructor call. There is no temporary — it's not just optimized away, the standard says it never existed. - Move-from-temporary. When elision can't apply, the temporary is an rvalue, so the move constructor (if available) is invoked instead of the copy constructor.
Pre-C++17, you could observe the elision in a debugger; post-C++17, the language guarantees the program behaves as if elision happened, and a debugger can't tell the difference because there's no copy/move call to break on.
See copy_elision.md for the full set of cases (NRVO, RVO, mandatory elision, conditional elision).
noexcept is a promise: if an exception escapes the function, std::terminate() is called. For move operations the promise has teeth: standard library containers will not move your objects unless the move is noexcept — they'll copy instead, to preserve the strong exception guarantee during reallocation.
That means a missing noexcept on Trajectory(Trajectory&&) silently turns every vector<Trajectory> growth into deep copies. On a 10,000-element trajectory log, that's an O(n²) catastrophe instead of O(n).
// ✅ Worth marking noexcept — these can't throw:
Trajectory(Trajectory&&) noexcept; // just steals pointers
Trajectory& operator=(Trajectory&&) noexcept; // ditto
~Trajectory() noexcept; // delete[] is non-throwing
friend void swap(Trajectory&, Trajectory&) noexcept;
// ❌ Don't mark noexcept — these can throw bad_alloc:
Trajectory(const Trajectory&);
Trajectory& operator=(const Trajectory&);
explicit Trajectory(std::size_t); // allocatesA simple checklist:
- Add
noexceptwhen the function doesn't allocate and doesn't call anything that throws. - Add
noexcepton move ops and swap if you ever put the type in a standard container — this is the single highest-leveragenoexceptin your codebase. - Don't add
noexceptto copy ops that allocate; letbad_allocpropagate. - If unsure, don't. A wrong
noexceptturns a recoverable exception into termination.
Destructors are implicitly noexcept in C++11+ unless something inside is potentially-throwing. You can rely on that without writing it. But:
- If a destructor does throw during stack unwinding (e.g. from a nested exception),
std::terminateruns. - Don't do throwing work in destructors — release-only operations (close, free, unlock) should be non-throwing by nature.