Skip to content

Module 9 — Worked Examples

Every example asks you to predict before it shows you the output, because the prediction is the exercise and the output is only the marking scheme. Where a program prints more than is useful here, an excerpt is marked with ….

The programs are in examples/module-09/, each folder with its own expected-output.txt. Build any of them with:

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

Example 1 — three relationships, three lifetimes [L4 Analyze]

The problem: one class holds a collection of students, a pointer to a lecturer, and a reference to a department. Which of the three does it destroy?

Before you read on, predict: the course object goes out of scope. Count what is still alive afterwards — students, lecturers, departments — and say which declaration decided each answer.

// examples/module-09/01-three-kinds-of-relationship/main.cpp
class Course {
private:
    std::string code_;
    std::size_t capacity_;
    std::vector<Student> students_;    // COMPOSITION: owned outright
    Lecturer* lecturer_ = nullptr;     // AGGREGATION: borrowed, may be absent
    Department& department_;           // ASSOCIATION: not null, not reseatable, not owned
};

Output (excerpt):

4 students offered a place in a course of capacity 3:
  enrol(Ayesha) -> true   students alive 1, in this course 1
  …
  enrol(Dania) -> false   students alive 3, in this course 3

The Course has gone out of scope.
  students alive  0   <- composed: the course destroyed them
  lecturers alive 2   <- borrowed: untouched
  departments alive 1   <- associated: untouched

Why it behaves that way: a value member is destroyed with its owner, so the students die with the course; a pointer member is just a value that happens to be an address, so nothing happens to the lecturer; a reference member likewise. The counters make the promise checkable: the same three declarations, three different lifetimes, and no ambiguity about who deletes what.

Note the two smaller lessons inside the same program. enrol refuses the fourth student, because the capacity rule is enforced in the only function that can change the collection — Module 4's invariant, surviving into a class that holds a container. And detach() sets the pointer to null: lecturer() says so in its type, so every caller has to answer the question "and if there is no lecturer?".

What to take away: write the three declarations and say, in words, who deletes each member and when. If you cannot, the design is not finished.


Example 2 — members are objects, and members are constructed first [L3 Modify]

The problem: a timetable is a header, a list of sessions, and a footer. Which comes first, and what does the caller see?

Before you read on, predict: four lines print before main's first statement completes. Write them in order, then write the destruction order. Then predict something harder: which of render() and schedule() a const Timetable& can call.

// examples/module-09/02-nested-objects-and-delegation/main.cpp
class Timetable {
public:
    std::string render() const;                                       // const: usable read-only
    void schedule(std::string when, std::string course, std::string room);
private:
    Header header_;            // declared first  -> constructed first, destroyed last
    SessionList sessions_;     // declared second -> constructed second
    Footer footer_;            // declared third  -> constructed third, destroyed first
};

Output (excerpt):

Constructing a Timetable. Watch the member order:
      member Header      constructed (declared first)
      member SessionList constructed (declared second)
      member Footer      constructed (declared third)
    Timetable body      (members already exist)
…
const Timetable& can still render: Data Science - timetable for Autumn 2026, size 3
  and cannot call schedule(): it is not a const member function.
…
Leaving main:
    Timetable body      destroyed (members still exist)
      member Footer      destroyed (first)
      member SessionList destroyed (second)
      member Header      destroyed (last)

Why it behaves that way: members are constructed in declaration order before the outer constructor body, and destroyed in the reverse — Module 3's rule, now visible through members that announce themselves. The const result is a consequence rather than a decision: render() const can call only const member functions, so the compiler refuses schedule() on a const Timetable& without a single extra const written for that purpose.

The program also prints why inheriting from std::vector<Session> is not an option:

  1. std::vector has no virtual destructor, so deleting through it is
     undefined behaviour (Module 7);
  2. push_back, insert, clear and erase would all be public on a type
     whose invariant is 'sorted by time' (Module 6);
  3. the caller would depend on std::vector, so replacing it later
     would be a change to every call site (Module 4).

What to take away: three member objects, one public operation that names the type the caller cares about, and nothing else exposed. "Has-a" classes stay small when they delegate.


Example 3 — value, pointer, reference, or parameter [L5 Design]

The problem: four classes need the same logger. One owns its own; one borrows it and may have none; one borrows it for life; one only needs it for a moment.

Before you read on, predict: OwnedAudit is copied, and the copy records one more line. How many lines does the original report — and how many does the shared logger have?

// examples/module-09/03-value-pointer-or-reference-member/main.cpp
class OwnedAudit   { Logger logger_; };                          // VALUE: owns its own
class BorrowedSession { Logger* logger_ = nullptr; };            // POINTER: nullable, reseatable
class PinnedTicker { Logger& logger_; };                         // REFERENCE: fixed for life
void logOnce(Logger& logger, const std::string& line);           // DEPENDENCY: never stored

Output (excerpt):

1. A value member is owned, and a copy is independent.
   Data Science's audit log has 2 lines
   original 2 line(s), copy 3 line(s)  <- the copy has its own Logger, so they diverge
   the shared logger was untouched: 0 line(s) in shared log

