5.2.10 · D4C++ Programming

Exercises — Move constructor and move assignment — Rule of Five

3,707 words17 min readBack to topic

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= with const Buffer&Copy assignment.
  • (e) operator= with Buffer&&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) alvalue. 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::move is only a cast; a still 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 a size_t, not a Buffer, so no copy/move choice arises. Allocates 1000 ints.
  • (b) a is an lvalue → it cannot bind to Buffer&&, so overload resolution picks Buffer(const Buffer&)copy constructor (deep copy of 1000 ints).
  • (c) std::move(a) is an rvalue (xvalue) → it binds to Buffer&&, 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) c is an lvalue on the right of =, d already exists → binds to operator=(const Buffer&)copy assignment.
  • (e) Buffer(2000) is a temporary (rvalue), d exists → binds to operator=(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_ == nullptr
  • a.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 intact

Because 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 took

The 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):

  1. delete[] data_; → frees x's buffer.
  2. data_ = o.data_; → but o is x, so data_ now equals the pointer we just freeddangling pointer.
  3. o.data_ = nullptr; → sets x.data_ (= o.data_) to nullptr, 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 initialize b of the same type → C++17 guaranteed elision applies. The temporary is constructed directly into b — no separate temporary, no move, no copy. The only work is the new int[1000] inside Buffer(1000). 1 heap allocation, 0 copies, 0 moves.
  • Line B: std::move(b) is an rvalue (xvalue) initializing a fresh object c → overload resolution picks the move constructor. It steals b's pointer (no allocation). 0 allocations, 0 copies, 1 move. (b is now empty.)
  • Line C: reserve(2) pre-sized capacity to 2, so this push_back does not reallocate; it constructs one element in place from the rvalue std::move(c)move constructor. It steals c's pointer. 0 allocations, 0 copies, 1 move. (c is 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 bc → 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:

  1. Implicit copy ctor does a shallow FILE* copy → two objects fclose the same handle → double-close / undefined behaviour.
  2. Implicit copy assignment same shallow-copy hazard, plus leaks the destination's original handle.
  3. 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 the FILE* and null the source. Mark moves noexcept.
  • 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, guard this != &o.
  • Can I choose Rule of Zero to write none of the five? ::: Use RAII members (vector, unique_ptr) and = default.

Connections