Skip to content
jieqi's archive
Go back

C++ Basics: The Five Special Member Functions

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 ctorCopy opsMove ops
nothinggeneratedgeneratedgenerated
any constructornot generatedgeneratedgenerated
destructorgeneratedgenerated (deprecated)not declared → falls back to copy
copy ctor or copy assignsuppressed*other one generated (deprecated)not declared → falls back to copy
move ctor or move assignsuppressed*deleted → compile errorother 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:

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.

Previous Post
C++ Basics: Polymorphism - Static Dispatch & Overloading
Next Post
C++ Basics: Polymorphism - Virtual Dispatch