Skip to content

Practice Lab 19 — Dynamic collection: Rule of Three to Rule of Five

Difficulty: Advanced · Estimated time: 6 hours · Module 10: Copying, Assignment and Resource Management

You will write the double free, watch it happen, and then take the rule of three and the rule of five all the way to the rule of zero. The counters in this lab are the evidence.


At a glance

Difficulty Advanced
Estimated time 6 hours
Module Module 10 — Copying, Assignment and Resource Management
Concepts practised Copy constructor, copy assignment, destructor, shallow versus deep copy, self-assignment, copy-and-swap, move constructor and move assignment, noexcept, rule of three/five/zero
Prerequisite Practice Lab 18
Class design giving you the class; you own its copy and move behaviour

Scenario

A measurement archive holds a growing number of samples in memory, and the archive is copied by other parts of the program: one copy per report, one for a backup, one for a comparison. Copies are the requirement, not a mistake.

The archive's first version used a std::vector internally and needed no copy code at all. The second version, rewritten for reasons nobody remembers, holds a double* and a size. It now crashes about once a week, and the crash always happens after two archives are assigned to each other.

Your job is to make the raw-pointer version correct, and then to show — with counter evidence — why the first version should have been kept.


Problem statement

Implement a SampleArchive that owns a dynamically allocated array of double samples.

  1. Reproduce the failure: write the version with no copy constructor, no copy assignment and a destructor that deletes the array. Copy an archive, destroy one, and show what happens.
  2. Apply the rule of three: copy constructor, copy assignment with self-assignment safety, destructor.
  3. Add the rule of five: move constructor and move assignment, both noexcept, leaving the moved-from archive in a documented valid-but-empty state.
  4. Show the difference: instrument every operation with a counter — copies made, moves made, allocations made — and print the counters for a scripted sequence.
  5. Reach the rule of zero: a second type that holds a std::vector<double> instead, with no copy or move code at all, run through the same sequence, with the same counters.
  6. Measure: for a sequence of 100 000 appends, report the number of allocations in each version, and the effect of a move constructor that is not noexcept.

In copying-notes.md:

  • The failure from step 1, verbatim, with the output.
  • The counter table for the three versions, side by side.
  • One sentence per special member function saying what it must do and why.
  • The sentence you would give a colleague about when the rule of five is the wrong answer.

Learning objectives

By the end of this lab you will be able to:

  • Reproduce the double free and explain it as "two destructors for one allocation".
  • Write the five special member functions correctly, including self-assignment safety and noexcept moves.
  • Use copy-and-swap, and explain what guarantee it gives when an allocation fails.
  • Show, with counters, the difference between copying and moving in a real sequence.
  • Explain the rule of zero and demonstrate why it is usually the right answer.

Functional requirements

  1. SampleArchive owns its array; the destructor releases it exactly once.
  2. The copy constructor allocates its own array; a copy is independent — prove it by changing the copy and printing both.
  3. Copy assignment is self-assignment safe — prove it by archive = archive;.
  4. Copy assignment either gives the strong guarantee or is documented as basic; say which, and demonstrate.
  5. The move constructor leaves the source empty and valid; the moved-to archive owns the original array.
  6. Move operations are noexcept, and you must show what happens to a vector's reallocation count when they are not. Record the difference.
  7. The moved-from archive can be destroyed, assigned to, and queried without crashing.
  8. The rule-of-zero version has no user-declared copy, move or destructor — prove it by showing the class body.
  9. Counters: copies, moves, and allocations, printed after each scripted phase.
  10. All samples in a copy equal the original's, and the sizes match, after every operation.

Constraints

  • Two headers and their sources, plus main.cpp; documented build command.
  • Raw double* in the first version, deliberately, with new[]/delete[]. It may not be replaced by unique_ptr until Module 11 — but you must state what that replacement would remove.
  • No std::vector in the first version. std::vector is the rule-of-zero version.
  • No warnings, and no -Werror suppressions: if the compiler warns about something real, fix the design.
  • Every special member function narrates, with its name — that is how the counters are collected.

Starter task

In copying-notes.md, before coding:

  1. Write the six declarations of the rule of five, with their signatures and noexcept where it belongs.
  2. Predict, for a = b; where both own arrays: how many allocations, how many copies, how many moves.
  3. Predict what happens in the un-instrumented version 1 when you copy an archive and destroy the copy.
  4. Predict the reallocation count for 100 000 appends with and without a noexcept move constructor, and say which one you expect to be larger. Then measure.

