Module 10 — Worked Examples¶
Every example asks you to predict before it shows you the output. The prediction is the
exercise; the output is only the marking scheme. Where a program prints more than is useful
here, the excerpt is marked with ….
The programs are in examples/module-10/, each folder with its own expected-output.txt.
Build any of them with:
Two of these programs contain deliberate undefined behaviour
01-shallow-copy-alias reproduces a double free, and 04-move-semantics moves from an
object and then shows what is left. Both say so in their header comment. The double free
cannot be run safely as written, so that program's destructor reports the second free
instead of performing it. Do not copy the patterns; the point is to recognise them.
Example 1 — the copy that shares one block [L4 Analyze]¶
The problem: a class owns a heap array, and declares no copy constructor. What does an ordinary copy line actually do?
Before you read on, predict: RawBuffer copy = original; runs. How many heap blocks exist
now — one or two? Then predict what happens at the end of the scope, when both objects are
destroyed, and whether the compiler said anything about it.
// examples/module-10/01-shallow-copy-alias/main.cpp
class RawBuffer {
public:
explicit RawBuffer(std::size_t n) : size_(n), data_(new int[n]{}) {}
~RawBuffer(); // instrumented: reports, never double-frees
bool sharesStorageWith(const RawBuffer& other) const { return data_ == other.data_; }
private:
std::size_t size_;
int* data_;
};
Output (excerpt):
One buffer, created and filled with 7:
original [7 7 7 7] blocks alive 1
A copy arrives - an ordinary line, no cast, no warning:
copy [7 7 7 7] blocks alive 1 <- no new block was allocated
copy.sharesStorageWith(original) -> true <- one block, two owners
Writing through the copy:
copy [99 7 7 7]
original [99 7 7 7] <- the original changed. That is what shared storage means.
…
after both destructors ran:
allocations 3
frees 2
second frees BLOCKED 1 <- would have been a double free
blocks alive 1
Why it behaves that way: the compiler generated a copy constructor, and it copied every
member. size_ was copied as a value, which is right. data_ was copied as an address, so
both objects point at one block. Destruction then tries to free that block twice, and the
program's guard is the only reason you read a line instead of losing the process. Note how far
apart the two events are: the copy happens in one function, the second free happens in another
function's destructor, and the symptom appears in neither.
What to take away: an owning class with no copy constructor has a copy constructor; the
compiler wrote it; and it is wrong. sharesStorageWith() is the diagnostic you write when you
do not have a sanitizer.
Example 2 — the Rule of Three, written out and counted [L2 Apply]¶
The problem: the same owning class, but with the three functions that make copying safe. How much of the behaviour can be predicted in advance?
Before you read on, predict: the program runs six steps and then prints three counters.
Write down the expected values of allocations, frees and blocks alive — and, more
usefully, predict the order of the destructor lines at step 6.
// examples/module-10/02-rule-of-three/main.cpp
Buffer(const Buffer& other) // NEW object: allocate
: size_(other.size_), data_(new int[other.size_]) { … }
Buffer& operator=(const Buffer& other) { // EXISTING object: release, then take on
if (this == &other) { return *this; } // self-assignment guard, one line
int* fresh = new int[other.size_]; // allocate BEFORE releasing
std::copy(other.data_, other.data_ + other.size_, fresh);
delete[] data_;
data_ = fresh;
size_ = other.size_;
return *this;
}
~Buffer() { delete[] data_; } // release exactly once, at death
Output (excerpt):
Step 2 - copy INITIALISATION: Buffer b = a; calls the copy constructor
copy constructor (a(copy) [4] <- a [4])
same storage? no - two independent blocks
Step 3 - copy ASSIGNMENT into an existing object of a different size
c before: [1 1]
c after : [7 7 7 7] <- the old 2-element block was released
Step 4 - self-assignment: a = a
copy assignment (refused: this is a = a)
a = [7 7 7 7] <- unchanged, still owns its block
…
Counts after every object has been destroyed
allocations 5
frees 5
blocks alive 0
Why it behaves that way: the two functions differ in what they may assume. The copy
constructor is building a new object, so it allocates and copies. The assignment operator is
replacing an existing object, so it must deal with what that object already holds — and the
order inside it matters: allocate first, then release, so that a throwing allocation leaves
the object untouched. The counters are the evidence: five allocations, five frees, nothing
alive. That single equality — allocations == frees, blocks alive == 0 — is the whole of
this module's correctness criterion, and it is checkable by hand.
What to take away: the guard is one line and it removes an entire class of bug. Write it, or use copy-and-swap and remove the question.
Example 3 — assignment in one line, and the guarantee it buys [L5 Design]¶
The problem: the hand-written assignment of Example 2 is correct and fragile: five statements, one ordering requirement, one guard, and it must be written again for every owning class. Can it be shorter and safer?
Before you read on, predict: copy-and-swap assigns by taking the parameter by value.
Predict how many copies an assignment makes, what happens when the copy throws, and what
x = y = z does to z.
// examples/module-10/03-copy-and-swap/main.cpp
void swap(Payload& other) noexcept {
std::swap(label_, other.label_);
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
Payload& operator=(Payload other) { // taken by VALUE: already a complete copy
swap(*this, other);
return *this;
} // `other` dies here, releasing the old resource
Output (excerpt):
2. Self-assignment, with no guard anywhere in the class
a = a leaves a unchanged: a [7 7 7 7]
no leak: blocks alive 1
3. Chained assignment: x = y = z
x = z [6 6 6]
y = z [6 6 6]
z = z [6 6 6] <- z was only read
4. The strong guarantee: a failed assignment changes nothing
safe before = safe [4 4 4]
caught: allocation refused: 20 exceeds the limit of 8
safe after = safe [4 4 4] <- untouched, not empty, not half-copied
5. Why swap must be noexcept
after swap: left = right [2 2 2], right = left [1 1]
no allocation, no copy: allocations still 14
Why it behaves that way: the parameter is a complete, independent copy of whatever was
assigned, built before the function body runs. Swapping it into *this makes *this hold
the new value and hands the old value to the parameter, whose destructor then releases it. The
cleanup work is done by code you already had to write. The three properties are consequences,
not features: there is no window in which *this is half-updated, so self-assignment cannot
hurt it; a throwing copy happens before anything is touched, so the strong guarantee holds; and
the return of *this keeps the chained assignment returning a reference as the built-in types
do.
What to take away: operator= taking its argument by value is the smallest correct
assignment there is. Its cost is one extra copy, paid on the success path, and it is worth
naming honestly as a cost rather than pretending it is free.
Example 4 — moves measured, and the claim that std::move moves [L4 Analyze]¶
The problem: std::move reads like an instruction to move. What does it actually do, and
when does the move it appears to request not happen?
Before you read on, predict: for each expression in the table below, write copy, move
or neither. Two of the eight are the ones people get wrong: a const member and a plain
lvalue argument.
// examples/module-10/04-move-semantics/main.cpp
Tracked c = a; // copy: an lvalue is being copied
Tracked c2 = std::move(a); // move: the cast made the move ctor viable
consume(d); // copy: a plain lvalue parameter
consume(std::move(config.value)); // COPY: the member is const
makePrvalue(); // neither: constructed in place
Output (excerpt):
Part A - std::move is a cast, not an action
std::move(Tracked&) -> Tracked&& (compile-time check in a static_assert)
std::move(const Tracked&) -> const Tracked&& (compile-time check)
Neither one touches a single byte of the object.
Part B - measured: what each expression actually costs
Tracked c = a; copies 1 moves 0 new objects 1
Tracked c2 = std::move(a); copies 0 moves 1 new objects 1
consume(std::move(d)); copies 0 moves 1 new objects 1
consume(d); // plain lvalue copies 1 moves 0 new objects 1
makePrvalue(); // return Tracked(...) copies 0 moves 0 new objects 1
makeNamedLocal(); // return a named local copies 0 moves 0 new objects 1
Part C - the const trap, and the moved-from state
consume(std::move(config.value)); copies 1 moves 0 new objects 1
-> a copy: the member is const, so the move was ignored
That was a COPY, and no compiler warned about it.
…
after the move: source = source (0 values) size 0
On this implementation the members were left empty, but relying
on that is relying on an implementation detail.
Why it behaves that way: overload resolution decides everything, and std::move only
changes the value category of an expression — it makes an lvalue look like an rvalue so that
a T&& overload becomes viable. If no such overload is viable, the copy is selected and
nothing warns. const T&& does not bind to T&&, so a const member is copied forever. And
makePrvalue shows the opposite surprise: zero copies and zero moves, because C++17
guarantees construction in place for a prvalue.
What to take away: std::move is a cast to an rvalue reference, and the function it makes
selectable does the moving. A moved-from object is valid but unspecified — destructible and
assignable, not readable. Count the copies; do not infer them from the syntax.
Example 5 — seven moves become seven copies [L4 Analyze]¶
The problem: a std::vector reallocates as it grows. Whether it moves or copies your
elements depends on one word in your class, and nothing announces the difference.
Before you read on, predict: the same element type, twice, once with noexcept on its move
constructor and once without. Eight push_back-style insertions each. Which counts change, and
which do not?
// examples/module-10/05-noexcept-moves/main.cpp
template <bool NoexceptMove>
class Item {
public:
Item(const Item& other) : id_(other.id_), payload_(other.payload_) { ++copies; }
Item(Item&& other) noexcept(NoexceptMove) : … { ++moves; }
…
};
Output (excerpt):
2. Growing a vector of 8 elements, from empty
Item<true>, noexcept move: constructions 15, copies 0, moves 7
Item<false>, throwing move: constructions 15, copies 7, moves 0
Read those two lines again. Same constructions, same result - but
where one version MOVED the seven surviving elements, the other
COPIED them, because it could not promise that moving them would
succeed.
3. reserve() removes the reallocations entirely
Item<false> with reserve(8): constructions 8, copies 0, moves 0
Why it behaves that way: std::vector offers the strong guarantee on growth — if the
transfer throws, the vector is unchanged. Moving elements out of the old buffer destroys them,
so a throw part-way through would leave the vector half-empty and unrecoverable. A vector
therefore uses the move constructor only when the move cannot throw, and falls back to
copying otherwise: seven moves become seven copies, and each copy of a string-owning element
allocates. reserve() is the cheapest fix of the three shown, because it removes the
reallocation rather than making it faster.
The program finishes by reproducing the mechanism itself: std::move_if_noexcept returns an
rvalue reference when moving is safe and a const lvalue reference when it is not, so that the
copy constructor is selected.
What to take away: noexcept on move operations is not documentation. Its absence is a
performance bug with no diagnostic, in a class that looks correct and passes every test.
Example 6 — the Rule of Zero, and the copy that never happens [L7 Refactor]¶
The problem: Examples 1 to 5 taught the Rule of Five by making you write it. What would the same behaviour cost if the class owned nothing directly?
Before you read on, predict: a Report holding a std::string and a std::vector<int>,
with no special member functions declared. Predict the result of Report copy = original;,
then of Report moved = std::move(original);, then of growing a std::vector<Report> from
empty to five elements.
// examples/module-10/06-rule-of-zero-and-elision/main.cpp
class Report { // no destructor
public: // no copy constructor
explicit Report(std::string title) : title_(std::move(title)) {}
void addFigure(int value) { figures_.push_back(value); }
private: // no move constructor
Instrumented title_; // no copy assignment
std::vector<int> figures_; // no move assignment
}; // ... and it is still correct
Output (excerpt):
Part A - the Rule of Zero: a class that declares none of the five
Report copy = original; copies 1 moves 0 new objects 1
Report moved = std::move(original); copies 0 moves 1 new objects 1
std::vector<Report> shelf; shelf.emplace_back(...) copies 0 moves 7 new objects 12
-> five Reports; the vector reallocated three times
Not one line of special member code was written for Report,
and its vector growth moved - no copies - because the moves are
noexcept, as example 05 explained.
Part C - copy elision: when no copy happens at all
Instrumented x = Instrumented("direct"); copies 0 moves 0 new objects 1
Instrumented z = x; // written out explicitly copies 1 moves 0 new objects 2
Instrumented w = std::move(z); // a real move copies 0 moves 1 new objects 2
Why it behaves that way: std::string and std::vector each implement the Rule of Five
correctly, with noexcept moves. A class built from them therefore needs nothing: the
compiler's member-wise copy duplicates the members, member-wise move moves them, and the
members' own destructors release everything. The generated code is not a compromise — it is
better than hand-written code, because it cannot forget a member added later.
Part B of the same program is the mirror image, for types that must not be copied at all:
std::is_copy_constructible<MoveOnly> -> false std::is_move_constructible<MoveOnly> -> true
std::is_copy_constructible<Fixed> -> false std::is_move_constructible<Fixed> -> false
What to take away: the destination is not "write the Rule of Five well". It is "arrange the
class so that nobody has to". Reach for a standard member type before reaching for a special
member function; when you cannot, write all five; when the class must not be copied, say
= delete and let the compiler say it for you at every call site.
Next: Misconceptions and mistakes — nine beliefs that feel obviously true, and the diagnostic that contradicts each one.