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
Copy constructor — deep copy (
const TrackedBuffer&). Allocate a freshnew int[m_size]{}array and copy every element withstd::copy_n. Setm_sizeandm_datafromother. Increments_copies. Constraint: the new object must own its own array — not shareother's.Move constructor — steal pointer (
TrackedBuffer&&,noexcept). Copyother.m_sizeandother.m_dataintothis. Then zero the source:other.m_size = 0; other.m_data = nullptr. Increments_moves. Constraint: marknoexcept. No allocation, nostd::copy_n— that is the entire point.Copy assignment (
const TrackedBuffer&). Guard withif (this == &other) return *this;. Release the current array (delete[] m_data). Then deep-copy exactly as in Task 1. Increments_copies. Return*this.Move assignment (
TrackedBuffer&&,noexcept). Guard withif (this == &other) return *this;. Release the current array. Stealother's pointer and size. Zeroother. Increments_moves. Return*this. The self-move guard (b = std::move(b)) must leave the object valid.makeTrackedfactory — one line. Returnstd::make_unique<TrackedBuffer>(size). The caller receives exclusive heap ownership; they never calldeletemanually.takeOwnership— accept astd::unique_ptr<TrackedBuffer>by value. If the buffer is non-empty, write42into element[0]. Theunique_ptrdestructs at the end of the function, automatically deleting the buffer. The caller muststd::movetheir local pointer at the call site; forgettingstd::moveis a compile error (unique_ptr's copy constructor is deleted).
Success criteria
s_moves == 1,s_copies == 0afterTrackedBuffer b2 { std::move(b1) }— the move path, not the copy path (notes 22.3)b1.size() == 0andb1.data() == nullptrafter the move — the moved-from state (notes 22.3)- Deep copy independence:
b2[0] = 99must NOT changeb1[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
b1empty afterb2 = std::move(b1) self = std::move(self)is safe (self-move guard, notes 22.3)makeTracked(n)returns a non-nullunique_ptrwithsize() == n- After
q = std::move(p),pis null (notes 22.5) - After
takeOwnership(std::move(p)),pis null (notes 22.5) shared_ptruse_countincrements on copy and decrements when an owner goes out of scope (notes 22.6)
Concepts practiced
- Move constructor (
T&¶meter, steal + null) — new in Ch 22 (notes 22.3) - Move assignment (release old, steal new, guard self-move) — Ch 22 (notes 22.3)
std::moveas 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) noexcepton move operations — Ch 22 (notes 22.3)std::unique_ptr<T>+std::make_unique— Ch 22 (notes 22.5)- Passing
unique_ptrby value to transfer ownership — Ch 22 (notes 22.5) std::shared_ptrcopy anduse_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:
staticclass members — definitions in.cpp - Reused from Ch 14: constructors, member-initialiser lists,
constmembers - Reused from Ch 18:
std::copy_nfrom<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(notnew TrackedBuffer+ wrapping). - Task 6 must accept a
unique_ptrby value (transfer semantics, notget()).
Forbidden (not yet taught or out of scope):
std::weak_ptrdeep dives or custom deleters (Ch 22 scope boundary).- Manual
deleteordelete[]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
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 artifactsmake 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:
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:
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:
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
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
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
void takeOwnership(std::unique_ptr<TrackedBuffer> p)
{
if (p && !p->empty())
(*p)[0] = 42;
} // p destructs here — TrackedBuffer is deleted automaticallyThe function signature std::unique_ptr<TrackedBuffer> p (by value) is the
convention for "I take ownership." The caller writes:
takeOwnership(std::move(myPtr)); // myPtr is null after this linestd::move casts myPtr to an rvalue reference so the unique_ptr move
constructor fires. Trying to pass without std::move is a compile error —
unique_ptr explicitly deletes its copy constructor to enforce unique ownership.
(notes 22.5)
Stretch goals
- Make
TrackedBufferprintable: addoperator<<(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 ofstd::initializer_list. - Add
swap(TrackedBuffer& a, TrackedBuffer& b) noexceptusing threestd::moves — demonstrate that a swap based on moves does zero heap allocations. - Add move-only semantics:
= deletethe copy constructor and copy assignment, makingTrackedBuffercommunicate "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 rawint*— 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.
// 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).
}
// ─────────────────────────────────────────────────────────────────────────────
Try the lab first — the learning is in the attempt.
// Chapter 22 — Move Semantics & Smart Pointers · Project: TrackedBuffer (SOLUTION)
// ─────────────────────────────────────────────────────────────────────────────
// Reference implementation — complete, correct, richly commented.
// Every design decision is explained so you can compare with your own solution.
//
// RULE OF FIVE (notes 22.3)
// TrackedBuffer owns a raw pointer (m_data). Any class that owns a raw pointer
// must define ALL FIVE special members explicitly, because the compiler-generated
// versions do shallow copies — they copy the pointer value, not the data:
//
// Two objects would point at the same array.
// Whichever object is destroyed first frees the array.
// The second object then holds a DANGLING POINTER → UB on destruction.
//
// The five members we define: destructor, copy ctor, copy assignment,
// move ctor, move assignment.
#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 are DECLARED in the header; they need exactly ONE
// DEFINITION in a .cpp file. (Chapter 15: static class members)
int TrackedBuffer::s_copies { 0 };
int TrackedBuffer::s_moves { 0 };
void TrackedBuffer::resetCounters()
{
s_copies = 0;
s_moves = 0;
}
// ── Default / size constructor (provided, not a task) ─────────────────────────
// Value-initialises the array with `new int[size]{}` so all elements start at 0.
// A size of 0 keeps m_data as nullptr — that is the "empty" state we use for
// moved-from buffers too. (Chapter 19: dynamic allocation with new[]/delete[])
TrackedBuffer::TrackedBuffer(std::size_t size)
: m_size { size }
, m_data { size > 0 ? new int[size]{} : nullptr }
{
}
// ── Destructor (provided, not a task) ────────────────────────────────────────
// delete[] nullptr is explicitly defined to be a no-op (C++ standard), so this
// destructor works safely for empty buffers, default-constructed buffers, and
// moved-from buffers (which had their pointer zeroed to nullptr).
TrackedBuffer::~TrackedBuffer()
{
delete[] m_data;
}
// ── TASK 1: Copy constructor (deep copy) ─────────────────────────────────────
// WHY deep copy: two objects must own INDEPENDENT arrays. If we just copied
// m_data (shallow), both destructors would delete the same memory — UB.
//
// DESIGN: We allocate a fresh array of the SAME size and then copy each element
// with std::copy_n (from <algorithm>). An empty `other` (size 0) is handled by
// keeping m_data = nullptr and skipping copy_n.
//
// s_copies lets tests observe that this path was taken (not the move path).
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;
}
// ── TASK 2: Move constructor (steal pointer, leave source empty) ───────────────
// WHY move: if `other` is about to be destroyed (a temporary or an explicit
// std::move), allocating a fresh array and copying every element is wasteful.
// Instead we STEAL the pointer — O(1) regardless of array size.
//
// AFTER THE STEAL: we MUST null out `other.m_data` and zero `other.m_size`.
// The source object still exists (until its own destructor fires) and its
// destructor calls `delete[] m_data`. If we left the original pointer in `other`,
// the same array would be deleted twice — UB.
//
// noexcept: pointer steal and integer copy cannot throw. Marking noexcept tells
// std::vector to prefer this move during reallocation (notes 22.3).
//
// C++17 NOTE: prvalue return values are subject to MANDATORY COPY ELISION —
// the compiler constructs the result directly; neither the copy nor the move
// constructor fires. That is why we do NOT test s_moves on the return value of
// makeTracked — and why you should NEVER std::move a local being returned by
// value (it interferes with elision). (notes 22.4)
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;
}
// ── TASK 3: Copy assignment ───────────────────────────────────────────────────
// Unlike the copy constructor, the destination *already* owns resources —
// we must RELEASE them before taking on the new ones.
//
// SELF-ASSIGNMENT: `b = b` or `b = someRef` where someRef refers to b. If we
// deleted m_data first and then tried to copy from *this, we'd be reading freed
// memory. Always detect and bail early.
//
// STEPS:
// 1. Guard: if (this == &other) return *this;
// 2. Release: delete[] m_data;
// 3. Deep-copy: allocate + copy_n (same as the copy constructor).
// 4. Increment s_copies; return *this.
TrackedBuffer& TrackedBuffer::operator=(const TrackedBuffer& other)
{
if (this == &other) // self-assignment guard
return *this;
delete[] m_data; // release current resource (Chapter 19 discipline)
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;
}
// ── TASK 4: Move assignment ────────────────────────────────────────────────────
// Combines the ideas of copy assignment (must release current resources) and
// the move constructor (steal instead of copy).
//
// ORDER MATTERS:
// 1. Self-move guard: if (this == &other) return *this;
// `b = std::move(b)` must leave b valid (not crashed). It is unusual but
// defined to be legal and must be safe. (notes 22.3)
// 2. Release: delete[] m_data — free the resource we currently own.
// 3. Steal: copy pointer + size from other.
// 4. Zero source: null out other.m_data, zero other.m_size.
// 5. Increment s_moves; return *this.
//
// noexcept: same reason as the move constructor.
TrackedBuffer& TrackedBuffer::operator=(TrackedBuffer&& other) noexcept
{
if (this == &other) // self-move guard
return *this;
delete[] m_data; // release current resource
m_size = other.m_size; // steal
m_data = other.m_data;
other.m_size = 0; // zero source so its dtor is safe
other.m_data = nullptr;
++s_moves;
return *this;
}
// ── TASK 5: makeTracked factory ───────────────────────────────────────────────
// Factory pattern: the caller receives a fully-initialised TrackedBuffer ALREADY
// wrapped in a unique_ptr. They never touch `new` or `delete` directly.
//
// std::make_unique<TrackedBuffer>(size):
// - allocates sizeof(TrackedBuffer) bytes on the heap,
// - constructs a TrackedBuffer(size) in that memory,
// - wraps the result in a std::unique_ptr<TrackedBuffer>,
// - returns it (by value — eligible for copy elision, no overhead).
//
// After this function returns, the caller holds EXCLUSIVE OWNERSHIP.
// When their unique_ptr goes out of scope (or is reset), the TrackedBuffer is
// automatically deleted — no leak possible. (notes 22.5)
std::unique_ptr<TrackedBuffer> makeTracked(std::size_t size)
{
return std::make_unique<TrackedBuffer>(size);
}
// ── TASK 6: takeOwnership ─────────────────────────────────────────────────────
// Accepting a unique_ptr by VALUE is the idiom for "this function takes ownership."
// The caller MUST write: takeOwnership(std::move(p));
// If they forget std::move, the copy constructor fires — but unique_ptr DELETES
// its copy constructor (unique ownership), so it is a COMPILE ERROR.
// std::move is simply a cast; it does not move by itself. (notes 22.4)
//
// After the function returns, `p` goes out of scope and ~unique_ptr() fires,
// which calls delete on the TrackedBuffer. No manual delete needed.
void takeOwnership(std::unique_ptr<TrackedBuffer> p)
{
if (p && !p->empty())
(*p)[0] = 42;
// p destructs here — TrackedBuffer is deleted automatically.
}
// Chapter 22 — Move Semantics & Smart Pointers · Project: TrackedBuffer (GRADER)
// ─────────────────────────────────────────────────────────────────────────────
// Tiny no-framework unit-test harness (same style as drills/CLAUDE.md).
// Each CHECK that fails prints its expression and line number.
// Any failure → non-zero exit → `make test` is RED.
//
// The Makefile links this file against starter/tracked_buffer.cpp for `make test`
// and against solution/tracked_buffer.cpp for `make test-solution`.
//
// IMPORTANT DESIGN NOTES FOR TEST AUTHORSHIP
// ─────────────────────────────────────────────
// C++17 MANDATORY COPY ELISION: constructing a TrackedBuffer from a prvalue
// (e.g. the return value of makeTracked) does NOT invoke any copy/move ctor —
// the object is constructed directly in the destination. Therefore we NEVER
// check s_moves after a factory return. We only check moves on NAMED objects
// wrapped in std::move(), or after move assignment. (notes 22.3, 22.4)
//
// MOVED-FROM STATE: after `TrackedBuffer b2 { std::move(b1) }`, b1 must have
// size() == 0 and data() == nullptr. We test this explicitly.
#include <iostream>
#include <memory>
#include "../tracked_buffer.h"
static int fails = 0;
// CHECK: assert a boolean condition; on failure report expression and line.
#define CHECK(cond) \
do { if(!(cond)){ std::cerr << "FAIL: " #cond " @line " << __LINE__ << "\n"; ++fails; } } while(0)
int main()
{
// ── Basic construction / empty state ──────────────────────────────────────
{
TrackedBuffer empty{};
CHECK(empty.size() == 0);
CHECK(empty.empty());
CHECK(empty.data() == nullptr);
TrackedBuffer buf{ 4 };
CHECK(buf.size() == 4);
CHECK(!buf.empty());
CHECK(buf.data() != nullptr);
// All elements are zero-initialised by new int[size]{}
CHECK(buf[0] == 0);
CHECK(buf[3] == 0);
}
// ── TASK 1: Copy constructor — deep copy, bumps s_copies not s_moves ─────
{
TrackedBuffer::resetCounters();
TrackedBuffer src{ 3 };
src[0] = 10; src[1] = 20; src[2] = 30;
TrackedBuffer dst{ src }; // copy constructor
// Counter check — copy path was taken, not move
CHECK(TrackedBuffer::s_copies == 1);
CHECK(TrackedBuffer::s_moves == 0);
// Value check — all elements duplicated
CHECK(dst.size() == 3);
// Guard element access: only dereference if the copy produced a valid array.
if (dst.size() == 3 && dst.data() != nullptr)
{
CHECK(dst[0] == 10);
CHECK(dst[1] == 20);
CHECK(dst[2] == 30);
// Independence check — modifying dst must NOT change src
dst[0] = 99;
CHECK(src[0] == 10); // src unchanged: DEEP copy
// Address check — they must own separate arrays
CHECK(dst.data() != src.data());
}
}
// ── TASK 1 (edge): copy of empty buffer ───────────────────────────────────
{
TrackedBuffer::resetCounters();
TrackedBuffer empty{};
TrackedBuffer copyEmpty{ empty };
CHECK(TrackedBuffer::s_copies == 1);
CHECK(copyEmpty.empty());
CHECK(copyEmpty.data() == nullptr);
}
// ── TASK 2: Move constructor — steals pointer, bumps s_moves not s_copies
{
TrackedBuffer::resetCounters();
TrackedBuffer src{ 5 };
src[0] = 7; src[1] = 8; src[2] = 9; src[3] = 4; src[4] = 5;
const int* originalData = src.data(); // remember the address
TrackedBuffer dst{ std::move(src) }; // move constructor (named object)
// Counter check — move path, not copy
CHECK(TrackedBuffer::s_moves == 1);
CHECK(TrackedBuffer::s_copies == 0);
// Destination received the data
CHECK(dst.size() == 5);
if (dst.size() == 5 && dst.data() != nullptr)
{
CHECK(dst[0] == 7);
CHECK(dst[4] == 5);
// The pointer was STOLEN (same address, not a new allocation)
CHECK(dst.data() == originalData);
}
// Moved-from state: src must be empty (notes 22.3)
CHECK(src.size() == 0);
CHECK(src.empty());
CHECK(src.data() == nullptr);
}
// ── TASK 2 (edge): move of empty buffer ───────────────────────────────────
{
TrackedBuffer::resetCounters();
TrackedBuffer empty{};
TrackedBuffer movedEmpty{ std::move(empty) };
CHECK(TrackedBuffer::s_moves == 1);
CHECK(movedEmpty.empty());
CHECK(empty.empty()); // both empty, no UB
}
// ── TASK 3: Copy assignment ────────────────────────────────────────────────
{
TrackedBuffer::resetCounters();
TrackedBuffer src{ 3 };
src[0] = 1; src[1] = 2; src[2] = 3;
TrackedBuffer dst{ 6 }; // dst already owns a different array
dst = src; // copy assignment
CHECK(TrackedBuffer::s_copies == 1);
CHECK(TrackedBuffer::s_moves == 0);
CHECK(dst.size() == 3);
if (dst.size() == 3 && dst.data() != nullptr)
{
CHECK(dst[0] == 1);
CHECK(dst[2] == 3);
// Independence: modifying dst does not affect src
dst[0] = 77;
CHECK(src[0] == 1);
// Separate arrays
CHECK(dst.data() != src.data());
}
}
// ── TASK 3 (edge): copy self-assignment ────────────────────────────────────
{
TrackedBuffer::resetCounters();
TrackedBuffer buf{ 2 };
buf[0] = 5; buf[1] = 6;
// Self-assignment must be safe and NOT corrupt the data
// Use a reference to avoid compiler warnings about obvious self-assign
TrackedBuffer& ref = buf;
buf = ref;
CHECK(buf.size() == 2);
CHECK(buf[0] == 5);
CHECK(buf[1] == 6);
}
// ── TASK 4: Move assignment ────────────────────────────────────────────────
{
TrackedBuffer::resetCounters();
TrackedBuffer src{ 4 };
src[0] = 10; src[1] = 20; src[2] = 30; src[3] = 40;
const int* originalData = src.data();
TrackedBuffer dst{ 7 }; // dst currently owns a 7-element array
dst = std::move(src); // move assignment
CHECK(TrackedBuffer::s_moves == 1);
CHECK(TrackedBuffer::s_copies == 0);
// Destination has the data
CHECK(dst.size() == 4);
if (dst.size() == 4 && dst.data() != nullptr)
{
CHECK(dst[0] == 10);
CHECK(dst[3] == 40);
// Pointer was stolen
CHECK(dst.data() == originalData);
}
// Source is empty (moved-from state)
CHECK(src.size() == 0);
CHECK(src.empty());
CHECK(src.data() == nullptr);
}
// ── TASK 4 (edge): self-move-assignment must be safe AND preserve data ────
// `b = std::move(b)` must leave b in a valid state (notes 22.3). The self-move
// guard (if (this == &other) return *this;) achieves this by bailing out
// BEFORE the delete[]/steal logic — so the buffer is left untouched. Without
// the guard, the buffer would `delete[] m_data` and then read its own freed
// pointer (this == &other), losing the data (and risking UB). We therefore
// require the data to survive intact, which is exactly what the guard
// guarantees and what TASK 4 instructs.
{
TrackedBuffer buf{ 3 };
buf[0] = 1; buf[1] = 2; buf[2] = 3;
const int* before = buf.data();
// Use a reference so the self-move is not flagged by -Wself-move.
TrackedBuffer& ref = buf;
buf = std::move(ref);
// Guard preserves the object exactly: same size, same array, same values.
CHECK(buf.size() == 3);
CHECK(buf.data() == before); // pointer not freed/reallocated
if (buf.size() == 3 && buf.data() != nullptr)
{
CHECK(buf[0] == 1);
CHECK(buf[1] == 2);
CHECK(buf[2] == 3);
}
}
// ── TASK 5: makeTracked factory ───────────────────────────────────────────
{
// Factory returns a non-null unique_ptr owning a fresh TrackedBuffer.
auto p = makeTracked(8);
CHECK(p != nullptr);
if (p) // guard: only dereference if non-null
{
CHECK(p->size() == 8); // correct size
CHECK(!p->empty());
CHECK((*p)[0] == 0); // zero-initialised
}
// Ownership transfer: move the unique_ptr to a second variable.
auto q = std::move(p); // p is now null; q owns the buffer
CHECK(!p); // p is empty after move (notes 22.5)
CHECK(q != nullptr);
if (q) // guard
CHECK(q->size() == 8);
// makeTracked(0) gives a valid empty buffer
auto empty = makeTracked(0);
CHECK(empty != nullptr);
if (empty)
CHECK(empty->empty());
}
// ── TASK 6: takeOwnership — unique_ptr by value ────────────────────────────
// takeOwnership accepts a unique_ptr BY VALUE, so the caller MUST std::move.
// After the call the caller's pointer is null — ownership has transferred.
// (notes 22.5: "takeOwnership(std::move(file)); // file is now empty")
//
// Side-effect note: takeOwnership also writes 42 into element [0] of a
// non-empty buffer. That write is, by design, NOT observable from the caller:
// because the unique_ptr is passed by value, takeOwnership becomes the sole
// owner and frees the buffer when it returns. Reading the buffer afterward
// would be a use-after-free, so we do not assert == 42 here. The observable,
// testable contract of a by-value-consuming function is the OWNERSHIP TRANSFER
// (the caller's pointer is emptied) — that is the lesson, and what we check.
{
auto p = makeTracked(4);
CHECK(p != nullptr);
if (p) // guard: only call if non-null (else UB in takeOwnership body)
{
CHECK((*p)[0] == 0); // zero-initialised before the call
takeOwnership(std::move(p)); // body writes [0]=42, then frees
CHECK(!p); // essential: caller's pointer is emptied
// Buffer is freed now: reading (*p) or any saved raw pointer = UAF.
}
}
// ── shared_ptr: unique→shared transfer, copy, and use_count ───────────────
// std::shared_ptr is a Chapter 22 concept (notes 22.6): shared ownership via a
// reference-counted control block. Copying a shared_ptr adds an owner and
// raises use_count; destroying or reset()-ing one lowers it. unique→shared
// ownership transfer is legal and one-directional (notes 22.6).
{
// Promote unique ownership to shared ownership.
auto uniq = std::make_unique<TrackedBuffer>(4);
std::shared_ptr<TrackedBuffer> keeper{ std::move(uniq) };
CHECK(!uniq); // uniq emptied by the move
CHECK(keeper.use_count() == 1);
CHECK((*keeper)[0] == 0); // shared object is usable
// Copy the shared_ptr → a second owner shares the same control block.
std::shared_ptr<TrackedBuffer> observer{ keeper };
CHECK(keeper.use_count() == 2);
CHECK(observer.use_count() == 2);
CHECK(observer->size() == 4);
observer.reset(); // drop one owner
CHECK(keeper.use_count() == 1); // keeper still alive, object intact
CHECK(keeper->size() == 4);
}
// ── shared_ptr basics: use_count ──────────────────────────────────────────
// (notes 22.6: shared ownership, control block, use_count)
// This is a READ-ONLY observation task — no implementation needed.
// It verifies that TrackedBuffer works correctly inside a shared_ptr.
{
TrackedBuffer::resetCounters();
std::shared_ptr<TrackedBuffer> a = std::make_shared<TrackedBuffer>(3);
CHECK(a.use_count() == 1);
{
std::shared_ptr<TrackedBuffer> b = a; // copy the shared_ptr (notes 22.6)
CHECK(a.use_count() == 2);
CHECK(b.use_count() == 2);
// b goes out of scope here
}
CHECK(a.use_count() == 1); // b is gone; a still alive
CHECK(a->size() == 3);
}
// ── Summary ───────────────────────────────────────────────────────────────
if (!fails)
std::cout << "PASS \xE2\x9C\x85 all TrackedBuffer checks passed.\n";
else
std::cerr << "\nFAIL \xE2\x9D\x8C " << fails
<< " check(s) failed — fill in the TASK blocks in "
"starter/tracked_buffer.cpp until every check passes.\n";
return fails ? 1 : 0;
}
# Chapter 22 — Move Semantics & Smart Pointers · TrackedBuffer · unit-test grader (Style B).
# Targets follow the drills/CLAUDE.md Makefile contract. TABS, not spaces.
#
# Layout:
# tracked_buffer.h — declarations only (provided, complete; do not edit)
# starter/tracked_buffer.cpp — bodies with TASK blocks (learner fills in)
# solution/tracked_buffer.cpp — reference bodies
# tests/tests.cpp — includes ../tracked_buffer.h, calls the API, CHECKs results
#
# The grader links tests/tests.cpp against whichever implementation is under test.
CXX := clang++
CXXFLAGS := -std=c++17 -Wall -Wextra
.PHONY: all build run test solution test-solution clean
all: build
# ── build — compile-check the learner's implementation (warning-clean) ────────
build:
$(CXX) $(CXXFLAGS) -c starter/tracked_buffer.cpp -o starter/tracked_buffer.o
@echo "OK \xE2\x9C\x85 starter/tracked_buffer.cpp compiles. Now run: make test"
# ── run — build and run a minimal smoke test on the starter ───────────────────
run: build
$(CXX) $(CXXFLAGS) tests/tests.cpp starter/tracked_buffer.cpp -o starter/run
@./starter/run; true
# ── test — grade the LEARNER's code (RED until tasks are filled in) ───────────
test:
$(CXX) $(CXXFLAGS) tests/tests.cpp starter/tracked_buffer.cpp -o tests/run
@./tests/run || echo "FAIL \xE2\x9D\x8C fill in the TASK blocks in starter/tracked_buffer.cpp until every check passes."
# ── solution — build + run the reference solution ─────────────────────────────
solution:
$(CXX) $(CXXFLAGS) tests/tests.cpp solution/tracked_buffer.cpp -o solution/run
@./solution/run
# ── test-solution — proof the lab is solvable: MUST be green ─────────────────
test-solution:
$(CXX) $(CXXFLAGS) tests/tests.cpp solution/tracked_buffer.cpp -o tests/run
@./tests/run
# ── clean — remove all build artifacts ────────────────────────────────────────
clean:
rm -f starter/tracked_buffer.o starter/run solution/run tests/run
rm -rf starter/run.dSYM solution/run.dSYM tests/run.dSYM
// Chapter 22 — Move Semantics & Smart Pointers · Project: TrackedBuffer
// ─────────────────────────────────────────────────────────────────────────────
// This header is the CONTRACT between you and the grader.
// It declares the TrackedBuffer class and two free functions. Do NOT edit it —
// tests/tests.cpp includes it, and so do BOTH starter/tracked_buffer.cpp (yours)
// and solution/tracked_buffer.cpp (the reference). Change a signature and the
// grader breaks.
//
// THE BIG IDEA OF THIS LAB
// A raw new[]-owning class (Chapter 19 payoff) is the perfect host for move
// semantics because moves are so much cheaper than copies: a move steals the
// pointer instead of allocating a fresh array and copying every element.
//
// TrackedBuffer *instruments* its special members with static counters so you
// can observe exactly which operations fire — and prove (via the tests) that
// std::move triggers the move path, NOT the copy path.
//
// This mirrors real C++: owning, resource-holding types in LLVM are routinely
// move-only. `llvm::MemoryBuffer`, for example, deletes its copy constructor and
// is handed around inside a `std::unique_ptr<MemoryBuffer>` — ownership moves,
// never copies, so a large file buffer is never silently deep-copied. The Rule of
// Five (and move-only types) is the idiom behind that discipline. (notes 22.3)
//
// Header guard (Chapter 2): prevents this file being included twice.
#ifndef TRACKED_BUFFER_H
#define TRACKED_BUFFER_H
#include <cstddef> // std::size_t
#include <memory> // std::unique_ptr, std::make_unique (notes 22.5)
// ─── TrackedBuffer ───────────────────────────────────────────────────────────
// An RAII class that OWNS a new[]-allocated int array.
// Instruments every special member function with static counters so tests can
// observe exactly how many copies and moves occurred.
//
// Rule of Five (notes 22.3): because we own a raw pointer we MUST provide all
// five special members — destructor, copy ctor, copy assignment, move ctor,
// move assignment — or let the compiler do the wrong thing (shallow copy).
class TrackedBuffer
{
public:
// ── Static observability counters ────────────────────────────────────────
// These let the grader ask "how many copies / moves happened?"
// They are separate from instance state so tests can reset and re-query.
static int s_copies; // incremented inside the copy ctor and copy assignment
static int s_moves; // incremented inside the move ctor and move assignment
// Reset both counters to zero.
static void resetCounters();
// ── Constructors ─────────────────────────────────────────────────────────
// Default / size constructor.
// Allocates an array of `size` default-initialised ints (all zero).
// A size of 0 is valid: m_data stays nullptr, nothing is allocated.
// (Provided — not a task.)
explicit TrackedBuffer(std::size_t size = 0);
// Copy constructor (TASK 1 — deep copy).
// Constructs *this as an independent copy of `other`.
// Must allocate its OWN array and copy every element.
// Must increment s_copies.
TrackedBuffer(const TrackedBuffer& other);
// Move constructor (TASK 2 — steal pointer, leave source empty).
// Constructs *this by STEALING other.m_data and other.m_size.
// Must set other.m_data = nullptr and other.m_size = 0 afterward.
// Must increment s_moves.
// noexcept: move ops are marked noexcept so std::vector can prefer them
// during reallocation (notes 22.3).
TrackedBuffer(TrackedBuffer&& other) noexcept;
// ── Assignment operators ──────────────────────────────────────────────────
// Copy assignment (TASK 3).
// Overwrites *this with an independent copy of `other`.
// Must handle self-assignment safely.
// Must increment s_copies.
TrackedBuffer& operator=(const TrackedBuffer& other);
// Move assignment (TASK 4).
// Overwrites *this by STEALING other's resources.
// Must release *this's current array first (no leak).
// Must handle self-move-assignment safely (this == &other).
// Must increment s_moves.
// noexcept: same reason as the move constructor.
TrackedBuffer& operator=(TrackedBuffer&& other) noexcept;
// ── Destructor ────────────────────────────────────────────────────────────
// Releases m_data with delete[]. (Provided — not a task.)
~TrackedBuffer();
// ── Observers ────────────────────────────────────────────────────────────
// (Provided — no tasks here. Read them to understand the moved-from state.)
std::size_t size() const { return m_size; }
// True when this buffer owns no array (size == 0, m_data == nullptr).
bool empty() const { return m_size == 0; }
// Element access — undefined behaviour if i >= m_size.
int operator[](std::size_t i) const { return m_data[i]; }
int& operator[](std::size_t i) { return m_data[i]; }
// Raw pointer — for testing moved-from state (nullptr after a move).
const int* data() const { return m_data; }
private:
std::size_t m_size {}; // number of elements owned
int* m_data {}; // owned array; nullptr when empty
};
// ─── Free functions ───────────────────────────────────────────────────────────
// TASK 5 — factory returning a unique_ptr<TrackedBuffer>.
// Allocate a TrackedBuffer of `size` elements on the heap, wrap it in a
// std::unique_ptr, and return it. Caller receives exclusive ownership.
// Use std::make_unique (notes 22.5).
std::unique_ptr<TrackedBuffer> makeTracked(std::size_t size);
// TASK 6 — take ownership through a unique_ptr parameter.
// Accept ownership of `p` by value. Inside, write the first element to 42
// if the buffer is non-empty. p is destroyed when this function returns —
// demonstrating that the unique_ptr's destructor cleans up automatically.
// The CALLER must std::move their local unique_ptr into this call; passing
// without std::move is a compile error (copy of unique_ptr is deleted).
void takeOwnership(std::unique_ptr<TrackedBuffer> p);
#endif // TRACKED_BUFFER_H
make test locally
(see “Build & run locally” above).