Implementation tasks

  1. Implement version 1 (no special members), copy, destroy, and record the failure.
  2. Implement version 2 with the rule of three, including self-assignment and the guarantee statement.
  3. Extend it to the rule of five with noexcept moves.
  4. Implement version 3 with std::vector and no special members.
  5. Write the scripted sequence and print the counters for each version.
  6. Measure the append sequence in both versions and with a non-noexcept move, and record the numbers.

Test cases

# Action Expected result
1 Version 1: copy then destroy both The recorded failure — do not "fix" it before recording
2 Copy an archive, change a sample in the copy The original is unchanged
3 archive = archive; No corruption; counters show the documented behaviour
4 Assign a smaller archive to a larger one The target owns exactly the source's data
5 Move into a new archive No allocation; source becomes empty and valid
6 Destroy a moved-from archive No crash, no double free
7 Use a moved-from archive (size, append) Works, with the documented empty semantics
8 100 000 appends with noexcept moves A reallocation count you record
9 The same with a move that is not noexcept A larger count, or the reason it is not, recorded
10 Rule-of-zero version through the same script Identical results, no user-declared special members
11 Counters table Copies, moves and allocations, side by side
12 Assign from an empty archive Target becomes empty; no allocation if avoidable

Boundary cases to run deliberately: a zero-sample archive; one sample; copying an archive into itself through a reference; moving an empty archive.


Expected behaviour

version 1 (no special members):
  copied archive, destroying the copy...
  [dtor] freed 0x...
  [dtor] freed 0x...      <- the same address twice: the failure this lab exists for
version 2 (rule of three):
  [copy ctor] allocated 5 samples
  [copy assign] freed old, allocated 5 samples
  archive = archive; -> self-assignment handled, 0 extra allocations
version 3 (rule of zero):
  [rule-of-zero type] no user-declared special members
counters: copies 4, moves 6, allocations 5   (version 2)
counters: copies 4, moves 6, allocations 0   (version 3, reusing capacity)
100000 appends: reallocation count 17 (noexcept moves) vs 100000 (copying moves)

Extension challenge

Add a reserve operation to the raw-pointer version, so the archive can grow without reallocating on every append.

Record in copying-notes.md:

  • The reallocation count before and after, for the same sequence, as numbers.
  • Which rule-of-five function had to change, if any, and why.
  • What reserve changes about the strong guarantee when it fails part-way.
  • Why std::vector already does this, and what that says about writing containers by hand.

Debugging challenge

The crash after assignment:

SampleArchive& operator=(const SampleArchive& other) {
    delete[] samples_;                       // free ours
    size_ = other.size_;
    samples_ = new double[size_];
    for (int i = 0; i < size_; ++i) {
        samples_[i] = other.samples_[i];
    }
    return *this;
}

Reproduce it — assign an archive to itself — and answer:

  1. What is other when the assignment is a = a, and what did delete[] do to other.samples_?
  2. Why does the copy-and-swap version avoid this entirely?
  3. Rewrite the assignment using copy-and-swap, and state the guarantee it now provides.
  4. Which test case in the table catches it, and did you run it before or after reading this?

Reflection questions

  1. Which of your five functions is hardest to write correctly, and what made it hard?
  2. A moved-from archive must be "valid", not "unchanged". What exactly does your moved-from object guarantee?
  3. noexcept on moves changed the vector's behaviour. What does the standard library do differently, and why does it have the right to?
  4. The rule-of-zero version needed no special members at all. Which version would you ship, and what would you have to be willing to lose?
  5. reserve reduces allocations. What does that do to the strong guarantee, and is that acceptable?
  6. If a sample were expensive to copy (a std::string, say), which of your functions would change, and how?

Completion checklist

  • [ ] It builds with -Wall -Wextra -Werror and no warnings
  • [ ] The version-1 failure is reproduced and recorded before any fix
  • [ ] Copy constructor allocates its own storage; independence demonstrated
  • [ ] Copy assignment handles self-assignment, with the guarantee stated
  • [ ] Move constructor and move assignment are noexcept and leave a documented empty state
  • [ ] A moved-from archive is destroyed and used without crashing
  • [ ] The rule-of-zero version has no user-declared copy, move or destructor
  • [ ] The counters table covers all three versions
  • [ ] The append measurement shows the noexcept difference, or explains its absence
  • [ ] All twelve test cases run, with output recorded
  • [ ] The debugging challenge was explained before being fixed
  • [ ] copying-notes.md has one sentence per special member function

Related: Lab index · Module 10 — Copying and Resource Management · Previous: Practice Lab 18 · Next: Practice Lab 20 — Invoice copy semantics