Skip to content

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:

g++ -std=c++17 -Wall -Wextra main.cpp -o program && ./program

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.