2. A pointer member is borrowed: nullable and reseatable.
   with nothing attached, record() -> false  <- refused, and the caller was told
   after attach(&shared):  2 line(s) in shared log
   after attach(&other):   shared 2 line(s), other 1 line(s)
   after detach(), hasLogger() -> false

3. A reference member is borrowed, never null, never reseated.
   two tickers, one logger: shared log now has 5 line(s)

Why it behaves that way: each form answers the three questions — owns? null? reseatable? — differently, and the program shows the consequences rather than describing them. The pointer version's refusals are the interesting part: because the pointer may be null, the interface has to say what happens then, and record() returns false rather than crashing.

The reference member has a price the program only mentions, because it cannot demonstrate a compile error at run time:

error: use of deleted function 'TickerA& TickerA::operator=(const TickerA&)'
error: non-static reference member 'LogA& TickerA::logger_', cannot use default
       assignment operator

What to take away: choose the form that makes the weakest true statement about the relationship. If a value will do, a pointer is a promise of complexity you did not need; if a parameter will do, storing anything at all is a lifetime question you volunteered for.


Example 4 — the accessor that gave the invariants away [L4 Analyze]

The problem: a roster of students with a capacity and a rule that credits never go negative. Both rules are enforced in add(). Something breaks them anyway.

Before you read on, predict: the class also has std::vector<Student>& students(). Write the two lines a caller could use to break both invariants, then predict what at(0).credits() prints afterwards. (at(0) starts at 96.)

// examples/module-09/04-exposing-a-collection-safely/main.cpp
    std::vector<Student>& students() { return students_; }   // ← the leak

Output (excerpt):

Now the attack, through the public non-const accessor:
  before:  size 3/3, every student has non-negative credits
  after:   size 5/3, and at(0) has -104 credits
  Both rules were broken by code that never mentions Roster:
    - the capacity is exceeded (5 of 3);
    - a student exists with a negative credit count;
    - totalCredits() is now wrong for every caller, forever.

Why it behaves that way: handing out the container hands out every mutation, and the class's own invariants are then only as strong as the most careless caller. Note where the damage appears: totalCredits(), a function that was correct and is now wrong for everyone. That is why the fix is in the interface — remove the accessor — and not in the callers.

The safe forms in the same program are worth comparing one by one:

  at(0)      -> Ayesha, 96 credits
  range-for  -> Ayesha Bilal Chaudhry
  forEach    -> Ayesha(96) Bilal(84) Chaudhry(77)
  snapshot() -> an independent copy that did not grow with the roster

What to take away: expose the capability, not the container. begin()/end() gives read-only traversal and works with every standard algorithm; forEach gives the same data without naming any type; snapshot() gives independence at the price of a copy. The non-const container reference gives everything, including the ability to break the class.


Example 5 — extending a container by holding one [L5 Design]

The problem: a queue of submissions that also counts how many were late. The count must stay correct while items arrive and are handed in.

Before you read on, predict: the queue has push and pop, and the late count is adjusted in both. A caller pushes five submissions, two late, then pops all five. Write the count after each pop, then predict what pop() on an empty queue does.

// examples/module-09/05-extension-by-composition/main.cpp
class DeadlineQueue {
public:
    void push(std::string student, std::string assignment, bool late);
    bool pop();
    const Submission& front() const;
    std::size_t size() const;
    std::size_t lateCount() const;
private:
    Storage items_;                    // std::deque, or std::vector: the caller never knows
    std::size_t lateCount_ = 0;
};

Output (excerpt):

  after 5 arrivals:  5 queued, 2 late  [std::deque]

Draining it, one at a time, watching the count stay in step:
  handing in Ayesha  (5 queued, 2 late  [std::deque])
  handing in Bilal  (4 queued, 2 late  [std::deque])
  handing in Chaudhry  (3 queued, 1 late  [std::deque])
  handing in Dania  (2 queued, 1 late  [std::deque])
  handing in Emaan  (1 queued, 0 late  [std::deque])
  empty: true, late count 0  <- it reached zero because push and pop are the only doors
  pop() on an empty queue -> false  <- refused, not undefined

What the inherited version would have published instead:
  push_front, insert, erase, clear, resize, operator[] ...
  Each one would have to keep lateCount_ in step, and none of them
  could be prevented from breaking arrival order.

Why it behaves that way: the count is correct because two functions change the contents and both adjust it — the invariant is maintained by making it impossible to change the contents any other way. That is the same technique as Example 4's fix, one level up: a small interface is not a style preference, it is how invariants survive.

The second half is the representation argument, and it is verifiable:

g++ -std=c++17 -Wall -Wextra -Werror main.cpp -o queue                  # std::deque
g++ -std=c++17 -Wall -Wextra -Werror -DQUEUE_USE_VECTOR main.cpp -o q2  # std::vector
./queue > a.txt && ./q2 > b.txt && diff a.txt b.txt

Every line the diff reports is a line that prints the storage name — describe() prints it on purpose. No other line changes. The class compiled unchanged, because its interface never mentioned the container; the inherited version could not have done this at all.

What to take away: composition costs forwarding and buys everything else — a small interface, invariants that hold, and the freedom to change the representation. The forwarding is the price, and it is small: five one-line functions in this program.


Next: Misconceptions and mistakes — including four defects that produce no diagnostic at all.