Skip to content

Module 9 — Concepts and Explanations

Read this with a compiler open. Every rule here is one you can test in a minute, and most of the mistakes it prevents produce no diagnostic at all — which is why the course spends a whole module on them.

Modules 6–8 were about types that are kinds of one another. This module is about the other half of design: types that are made of other types, and the lifetime rules that come with each way of saying so.


1. "How does a class hold another object?"

There are three relationships, and one picture each. Learn the pictures, because the words are used loosely everywhere else.

COMPOSITION  "owns a"  — filled diamond, at the owner's end
    Course ◆── Item
    The owner created it, keeps it, and destroys it. It is not shared, and nothing
    outside the owner has a name for it.

AGGREGATION  "has a"  — hollow diamond, at the owner's end
    Course ◇── Lecturer
    The owner holds it, but did not create it and does not destroy it. It is shared:
    a lecturer teaches several courses, and outlives any one of them.

ASSOCIATION  "knows a"  — plain line, with multiplicities
    Course ── Department
    The owner knows about it and may use it, but owns nothing and controls no lifetime.
    In code this is a reference, a pointer, or an identifier looked up on demand.

DEPENDENCY   "uses"  — dashed arrow
    Report ┄┄> Formatter
    No member at all: the object is needed for one call, and nothing is stored.

The distinctions are not decoration. Each one is a different answer to who deletes it, and when? — and that question is the source of almost every serious bug you will meet in C++.


2. "Who owns what?"

Ownership is the decision that everything else follows from. Write it down before you write the class:

Question If the answer is… Then the member is
Does this object create and control that one? yes a value member (std::vector<Item> items_;)
Is the other object shared, longer-lived, or created elsewhere? yes a pointer or reference member — and the class does not delete it
Is it needed only during one call? yes a parameter, and nothing is stored
Does something else own it, and this class only observes? yes a pointer, plus a rule about how long it stays valid

examples/module-09/01-three-kinds-of-relationship/main.cpp puts all three member forms in one class and counts constructions and destructions, so the promises become measurements:

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

Three declarations, three different lifetimes, and the program proves each one instead of asserting it. The declarations are worth copying:

class Course {
private:
    std::vector<Student> students_;   // COMPOSITION: owned outright
    Lecturer* lecturer_ = nullptr;    // AGGREGATION: borrowed, may be absent
    Department& department_;          // ASSOCIATION: not null, not reseatable, not owned
};

Every have-a relationship is one of those three lines. The rest of this module is about choosing between them and living with the consequences.


3. "Members are objects too" — construction, delegation, const

An object built from member objects is constructed in a fixed order, and the order is the one Module 3 taught, now observable through non-trivial members:

      member Header      constructed (declared first)
      member SessionList constructed (declared second)
      member Footer      constructed (declared third)
    Timetable body      (members already exist)
… and on destruction, the same list read backwards:
    Timetable body      destroyed (members still exist)
      member Footer      destroyed (first)
      member SessionList destroyed (second)
      member Header      destroyed (last)

Two design rules fall out of that trace, and examples/module-09/02-nested-objects-and-delegation/main.cpp demonstrates both.

Delegate instead of inheriting. Timetable is not a kind of Header; it has one. It exposes render() and schedule(), and the three members stay private — so no caller can reorder sessions, read a half-built timetable, or reach a member's mutator.

const propagates through delegation, for free. render() const may call only const member functions, so a const Timetable& can render but cannot schedule, without a single const written for that purpose:

    std::string render() const {          // const outer
        std::string out = header_.line(); // hence these calls must be const too
        …
    }

That is the compiler enforcing Module 4's rule one level down. It is also the cheapest const-correctness you will ever get, because it was not written deliberately — it is a consequence of the member forms.


4. "Which member form should I use?"

Four shapes, four sets of promises, and each has a failure mode that is not a compiler error unless you choose the wrong one deliberately. examples/module-09/03-value-pointer-or-reference-member/main.cpp runs all four and counts what each one does.

Value member — owns a copy.

class OwnedAudit {
    Logger logger_;        // constructed with the object, destroyed with it, copied with it
};
   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 copy is independent, which is either exactly what you want or exactly what you do not: separate logs for separate owners, and no way to share one.

Pointer member — borrows, may be absent, may change.

   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)

