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 == freesandconstructors == 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.
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¶
- Read the first error, including any
note:line. For this module thenote:is often the answer —note: … implicitly declared as deleted because … declares a move constructorexplains the whole problem. - 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.
- 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.
- Check the common-mistakes table in Misconceptions and mistakes, then the glossary.
- 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.
- Restate the problem in words, without code. "Two objects are freeing one block." Which two? Which line copied? Which function owns each?
- 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.