Module 16 — Concepts and Explanations¶
There is no new language feature in this module. The system already works: encapsulated types, an interface for items, honest ownership, designed failure paths, measured queries, and a design documented with UML.
What has never happened is the last step — turning a set of files that compile into an application: one a stranger could navigate, whose data survives being closed and reopened, that can be changed without fear, and whose design is written down including what is wrong with it.
The module's question: what separates a long program from an application? The answer has four parts — structure, persistence, tests, and a written account of the design — and the last one is what the exit level of 8 is about.
Every program in this page is in examples/module-16/, and every diagnostic quoted was produced by
compiling on the toolchain this course is checked against.
1. How do I organise fifteen classes?¶
By responsibility, not by line count, and with a read order written down:
catalogue.h the public interface: what a caller may use
catalogue.cpp its implementation
record_format.h internal declarations: not for callers
record_format.cpp its implementation, with an unnamed-namespace helper
main.cpp the entry point; includes catalogue.h and nothing else
The demonstration is what main.cpp is allowed to include. In
examples/module-16/01-multi-file-layout/main.cpp, the entry point includes catalogue.h and nothing
else from the project. It cannot call library::internal::format_record, because that declaration is
not visible to it — and that is the point: the format is not part of the program's public interface, so
changing it cannot break a caller.
the program, built from four translation units:
Catalogue (3 records, format # catalogue v1)
Compilers: Principles, Techniques, and Tools|Aho et al.|2006
The C++ Programming Language|Stroustrup|2013
Thinking in Systems \p A Primer|Meadows|2008
find: Stroustrup
Two habits make the structure legible:
- A reader should find the entry point in ten seconds. One directory listing, one obvious
main, and one sentence in the README saying which file to read first. - The boundary follows responsibility. A file called
utils.cppholding string helpers, date helpers and file helpers has no responsibility, which is why nobody can say what belongs in it.
More files is not the same as better structure. Two files split along the wrong boundary are harder to read than one file: the reader has to hold both and reconstruct the whole. Split when a nameable responsibility ends.
2. Which headers are for callers, and which are mine?¶
A header is public if a caller may include it, and internal if only your own source files may. The rule that makes the distinction real:
A public header must compile without including any internal header.
record_format.h in the example includes catalogue.h; catalogue.h includes record_format.h
nowhere. That direction is what keeps the format internal. If the public header needed the internal one,
the format would be public in effect, whatever the comment at the top says.
// record_format.h — INTERNAL. Included by the program's own source files, never by main.cpp.
#include "catalogue.h"
namespace library {
namespace internal {
inline constexpr const char* kVersionMarker = "# catalogue v1";
std::string format_record(const Book& book);
} // namespace internal
} // namespace library
Include-what-you-use follows from the same idea: each file includes what it names, and nothing else. An include that is only there "because it has always been there" is a dependency nobody chose.
3. Why did adding a second file break the build?¶
Because a definition in a header ends up in every translation unit that includes it. The One Definition Rule (ODR) says a function may be declared many times and defined once. Put a definition in a header and the link step is handed two of them:
// dup.h — the probe used to produce the diagnostics below
int shelf_capacity() { // a definition, and not `inline`
return 40;
}
That is the whole message on this toolchain — no "multiple definition of", no file names, nothing. Other toolchains are friendlier, but a course that quotes a message only some readers will see is teaching something untrue, so here is the tool that answers the question instead: look at the symbol tables of the objects you built.
Both objects define the same global symbol. T means "defined, external linkage". Two T lines
for one name is the duplicate, and the linker is entitled to complain. The habit generalises to every
link error in this module: when the linker's message is useless, read the symbols yourself.
The two fixes, and when each is right:
| Fix | What it looks like | When it is right |
|---|---|---|
| Declare in the header, define in one source file | int shelf_capacity(); in the header; the body in totals.cpp |
The function is part of the interface but not trivial. The default choice. |
Mark the definition inline |
the body in the header, with inline |
The function genuinely belongs to the interface and is small. inline here is an ODR permission, not an optimisation hint. |
Relationships round a file with no include guard produce a compiler error, and this one is genuinely helpful — so it is quoted verbatim:
In file included from double_include.cpp:2:
no_guard.h:2:8: error: redefinition of 'struct Box'
2 | struct Box {
| ^~~
In file included from double_include.cpp:1:
no_guard.h:2:8: note: previous definition of 'struct Box'
no_guard.h:6:5: error: redefinition of 'int shelf_capacity()'
no_guard.h:6:5: note: 'int shelf_capacity()' previously defined here
The note lines name the earlier include. #pragma once works everywhere this course supports, and
a guard macro is the portable spelling; either way, every public header needs one.
4. How do I stop two files seeing each other's helpers?¶
An unnamed namespace gives a name internal linkage: it cannot be linked from another translation
unit, so it cannot collide with a same-named function there. That is the modern replacement for a
file-scope static function.
The failure it prevents is worth seeing, because the diagnostics are so disproportionate:
// helper.h — the declaration promises other files can link against this name
namespace library { namespace internal {
int scale(int value);
}}
// helper.cpp — the definition lands in an unnamed namespace instead
namespace {
int scale(int value) { return value * 2; }
}
helper.cpp:8:5: error: 'int {anonymous}::scale(int)' defined but not used [-Werror=unused-function]
8 | int scale(int value) {
| ^~~~~
cc1plus.exe: all warnings being treated as errors
Read the symbol name in that message: {anonymous}::scale(int). The declaration in the header promised
library::internal::scale(int), and the definition created a different name that no other file can
reach. The nm output confirms it — and note the lowercase t, which is what internal linkage looks
like:
U library::internal::scale(int) # main.o: undefined, and wants the global one
0000000000000000 t (anonymous namespace)::scale(int) # helper.o: defined, and LOCAL
A capital U and a lowercase t are the two sides of this bug, and no linker message on this machine
will tell you so — collect2.exe: error: ld returned 1 exit status is all it says.
The rule that follows: declare in a header only what another file is meant to use. A helper with internal linkage belongs in an unnamed namespace and needs no declaration in any header.
5. How does data survive the program ending?¶
Streams. std::ofstream and std::ifstream own a file the way every RAII class in this course owns a
resource: the destructor closes it, including when an exception unwinds past it (Module 11).
bool save(const std::filesystem::path& path) const {
std::ofstream out(path, std::ios::trunc);
if (!out.is_open()) {
return false;
}
out << kMarker << "\n";
for (const Book& book : books_) {
out << format_record(book) << "\n";
}
out.close();
return static_cast<bool>(out); // the flush happens at close, so check AFTER it
}
Three details that students lose marks for:
static_cast<bool>(out)afterclose(), because a failed flush is reported by the stream's state, not by the last<<. Checking earlier checks nothing.std::ios::truncon an output stream, so a re-save does not leave the tail of a longer previous file behind.- The format is documented, and carries a version marker, because a file outlives the program that wrote it:
# catalogue v1
Compilers: Principles, Techniques, and Tools|Aho et al.|2006
The C++ Programming Language|Stroustrup|2013
Thinking in Systems \p A Primer|Meadows|2008
The escaping matters: a title containing the separator round-trips as \p, which is the difference
between a format that survives real data and one that corrupts the first book with a | in its title.
6. How do I know the file opened?¶
By checking the stream state, because the failure is otherwise completely silent. From
examples/module-16/03-stream-state/:
A. unchecked open : loaded 0 records. No message, no crash, and the program believes the catalogue is empty.
B. checked open : refusing to continue - oop-course-stream-state.txt could not be opened
Line A is the dangerous one. Nothing threw, nothing crashed, and a program that then behaves as though the catalogue were empty looks like a working program with no data. The check is one line:
The three flags, and what each means:
| Flag | Set when | What it tells you |
|---|---|---|
fail() |
an extraction or open produced no value | recoverable in principle; clear() and retry is legitimate |
eof() |
the end of input was reached | normal at the end of a read loop — and note it can be set by a successful extraction of the last token, as part E of the example shows |
bad() |
the stream itself is unreliable | the data read from it cannot be trusted; continuing is pointless |
Which is exactly why while (in >> x) and while (std::getline(in, line)) work: they stop when the
extraction fails, and end of input is one way that happens.
One trap that costs everyone an afternoon, from part D of the same program:
D. >> then getline: number 42, rest [] - the newline is still in the stream
after ignore() : number 42, rest [second line]
>> stops before the delimiter; getline consumes the whole line including it. Mixing them without
ignore(std::numeric_limits<std::streamsize>::max(), '\n') gives you an empty string, and the next read
starts one field earlier than you think.
7. What if the file is empty, or half-written?¶
Then it is input, and input is validated — the rule from Module 4, now across a process boundary.
examples/module-16/05-untrusted-files/ runs twelve corruptions through one loader. Sample, verbatim:
1. missing file : rejected - the file could not be opened
2. empty file : rejected - the file is empty
3. hand-edited header : rejected - line 1: expected '# catalogue v1', found '# catalogue v2'
4. count over the limit : rejected - line 2: declared 1000000 records, over the limit of 10000
6. count that does not fit : rejected - line 2: the declared count '99999999999999999999999' does not fit in a size_t
7. truncated record : rejected - line 3: malformed record 'Compilers|Aho'
9. a record deleted by hand: rejected - line 2: declared 3 records, found 1 - a record was added or deleted by hand
10. good file, blank line : accepted - loaded 3 records
Four lessons are hiding in that list:
- Validate at the boundary, then trust the type. After
loadsucceeds, the catalogue's contents are a validCatalogue— the rest of the program does not have to re-check anything. That is the whole point of validating at the edge. - A count read from a file is hostile until checked. Case 4 refuses to allocate a million records on
the word of a text file; case 6 catches a number that does not even fit in the type the code would
have used.
std::stoulthrowsstd::out_of_range, and untrusted input means catching what the conversion does when it cannot. - A message with a line number is a message somebody can act on. "Malformed file" leaves the user
with nothing;
line 3: malformed record 'Compilers|Aho'says exactly what to fix. - Blank lines are not corruption. A hand edit that leaves an empty line should not stop a load — strictness belongs where the danger is, not everywhere.
And the guarantee that makes this a design rather than error handling: a rejected load leaves the existing data untouched, because the parsed records go into a local vector and only a fully validated result is assigned to the member. From the same program:
11. into a live catalogue : rejected - line 3: malformed record 'Compilers|Aho'
records before 2, after 2 (the half-read records were discarded)
8. How do I know my save is correct?¶
By a round trip: save, load, compare every field. Anything less is an assertion about code you have
not run. examples/module-16/04-persistence-round-trip/ compares whole catalogues:
populated catalogue: 3 saved, 3 loaded, identical = true
the title containing '|' came back intact: true
empty catalogue : 0 saved, 0 loaded, identical = true
The empty case is not padding. It is the case that catches most bugs, because every part of a naive
implementation assumes at least one record: a loop that reads a header and then dos its work on the
first line, a format that writes the marker and then nothing, a test written against a populated
catalogue. Include the separator inside a field and a blank value, and you have covered the three or
four inputs that actually break persistence code.
9. What should I test when nobody is marking me?¶
The cases that would cost you a debugging session, which are almost never the happy path. From
examples/module-16/06-tests-that-assert/, the assertion suite covers:
ok 0 days costs nothing
ok 1 day costs 3p
ok the cap is reached at 14 days
ok 15 days is still capped at 42p
ok a year overdue is still 42p
ok staff pay half, rounded up
ok 1 day, staff: 3p halves up to 2p
ok a negative day count is rejected
Read as a list of categories: an invariant (the cap holds however large the input), boundaries (0, 1, exactly at the cap, one past it), the two halves of a rule (the discount), and a failure path (invalid input is rejected rather than returning a number). Those four categories are the template for every test suite you write from here.
Then the honest test of your tests: introduce the bug deliberately and see whether the suite notices. The program in the examples folder does exactly that, with a clone whose cap is one day too generous:
=== the same assertions, against the buggy implementation ===
ok 0 days costs nothing
ok 1 day costs 3p
ok the cap is reached at 14 days
FAILED 15 days is still capped at 42p
FAILED a year overdue is still 42p
buggy implementation: 6 passed, 2 failed
Two of eight assertions failed, and both of them were boundary cases. That is what a suite is for.
10. My tests print, and I read them. Is that enough?¶
No, and this is the module's most consequential lesson. Here is the same test, on both implementations above, from the same program:
correct implementation:
fine for 10 overdue days (staff): 15
implementation with a cap one day too generous:
fine for 10 overdue days (staff): 15
Identical output. A printing test cannot distinguish a correct implementation from a wrong one, at all — not "usually", not "unless you look carefully". It produces evidence for a human and checks nothing itself, so it cannot run unattended, cannot protect a refactoring, and cannot fail in the morning when somebody breaks the cap.
An assertion is the mechanism that makes a test a test:
void check(bool condition, const std::string& what) {
if (condition) {
++passed_;
std::cout << " ok " << what << "\n";
} else {
++failed_;
std::cout << " FAILED " << what << "\n";
}
}
Forty lines, no framework, and enough: a test suite is an assertion count and an exit code, nothing more. What it buys is the next section.
11. How do I restructure without breaking it?¶
Small steps, each verified, with the tests as the safety net. Tests are not a formality you add at the end — they are the reason refactoring is possible at all, and a design nobody dares change is a design that has stopped improving.
The procedure that works:
- Run the suite first, and see it pass. A suite that was already failing tells you nothing about the change you are about to make.
- Change one thing, and re-run. A file split, a header extracted, a dependency injected.
- Keep behaviour identical, and prove it: compare the program's output before and after, or the assertions' results. "It looks the same" is not a check.
- Do not touch the tests to make them pass. If a test needs editing to accommodate a refactoring, either the refactoring changed behaviour or the test was asserting an implementation detail.
That last rule is the one to hold on to. A refactoring is defined by behaviour not changing, so a test edited to fit is evidence against the refactoring, not a formality to get past.
12. Is my design good?¶
Now you can answer with criteria rather than taste, because Module 15 gave you the instrument and Module 16 gives you a system large enough for it to matter:
| Question | The evidence that answers it |
|---|---|
| Is change local? | Pick a requirement, count the files it touches — a number, before and after |
| Is each class's responsibility one sentence? | The sentences, and what you did about the ones needing "and" |
| Who owns what? | Every owning member named, and the smart pointer or value that says so |
| Can a component be tested alone? | A test double where the real collaborator would have been |
| Does failure leave a usable state? | The corruption log: what the program did for each broken file |
| Would a stranger find the entry point? | The file tree, and the read order you wrote down |
The last row is the capstone's own acceptance test, and it is worth running literally: hand the folder to somebody for five minutes and ask what the program does.
Then the measurement that settles the design argument, from the blueprint's exercise X2: add one new kind of item, and count the files you had to touch. If the number is one or two, the design kept the promise Module 15 made. If it is ten, the design promised more than it delivered — and writing that down honestly is worth more than the extension working.
13. Which decision would I reverse?¶
This is the report's most valuable line, and the module's exit level in one question. A reversal is not a confession; it is the difference between a design you understand and a design you only have:
My decision: the catalogue stores items by value in a vector
The context: a few hundred items, lookups by id on every user action
The cost of that choice: lookups are linear; the code is simple and the items are contiguous
The option I rejected: an unordered_map keyed by id — faster lookups, and every iteration
site has to be rewritten to walk the index
What would change my mind: several thousand items, or a lookup on a hot path
The decision I would reverse: modelling loans as a member of Item. Reversing it means a
separate Loan collection, a real migration of the persist format,
and rewriting the report — which is why I have a TODO instead
What makes that credible, and what makes it noise:
| Credible | Noise wearing the same words |
|---|---|
| The context is stated first | "The design could be improved" |
| The chosen option's cost is named | Praise for the design |
| A real alternative, with its cost | A strawman you would never have built |
| A condition that would reverse it | A vague intention to do better next time |
| The cost of the reversal, which is why you did not do it | An apology |
The last line is the one students miss. Every design has something worth reversing; what most cannot say is what the reversal costs, and without that number the admission is not a judgement.
14. How would someone else extend this?¶
The extension exercise, and the last thing to do before calling the project finished: pick a feature the design should accommodate, add it, and count the files the extension touched.
Two outcomes, both useful:
- One or two files. The abstractions earned their place. Record the number; it is your evidence for the design section of the report.
- Many files, including several you had forgotten about. The design has a seam missing, and you now know exactly where: in the code that had to change for reasons unrelated to the feature. Record that too — it is a better finding than any of the ones you would have invented while designing.
The extension is also the honest test of the tests. A feature added under a green suite that then stays green tells you the suite covers the system; one that stayed green while the feature was broken tells you what to add.
Checkpoint¶
Answer these before Lab 16. The section is given for each.
- What distinguishes a public header from an internal one, and how do you check the distinction rather than trusting it? (Section 2 — a public header must compile with no internal include.)
- State the One Definition Rule in one sentence, and name its two fixes with the case each suits.
(Section 3 — declare in the header and define in one source file for interface functions;
inlinefor small ones that belong to the interface.) - The linker prints only
collect2.exe: error: ld returned 1 exit status. What do you do next? (Section 3 — look at the symbols:nm -Con the objects, and look for a name defined twice with a capitalT.) - What does an unnamed namespace give a function, and what does that change about declaring it in a
header? (Section 4 — internal linkage; and it means the declaration can never be satisfied, which is
exactly the bug the
{anonymous}::scale(int)warning names.) - Why is a rejected load the right behaviour for a hand-edited file, and what does "rejected" have to leave behind? (Sections 7 and 11 — the file is input, and a rejection must leave the in-memory state exactly as it was.)
- Name the categories a test suite must cover, and say why the empty case belongs in a round trip. (Sections 9 and 8 — invariants, boundaries, rule halves, failure paths; and because every naive implementation assumes at least one record.)
- Predict: what does a test that only prints prove, and what does an assertion prove that printing does not? (Section 10 — printing proves the program ran and shows a value a human may compare; an assertion fails when the value is wrong, so it works unattended and catches a regression.)
- Why does a refactoring need the tests to be unmodified? (Section 11 — a refactoring is defined as behaviour unchanged, so a test edited to fit is evidence the behaviour changed.)
- What four things does a credible design evaluation contain, and what is the fifth line students usually omit? (Sections 12 and 13 — context, choice, cost, rejected option, reversal condition; and the cost of the reversal, which is why it was not made.)
- Why is the file count from extending the finished system the strongest evidence in the report? (Section 14 — it comes from code that exists rather than from intention, and it is the same measurement Module 15 used to argue for the design.)
Next: Worked examples — the application built up in stages: structure, persistence, robustness, tests, and the report.