Chapter 22 · Move Semantics and Smart Pointers
Exercise · Chapter 22

Chapter 22 — Move Semantics & Smart Pointers: TrackedBuffer

You will complete TrackedBuffer — a class that owns a new[]-allocated int array (the Chapter 19 payoff) and instruments every special member function with static counters so you can observe at runtime exactly how many copies and how many moves occurred. You implement the copy constructor, move constructor, copy assignment, and move assignment, then use std::unique_ptr to manage TrackedBuffer objects without writing a single manual delete.

Why this project? Because move semantics are most vivid when you can see them fire. A copy increments s_copies; a move increments s_moves. The tests pass a named object through std::move and verify that only s_moves went up and that the source's pointer is now nullptr. There is no ambiguity — either you stole the pointer (correct) or you copied it (wrong counter, wrong state). The tests also verify std::unique_ptr's ownership guarantee: once you std::move a unique_ptr into a function, your local handle becomes null.

This maps directly to LLVM pass helper classes that own a dynamic buffer (a BranchCountMap, a SlotTracker, a CoverageDB). Those classes must move cheaply during vector reallocation and transfer ownership cleanly through factory functions. The Rule of Five, noexcept move ops, and make_unique are the same idioms you need there.


Your tasks

  1. Copy constructor — deep copy (const TrackedBuffer&). Allocate a fresh new int[m_size]{} array and copy every element with std::copy_n. Set m_size and m_data from other. Increment s_copies. Constraint: the new object must own its own array — not share other's.

  2. Move constructor — steal pointer (TrackedBuffer&&, noexcept). Copy other.m_size and other.m_data into this. Then zero the source: other.m_size = 0; other.m_data = nullptr. Increment s_moves. Constraint: mark noexcept. No allocation, no std::copy_n — that is the entire point.

  3. Copy assignment (const TrackedBuffer&). Guard with if (this == &other) return *this;. Release the current array (delete[] m_data). Then deep-copy exactly as in Task 1. Increment s_copies. Return *this.

  4. Move assignment (TrackedBuffer&&, noexcept). Guard with if (this == &other) return *this;. Release the current array. Steal other's pointer and size. Zero other. Increment s_moves. Return *this. The self-move guard (b = std::move(b)) must leave the object valid.

  5. makeTracked factory — one line. Return std::make_unique<TrackedBuffer>(size). The caller receives exclusive heap ownership; they never call delete manually.

  6. takeOwnership — accept a std::unique_ptr<TrackedBuffer> by value. If the buffer is non-empty, write 42 into element [0]. The unique_ptr destructs at the end of the function, automatically deleting the buffer. The caller must std::move their local pointer at the call site; forgetting std::move is a compile error (unique_ptr's copy constructor is deleted).

Success criteria

  • s_moves == 1, s_copies == 0 after TrackedBuffer b2 { std::move(b1) } — the move path, not the copy path (notes 22.3)
  • b1.size() == 0 and b1.data() == nullptr after the move — the moved-from state (notes 22.3)
  • Deep copy independence: b2[0] = 99 must NOT change b1[0] (notes 22.3)
  • Separate array addresses after copy (b2.data() != b1.data())
  • Pointer was stolen not copied in the move: b2.data() == originalAddress
  • Copy assignment does NOT corrupt data when b = b (self-assignment guard)
  • Move assignment leaves b1 empty after b2 = std::move(b1)
  • self = std::move(self) is safe (self-move guard, notes 22.3)
  • makeTracked(n) returns a non-null unique_ptr with size() == n
  • After q = std::move(p), p is null (notes 22.5)
  • After takeOwnership(std::move(p)), p is null (notes 22.5)
  • shared_ptr use_count increments on copy and decrements when an owner goes out of scope (notes 22.6)
Concepts practiced
  • Move constructor (T&& parameter, steal + null) — new in Ch 22 (notes 22.3)
  • Move assignment (release old, steal new, guard self-move) — Ch 22 (notes 22.3)
  • std::move as an ownership-transfer cast, not a move itself — Ch 22 (notes 22.4)
  • Moved-from state: size() == 0, data() == nullptr — Ch 22 (notes 22.3)
  • noexcept on move operations — Ch 22 (notes 22.3)
  • std::unique_ptr<T> + std::make_unique — Ch 22 (notes 22.5)
  • Passing unique_ptr by value to transfer ownership — Ch 22 (notes 22.5)
  • std::shared_ptr copy and use_count — Ch 22 (notes 22.6)
  • C++17 mandatory copy elision trap: never assert counters on prvalue returns
  • Reused from Ch 19: new[] / delete[], dangling-pointer discipline
  • Reused from Ch 15: static class members — definitions in .cpp
  • Reused from Ch 14: constructors, member-initialiser lists, const members
  • Reused from Ch 18: std::copy_n from <algorithm>
  • Reused from Ch 5–9: const, header guards, assertions

Constraints

Allowed: everything from Chapters ≤ 22; new int[size]{}, delete[]; std::copy_n (<algorithm>); std::unique_ptr, std::make_unique, std::shared_ptr, std::make_shared (<memory>); std::move.

Required idioms:

  • Move constructor and move assignment must be marked noexcept (notes 22.3).
  • Move constructor must leave the source empty: size() == 0, data() == nullptr.
  • Both assignment operators must have a self-assignment guard (this == &other).
  • Task 5 must use std::make_unique (not new TrackedBuffer + wrapping).
  • Task 6 must accept a unique_ptr by value (transfer semantics, not get()).

Forbidden (not yet taught or out of scope):

  • std::weak_ptr deep dives or custom deleters (Ch 22 scope boundary).
  • Manual delete or delete[] anywhere except the provided destructor and the assignment operators' "release current" step.
  • Returning an rvalue reference (T&&) from any function — dangle risk (notes 22.2).

C++17 elision trap: Do NOT try to assert s_moves on the return value of makeTracked(). In C++17 that return is a prvalue — the compiler constructs the object directly in its destination (mandatory copy elision). No move constructor fires. Only test s_moves on named objects wrapped in std::move. (notes 22.3, 22.4)


Build & run locally
shell
make               # compile-check starter/tracked_buffer.cpp (warning-clean)
make test          # grade your code  →  RED until the TASK blocks are filled in
make test-solution # run the grader against the reference solution (always green)
make solution      # build + run the reference solution
make clean         # remove all build artifacts

make test is the grade. The starter compiles immediately (make is green from the start) but all checks fail. Fill in the tasks; make test turns green.


Hints
Task 1 — the copy constructor: what does "deep copy" mean?

The key is that two objects must own separate arrays. The compiler-generated copy would copy m_data (a pointer), leaving both objects pointing at the same memory — double delete on destruction. Instead:

C++
TrackedBuffer::TrackedBuffer(const TrackedBuffer& other)
    : m_size { other.m_size }
    , m_data { other.m_size > 0 ? new int[other.m_size]{} : nullptr }
{
    if (m_size > 0)
        std::copy_n(other.m_data, m_size, m_data);
    ++s_copies;
}

The member-initialiser list does the allocation; the body copies the elements. std::copy_n(src, count, dst) copies count elements from src into dst.

Task 2 — the move constructor: steal and zero

The pointer steal is two assignments; zeroing the source is two more:

C++
TrackedBuffer::TrackedBuffer(TrackedBuffer&& other) noexcept
    : m_size { other.m_size }
    , m_data { other.m_data }
{
    other.m_size = 0;
    other.m_data = nullptr;
    ++s_moves;
}

After these four lines the source is in the moved-from state: safe to destruct (delete[] nullptr is a no-op), but no longer owns data. The noexcept is not optional — leave it off and std::vector will copy instead of move during reallocation.

Why is std::move(b1) needed at the call site? Because b1 is a named variable — a named variable is always an lvalue expression, even if its declared type is T&& (notes 22.2). std::move is a cast that says "treat this lvalue as movable." It does not itself move anything.

Task 3 — copy assignment: release before you assign

Unlike a copy constructor, the destination already owns resources. Release them first — otherwise you leak the old array:

C++
TrackedBuffer& TrackedBuffer::operator=(const TrackedBuffer& other)
{
    if (this == &other) return *this;   // self-assignment guard

    delete[] m_data;                    // release old resource

    m_size = other.m_size;
    m_data = (m_size > 0) ? new int[m_size]{} : nullptr;
    if (m_size > 0)
        std::copy_n(other.m_data, m_size, m_data);

    ++s_copies;
    return *this;
}

Without the self-assignment guard, b = b would delete m_data and then try to copy from the already-freed memory — undefined behaviour.

Task 4 — move assignment: the order matters
C++
TrackedBuffer& TrackedBuffer::operator=(TrackedBuffer&& other) noexcept
{
    if (this == &other) return *this;   // self-move guard

    delete[] m_data;       // release CURRENT resource

    m_size = other.m_size; // steal from source
    m_data = other.m_data;

    other.m_size = 0;      // zero source
    other.m_data = nullptr;

    ++s_moves;
    return *this;
}

The self-move guard is important even though b = std::move(b) is unusual code. Without it, you would delete[] m_data and then try to read other.m_data from freed memory — since this == &other, they are the same pointer.

Task 5 — makeTracked: one line with make_unique
C++
std::unique_ptr<TrackedBuffer> makeTracked(std::size_t size)
{
    return std::make_unique<TrackedBuffer>(size);
}

std::make_unique<TrackedBuffer>(size) calls TrackedBuffer(size) on the heap and wraps the result in a unique_ptr. The return is a prvalue — C++17 mandatory copy elision applies, so no copy or move constructor fires on the return. Do not add std::move(...) around the return expression — it would interfere with elision. (notes 22.4)

Task 6 — takeOwnership: by value = ownership transfer
C++
void takeOwnership(std::unique_ptr<TrackedBuffer> p)
{
    if (p && !p->empty())
        (*p)[0] = 42;
}  // p destructs here — TrackedBuffer is deleted automatically

The function signature std::unique_ptr<TrackedBuffer> p (by value) is the convention for "I take ownership." The caller writes:

C++
takeOwnership(std::move(myPtr));  // myPtr is null after this line

std::move casts myPtr to an rvalue reference so the unique_ptr move constructor fires. Trying to pass without std::move is a compile errorunique_ptr explicitly deletes its copy constructor to enforce unique ownership. (notes 22.5)


Stretch goals
  • Make TrackedBuffer printable: add operator<<(std::ostream&, const TrackedBuffer&) (reuses Ch 21 operator overloading) that prints [1, 2, 3].
  • Add TrackedBuffer(std::initializer_list<int>) that fills the buffer from a brace-list — a Ch 23 preview of std::initializer_list.
  • Add swap(TrackedBuffer& a, TrackedBuffer& b) noexcept using three std::moves — demonstrate that a swap based on moves does zero heap allocations.
  • Add move-only semantics: = delete the copy constructor and copy assignment, making TrackedBuffer communicate "there is exactly one owner" at compile time (Ch 22: move-only types, notes 22.3).
  • Use std::unique_ptr<int[]> as the internal storage type instead of a raw int* — then the destructor becomes trivial and the Rule of Five reduces to three members (move ctor, move assign; copy remains deleted). Compare the complexity with and without the raw pointer.
starter/tracked_buffer.cpp C++
// Chapter 22 — Move Semantics & Smart Pointers · Project: TrackedBuffer (STARTER)
// ─────────────────────────────────────────────────────────────────────────────
// Fill in the six TASK blocks below. Each maps 1:1 to a task in the README and
// to a declaration in ../tracked_buffer.h. The bodies currently return
// PLACEHOLDERS so the file compiles immediately — that's why `make test` is RED
// right now. Your job is to turn it GREEN.
//
//     make build           compile your code (should already work)
//     make test            grade it           (RED until you fill these in)
//     make test-solution   run grader against the reference
//
// KEY IDEAS to keep in mind while you work
// ─────────────────────────────────────────
// * DEEP COPY vs SHALLOW COPY (notes 22.1, 22.3)
//   A default compiler-generated copy would copy the POINTER (shallow), so two
//   objects would think they own the same array — double-delete UB. Deep copy
//   allocates a NEW array and copies each element.
//
// * MOVE = STEAL + ZERO (notes 22.3)
//   A move constructor / assignment STEALS m_data and m_size from the source,
//   then ZEROS them out. After a move the source is "empty" (size==0, data==nullptr),
//   so its destructor safely runs `delete[] nullptr` — which is a no-op.
//
// * noexcept on move ops (notes 22.3)
//   std::vector checks whether the move constructor is noexcept before deciding
//   to move vs copy during reallocation. Mark your move ops noexcept!
//
// * self-assignment (notes 22.3)
//   Both assignment operators must check (this == &other) and do nothing if true,
//   otherwise you'd delete your own array before reading from it.
//
// * std::unique_ptr = RAII for a heap object (notes 22.5)
//   make_unique<T>(args) allocates, constructs, and wraps in one step.
//   Passing unique_ptr by VALUE transfers ownership — the caller MUST std::move.

#include "../tracked_buffer.h"

#include <algorithm>   // std::copy_n
#include <cstddef>     // std::size_t
#include <memory>      // std::unique_ptr, std::make_unique

// ── Static counter definitions ────────────────────────────────────────────────
// Static class members need ONE definition in a .cpp file (Chapter 15 rule).
int TrackedBuffer::s_copies { 0 };
int TrackedBuffer::s_moves  { 0 };

void TrackedBuffer::resetCounters()
{
    s_copies = 0;
    s_moves  = 0;
}

// ── Provided: default / size constructor ─────────────────────────────────────
// Allocates `size` ints, zero-initialised, if size > 0. Otherwise m_data stays
// nullptr. This is the only ctor the learner does NOT implement.
TrackedBuffer::TrackedBuffer(std::size_t size)
    : m_size { size }
    , m_data { size > 0 ? new int[size]{} : nullptr }
{
}

// ── Provided: destructor ──────────────────────────────────────────────────────
// delete[] on a null pointer is defined by the C++ standard to have no effect
// ([expr.delete]), so this works for empty buffers and for buffers whose pointer
// was stolen by a move (their m_data was zeroed to nullptr). (notes 22.3)
TrackedBuffer::~TrackedBuffer()
{
    delete[] m_data;
}

// ─── TASK 1: Copy constructor (deep copy) ────────────────────────────────────
// Construct *this as an independent copy of `other`.
//
// Steps:
//   1. Set m_size = other.m_size.
//   2. Allocate a NEW array of m_size ints (use new int[m_size]{}).
//      If other is empty (m_size == 0), set m_data = nullptr instead.
//   3. Copy each element: std::copy_n(other.m_data, m_size, m_data).
//   4. Increment TrackedBuffer::s_copies.
//
// Constraint: the resulting object must own its OWN array — NOT share other's.
// Hint: allocate with `new int[m_size]{}` (value-initialises to 0, then copy).
//
//   >>> YOUR CODE HERE <<<
//
TrackedBuffer::TrackedBuffer(const TrackedBuffer& other)
    : m_size { 0 }
    , m_data { nullptr }
{
    // PLACEHOLDER — always produces an empty buffer (wrong; tests will fail).
    // Replace this body with the real deep-copy logic.
    (void)other;   // suppress unused-parameter warning on the placeholder
}
// ─────────────────────────────────────────────────────────────────────────────

// ─── TASK 2: Move constructor (steal pointer, leave source empty) ─────────────
// Construct *this by STEALING other's resources — no allocation, no copying.
//
// Steps:
//   1. Set m_size = other.m_size.
//   2. Set m_data = other.m_data.
//   3. Zero out the source: other.m_size = 0; other.m_data = nullptr.
//      (This leaves `other` in the valid "empty" state so its dtor is safe.)
//   4. Increment TrackedBuffer::s_moves.
//
// Constraint: mark noexcept — this member function must not throw. (notes 22.3)
// Hint: a move ctor is NEVER called by the compiler for prvalue returns in
// C++17 due to mandatory copy elision — only for NAMED objects wrapped in
// std::move. Never test s_moves on a prvalue return from a factory.
//
//   >>> YOUR CODE HERE <<<
//
TrackedBuffer::TrackedBuffer(TrackedBuffer&& other) noexcept
    : m_size { 0 }
    , m_data { nullptr }
{
    // PLACEHOLDER — always produces an empty buffer (wrong; tests will fail).
    (void)other;
}
// ─────────────────────────────────────────────────────────────────────────────

// ─── TASK 3: Copy assignment ──────────────────────────────────────────────────
// Overwrite *this with an independent copy of `other`.
//
// Steps:
//   1. Self-assignment guard: if (this == &other) return *this;
//   2. Release existing resources: delete[] m_data.
//   3. Deep-copy: m_size = other.m_size; m_data = new int[m_size]{} (or nullptr
//      if empty); std::copy_n(other.m_data, m_size, m_data).
//   4. Increment s_copies.
//   5. return *this;
//
//   >>> YOUR CODE HERE <<<
//
TrackedBuffer& TrackedBuffer::operator=(const TrackedBuffer& other)
{
    // PLACEHOLDER — does nothing (leaves *this unchanged, which is wrong).
    (void)other;
    return *this;
}
// ─────────────────────────────────────────────────────────────────────────────

// ─── TASK 4: Move assignment ──────────────────────────────────────────────────
// Overwrite *this by STEALING other's resources.
//
// Steps:
//   1. Self-move-assignment guard: if (this == &other) return *this;
//      (std::move on self is rare but must be safe — notes 22.3)
//   2. Release existing resources: delete[] m_data.
//   3. Steal: m_size = other.m_size; m_data = other.m_data.
//   4. Zero source: other.m_size = 0; other.m_data = nullptr.
//   5. Increment s_moves.
//   6. return *this;
//
// Constraint: mark noexcept. (notes 22.3)
//
//   >>> YOUR CODE HERE <<<
//
TrackedBuffer& TrackedBuffer::operator=(TrackedBuffer&& other) noexcept
{
    // PLACEHOLDER — does nothing (leaves *this unchanged, which is wrong).
    (void)other;
    return *this;
}
// ─────────────────────────────────────────────────────────────────────────────

// ─── TASK 5: makeTracked factory ─────────────────────────────────────────────
// Allocate a TrackedBuffer of `size` elements on the HEAP and return it wrapped
// in a std::unique_ptr<TrackedBuffer>.
//
// Use:  return std::make_unique<TrackedBuffer>(size);
//
// Why make_unique? It allocates + constructs in one step and avoids the
// "new in one expression, unique_ptr ctor in another" exception-safety hole.
// (notes 22.5: "Prefer std::make_unique")
//
//   >>> YOUR CODE HERE <<<
//
std::unique_ptr<TrackedBuffer> makeTracked(std::size_t /*size*/)
{
    return nullptr;   // PLACEHOLDER — returns an empty (null) unique_ptr
}
// ─────────────────────────────────────────────────────────────────────────────

// ─── TASK 6: takeOwnership via unique_ptr by value ────────────────────────────
// Accept ownership of `p` by value; if the buffer is non-empty, write 42 into
// element [0]. When this function returns, p goes out of scope and the
// unique_ptr destructor deletes the TrackedBuffer automatically.
//
// The call site MUST be:  takeOwnership(std::move(local_ptr));
// Trying to copy a unique_ptr is a COMPILE ERROR — unique_ptr deletes its copy
// constructor. std::move is the cast that says "I'm transferring ownership."
// (notes 22.5: "Passing unique_ptr to functions")
//
//   >>> YOUR CODE HERE <<<
//
void takeOwnership(std::unique_ptr<TrackedBuffer> /*p*/)
{
    // PLACEHOLDER — does nothing (element[0] stays 0; tests will notice).
}
// ─────────────────────────────────────────────────────────────────────────────
Run
Submit
Run in your browser — coming soon For now: copy or download the files and use make test locally (see “Build & run locally” above).