5.2.10 · D3C++ Programming

Worked examples — Move constructor and move assignment — Rule of Five

3,710 words17 min readBack to topic

We reuse the same Buffer class from the parent throughout. Crucially, its move constructor and move assignment take a parameter named o (for "other") — that name appears in the parent's code and we refer to it below:

class Buffer {
    int*   data_;
    size_t size_;
    // ctor, dtor, copy ctor, copy assign, swap ... (as in parent)
 
    Buffer(Buffer&& o) noexcept;             // move ctor  — 'o' is the source
    Buffer& operator=(Buffer&& o) noexcept;  // move assign — 'o' is the source
};

The scenario matrix

Every situation that can happen to a resource-owning object falls into one of these cells. The worked examples below each carry a tag like (C2) telling you which cell they fill.

Cell Case class The question it asks
C1 Construct-from-temporary (rvalue) Does the move ctor run, or is it elided?
C2 Construct-from-named (lvalue + std::move) What state is the source left in?
C3 Move-assign into a non-empty object Is the old resource freed before stealing?
C4 Self-move (x = std::move(x)) Does the guard save us?
C5 Degenerate input — empty buffer (size_ == 0) Does stealing a null pointer stay valid?
C6 Copy vs move fork — is source an lvalue or rvalue? Which of the five actually fires?
C7 noexcept boundary — container reallocation Move used, or silent fallback to copy?
C8 Real-world word problem End-to-end ownership through a function pipeline
C9 Exam twist — the "silently copies" trap Spot the bug that compiles cleanly

The figure below is the master picture for every move on this page: study it once and every later example is a variation on it. It shows the two irreducible steps of a move — steal (green arrow: the destination copies the source's pointer) and sterilize (red X: the source's data_ is set to null). Notice the yellow heap buffer is never duplicated — only the pointer to it changes hands.

Figure — Move constructor and move assignment — Rule of Five
Figure s01 — "Steal, then Sterilize": one heap buffer (yellow), the pointer baton passing from SOURCE to DEST (green), then the source pointer cut (red X) so only one owner remains.


Example 1 — construct from a temporary (C1)

  1. WHAT: Buffer(1000) runs the plain constructor — new int[1000]{}. That is one allocation. Why this step? The constructor is the only place new appears; the count of allocations = count of constructor calls.
  2. WHAT: make() returns a prvalue (a pure temporary with no name). Why this step? Return-by-value of a freshly built object is the textbook case where the compiler is allowed to skip building a separate object — see Copy elision and RVO.
  3. WHAT: With RVO, the object is built directly in b's storage. Why this step? Nothing to copy or move — the temporary and b are literally the same bytes. So 0 extra copies, 0 moves.
  4. WHAT: If your compiler somehow did not elide (older standard), the move ctor runs instead — grab pointer, null source (exactly the green-then-red sequence of Figure s01). Why this step? Even the non-elided path never touches the 1000 ints; it copies one pointer.

Verify: The 1000 ints are copied in exactly zero cases here — RVO gives 0, non-elided move gives 0. The only int allocation is the single new int[1000]. Units check: allocation count is dimensionless, one new = one allocation. ✓ (checked in VERIFY as ex1_allocs, ex1_copies.)


Example 2 — construct from a named object with std::move (C2, C6)

This is Figure s01 made concrete: a is the SOURCE, c is the DEST, 0xA is the yellow heap buffer.

  1. WHAT: std::move(a) is a cast of the lvalue a to an rvalue reference Buffer&&. Why this step? Overload resolution picks the move ctor only when the argument is an rvalue; a has a name so it is an lvalue until we cast it. Nothing is stolen by the cast itself — see std::move and rvalue references.
  2. WHAT: The move ctor (parameter o bound to a) copies o.data_ into c.data_ — the green steal arrow of Figure s01. Why this step? Ownership transfer = pointer copy, O(1), no new.
  3. WHAT: The move ctor sets o.data_ = nullptr, o.size_ = 0 — the red X of Figure s01. Why this step? If we left a.data_ pointing at 0xA, then both destructors would delete[] 0xA → double free. Nulling a sterilizes it.
data_ size_
a before 0xA 1000
c before
a after null 0
c after 0xA 1000
  1. WHAT: Reading a.data_[0] after the move dereferences nullptr → undefined behaviour. Why this step? a is in a valid-but-unspecified state: you may destroy it or assign to it, never read its contents.

