Skip to content

Module 7 — Debugging

Module 6's technique was draw before reading — the subobject map. This module's is narrower and sharper: answer one question about every call site, in writing.

The technique this module adds

The declared-type test. For a call that behaves wrongly, write a single line:

<file>:<line>   static type of the expression: <the type the compiler sees>
                virtual: <yes/no>          handle: <reference/pointer/object>

Three examples from the module's own programs:

01-virtual-or-not/main.cpp:88   expression: const PlainVehicle&    virtual: no    handle: reference
                                → PlainVehicle::dailyRate is called. Static binding.
04-slicing/main.cpp:52          expression: Person                  virtual: yes   handle: OBJECT (by value)
                                → the copy is a Person; there is no derived part to dispatch to.
05-virtual-calls/main.cpp:31    expression: *this (inside Base())   virtual: yes   handle: reference
                                → still Base: the derived part does not exist yet.

Two minutes per call site, and the three conditions that produce every non-dispatch in this module are visible in the written line: virtual: no, handle: object, or at a moment when the object is not yet complete. When a call site looks correct on all three and still misbehaves, you are looking at the wrong call site — which is itself useful information.

The test is worth writing rather than performing mentally because the compiler's answer is not intuitive: plainBike.dailyRate() on a named object dispatches to the derived function even without virtual, while the same object through a reference does not. Writing both lines down is what makes that pair believable.

A worked diagnosis

Symptom. A billing loop prints correct-looking invoices, and the monthly total is about 35% too high. Nothing was changed in the loop this week. The hierarchy was.

First hypothesis. An arithmetic error in the total, or a rounded rate. Cheap to check, and wrong: the per-invoice figures are also wrong, for motorbikes only.

Test. Add one line to the loop, printing what the compiler sees rather than what the object is:

std::cout << typeid(vehicle).name() << "  sizeof " << sizeof(vehicle) << '\n';

Result. typeid names the dynamic type, so it prints PlainMotorbike — the object is right. sizeof prints 40 where the motorbike is 48: the loop is holding a slice. The container is std::vector<PlainVehicle>, and every push_back copied only the base part. (sizeof was the useful half of that output; typeid was the misleading half, because a sliced copy really is a PlainVehicle.)

Actual cause. Two defects, one hiding the other. The container stores objects, so the motorbikes were sliced at push_back; and dailyRate is not virtual, so even though the copy is a PlainVehicle, the loop's expression would have bound statically had no slicing occurred. Fixing either alone changes the number and leaves a bug.

Fix.

std::vector<std::unique_ptr<Vehicle>> fleet;      // no copies; ownership explicit
…
for (const auto& vehicle : fleet) {               // reference to the pointer
    total += bill(*vehicle, days);                // and a reference through it
}

Lesson. A wrong number with no diagnostic is a type question. sizeof and the declared-type test answer type questions; adding print statements inside the loop does not, because the loop is behaving exactly as written.

The three silent failures, in the order to check them

  1. A missing virtual. Grep the base class for the operation and read its declaration. If it is not virtual, a base handle will run the base version.
  2. A by-value parameter or container. Search for the type name with a space or a < after it: void f(Person, std::vector<Person>, Person person =. Each hit is a slice.
  3. A virtual call during construction or destruction. Search the constructors and destructors of the base class for calls to virtual members. Each hit is either dead code (the base version runs) or undefined behaviour (if the function is pure).

Then, and only then, look for logic errors. Evidence gathered while one of these three is present is not worth much: the symptom moves when you fix them.

The escalation ladder

  1. Read the first error, including any note: line. marked 'override', but does not override is the whole diagnosis; the note: names the function whose signature you failed to match.
  2. Check the line above the reported one. A missing virtual is reported nowhere, but a constructor that calls a pure virtual is reported at the call site inside the constructor, with a note: showing the pure function.
  3. Reduce the problem until it builds, then restore it piece by piece. Two classes and one call site reproduce every dispatch mistake in this module.
  4. Write the declared-type test for each call site — static type, virtual, handle form. This is the step that resolves most cases.
  5. Print sizeof and typeid(...).name(). Sizes find slices; typeid tells you what the object really is at that moment, which distinguishes "wrong function" from "wrong object".
  6. Build with a virtual destructor and a sanitizer. -Wnon-virtual-dtor and -fsanitize=address turn the silent leak of Mistake 3 into a named allocation.
  7. Restate the problem in words without code — "a motorbike is being billed at the car rate through a base reference" is already half the diagnosis.
  8. Then consult a solution, and rewrite your own version afterwards.

Tools introduced here

Tool What it shows How
override Whether your function really overrides, at compile time Write it on every override; misspell the signature on purpose once
typeid(x).name() The dynamic type of a polymorphic object at that moment Needs <typeinfo>; useful in a constructor, where it shows the dynamic type is not yet the derived one
sizeof Whether what you are holding is the whole object Compare the container element's size with the derived type's
-Wnon-virtual-dtor A class with virtual functions and a non-virtual destructor Add to the compile command; the compiler explains the risk
-Woverloaded-virtual A derived function hiding a virtual base function The hidden warning is how Mistake 2 is caught without override
-fsanitize=address The allocation a missing virtual destructor leaked g++ -std=c++17 -g -fsanitize=address main.cpp -o main
virtual ~Base() = default; The rule, applied before it is needed Put it in the base class as you create it

Reading a polymorphic hierarchy you did not write

Five minutes, in this order:

  1. List every virtual function in the base. Anything not on the list binds statically through a base handle.
  2. Check the destructor. If it is not virtual and the object can be deleted through a base handle, that is the bug you were looking for.
  3. Look for parameters and containers holding polymorphic objects by value. Each is a slice.
  4. Look for virtual calls inside constructors and destructors. Each is dead code or undefined behaviour.
  5. Only then look for switch statements on a type tag: they are where dispatch was never added, and they are usually the reason two files must be edited together.

Next: Exercises — three tiers, including a hierarchy with one missing virtual to find, and a deliberately sliced container to fix.