Skip to content

Module 11 — Debugging

The technique this module adds

Instrumenting acquisition and release, so that "did it get cleaned up?" is a number.

Module 10 taught you to count allocations and frees, and to let a sanitizer name an invalid access. This module's bugs are a different species: most of them are legal. A reference cycle is not an invalid access. A second control block only becomes a double free when the second pointer dies. A non-virtual base destructor does nothing visible until the object is destroyed. A shared_ptr parameter where a reference would do is not a bug at all — it is a cost.

So the technique is two-layered:

1. Prove the property by instrumentation, on every path. Put a counter in whatever acquires and releases the resource — or in the type that does, as examples/module-11/01-raii-across-exit-paths and 02-raw-memory-bugs-made-visible both do. Then run the same scripted sequence three ways:

normal return            acquisitions == releases, devices open 0
early return             acquisitions == releases, devices open 0
an exception mid-work    acquisitions == releases, devices open 0

A run where the count does not come back to zero has found a leak, whether or not anything crashed. This is the only instrument that works on the cycle in Example 4 of the worked examples: the program is memory-correct and still grows forever.

2. Let a sanitizer name what is genuinely invalid. AddressSanitizer reports a double free with both stack traces, and that is exactly what you want for the two-control-block mistake:

g++ -std=c++17 -g -fsanitize=address -fno-omit-frame-pointer main.cpp -o prog-asan
./prog-asan
ERROR: AddressSanitizer: attempting double-free on 0x602000000010
    #0 operator delete(void*)
    #1 Node::~Node()                      graph.cpp:14      <- the second delete
...
freed by thread T0 here:
    #0 operator delete(void*)
    #1 Node::~Node()                      graph.cpp:14
...
previously allocated by thread T0 here:
    #0 operator new(unsigned long)
    #1 Shared::Shared(...)                graph.cpp:6

The two identical ~Node() frames with only one allocation are the signature of two control blocks: one object, two owners, two deletes.

If your toolchain has no sanitizer, the course's own MSYS2 GCC does not ship the runtime, and the build stops with:

g++.exe: fatal error: cannot read spec file 'libsanitizer.spec': No such file or directory

Fall back to counting, and if you have access to Linux or WSL, run the same program there with the sanitizer for comparison. Module 10's debugging page covers the same limitation and the reasons for it.

What no tool catches, and therefore what counting must cover:

Bug Compiler Sanitizer Leak checker Counters
shared_ptr cycle silent silent reports still reachable visible: release count stops short
Two control blocks silent double free at run time maybe visible: two destructor calls for one construction
Non-virtual base destructor silent sometimes sometimes visible: derived destructor never runs
shared_ptr parameter where T& would do silent silent silent visible as extra atomic operations, not as correctness
weak_ptr used after expired() silent silent silent nothing — a race needs two threads to observe

The last row is the honest limit of the technique: a race is not observable from one thread's counters. It is prevented by using lock() correctly, which is a design discipline, not a diagnostic.


A worked diagnosis

Symptom. A drawing program holds shapes in a unique_ptr<Shape> container, and a statistics report says the total area is right but the program's memory grows with every rebuild of the drawing. Nothing crashes. No warning is issued at any warning level.

First hypothesis. "The vector is not releasing its elements."

Test. Instrument both halves — the container's element ownership and the shapes themselves:

struct CountingShape : Shape {                    // in a test harness only
    CountingShape()  { ++alive;  std::cerr << "shape created, alive " << alive  << "\n"; }
    ~CountingShape() { --alive;  std::cerr << "shape destroyed, alive " << alive << "\n"; }
    static inline int alive = 0;                  // C++17: inline static, one variable (Module 5)
};

Result.

shape created, alive 1
shape created, alive 2
shape created, alive 3
rebuilding the drawing...
shape created, alive 4
shape created, alive 5
shape created, alive 6

Six creations, no destructions. The container is being cleared, so its elements should be dying.

Actual cause. The Drawing::rebuild() function takes the container by value, and the caller passes a copy of the vector — an operation that cannot even compile for unique_ptr elements. So the real code had grown a raw-pointer variant "to make the copy work", and the raw pointers were never deleted. The second hypothesis was the right one: the container was fine; the type of the elements had been weakened at the one place where ownership was being duplicated.

Fix. Make the operation a move, and keep ownership in the type:

void rebuild(std::vector<std::unique_ptr<Shape>> shapes);   // takes ownership of the incoming set

Lesson. The counter said "created but not destroyed" long before anything crashed. The instance count — alive — is the single most useful instrument in this module: it catches leaks that are perfectly legal, that a sanitizer passes, and that no diagnostic mentions. If you take one habit from Module 11, take this one: every resource-owning type gets a static instance counter while you are developing it.


The escalation ladder

  1. Read the first error, including any note: line. In this module the note: usually names the parameter (note: initializing argument 1 of 'void take(unique_ptr<Shape>)') or the deleted function (note: declared here), and that is enough to fix the call.
  2. Check the line above the reported one. A unique_ptr error is usually a std::move that should be there, or a parameter that should not be.
  3. Reduce the problem until it builds and runs, then restore piece by piece. For a template error from the standard library, strip the container first: a bare unique_ptr<Base> and one make_unique<Derived> will reproduce every message in Part 2 of Misconceptions and mistakes.
  4. Check the common-mistakes table, then the glossary.
  5. Ask the machine. An acquisition/release counter, an instance counter on the type, a destructor identity print, and — where available — a sanitizer or a memory profiler. This is again the step that finds what reading cannot.
  6. Restate the problem in words, without code. "Something acquires a resource that nothing releases." Which object? On which path? What owns it while it is alive?
  7. Then consult a solution — the standard's own documentation for shared_ptr, or a reference in Further Reading — and rewrite your own version afterwards.

Tools introduced here

Tool What it shows How
Acquisition/release counters Whether cleanup balances on each exit path A counter pair in the RAII wrapper or the resource shim
Static instance counter on a type Objects created versus destroyed — the leak detector that works on legal leaks inline static int alive = 0; incremented in the constructor, decremented in the destructor
Destructor identity print Which object (and which control block) released what std::cerr << this << ' ' << static_cast<const void*>(ptr)
use_count() How many owners a shared object has right now Print it at each point of interest — several times in the interesting cases
weak_ptr::expired() and lock() Whether an observed object still exists, and a safe owner if it does if (auto locked = weak.lock()) { use(*locked); }
std::is_copy_constructible / std::is_move_constructible Whether the ownership you intended is the ownership the type has static_assert on them, so a change to the class fails the build
AddressSanitizer The two stack traces of a double free or invalid access -fsanitize=address -fno-omit-frame-pointer, on a toolchain that ships it
Valgrind (Linux) Leaks and reachability classes, including still reachable for cycles valgrind --leak-check=full --show-reachable=yes ./prog
The memory profiler of your platform That a program with no invalid access can still grow Take a snapshot, wait, take another, and diff

Two of those are worth making permanent habits rather than debugging excursions.

static_assert on the ownership properties you depend on. A container of owning pointers depends on its elements being movable and not copyable, and on the base destructor being virtual. State it, and the day somebody changes the base class, the build stops:

static_assert(!std::is_copy_constructible<std::unique_ptr<Shape>>::value,
              "an owning pointer must not be copyable");

The instance counter, during development. Three lines. It finds the leaks that are legal — the cycle and the weakened element type above — which nothing else in this table finds.


Next: Exercises — ten tasks, from reading eight declarations to refactoring a working program's ownership with the behaviour proved unchanged.