Module 16 — Debugging¶
The technique this module adds¶
Reading a link error from the symbol table, and treating a loaded file as hostile input.
Every previous module's debugging started from a symptom inside one file: a wrong value, a crash, a leak, a slow query. This module adds two failures that live between files and outside the program:
- Link errors, where the compiler has already succeeded in every individual file and the fault is in how the files fit together.
- Loaded data, which is the one input you did not write.
The tools are nm -C for the first and a documented validation pass for the second.
Move 1 — When the linker says nothing, read the symbols¶
This toolchain's response to a duplicate definition is one line, and it names neither the file nor the function:
That is not a dead end; it is a different question. The linker is comparing symbol tables, so read the tables yourself:
The taxonomy, and what each line of nm output means:
nm output |
Means | The fault |
|---|---|---|
T name (capital) in two objects |
defined, external linkage, twice | a definition in a header, or the same source listed twice |
U name and no T name anywhere |
undefined, and nothing provides it | a missing source file, or a declaration whose definition is unreachable |
U name in one object, t name (lowercase) in another |
wanted global, provided local | the definition sits in an unnamed namespace, or static |
t name in every object, no U |
every unit has its own private copy | correct, if that was the intent — internal linkage doing its job |
Two questions answer every link error in a multi-file project.
- Which translation unit should define this? If the answer is "the one that owns it", the fix is a
move to a source file, or
inlineif the function belongs to the interface. - Is the name visible outside its own file? If it should not be, delete its declaration from the header; if it should be, get it out of the unnamed namespace.
The related compiler failure does name things, which is why it is worth reading in full:
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
Note what the note lines do: they name the earlier include. In a project where a header is reached
through three others, that is the only thing telling you which include chain has no guard.
Move 2 — Include hygiene: follow the arrows in the failure, not the file¶
An include loop produces the most misleading error in this module, because the diagnostic names a type that "does not exist" in a file that includes the header defining it:
The type exists. The file was compiled before the definition had been seen, because the two headers include each other and one of them had to lose.
The two fixes, in order of preference:
- A forward declaration, if the header only needs the name — a reference parameter, a pointer
member, a returned pointer.
class Catalogue;costs nothing and breaks the cycle. - A move: if
a.hneeds everything aboutb.handb.hneeds everything abouta.h, they are describing one concept and belong in one header.
A check that catches this class of bug before it happens: list each header's includes in the order the
compiler sees them, and confirm the graph is acyclic. If your public header can be included by itself and
compile — put it alone in a one-line .cpp and compile — nothing downstream can surprise you.
Move 3 — Treat the file as hostile¶
The rule from Module 4, applied at a process boundary: a value that came from outside is not validated until you have checked it, even if your own program wrote it last time.
The check every loaded field needs, in this order:
- Was the file opened at all?
is_open()before anything else. An unchecked open that silently produces zero records is this module's worst failure. - Is the header what I expect? The version marker catches a file from an older or hand-edited version, and it is one comparison.
- Does every conversion succeed?
std::stoul("99999999999999999999999")throwsstd::out_of_range; catch it rather than letting it end the program. - Is every declared quantity plausible before it is used? A record count is checked against a limit before anything is allocated from it.
- Does the count agree with the data? A file that declares three records and contains one has been edited, and the disagreement is the finding.
- Where does the partially-read result live until validation finishes? In a local container, so a rejection leaves the program's state untouched.
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
9. a record deleted by hand: rejected - line 2: declared 3 records, found 1 - a record was added or deleted by hand
A message with a line number is the deliverable. "Invalid file" sends the user to you; line 3:
malformed record 'Compilers|Aho' sends them to the line — and you will be that user in six months.
And there is such a thing as too strict. A blank line left by an editor is not corruption; a trailing newline is not a malformed record. Strictness belongs where the ambiguity is, and a loader that rejects files a text editor produced is a defect with a plausible-sounding justification.
Move 4 — Reproduce the bug as a failing test¶
The debugging loop for this module is not "add a print, guess, change something" but:
- Reproduce the failure as a test — a corruption, a boundary, a failure path.
- Watch it fail, and read what it says. A test that fails for the wrong reason is a false trail.
- Fix the code, not the test.
- Keep the test. This is the step that converts a bug into a permanent guarantee, and it is the reason the capstone requires tests at all.
A suite that has caught three real bugs is worth more than one that has never been red — and you can
engineer that deliberately: introduce the bug, watch the assertion fail, revert. That is how
examples/module-16/06-tests-that-assert/ is built, and it is the only way to know your tests would
notice anything.
Move 5 — Diagnose the build, not just the program¶
When the build itself is the problem, the incantation to avoid is "delete the objects and try again". Two commands answer most multi-file build questions:
g++ -std=c++17 -Wall -Wextra -Werror -c file.cpp -o file.o # does THIS file compile?
nm -C file.o | grep -i name # what does it define and need?
If a file compiles alone but not in the build, the difference is an include path or a macro — not the C++ in the file. If it compiles but will not link, the objects have told you why. And if the build command does not match the one in the README, fix the README: the documented command is what a reader will run.
The escalation ladder for this module¶
- Read the whole diagnostic, including the
notelines. In multi-file work the note is often the answer, and it is always the part people skim. - Isolate the translation unit. Compile the one file with
-c. If it fails alone, it is a C++ problem in that file; if it succeeds, it is a link or include problem. - Read the symbols.
nm -Con the objects, and classify the lines with the table in Move 1. - Reduce the build. Remove source files until it links, then add them back one at a time — the same narrowing technique as every earlier module, applied to translation units.
- For a data failure, start from the file. Print the file. Then validate it field by field against the documented format, and see which check the file should have failed.
- Reproduce it as a test, and keep the test. A fixed bug with no test is a bug that will return.
- For an insufficient diagnostic, build your own.
nmfor symbols, a line number in every message, a deliberate failure for every assertion. Tools that do not tell you things are worth replacing.
Checklist before declaring the application finished¶
- [ ] It builds from the documented command in a clean copy of the repository
- [ ] No file compiles with a warning, with
-Wall -Wextra -Werror - [ ] Every public header compiles alone, with no internal include
- [ ] Every definition is in exactly one translation unit, or
inline - [ ] Every header has an include guard, and the include graph has no cycle
- [ ] Every open is checked, in both directions, before the program relies on it
- [ ] Every value read from a file is validated, and every message has a line number
- [ ] A rejected load leaves the program's state exactly as it was
- [ ] The save–load round trip preserves every field, including on an empty catalogue
- [ ] Every assertion fails when the bug it covers is introduced — verified by introducing it
- [ ] The file tree and the read order are documented, and a stranger can find the entry point
- [ ] The design report names the costs accepted, the alternatives rejected, and one decision I would reverse
Continue¶
Previous: Misconceptions and mistakes · Next: Exercises · Module home: Module 16