Three facts to make visible rather than surprising: the pointer may be null (so the interface must answer for that case), it may be reseated (so the relationship is a currently attached one), and the class never deletes it (so its lifetime is somebody else's problem — say whose).

Reference member — borrows, never null, never reseated.

class PinnedTicker {
    Logger& logger_;       // set once, for the object's whole life
};

This is the strongest statement of a fixed relationship, and it has a price the compiler collects immediately — see §5.

Parameter only — a dependency.

void logOnce(Logger& logger, const std::string& line);   // nothing stored, no lifetime rule

The most under-used of the four. If an object is needed only during a call, taking it as a parameter means there is no member to dangle, no null state, and no relationship to document. Reach for this before you reach for a pointer member.

The decision table, in one place:

Form Owns? Null? Reseatable? Copies? Use when
Value member yes no — copies the whole member the object is part of this one
T* member no yes yes copies the pointer the relationship is optional or changes
T& member no no no copies the reference the relationship is fixed for life
Parameter no no — nothing the object is needed for one call

5. "What does a reference member cost me?"

The class loses its assignment operator, and the compiler says so in terms you can act on. Captured from this toolchain, for a class with one Logger& member:

error: use of deleted function 'TickerA& TickerA::operator=(const TickerA&)'
      |             ^~~~~~
note: 'TickerA& TickerA::operator=(const TickerA&)' is implicitly deleted because the
      default definition would be ill-formed:
error: non-static reference member 'LogA& TickerA::logger_', cannot use default
       assignment operator

The reasoning is worth internalising: assignment means "make this object equal to that one", and a reference cannot be made to refer to something else. So the compiler deletes the operation rather than pretending. Copy construction is still fine — a new object can bind its reference to the same target.

Two more messages a reference member produces, both worth recognising instantly:

error: cannot bind non-const lvalue reference of type 'LogB&' to an rvalue of type 'LogB'
— you tried to initialise the member from a temporary, which would leave a reference to an object that dies at the end of the statement. const T& members do not have this problem and have a different one: the temporary's lifetime is extended to the reference's lifetime, which is legal, useful and easy to misread.

error: passing 'const RosterC' as 'this' argument discards qualifiers [-fpermissive]
— a const object calling a non-const member function. The message names the object, the qualifier and the function; the fix is a const overload, not a const_cast.


6. "How do I expose a collection I own?"

Six forms, and only one of them is wrong. The wrong one is worth understanding first, because it is the one people write:

class Roster {
public:
    std::vector<Student>& students() { return students_; }   // ← hands out the container
private:
    std::size_t capacity_;
    std::vector<Student> students_;
};

examples/module-09/04-exposing-a-collection-safely/main.cpp performs the attack and prints the damage:

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.

Two invariants — capacity and non-negative credits — are lost at once, and the code that lost them never mentions Roster. That is the signature of a representation leak from Module 4, in its most common form. The five safe alternatives, in increasing order of independence:

Form Caller gets Cost
const std::vector<Student>& read-only access every caller now depends on std::vector
at(i) returning const Student& one element none, but no traversal
begin()/end() returning const_iterator read-only traversal, works with std::find, std::count, range-for the iterator type still names the container
snapshot() returning a copy an independent value that can outlive the roster a copy per call, and it can be stale immediately
forEach(callback) whatever the owner chooses to pass a std::function call per element; the caller needs no type at all
non-const container& everything, including the ability to break the class never

The rule: hand out the capability the caller needs — read one, read all, copy — and never the container itself. begin()/end() and for is the standard-library-friendly middle ground; forEach is the form that lets you change the container later without touching a caller.


7. "Two classes that need each other"

The catalogue's Member wants to know its current Loan, and a Loan wants to know its Member. Written as two headers that include each other, it does not compile — and the error is famously unhelpful about the real cause:

In file included from cycle-member.h:2,
                 from cycle-main.cpp:1:
cycle-loan.h: In member function 'void Loan::assignTo(const Member&)':
cycle-loan.h:15:49: error: invalid use of incomplete type 'const class Member'
cycle-loan.h:6:7: note: forward declaration of 'class Member'

Follow the In file included from lines: cycle-main.cpp includes cycle-member.h, which includes cycle-loan.h, which includes cycle-member.h — whose #pragma once has already fired, so the second time its contents are skipped. Loan therefore sees only the forward declaration of Member, and cannot call a member function on it.

The rule that fixes it, in three parts:

  1. A pointer or reference member needs only a declaration. class Member; is enough for Member* member_; — the compiler needs the name, not the layout.
  2. Calling a member function needs the definition. So the definition of Loan::assignTo must see Member's definition; put it in loan.cpp, which includes both headers, and leave the declaration in loan.h.
  3. Include the header only where you need the definition — in the .cpp, or in a header that genuinely stores the object by value. Header files declare; source files include.

In one translation unit the same principle shows up as ordering: declare both classes first, then define the functions that need each other's layout. That is what all the course's examples do, because they are single files.


8. "Composition or inheritance?"

This is Module 6's question, and this module supplies the second half of the answer: inheritance is one of the have-a forms when what you want is reuse.

    class DeadlineQueue : public std::deque<Submission> { };   // tempting, and wrong
    class DeadlineQueue { Storage items_; };                    // composed, and correct

examples/module-09/05-extension-by-composition/main.cpp builds the second one and prints why the first cannot be made correct:

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. The class would be
  one operation away from a wrong answer at all times.

Three reasons, and each is independently sufficient:

  1. std::deque has no virtual destructor, so deleting through a std::deque* is undefined behaviour (Module 7).
  2. Every inherited public operation becomes part of your interface, and the derived type must honour all of them (Module 6).
  3. Every caller would depend on std::deque — Module 4's representation leak, at type level. The composed version proves the point by compiling unchanged against a different container:
And the representation is free to change: this build uses std::deque.
  Build with -DQUEUE_USE_VECTOR and diff the two outputs: every line
  that differs is one that prints the storage name.

The cost of composition is honest and visible in the same program: five forwarders (push, pop, front, size, empty, and lateCount) to publish the six operations the callers need. Forwarding is the price of a small interface.

When inheritance is still right: the Module 6 test — every public operation of the base stays true of the derived type, the relationship is genuinely is-a, and the base was written to be derived from (virtual destructor, protected virtual extension points). A standard-library container fails all three tests at once.

UML notation, for the diagrams you will read in the rest of this course:

    ◇──  aggregation      the diamond is hollow: shared, not owned
    ◆──  composition      the diamond is filled: owned, lifetime-bound
    ──>  association      an arrow: knows about, navigates to
    ┄┄>  dependency       a dashed arrow: uses for one operation, owns nothing
    ──▷  inheritance      a hollow triangle: is-a  (Modules 6-8)
    ──*  multiplicity     "0..*", "1", "1..*" — at the far end of the line

9. "Which way should the dependencies point?"

The last question, and the one that makes the difference between a design you can change and one you cannot.

            ┌──────────────────────┐
            │    Registration      │
            └───────┬──────────────┘
                    │  depends on
        ┌───────────┴────────────┐
        ▼                        ▼
┌───────────────┐       ┌──────────────────┐
│  StudentStore │       │  EmailNotifier   │
└───────────────┘       └──────────────────┘

Registration needs both, and there is nothing wrong with that. The design question is what Registration is allowed to know about them. Three versions, in increasing order of changeability:

  1. It creates them itself — StudentStore store_; and EmailNotifier notifier_; inside the class. Now Registration cannot be tested without a database and an SMTP server, and it cannot be configured. This is the version beginners write.
  2. It borrows them — Registration(StudentStore& store) and a reference member. Better: the dependencies are supplied, so a test can supply a fake. This is dependency injection in its plainest form, and it is worth learning now.
  3. It depends on what it needs, not on what is there — the parameter type is a small interface (Notifier&, Module 8) rather than a concrete class. Now the implementation can be replaced, and nothing in the class's own code needs to change. Module 15 calls the principle dependency inversion and builds patterns on it.

The rule of thumb for this course: a class should know about the smallest thing that lets it do its job. If it can be told what to use, it should not construct it; if it can be given an interface, it should not name a concrete class.


10. "How much of my member should a caller see?" — delegation and Demeter

Two rules, and they point the same way.

Delegate rather than expose. A class that holds a member should offer the operations its callers need, implemented by forwarding, instead of handing out the member. DeadlineQueue in §8 offers push, pop, front, size, empty and lateCount — six operations, five of them one line — and never mentions its storage. That is what makes the storage replacable.

The Law of Demeter states the same thing as a rule about call sites: talk to your immediate collaborators, not to their internals.

roster.students().front().addCredits(10);   // reaching through three objects
roster.awardCredit(0, 10);                  // one conversation with one object

The first line couples the caller to Roster's container and to Student's mutator, so a change to either breaks it — and it is exactly the line that broke both of Roster's invariants in §6. The second line names one operation on one object, and the class is free to change how it is stored, or who is allowed to award credit.

The practical test, which needs no vocabulary: count the dots that are not this. a.b().c().d() is three types in one expression; each one is a place a future change lands. A chain of accessors in your own class is usually a missing operation rather than a style problem.


Checkpoint

  1. Three classes hold a Logger: by value, by pointer, by reference. Which can be assigned, which can be null, and which can be reseated?
  2. A class has one T& member. Explain, in terms of what assignment means, why the compiler deletes its assignment operator.
  3. A catalogue holds std::vector<Item> items_. Name four ways to let a caller read the items, and say what each one costs.
  4. Two classes need each other. Where do you put the #include, where the forward declaration, and why can the compiler not work either out for itself?
  5. Given a class with six operations you want to reuse, write the two sentences that decide between inheriting and holding a member.

Next: Worked examples — five programs, including the one that demonstrates an invariant being broken through a public accessor, and one that counts destructions to prove who owns what.