Skip to content

Module 4 — Debugging

The first three modules were about reading messages. This one is about finding bugs that a compiler cannot see: an invariant that is not enforced, an interface that gives away something it should not, a class that is correct-looking and breakable.

The technique is unusual for a debugging chapter: you write the bug on purpose, and you are the detector.


The technique this module adds

The attack test

Write a small program whose only job is to break your own class from outside. Every attack that succeeds is a design bug — not a misuse of your class.

It inverts the usual direction of testing. Instead of asking "does my class do the right thing?", you ask "what does it let me do that it should not?" The attacks that succeed are your bug list, and each one points at a specific missing piece of design.

The routine

1. Write the invariant down, in one sentence.

INVARIANT: a Member holds at most 3 items, and every title it holds is one it is
           currently borrowing.

If you cannot write this sentence, you cannot test the class — you have nothing to attack.

2. Write the attacks. For each clause of the invariant, at least one line that would make it false. Use every kind of access the class offers:

// attacks.cpp — this file's only purpose is to fail to compile, or to be deleted

Member alice("Alice");

// (a) reach the count directly
alice.setBorrowedCount(-2);

// (b) reach it through a getter's return value
alice.borrowedTitles().push_back("not mine");

// (c) construct one in a state that is already invalid
Member bob("");                       // no name at all?

// (d) borrow past the limit
for (int i = 0; i < 50; ++i) { alice.borrow("book " + std::to_string(i)); }

// (e) borrow something that was never returned
alice.returnItem("a book I never had");

3. Compile it, and classify each outcome. Three possible results, and the interesting one is the middle:

Result What it means
Compile error (no member named 'setBorrowedCount', is private within this context) The attack is impossible. The design is working
Compiles and changes the object A design bug. The invariant is not enforced — this is what you are hunting
Compiles, changes nothing, returns false The attack is refused by the operation. Correct behaviour, and it must also have left the object unchanged

4. Fix each successful attack in one place. Not at the call site, and not in a second check. If two places enforce a rule, one of them will eventually disagree:

Attack that worked The fix, and where
A setter let the count go negative Delete the setter; add the operation (borrow) that maintains the rule
A getter returned mutable internal state Return a copy, or a const reference with a documented lifetime, or offer the operation instead
The constructor accepted an invalid argument Validate in the constructor — the one moment an object can be stopped from existing
Fifty borrows all succeeded The limit check belongs in borrow, using a derived canBorrowMore() so the count and the collection cannot disagree
returnItem accepted a title that was never borrowed Check membership before erasing; report which failure it was

5. Keep the attack as a regression test. This is the beginning of a test suite, and Module 16 formalises it: every attack that once succeeded becomes a line that must now fail to compile or return false. A comment at the top of the file explaining what it is for saves you from deleting it as "broken code" six weeks later.

The attack test on somebody else's class

Given a class you did not write, the attack test is the fastest way to decide whether you trust it. Five minutes, a handful of lines, and you know whether the invariant is enforced or merely documented — which is exactly the information a doc comment cannot give you.


The three failures this module produces

All three are silent. That is what makes the attack test necessary.

Failure A — a public member that should not exist

You wrote the class, you deleted every setter you could find, and one accessor survived because something needed it once:

std::vector<std::string>& items() { return items_; }

Symptom: none. Diagnosis: the attack test's attack (b). Fix: ask what the caller does with the returned collection. If they read it, std::vector<std::string> items() const (a copy) is the safe default. If they modify it, the modification is an operation the class should offer — and it should enforce the invariant while doing it.

Failure B — a rule enforced twice

Usually discovered later, and it looks like this: the count is checked in borrow and in the code that calls it, and a third caller checks it differently. The first two agree; the third does not.

Symptom: an invariant that "sometimes" breaks, in a way that is hard to reproduce. Diagnosis: count the places the rule is enforced. The right number is one. Fix: keep the enforcement inside the class, and let callers ask — canBorrowMore() — rather than re-derive it.

The related failure is a derived fact stored as a member. If a class holds both a collection and a count of it, they can disagree; the count should be a function (itemCount() { return static_cast<int>(borrowed_.size()); }), not a second copy of the truth. examples/labs/lab-04/solution/justification.md calls this "the single change that removed the most possible bugs from the type".

Failure C — an invariant established but not preserved

