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.
This is the strongest statement of a fixed relationship, and it has a price the compiler collects immediately — see §5.
Parameter only — a dependency.
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:
— 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.
— 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:
- A pointer or reference member needs only a declaration.
class Member;is enough forMember* member_;— the compiler needs the name, not the layout. - Calling a member function needs the definition. So the definition of
Loan::assignTomust seeMember's definition; put it inloan.cpp, which includes both headers, and leave the declaration inloan.h. - 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:
std::dequehas no virtual destructor, so deleting through astd::deque*is undefined behaviour (Module 7).- Every inherited public operation becomes part of your interface, and the derived type must honour all of them (Module 6).
- 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:
- It creates them itself —
StudentStore store_;andEmailNotifier notifier_;inside the class. NowRegistrationcannot be tested without a database and an SMTP server, and it cannot be configured. This is the version beginners write. - 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. - 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¶
- Three classes hold a
Logger: by value, by pointer, by reference. Which can be assigned, which can be null, and which can be reseated? - A class has one
T&member. Explain, in terms of what assignment means, why the compiler deletes its assignment operator. - A catalogue holds
std::vector<Item> items_. Name four ways to let a caller read the items, and say what each one costs. - 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? - 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.