Exercises — Move constructor and move assignment — Rule of Five
Throughout we reuse the Buffer class from the parent note — a class owning a raw heap array int* data_ with a count size_t size_. Keep its five special members in your head; every exercise leans on them.
Level 1 — Recognition
Goal: can you name and identify the pieces? No code writing yet.
Recall Solution L1.1
The single & vs double && and the const are the whole story.
- (a)
~Buffer()→ Destructor (the~names it). - (b)
const Buffer&→ Copy constructor (takes an existing object without modifying it). - (c)
Buffer&&→ Move constructor (&&= rvalue reference = "a temporary I may gut"). - (d)
operator=withconst Buffer&→ Copy assignment. - (e)
operator=withBuffer&&→ Move assignment.
Reading rule: one ampersand = "lvalue reference, look but don't loot". Two ampersands = "rvalue reference, you may steal my insides".
Recall Solution L1.2
- (a)
a→ lvalue. It has a name; you can use it again on the next line. - (b)
Buffer(5)→ rvalue (a prvalue, a pure temporary with no name). - (c)
std::move(a)→ rvalue (an xvalue).std::moveis only a cast;astill exists, but this expression now says "treat me as loot-able". - (d)
make()→ rvalue (a prvalue returned by value).
Litmus test: Can I write its name and read it on a later line? Yes → lvalue. No → rvalue. See std::move and rvalue references.
Level 2 — Application
Goal: use the rules to predict which member runs and what state results.
Recall Solution L2.1
The engine behind every answer is overload resolution on value category: an rvalue argument matches the Buffer&& overload; an lvalue argument cannot bind to Buffer&& and so matches the const Buffer& overload.
- (a) ordinary constructor
Buffer(size_t)— the argument is asize_t, not aBuffer, so no copy/move choice arises. Allocates 1000 ints. - (b)
ais an lvalue → it cannot bind toBuffer&&, so overload resolution picksBuffer(const Buffer&)→ copy constructor (deep copy of 1000 ints). - (c)
std::move(a)is an rvalue (xvalue) → it binds toBuffer&&, the better match, so overload resolution picks the move constructor (steals pointer;a.size_becomes 0). Lvalue → copy, rvalue → move — that is the whole mechanism. - (d)
cis an lvalue on the right of=,dalready exists → binds tooperator=(const Buffer&)→ copy assignment. - (e)
Buffer(2000)is a temporary (rvalue),dexists → binds tooperator=(Buffer&&)→ move assignment.
Rule: rvalue on the right + fresh object = move ctor; rvalue + existing object = move assign; lvalue = the copy versions — all because the rvalue overload (&&) is the preferred match, and lvalues cannot bind to it.
Recall Solution L2.2
After the move constructor runs its two "sterilize" lines o.data_ = nullptr; o.size_ = 0;:
a.data_ == nullptra.size_ == 0
And c now owns the original 1000-int buffer. a is valid but empty: you may destroy it or reassign it, but reading its (nonexistent) contents is a logic bug. So a.size_ = 0.
Recall Solution L2.3
First, why the numbers come out this way — trace the parent's code.
Copy constructor (Buffer(const Buffer& o)):
data_(new int[o.size_]) // must have its OWN buffer:
std::copy(o.data_, o.data_ + o.size_, data_); // source must survive intactBecause the source keeps living (it is an lvalue), the copy cannot share the source's buffer — sharing would mean two owners and a double free. So it allocates a fresh buffer (that is the 1 heap allocation) and then copies every element one by one (that is the int copies). There is no shortcut: to have independent storage you must physically duplicate all ints — hence .
Move constructor (Buffer(Buffer&& o)):
data_(o.data_) // grab the existing pointer — NO new int[...]
o.data_ = nullptr; // null the source so it won't free what we tookThe source is a dying rvalue, so we may simply adopt its already-allocated buffer. We copy the pointer (one machine word) and the size (one word), then null the source — a fixed number of assignments regardless of . Nothing is allocated, nothing is element-copied → .
| Operation | Heap allocations | Ints copied |
|---|---|---|
| Copy | 1 (new int[1000000]) |
1000000 |
| Move | 0 | 0 (only a pointer + size are grabbed) |
A move does allocations and copies ints; a copy does allocation and int copies. This matches the parent's vs .
Level 3 — Analysis
Goal: explain WHY the rules are the way they are; find the bug.
Recall Solution L3.1
Declaring a destructor suppresses the implicit move members but the implicit copy constructor is (deprecatedly) still generated. That copy does a shallow copy: y.data_ = x.data_ — both objects now hold the same pointer.
Timeline at end of scope: y's destructor runs delete[] data_ (frees the buffer), then x's destructor runs delete[] data_ on the same, already-freed pointer → double free → crash / undefined behaviour.
Fix: obey the Rule of Three / Rule of Five — write the copy ctor as a deep copy, and add the two move members. Or better, use Rule of Zero with an std::vector<int> member and = default everything.
Recall Solution L3.2
On reallocation the vector must transfer existing elements to the new, larger storage. It promises the strong exception guarantee: if anything throws mid-transfer, the vector is left unchanged.
- If your move ctor is
noexcept, moving cannot throw, so a half-done move can never leave a broken vector → the vector uses your move (cheap). - If your move ctor might throw, a move partway through could corrupt state with no way to roll back → the vector conservatively copies instead (which can safely be abandoned, leaving originals intact) → your move optimization is silently lost.
This is why the parent marks move members noexcept. See Exception safety guarantees.
Recall Solution L3.3
Without the guard, the body runs on the same object (this == &o):
delete[] data_;→ freesx's buffer.data_ = o.data_;→ butoisx, sodata_now equals the pointer we just freed → dangling pointer.o.data_ = nullptr;→ setsx.data_(=o.data_) tonullptr, so we lost even the dangling pointer.
Net result: buffer freed, data_ clobbered, undefined behaviour on next use. The if (this != &o) guard skips the whole body when source and target are identical, keeping x intact.
Level 4 — Synthesis
Goal: build correct members from scratch and combine ideas.
Recall Solution L4.1
The two jobs are Steal (grab the pointer) then Sterilize (null the source), per the parent's mnemonic.
String(String&& o) noexcept
: buf_(o.buf_), len_(o.len_) { // Steal: no allocation, just copy handles
o.buf_ = nullptr; // Sterilize: source dtor must not free our buffer
o.len_ = 0;
}Key points: noexcept (so containers use it), no new (that's the whole point), and set o.buf_ = nullptr and o.len_ = 0 so the source is in a valid empty state.
Recall Solution L4.2
Direct form (matches parent):
String& operator=(String&& o) noexcept {
if (this != &o) { // guard against self-move
delete[] buf_; // free our current resource first
buf_ = o.buf_; // steal
len_ = o.len_;
o.buf_ = nullptr; // sterilize
o.len_ = 0;
}
return *this;
}Swap-based alternative: String tmp(std::move(o)); swap(*this, tmp); return *this; — here tmp construction steals from o, the swap hands us o's guts and hands tmp our old guts, and tmp's destructor frees them. This form needs no explicit self-move check (self-move just swaps twice harmlessly) and reuses the move ctor. Preferable when you value brevity and safety; the direct form is marginally faster (no extra object). Both are correct.
Recall Solution L4.3
Replace the raw int* with an RAII container so ownership is managed by the member:
class Buffer {
std::vector<int> data_; // owns its heap, knows how to copy/move/free itself
public:
explicit Buffer(size_t n) : data_(n) {}
// no destructor, no copy/move members needed — all = default (implicit)
};Because std::vector already defines correct deep-copy, move, and destructor, the compiler-generated members of Buffer simply delegate to vector's. This is Rule of Zero: write zero of the five. A std::unique_ptr member would make Buffer move-only (see Smart pointers (unique_ptr / shared_ptr)).
Level 5 — Mastery
Goal: combine elision, noexcept, self-move and container behaviour into one reasoned answer.
Recall Solution L5.1
Track the single 1000-int buffer through each line.
- Line A:
make()yields a prvalue used to initializebof the same type → C++17 guaranteed elision applies. The temporary is constructed directly intob— no separate temporary, no move, no copy. The only work is thenew int[1000]insideBuffer(1000). 1 heap allocation, 0 copies, 0 moves. - Line B:
std::move(b)is an rvalue (xvalue) initializing a fresh objectc→ overload resolution picks the move constructor. It stealsb's pointer (no allocation). 0 allocations, 0 copies, 1 move. (bis now empty.) - Line C:
reserve(2)pre-sized capacity to 2, so thispush_backdoes not reallocate; it constructs one element in place from the rvaluestd::move(c)→ move constructor. It stealsc's pointer. 0 allocations, 0 copies, 1 move. (cis now empty.)
Totals across A–C: 1 heap allocation, 0 copy constructions, 2 move constructions. The single 1000-int buffer is allocated exactly once (line A) and then merely handed from b → c → the vector by two pointer-stealing moves. See Copy elision and RVO.
Recall Solution L5.2
On reallocation the vector must transfer its existing element. With a throwing move ctor it cannot use the move (that would break the strong guarantee), so it copies the element into new storage → that copy does 1 heap allocation + copies 1000 ints, then destroys the old.
So compared with the noexcept case you gain +1 heap allocation and +1000-int copy per reallocated element. This is the concrete, measurable cost of forgetting noexcept. See Exception safety guarantees.
Recall Solution L5.3
Bugs, in order:
- Implicit copy ctor does a shallow
FILE*copy → two objectsfclosethe same handle → double-close / undefined behaviour. - Implicit copy assignment same shallow-copy hazard, plus leaks the destination's original handle.
- Move members are suppressed (because a destructor was declared) → "moves" silently fall back to the buggy copy.
Minimal fixes (two options):
- Rule of Five: define copy ctor/assign (deep-duplicate or forbid with
= delete), and move ctor/assign that steal theFILE*and null the source. Mark movesnoexcept. - Rule of Zero (preferred): wrap the handle in a
std::unique_ptr<FILE, decltype(&fclose)>and write none of the five — the smart pointer is move-only and closes on destruction. See Smart pointers (unique_ptr / shared_ptr) and Rule of Zero.
Recall Self-test checklist
Reveal only after finishing all five levels.
- Can I tell lvalue from rvalue by the "name on a later line" test? ::: Yes — nameable & reusable = lvalue; temporary/
std::moved = rvalue. - Do I know why a destructor-only class double-frees? ::: Implicit shallow copy shares the pointer; two destructors free it.
- Can I write Steal-then-Sterilize move members with a self-move guard and
noexcept? ::: Steal handles, delete old (in assignment), null the source, guardthis != &o. - Can I choose Rule of Zero to write none of the five? ::: Use RAII members (
vector,unique_ptr) and= default.
Connections
- Parent: Rule of Five
- Rule of Three — the three-member ancestor these exercises extend
- Rule of Zero — L4.3 and L5.3 preferred fixes
- std::move and rvalue references — L1/L2 lvalue-vs-rvalue foundation
- Copy elision and RVO — L5.1 and L5.3 elision reasoning
- Smart pointers (unique_ptr / shared_ptr) — RAII owners in L4.3 / L5.3
- Exception safety guarantees — the
noexceptreasoning in L3.2 / L5.2