Skip to content

Module 3 — Debugging

Module 2's debugging was about messages: the compiler says something, you work out what. This module is harder, because its subject is invisible. Constructors and destructors run without printing anything, and a lifetime bug produces no compiler message at all — which is why this page's technique is about making the invisible visible rather than about reading error text.


The technique this module adds

The lifecycle trace: predict, print, compare

Before you investigate a lifetime, write down what you believe happens. Then make the object announce every construction, copy and destruction, run it, and explain every difference between the trace and your prediction.

The prediction is not a warm-up. It is the instrument. A trace with no prediction beside it shows you what happened and tells you nothing about what you believed — and the bug is always in the belief, not in the code.

The routine

1. Write the prediction first — on paper or in a comment.

// Prediction, written before running:
//   + first        (live: 1)
//   + second       (live: 2)
//   consumeByValue: one copy, destroyed at the end of the call
//   end of main: second, then first

Order, count, and the counter values at each line. Vague predictions ("there will be some copies") cannot be compared with anything.

2. Instrument the special members. Four lines, and each one is a different event:

class Tracked {
public:
    explicit Tracked(std::string name) : name_(std::move(name)) {
        ++live_;
        std::cout << "+ " << name_ << "   (live: " << live_ << ")\n";   // construction
    }
    Tracked(const Tracked& other) : name_(other.name_ + " (copy)") {
        ++live_;
        std::cout << "c " << name_ << "   (live: " << live_ << ")\n";   // COPY
    }
    ~Tracked() {
        --live_;
        std::cout << "- " << name_ << "   (live: " << live_ << ")\n";   // destruction
    }
private:
    std::string name_;
    static int live_;
};

Four details that make the trace readable, and each earns its place:

  • A different prefix per event (+, c, -) so you can scan a column, not read sentences.
  • The object's name, copied into the object. Without it you cannot tell which object a line refers to — and marking copies with a suffix (" (copy)") is what makes a copy visible in the output rather than inferable from the count.
  • A static counter of live objects. This is the single most valuable line. Every construction has exactly one destruction, so the counter must return to 0. A trace that ends at -1 or 2 has found a bug that no error message would ever have reported.
  • No std::endl. Use '\n'; you are printing a lot, and flushing on every line distorts nothing here but costs plenty later.

3. Run it and compare, line by line. Not "roughly": the actual differences.

4. Explain every difference in terms of the language, not the code. The useful vocabulary, in the order these usually appear:

Difference you see The mechanism
An extra c line where no copy is visible in the source a by-value parameter, or a copy initialisation (Tracked b = a;)
An extra + … - … pair around a call a temporary — most often a returned value under C++14, or a conversion
No extra line where you expected a copy mandatory copy elision (C++17) for a returned prvalue
Destruction in an order you did not expect reverse order of construction, per scope; members in reverse declaration order
A line printed before the delegating constructor's body delegation: the delegated-to constructor runs first and finishes

5. Remove the instrumentation. It is a diagnostic, not a design. If a class ships with std::cout in its destructor, you will meet that output in the middle of a later program's real output and waste an hour deciding it is a bug. (The course's own example programs keep the prints because they are the lesson; your Book and Member should not.)

There is no debugger or flag that replaces this

A stepping debugger will show you the same thing, one breakpoint at a time, and it is slower for this particular question. Lifetime is about order and count, and a printed trace answers both at a glance. Use the trace for lifetime questions; keep the debugger for logic.


Diagnosing four lifetime problems

Problem A — the trace does not end at zero

+ first   (live: 1)
+ second   (live: 2)
- second   (live: 1)

live ended at 1. One object was constructed and never destroyed — which at this point in the course means almost exactly one thing: it was created with new. Nothing else survives its scope.

Book* book = new Book(1001, "C++ Primer");   // no delete anywhere

