In this post we’re going to run through the 5 special member functions: the destructor, copy constructor, copy assignment, move constructor, and move assignment. (The default constructor is technically special too, but it’s excluded here because it says nothing about ownership.) These 5 travel as a pack: if the compiler’s default memberwise behavior is wrong for one, it’s suspect for all - which is why declaring any of them triggers the suppression matrix on the others.
A Simple Example
Take a class that declares none of them:
class Buffer
{
int* m_data; // built-in: dumb bytes
std::size_t m_size;
public:
This is what the compiler generates under the hood:
// 1. default ctor: default-construct each member in decl order
Buffer()
// m_data, m_size: NOT initialized, garbage
// (unless they had default member initializers)
{}
// 2. destructor: destroy members in REVERSE decl order
~Buffer()
{
// no body of its own; after it runs:
// m_size, m_data: nothing to do (built-ins), NOT deleted
}
// 3. copy ctor: memberwise copy-construct
Buffer(const Buffer& o)
: m_data{ o.m_data } // pointer VALUE copied: shallow
, m_size{ o.m_size }
{}
// 4. copy assign: memberwise copy-assign
Buffer& operator=(const Buffer& o)
{
m_data = o.m_data; // shallow
m_size = o.m_size;
return *this;
// note: NO self-assignment check; memberwise
// assignment is assumed to tolerate it
}
// 5. move ctor: memberwise move-construct
Buffer(Buffer&& o) noexcept
: m_data{ std::move(o.m_data) } // for a pointer, = plain copy,
// o.m_data UNTOUCHED
, m_size{ std::move(o.m_size) } // plain copy
{}
// 6. move assign: memberwise move-assign
Buffer& operator=(Buffer&& o) noexcept
{
m_data = std::move(o.m_data); // shallow, source untouched
m_size = std::move(o.m_size);
return *this;
}
};
The Suppression Matrix
Everything above was the “you declare nothing” case. The moment you declare any special member yourself, the compiler starts second-guessing the rest - some stay generated, some quietly vanish, some get deleted outright:
| You declare… | Default ctor | Copy ops | Move ops |
|---|---|---|---|
| nothing | generated | generated | generated |
| any constructor | not generated | generated | generated |
| destructor | generated | generated (deprecated) | not declared → falls back to copy |
| copy ctor or copy assign | suppressed* | other one generated (deprecated) | not declared → falls back to copy |
| move ctor or move assign | suppressed* | deleted → compile error | other one not declared |
* only for the constructor variant (copy ctor / move ctor), which counts as “any constructor”; declaring the assignment variant leaves the default ctor generated.
Declaring a Constructor
Say you define a custom ctor. The compiler takes this as a statement that constructing your class requires arguments, concludes it can’t know whether a zero-argument Buffer even makes sense, and refuses to guess. So the default ctor is not generated.
class Buffer
{
int* m_data;
std::size_t m_size;
public:
Buffer(std::size_t n) : m_data{ new int[n] }, m_size{ n } {}
};
Buffer a{ 100 }; // fine
Buffer b; // ERROR: no default ctor exists
Declaring a Destructor
Adding a custom destructor tells the compiler that destroying a Buffer has consequences beyond its own bytes - something external probably also gets released. Ergo this class owns a resource through one of its members - and if destroying it needs custom logic, blindly copying its members around probably isn’t safe either. The compiler reacts by no longer generating the moves:
class Buffer
{
int* m_data;
std::size_t m_size;
public:
Buffer(std::size_t n) : m_data{ new int[n] }, m_size{ n } {}
~Buffer() { delete[] m_data; }
};
Buffer a{ 100 };
Buffer b = std::move(a); // compiles, but COPIES: no move ctor was
// generated, so const Buffer& binds instead
STACK HEAP
+-----------------+
| a |
| m_data -------+-------+
| m_size: 100 | | +------------------+
+-----------------+ +----->| int[100] |
| | (one allocation) |
+-----------------+ | +------------------+
| b | |
| m_data -------+-------+
| m_size: 100 |
+-----------------+
b was "moved" but actually shallow-copied: both m_data
point at the SAME allocation, and a is untouched (not
nulled). At scope exit ~Buffer() runs for b, then for a:
delete[] twice on the same pointer -> undefined behavior.
Note that the copy ops are still generated, and they share the exact same shallow-copy flaw the suppressed moves would have had. That’s why generating them here is deprecated - the copies survive only for legacy compatibility, and we can expect them to be suppressed too in a future standard.
Declaring a Copy Ctor / Assign
By defining a copy ctor / assign, we’re saying that the usual memberwise walk-and-copy produces the wrong result for this class.
For Buffer, the correct copy duplicates the allocation instead of the pointer:
Buffer(const Buffer& o)
: m_data{ new int[o.m_size] }, m_size{ o.m_size }
{
std::copy(o.m_data, o.m_data + o.m_size, m_data);
}
The compiler stops generating the move ctor / assign, because they’d do the same memberwise walk (just with std::move) and commit the same error of shallow-sharing the array. By rights the other copy op (copy assign here) should be suppressed too - only legacy compatibility shields it, which is why the matrix marks it deprecated.
Declaring a Move Ctor / Assign
A defined move ctor for Buffer looks like this:
Buffer(Buffer&& o) noexcept
: m_data{ o.m_data }, m_size{ o.m_size }
{
o.m_data = nullptr; // steal the pointer, then null the source
o.m_size = 0; // so its ~Buffer() is a harmless delete[] nullptr
}
Implementing a move ctor / assign tells the compiler that this class has ownership transfer logic - and that a memberwise shallow copy would likely be a source of double frees. So it takes the strictest stance in the whole matrix: the copy ctor / assign are deleted, not merely suppressed. Deleted members still participate in overload resolution, so any attempt to copy is a hard compile error - the path to copying is explicitly closed until you reopen it yourself.
Appendix: Canonical Signatures
For reference, the exact shapes of all six:
class T
{
public:
T(); // 1. default ctor: no params (or all defaulted)
~T(); // 2. destructor: no params, no return, only one
T(const T& other); // 3. copy ctor: takes const lvalue ref
T& operator=(const T& other); // 4. copy assign: const lvalue ref in,
// T& (i.e. *this) out
T(T&& other) noexcept; // 5. move ctor: rvalue ref, non-const
T& operator=(T&& other) noexcept; // 6. move assign
};
Why each part is shaped the way it is:
const T& for copies. Reference because pass-by-value would itself require a copy (infinite regress). const because copying must not mutate the source, and because a non-const ref couldn’t bind to temporaries or const objects.
T&& non-const for moves. Rvalue ref so it only grabs expiring objects (std::move results, temporaries). Necessarily non-const: the whole job is vandalizing the source, you can’t null out a const object’s members. A const T&& overload is legal and useless.
T& return on both assignments. Returns *this by convention to allow chaining a = b = c;. Not required by the language, but the generated ones do it and everyone expects it.
noexcept on moves. Not part of the required signature, but effectively mandatory in practice: std::vector will only move your elements during reallocation if the move ctor is noexcept, and silently falls back to copying otherwise (it needs the guarantee that a half-finished reallocation can’t be left behind by a throwing move). The generated moves are noexcept whenever every member’s move is.
The Fallbacks
When the move ops are not declared (e.g. suppressed by a user-declared destructor or copy op), moving doesn’t fail - it degrades:
- move ctor → copy ctor.
Buffer b = std::move(a);still compiles:std::move(a)is an rvalue, and aconst T¶meter can bind to rvalues. With noT(T&&)in the overload set, the copy ctor is simply the best (only) match. - move assign → copy assign. Same mechanism:
b = std::move(a);finds onlyoperator=(const T&), which happily accepts the rvalue.
Why this is the design: a copy is always a valid implementation of a move - the destination ends up with the right value, the source just doesn’t get scavenged. Moves are an optimization, not a semantic requirement, so the language makes suppressed moves quietly disappear from the overload set (“not declared”) rather than making them = deleted. The distinction matters: a deleted member still participates in overload resolution, wins for its argument type, and turns the call into a hard compile error - which is exactly the treatment the copies get in the last row of the matrix.