The constructor validates, and one operation forgets:

bool borrow(const std::string& itemTitle) {
    borrowed_.push_back(itemTitle);      // ← the limit check is missing here
    return true;
}

Symptom: an object that was valid at birth and is not valid now. Every function that trusts the invariant is now wrong, and the trace leads back to whichever call site happened to be the fifty-first.

Diagnosis: walk every public operation and ask "can this one make my sentence false?" Not "does it look reasonable" — can it make the sentence false? Operations that change the object are the only candidates, and const member functions can be dismissed in one pass, which is a second reason to be disciplined about const.

Fix: in the operation, and nowhere else.


The escalation ladder

This module's problems mostly cannot be reproduced from a message, so the ladder is shorter and its middle rungs are unusual.

Rung 1 — State the invariant out loud

Not "it should be fine" — the sentence, spoken. "A member holds at most three items, and every title it holds is one it is currently borrowing." A bug in this module is always a gap between that sentence and the code, and the sentence has to exist before you can find one. If you cannot say it, the class has no invariant and therefore cannot have one enforced — which is itself the finding.

Rung 2 — Run the attack test

The technique above. Five lines, and you know whether the invariant is enforced.

Rung 3 — Ask the compiler for its opinion anyway

A surprising amount of this module is caught, if you ask properly:

g++ -std=c++17 -Wall -Wextra -Werror -c attacks.cpp   # the leaks that are caught, are caught here
g++ -std=c++17 -Wall -Wextra -Wconversion main.cpp    # implicit narrowing in your validators
g++ -std=c++17 -Wall -Wextra -Weffc++ main.cpp        # opinionated C++-specific warnings

-Wconversion is worth trying at least once: a validator that mixes int and size_t, or that compares a signed count with an unsigned size, is a rule that will be wrong for exactly one input. (-Weffc++ is noisy and not part of the course's build — run it, read it, and decide for yourself.)

Rung 4 — Write the smallest class that has the same problem

Strip the class to the one member and the one operation involved, and attack it again. Fifteen lines of Minimal with a std::vector and a setCount reproduces Failure A in ten seconds, and it removes every distraction from your real class.

Rung 5 — Change the interface and see if the bug survives

This is the module's equivalent of "ask a different question", and it is remarkably effective:

  • Replace the getter with the operation it was standing in for. If the bug disappears, the getter was the bug.
  • Make the members const and see what stops compiling. Everything that breaks is a place your design was mutating state that the invariant says is fixed.
  • Return a copy instead of a reference everywhere, and see what stops compiling. Same diagnostic value, and it points at the leaks.

Rung 6 — Explain the invariant to a person

"The rule is the member holds at most three items. Borrowing is the only operation that adds one, and it checks this function first. Returning removes one, and refuses if it is not there. There is no way for a caller to set the count…" — and the sentence that stalls is the bug. In this module the stalls are always in the same three places: a setter someone forgot about, a getter that returns storage, and a constructor that does not validate.

Rung 7 — Ask for a design review, with the attack attached

The best question here is not "is my class encapsulated?" but:

"Here is my invariant, here is my interface, and here are the six attacks I could not make work. What can I still do that I should not?"

That question invites the answer you need. "Is this good?" invites "looks fine".

Rung 8 — When the problem is downstream

Some symptoms that look like encapsulation bugs are not:

Symptom Actually about
Values change when nobody is looking a leaked reference (Failure A) — or a static member shared between objects (Module 5)
A rule holds in tests and not in production two places enforcing it (Failure B)
A crash after a successful-looking operation the operation half-applied before failing; requiring "refuse and change nothing" is the fix
Performance is worse than the public-field version measure before believing it; accessors are inlined (M3)

What good looks like, at this point in the course

  1. You can state any class's invariant in one sentence, or say deliberately that it has none.
  2. You attack your own classes before you ship them, and you keep the attacks.
  3. Your classes have operations, not accessors, and you can name the caller's sentence for every public member.
  4. You can point at the one place each rule is enforced — and there is exactly one.
  5. You reach for a test rather than a flag when the question is about design, because you have learned that the compiler cannot see this kind of bug.
  6. You recognise the two silences — a mutable-reference getter and a non-validating setter — as the two most expensive five-second mistakes in the module.

Next: Exercises — three tiers, each stating its level and what success looks like. · All modules