The fix is delete book; — or, far better, Book book(1001, "C++ Primer"); and no pointer at all. Module 9 asks whether you needed that lifetime to outlive the scope; if the answer is no, the stack is the answer. If it is yes, Modules 10 and 11 give you something that releases it for you.

Problem B — the trace shows a copy where the source does not

c second (copy)   (live: 3)
  consumeByValue received 3 live object(s)
- second (copy)   (live: 2)

One construction and one destruction around the call came from nowhere — unless you count the parameter:

void consumeByValue(Tracked item) {   // `item` is a copy. This line IS the `c`.

The fix depends on what the function does:

The function's job Fix
Only reads the object void consume(const Tracked& item) — no copy
Needs its own copy to modify Keep it, and know why: the copy is part of the contract
Genuinely takes ownership Module 10 — the parameter should be an rvalue reference or a smart pointer

Do not "fix" this by making the parameter a const& everywhere by reflex. A by-value parameter is sometimes exactly right, and the rule is "know which you meant", not "always pass by reference".

Problem C — a member is garbage, with no error message

There is no error here, which makes it the worst case in the module:

class Rectangle {
public:
    Rectangle(int width, int height) : width_(width), height_(height) {}
    int area() const { return width_ * height_; }
private:
    int width_;
    int height_;
    int label_;              // ← never initialised by anything
};

-Wall -Wextra does not warn about label_ — the class has a user-provided constructor, so the compiler assumes you know what you are doing. The two defences:

  • Member initialisers on every member (int label_ = 0;), which makes the generated behaviour of any constructor sensible and removes the whole class of problem.
  • A trace of the object's state, if the value genuinely comes from somewhere: print each member in the constructor and compare with what you passed.

The general lesson, and it is why this module precedes Modules 10 and 11: an uninitialised member is not a compile error, and it is not a crash. It is a value that will eventually be used somewhere else, in code you will be debugging later.

Problem D — a crash with no location

At this point in the course you should not be able to produce one, but Example 5 of Worked examples explains the class that will:

free(): double free detected in tcache 2
Aborted (core dumped)

Two objects destroyed the same resource, because the compiler's generated copy constructor copied a pointer. There is no source location in the message because the bug is in the absence of code — no destructor, no copy constructor, no copy assignment. Module 10 walks through reproducing, diagnosing and fixing it.

If you do meet it early, the diagnosis is: find the class that owns a raw pointer, and count its copies. Module 10 formalises it, and the sanitizer below will point at the second free.


The escalation ladder

Same discipline as Module 2: do not climb until the rung below has been ruled out. Module 3's bugs are worse at wasting time than Module 2's, because there is no message to argue with — so people jump straight to "it must be the compiler" or to rewriting the class.

Rung 1 — Read only the first error

Still true. Constructor errors cascade badly: one wrong member produces no matching function, then use of deleted function, then two more. Fix the first.

Rung 2 — Go to the line, then the line above

In constructors specifically: the named line is where the object was used, and the mistake is usually in the initialiser list or a member declaration. The note: lines are where the answer is — they name the member ('const int A::v_' should be initialized) or the candidate overload you actually wanted.

Rung 3 — Predict, instrument, compare

This page's technique. Reach for it whenever the question is "when?" or "how many?" rather than "why won't it compile?".

Rung 4 — Delete code until it compiles or stops misbehaving

The minimal reproduction, and specifically valuable for constructors: strip the class to the one member and one constructor that misbehaves. Half the time the bug is a member you had forgotten was being initialised in a different order.

class Minimal {
public:
    Minimal() : a_(1), b_(a_) {}   // the list says a_ first...
private:
    int b_;                        // ...but b_ is DECLARED first, so b_ is initialised
    int a_;                        //    first, from an a_ that does not exist yet
};

Ten lines like that answer the question where the original class does not: the trace prints b_ as garbage, the compiler warns (-Wreorder, and usually an uninitialised-use warning as well), and you have the whole mechanism in view at once.

Rung 5 — Ask the compiler a different question

g++ -std=c++17 -Wall -Wextra -c main.cpp            # compile only: is this a link error?
g++ -std=c++17 -Wextra -Wreorder main.cpp           # is my list order the problem?
g++ -std=c++14 -Wall -Wextra main.cpp               # does the standard change the trace?
g++ -std=c++17 -Wall -Wextra -fdiagnostics-all-candidates main.cpp   # which overload won?
g++ -std=c++17 -fsanitize=address,undefined main.cpp -o prog         # see below

The sanitizer build is the single most useful addition to this list, and it is worth knowing what it does before you need it: AddressSanitizer reports use-after-free, double-free and heap overflow with a source location and a stack trace; UndefinedBehaviorSanitizer reports signed overflow, null dereference and misaligned access. Both turn silence into a message.

The report has this shape — the failing operation first, then the stack that reached it (shown here as the shape of the output, not as a run from this repository):

=================================================================
==12345==ERROR: AddressSanitizer: attempting double-free on 0x602000000010
    #0 0x... in operator delete[](void*)
    #1 0x... in Catalogue::~Catalogue()
    #2 0x... in main

Note this is a build change, not a source change. If your toolchain refuses to link a sanitized build (some MinGW installations cannot), that is a limitation of the installation, not of the technique — the course's continuous integration runs these on Linux, and the flags are the same.

Rung 6 — Say the lifetime out loud

"first is constructed here. second here. I pass second by value, so a copy exists during the call and is destroyed when the function returns. Then inner is constructed inside the braces and destroyed at the closing brace. At the end of main, second is destroyed, then first."

Saying it in order, out loud, catches the two errors that reading does not: a copy you forgot, and an order you assumed rather than derived.

Rung 7 — Ask somebody, with your prediction attached

The best prepared question for this module includes your written prediction, the trace, and the difference you cannot explain:

"I predicted the temporary would be destroyed before the function returns; the trace shows it destroyed after. Here are both, and here is the fifteen-line program. What is my model missing?"

That question gets a precise answer in one reply. "Why is my destructor called twice?" gets a guess.

Rung 8 — When it is not about lifetime at all

Check you are asking the right question before climbing further:

Symptom Actually about
Wrong output, objects fine your logic — print the values, not the lifetimes
Crash inside a constructor a member used before it exists (Rung 4), or a bad argument
Program exits silently main finished, or a loop condition was false immediately
Works on one machine, not another undefined behaviour — read the trace, then Rung 5's sanitizer

A debugging log, and why to keep one

Two or three lines per problem. Module 2 gave the shape; here is what Module 3's entries look like:

### Trace ended at live: 1 instead of 0
- Question: which object was never destroyed?
- Prediction: 2 constructed, 2 destroyed, counter back to 0
- Trace: only `second` destroyed at the end of main
- Cause: `Book* book = new Book(...)` in the loop — no delete, and the loop re-assigned
  the pointer, so the previous object was unreachable
- Fix: `Book book(...)` on the stack — never needed to outlive the iteration
- Time: 12 minutes. Instrumenting took 2 of them; believing my own prediction took 10.

The last line is the point of the exercise. In this module, most of the time is spent arguing with a prediction that was wrong, and that argument is where the learning is.


What good looks like, at this point in the course

  1. You write a prediction before instrumenting, and your predictions are getting closer to the trace — which is the measurable progress this module offers.
  2. You instrument with four lines and remove them afterwards, rather than leaving prints in a class.
  3. You check the live counter. A trace that does not end at zero is a bug, every time, and you now look for it by default.
  4. You can name, for any object in a program, when it is constructed and when it is destroyed — including members (reverse declaration order) and temporaries (end of the full expression).
  5. You reach for -fsanitize=address,undefined before rewriting a class, because the sanitizer names the second free and guessing does not.

Next: Exercises — three tiers, each stating its level and what success looks like. · All modules