Skip to content

Module 10 — Debugging

The technique this module adds

Counting, and letting a tool watch memory for you.

Every previous module's technique worked on the source: read the error, check the line above, reduce the problem. This module is where that stops being enough. A shallow copy of an owning pointer is not visible in the code, produces no diagnostic, and usually does not fail anywhere near the line that caused it. Reading harder will not find it.

Two techniques do:

1. Instrument the resource, and count. Add a counter to the class that owns the resource, increment it in every operation that matters, and print the totals at the end. This is Module 3's lifecycle counters applied to memory, and it is the technique the examples in examples/module-10/ use throughout. It needs no tooling, works everywhere, and answers the one question that matters:

Does allocations == frees and constructors == destructors, for every scripted sequence?

If not, the class is wrong — regardless of what the program appears to print.

2. Let a sanitizer instrument the program. AddressSanitizer (-fsanitize=address) reports the point of an invalid access or a double free, with the stack traces of both the allocation and the offending free. That is what turns a crash three functions later into a line number.

g++ -std=c++17 -g -fsanitize=address -fno-omit-frame-pointer main.cpp -o prog-asan
./prog-asan

A double-free report from AddressSanitizer has a shape worth learning before you need it:

ERROR: AddressSanitizer: attempting double-free on 0x602000000010
    #0 operator delete[](void*)
    #1 RawBuffer::~RawBuffer()        main.cpp:12      ← the second free
    ...

freed by thread T0 here:
    #0 operator delete[](void*)
    #1 RawBuffer::~RawBuffer()        main.cpp:12
    ...

previously allocated by thread T0 here:
    #0 operator new[](unsigned long)
    #1 RawBuffer::RawBuffer(...)      main.cpp:7
    ...

Three things to read in that report, in order: which address (one block), who freed it last (your destructor), and who allocated it (your constructor — one allocation, so the second free came from a copy you did not know about). The two RawBuffer::~RawBuffer() frames with no matching second allocation are the bug: one block, two owners.

If the sanitizer is not available on your setup. Some MinGW installations — including the MSYS2 GCC used to build this course — do not ship the sanitizer runtime at all, and the build fails before your program runs:

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

That is a limitation of the toolchain, not of your code. On that machine, fall back to counting (technique 1 above), which needs nothing extra and answers the same question. If you have a Linux machine or WSL available, run the same program there with the sanitizer: the report will name the line, and comparing the two ways of finding the same bug is itself worth the effort. On Linux, Valgrind (valgrind --leak-check=full ./prog) is the alternative when rebuilding is inconvenient.

What a sanitizer does not catch. A sanitizer reports invalid accesses, double frees and leaks. It does not catch a shallow copy that happens to be harmless in your test run, a self-assignment bug on a path your tests never reach, a missing noexcept (which is a performance defect, not a correctness one), or a move that quietly copied. For those, counting is the only instrument there is.


A worked diagnosis

This is the bug the module exists for, found the way a person finds it — by noticing a symptom that does not match the code.

Symptom. A program that manages a catalogue of items crashes at the end of main, after it has printed every line of its output. It crashes more often in a debug build than a release build, and it never crashes in the small test case the developer wrote first.

First hypothesis. "Something is wrong with the destructor." It is the code that runs at that moment, and the crash is inside the allocator.

Test. Add a print to the destructor, showing this and the pointer it is about to free:

Catalogue::~Catalogue() {
    std::cerr << "~Catalogue " << this << " freeing " << static_cast<const void*>(items_) << "\n";
    delete[] items_;
}

Result.

~Catalogue 0x7ffd2b4c1a30 freeing 0x55d1c8e2ceb0
~Catalogue 0x7ffd2b4c1a60 freeing 0x55d1c8e2ceb0     ← the same address, from a different object

Two different objects, one block. The destructors are correct; the copy that produced the second object is not.

Actual cause. Catalogue owns a raw array and declares a destructor but no copy constructor, so the compiler generated a member-wise copy. The catalogue was passed by value to a helper function — one copy, one shared pointer, two frees. Read the class again with that in mind and it takes ten seconds to see; read it without the evidence and it takes an afternoon.

Fix. Two options, and the choice is a design decision:

// (a) take control: deep copy, Rule of Three
Catalogue(const Catalogue& other) : size_(other.size_), items_(new Item[other.size_]) {
    std::copy_n(other.items_, size_, items_);
}

// (b) own nothing: Rule of Zero, and the bug class disappears
private:
    std::vector<Item> items_;      // no destructor, no copy, no move, nothing to get wrong

Lesson. The crash location and the defect location were in different functions. Every technique in Modules 1 to 9 assumed the two were close — that the error pointed at the mistake. From here on, the first move is to stop reading and start measuring: print the address, count the frees, or let a sanitizer name both.


The escalation ladder

  1. Read the first error, including any note: line. For this module the note: is often the answer — note: … implicitly declared as deleted because … declares a move constructor explains the whole problem.
  2. Check the line above the reported one. A copy is usually one line above the crash, and the cause is one line above that: a parameter taken by value.
  3. Reduce the problem until it builds and runs, then restore it piece by piece. On this module's subject, reduction means cutting the class down to the one owning member and reproducing the double free in twenty lines.
  4. Check the common-mistakes table in Misconceptions and mistakes, then the glossary.
  5. Ask the machine. A counter in the owning class, printed at every construction and destruction; a sanitizer if your toolchain has one. Memory bugs are found by tools, not by reading. This is the step that finds what steps 1 to 4 cannot.
  6. Restate the problem in words, without code. "Two objects are freeing one block." Which two? Which line copied? Which function owns each?
  7. Then consult a solution — a reference implementation, a reference in Further Reading — and rewrite your own version afterwards.

Steps 1 to 4 are what you already do. Step 5 is the one this module adds, and the one that pays for itself in this module and every module after it.


Tools introduced here

Tool What it shows How
Instrumented counters allocations, frees, blocks alive, copies versus moves A static counter in the owning class, incremented in each special member
An identity print in the destructor Which objects free which address std::cerr << this << ' ' << static_cast<const void*>(pointer)
AddressSanitizer The point of an invalid access or double free, with both stack traces g++ -std=c++17 -g -fsanitize=address -fno-omit-frame-pointer main.cpp -o prog
LeakSanitizer Memory allocated and never released, with the allocating stack Included in -fsanitize=address on Linux; ASAN_OPTIONS=detect_leaks=1
Valgrind The same reports, without rebuilding valgrind --leak-check=full --track-origins=yes ./prog (Linux)
-Wpessimizing-move, -Wredundant-move A std::move that prevents an elision the compiler would have done -Wall (the course promotes it with -Werror)
static_assert(std::is_nothrow_move_constructible<T>::value, …) Whether a container will move or copy your type A compile-time check, so the answer cannot be forgotten
std::is_copy_constructible<T>, std::is_move_constructible<T> What the compiler actually generated, after your declarations static_assert on them, in a test that compiles

Two of those deserve a note about when to reach for them.

Counters first. They are three lines of code, they need no tooling, and they answer the module's central question (allocations == frees?). Add them the moment a class owns anything.

static_assert for the properties you want to be permanent. A class whose moves must stay noexcept should say so in a way that fails the build if it stops being true:

static_assert(std::is_nothrow_move_constructible<Buffer>::value,
              "Buffer's move must be noexcept or containers will copy");

That assertion is the only guard against the silent degradation Example 5 measures — and unlike the measurement, it fails at compile time, in a place where you will notice.


Next: Exercises — ten tasks, from predicting which functions the compiler generates to refactoring a working class without changing its behaviour.