Verify: allocations = 1 (only a's constructor). Move copied the pointer, so c.size_ equals the original 1000 and a.size_ equals 0. Sum of live sizes is conserved: it was 1000 (in a), it is still 1000 (now in c). ✓ (ex2_a_size, ex2_c_size.)


Example 3 — move-assign into a non-empty object (C3)

The figure below is the timeline for this example. Follow it left-to-right: p and q start owning separate buffers; the red box shows the free-first step (p's old 0xP is deleted); the green arrow shows the steal; the white box shows q being sterilized. Match each numbered step below to a box in the picture.

Figure — Move constructor and move assignment — Rule of Five
Figure s02 — move-assign timeline: free the destination's OLD buffer (red) before stealing the source's buffer (green), then sterilize the source (white). Net result: one live buffer, zero leaks.

Inside operator=(Buffer&& o), the parameter o is the source — here o refers to q, and this refers to p:

  1. WHAT: Enter move assignment; this == &p and &o == &q. They differ, so the guard if (this != &o) passes. Why this step? The if (this != &o) guard only blocks self-move (Example 4); here it lets us through. (o is the move-assignment parameter introduced in the class sketch at the top of this page.)
  2. WHAT: delete[] data_ frees p's old buffer 0xP — the red box in Figure s02. Why this step? p already owns 500 ints. If we overwrote p.data_ without freeing first, 0xP would leak — no tag points at it ever again.
  3. WHAT: Steal — data_ = o.data_ (=0xQ), size_ = o.size_ (=800) — the green arrow in Figure s02. Why this step? Pointer baton pass, O(1).
  4. WHAT: Sterilize — o.data_ = nullptr, o.size_ = 0 (i.e. q is emptied) — the white box in Figure s02. Why this step? So q's eventual destructor does not free 0xQ, which p now owns.
data_ size_
p before 0xP 500
q before 0xQ 800
p after 0xQ 800
q after null 0
0xP freed

Verify: buffers alive at start = 2 (0xP, 0xQ). After: 0xP freed, 0xQ owned by p, q empty → 1 live buffer, 1 freed, 0 leaked. p.size_ = 800. ✓ (ex3_p_size, ex3_freed_count.)


Example 4 — self-move (C4)

Here this and the parameter o alias the same object — both are &x.

  1. WHAT (with guard): this == &o, so if (this != &o) is false — the body is skipped entirely. Why this step? Skipping is correct: x already owns its buffer; there is nothing to transfer.

  2. WHAT (state): x.data_ stays 0xX, x.size_ stays 300. Nothing freed, nothing nulled. Why this step? A self-move must be a no-op on contents (leave x fully usable).

  3. WHAT (without the guard — the bug): with the guard removed the body runs even though o is x:

    • delete[] data_ frees 0xX.
    • data_ = o.data_ copies the now-dangling 0xX back into itself (since o.data_ is the same member).
    • size_ = o.size_ copies 300 into 300 — no change yet.
    • o.data_ = nullptr nulls x.data_ (same object!), and o.size_ = 0 resets x.size_ to 0 — because writing o.size_ writes x.size_.

    Why this step? Because o and x alias, every write to o lands on x. You end with x.data_ == null and x.size_ == 0, pointing at freed memory earlier — a self-inflicted free followed by loss of the size too. The object is left empty and its old buffer double-jeopardy freed.

(no guard) data_ size_
x before 0xX 300
after delete[] 0xX (dangling) 300
after o.size_=0 null 0

Verify: with the guard, x.size_ stays 300 and buffer count freed = 0. Without the guard, x.size_ collapses to 0. ✓ (ex4_x_size, ex4_freed, ex4_noguard_size.)


Example 5 — degenerate empty buffer (C5)

  1. WHAT: new int[0] is legal in C++ — it returns a valid, unique, non-dereferenceable pointer. Why this step? The move ctor never dereferences data_; it only copies the pointer value, so even a zero-length or null pointer moves fine (same green-then-red path as Figure s01, just with a "empty suitcase").
  2. WHAT: Move copies o.data_f.data_, sets f.size_ = 0, then nulls o (i.e. e). Why this step? Same mechanics as any move; the code path does not branch on size.
  3. WHAT: delete[] of the moved pointer (in f's dtor) and delete[] nullptr (in e's dtor) are both legal. Why this step? delete[] on the result of new int[0] is defined; delete[] nullptr is a guaranteed no-op. No case slips through.

Verify: both sizes 0, and the degenerate move performs 0 int-copies and 0 crashes. ✓ (ex5_f_size, ex5_copies.)


Example 6 — the copy-vs-move fork (C6)

  1. (A): g is a named lvalue, not cast → the copy ctor fires → new int[100] + copy 100 ints. Why this step? Overload resolution never picks a T&& overload for an lvalue.
  2. (B): std::move(g) casts to rvalue → the move ctor fires → pointer steal, 0 int-copies. Why this step? The cast makes g bind to Buffer&&.
  3. (C): Buffer(100) is a prvalue temporary → move ctor or elision → 0 int-copies. Why this step? Prvalue initialization is the elision-eligible case.

Verify: total int-copies across A+B+C = 100 (only A copies). ✓ (ex6_total_copies.)


Example 7 — the noexcept boundary (C7)

  1. WHAT: On reallocation the vector must relocate its 4 elements into fresh storage. Why this step? Growth allocates a new block; old elements must transfer.
  2. WHAT (V1): Because the move ctor is noexcept, the vector moves all 4 → 4 pointer steals, 0 deep copies. Why this step? std::vector uses move_if_noexcept: a throwing move could leave the container half-migrated with no way to roll back, violating the strong guarantee — see Exception safety guarantees.
  3. WHAT (V2): Without noexcept, the vector must assume the move might throw, so it copies all 4 to preserve the strong guarantee → 4 allocations + 4 deep copies. Why this step? If a copy throws mid-way, the originals are untouched and can be restored.

Verify: V1 does 0 deep copies, V2 does 4 (one per element). ✓ (ex7_v1, ex7_v2.)


Example 8 — real-world pipeline (C8)

  1. WHAT: load() runs the constructor → 1 allocation (new int[4096]). Its return Buffer(4096); is a return statement, so RVO applies — the object is built directly into filter's parameter b. Why this step? Copy elision / RVO is defined only for return statements and prvalue initialization, and a return of a prvalue is the classic RVO case — 0 copies here.
  2. WHAT: The result of load() is a prvalue used to initialize filter's by-value parameter b. This is prvalue materialization, not RVO — there is no return statement at the call site. The prvalue is materialized straight into b's storage, still 0 copies. Why this step? Passing a temporary to a by-value parameter never copies a named object; the temporary becomes the parameter.
  3. WHAT: Inside filter, return b; returns a local named variable. The compiler treats it as an rvalue for overload resolution (NRVO, or an implicit move if NRVO does not apply). Why this step? Returning a local by value never deep-copies our 4096 ints — worst case a pointer-steal move.
  4. WHAT: The result of filter(...) is again a prvalue, materialized into store's by-value parameter b. Why this step? Same as step 2 — prvalue materialization into a by-value parameter, 0 deep copies.

Every stage passes the same 4096-int buffer along by stealing (or materializing into place) the pointer. One new, one eventual delete[].

Verify: allocations = 1, deep-copies = 0 across all three stages. ✓ (ex8_allocs, ex8_copies.)


Example 9 — the exam twist: the silent-copy trap (C9)

  1. WHAT: Declaring a destructor (and a copy ctor) suppresses the implicitly-generated move ctor. Why this step? The language rule: any user-declared destructor / copy member disables the implicit move members — see the parent's steel-man.
  2. WHAT: With no move ctor available, overload resolution falls back to the copy ctor (an rvalue binds to const Buffer& too). Why this step? const T& accepts rvalues, so std::move(a) legally selects the copy ctor. It compiles cleanly — that's the trap.
  3. WHAT: The copy ctor runs → new int[2000] + copy 2000 ints. Why this step? No stealing; a full deep copy despite the std::move. There is no green-then-red steal (Figure s01) here — the buffer is genuinely duplicated.

The std::move was a lie the reader tells themselves; the class never opted in. Fix: define all five (or use Rule of Zero with a std::vector/unique_ptr member — see Smart pointers (unique_ptr / shared_ptr)).

Verify: int-copies = 2000, and the operation is a copy not a move. ✓ (ex9_copies.)


Coverage check

Recall Did we hit every cell?

C1 → Ex1 ::: construct from temporary (RVO/move) C2 → Ex2 ::: named + std::move, source left empty C3 → Ex3 ::: move-assign frees old buffer first C4 → Ex4 ::: self-move guard C5 → Ex5 ::: empty/degenerate buffer C6 → Ex6 ::: copy-vs-move fork C7 → Ex7 ::: noexcept container reallocation C8 → Ex8 ::: real-world pipeline C9 → Ex9 ::: silent-copy exam trap

Connections