Worked examples — Move constructor and move assignment — Rule of Five
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.

Example 1 — construct from a temporary (C1)
- WHAT:
Buffer(1000)runs the plain constructor —new int[1000]{}. That is one allocation. Why this step? The constructor is the only placenewappears; the count of allocations = count of constructor calls. - 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. - WHAT: With RVO, the object is built directly in
b's storage. Why this step? Nothing to copy or move — the temporary andbare literally the same bytes. So 0 extra copies, 0 moves. - 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.
- WHAT:
std::move(a)is a cast of the lvalueato an rvalue referenceBuffer&&. Why this step? Overload resolution picks the move ctor only when the argument is an rvalue;ahas a name so it is an lvalue until we cast it. Nothing is stolen by the cast itself — see std::move and rvalue references. - WHAT: The move ctor (parameter
obound toa) copieso.data_intoc.data_— the green steal arrow of Figure s01. Why this step? Ownership transfer = pointer copy,O(1), nonew. - WHAT: The move ctor sets
o.data_ = nullptr,o.size_ = 0— the red X of Figure s01. Why this step? If we lefta.data_pointing at0xA, then both destructors woulddelete[]0xA→ double free. Nullingasterilizes it.
data_ |
size_ |
|
|---|---|---|
a before |
0xA |
1000 |
c before |
— | — |
a after |
null |
0 |
c after |
0xA |
1000 |
- WHAT: Reading
a.data_[0]after the move dereferencesnullptr→ undefined behaviour. Why this step?ais 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.

Inside operator=(Buffer&& o), the parameter o is the source — here o refers to q, and this refers to p:
- WHAT: Enter move assignment;
this == &pand&o == &q. They differ, so the guardif (this != &o)passes. Why this step? Theif (this != &o)guard only blocks self-move (Example 4); here it lets us through. (ois the move-assignment parameter introduced in the class sketch at the top of this page.) - WHAT:
delete[] data_freesp's old buffer0xP— the red box in Figure s02. Why this step?palready owns 500 ints. If we overwrotep.data_without freeing first,0xPwould leak — no tag points at it ever again. - WHAT: Steal —
data_ = o.data_(=0xQ),size_ = o.size_(=800) — the green arrow in Figure s02. Why this step? Pointer baton pass,O(1). - WHAT: Sterilize —
o.data_ = nullptr,o.size_ = 0(i.e.qis emptied) — the white box in Figure s02. Why this step? Soq's eventual destructor does not free0xQ, whichpnow 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.
-
WHAT (with guard):
this == &o, soif (this != &o)is false — the body is skipped entirely. Why this step? Skipping is correct:xalready owns its buffer; there is nothing to transfer. -
WHAT (state):
x.data_stays0xX,x.size_stays 300. Nothing freed, nothing nulled. Why this step? A self-move must be a no-op on contents (leavexfully usable). -
WHAT (without the guard — the bug): with the guard removed the body runs even though
oisx:delete[] data_frees0xX.data_ = o.data_copies the now-dangling0xXback into itself (sinceo.data_is the same member).size_ = o.size_copies300into300— no change yet.o.data_ = nullptrnullsx.data_(same object!), ando.size_ = 0resetsx.size_to 0 — because writingo.size_writesx.size_.
Why this step? Because
oandxalias, every write toolands onx. You end withx.data_ == nullandx.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)
- WHAT:
new int[0]is legal in C++ — it returns a valid, unique, non-dereferenceable pointer. Why this step? The move ctor never dereferencesdata_; 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"). - WHAT: Move copies
o.data_→f.data_, setsf.size_ = 0, then nullso(i.e.e). Why this step? Same mechanics as any move; the code path does not branch on size. - WHAT:
delete[]of the moved pointer (inf's dtor) anddelete[] nullptr(ine's dtor) are both legal. Why this step?delete[]on the result ofnew int[0]is defined;delete[] nullptris 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)
- (A):
gis a named lvalue, not cast → the copy ctor fires →new int[100]+ copy 100 ints. Why this step? Overload resolution never picks aT&&overload for an lvalue. - (B):
std::move(g)casts to rvalue → the move ctor fires → pointer steal, 0 int-copies. Why this step? The cast makesgbind toBuffer&&. - (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)
- 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.
- WHAT (V1): Because the move ctor is
noexcept, the vector moves all 4 → 4 pointer steals, 0 deep copies. Why this step?std::vectorusesmove_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. - 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)
- WHAT:
load()runs the constructor → 1 allocation (new int[4096]). Itsreturn Buffer(4096);is a return statement, so RVO applies — the object is built directly intofilter's parameterb. 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. - WHAT: The result of
load()is a prvalue used to initializefilter's by-value parameterb. This is prvalue materialization, not RVO — there is no return statement at the call site. The prvalue is materialized straight intob'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. - 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. - WHAT: The result of
filter(...)is again a prvalue, materialized intostore's by-value parameterb. 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)
- 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.
- 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, sostd::move(a)legally selects the copy ctor. It compiles cleanly — that's the trap. - WHAT: The copy ctor runs →
new int[2000]+ copy 2000 ints. Why this step? No stealing; a full deep copy despite thestd::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
- Parent: Rule of Five
- Rule of Three — the copy-only ancestor (cells C6, C9 without moves)
- Rule of Zero — the fix for C9's trap
- std::move and rvalue references — the cast behind C2, C6, C9
- Copy elision and RVO — why C1 and C8 do 0 copies
- Smart pointers (unique_ptr / shared_ptr) — move-only owners
- Exception safety guarantees — the reason for C7's
noexceptrule