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:
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:
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:
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¶
- Read the first error, including any
note:line. In this module thenote: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. - Check the line above the reported one. A
unique_ptrerror is usually astd::movethat should be there, or a parameter that should not be. - 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 onemake_unique<Derived>will reproduce every message in Part 2 of Misconceptions and mistakes. - Check the common-mistakes table, then the glossary.
- 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.
- 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?
- 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.