Contents

Chapter 11

Printing, Poking, and Proving It Works

Finding a bug by hand, and then finding it in four commands.

Here is a program that works. It compiles clean, it runs, it doesn’t crash, and its answer is wrong.

#include <iostream>
#include <vector>

int total(std::vector<int> numbers) {
    int sum = 0;
    for (unsigned int i = 1; i < numbers.size(); ++i) {
        sum += numbers[i];
    }
    return sum;
}

int main() {
    std::vector<int> scores{10, 20, 30};

    std::cout << "total:   " << total(scores) << '\n';
    std::cout << "average: " << total(scores) / 3 << '\n';
}
total:   50
average: 16

Ten, twenty and thirty come to sixty. The compiler has nothing to say, because nothing here is against the rules. You asked for the wrong sum quite legally.

This is the normal case. Chapter 2 was about the compiler telling you what’s wrong; from here on, mostly nothing tells you anything, and finding out is a skill rather than an act of reading.

Read it first

Before touching anything, look at the code and ask what you’d expect. Sixty. You got fifty. Fifty is sixty minus ten, and ten is the first element.

That is often as far as you need to go, and it costs nothing. The trouble is that it works right up until it doesn’t. On a longer program, or when the number is wrong in a way that doesn’t decompose so neatly, staring produces a theory you believe and can’t check. Then you need the machine to tell you.

Printing, done properly

The oldest tool there is: make the program say what it’s thinking.

for (unsigned int i = 1; i < numbers.size(); ++i) {
    std::cout << "  i = " << i << ", numbers[i] = " << numbers[i]
              << ", sum = " << sum << '\n';
    sum += numbers[i];
}
total:   i = 1, numbers[i] = 20, sum = 0
  i = 2, numbers[i] = 30, sum = 50

There it is on the first line: i = 1. The loop never looked at numbers[0].

Three things make that output useful rather than noise, and beginners skip all three:

Printing has one real drawback, and it isn’t ugliness. Every question costs you an edit, a recompile and a rerun, and you have to know which question to ask before you ask it. On a program this size that’s seconds. It stops scaling almost immediately.

The method underneath both

Printing and breakpoints are tools. The method is the same either way, and it is worth stating on its own because it is what actually finds bugs:

Find a place where things are still right, find a place where they’re wrong, and close the gap.

In the program above, scores is right when it goes into total, and you can check that in one look, and the returned number is wrong. So the fault is somewhere between those two points, which is four lines. Look in the middle of them.

That sounds obvious written down. Under pressure almost nobody does it; they re-read the whole function hoping to spot something, which is guessing with extra steps. When you’re stuck, the useful question is never “what’s wrong with this code” but “where does it stop being right”.

It also tells you when to stop. Once you can name a line where the value is correct and the very next line where it isn’t, you’re done looking.

The debugger

A debugger runs your program and lets you stop it mid-flight and look around. Nothing gets added to your code and nothing gets recompiled.

You are already set up for it. The command from chapter 0 has carried -g since the first page:

g++ -std=c++17 -Wall -Wextra -g buggy.cpp -o buggy

That -g is what puts your variable names and line numbers into the program. Without it a debugger still runs, and shows you machine registers instead of your code, which is not a conversation you want.

Starting it depends on your platform, and it is the only part that does:

PlatformCommand
macOSlldb ./buggy
Windows (MSYS2) and Linuxgdb ./buggy

Two different debuggers, and from here on the commands in this chapter are spelled identically in both. That is not a coincidence. lldb deliberately answers to gdb’s short forms.

Four commands

Start it, set a breakpoint on the line you care about, and run:

$ lldb ./buggy
(lldb) b buggy.cpp:7
Breakpoint 1: where = buggy`total(...) + 72 at buggy.cpp:7:24
(lldb) run
Process 13441 stopped
* thread #1, stop reason = breakpoint 1.1
    frame #0: buggy`total(numbers=size=3) at buggy.cpp:7:24
   5   	    int sum = 0;
   6   	    for (unsigned int i = 1; i < numbers.size(); ++i) {
-> 7   	        sum += numbers[i];
   8   	    }
   9   	    return sum;

The program is frozen on line 7, before that line has run. The arrow marks where it is. Now ask it things:

(lldb) p i
(unsigned int) 1

That’s the bug, in one command, on the first stop. The loop’s very first pass has i at 1 and element 0 goes unread.

You can ask about anything in scope, including the whole vector:

(lldb) p numbers
(std::vector<int>) size=3 {
  [0] = 10
  [1] = 20
  [2] = 30
}

The other two commands move time forward. n runs the current line and stops again on the next one; c lets the program go until it hits a breakpoint again, which for a loop means one more pass:

(lldb) n
(lldb) p sum
(int) 20

After one full pass sum is 20, not 30. Confirmation, not a guess.

That’s the whole set:

CommandWhat it does
b file.cpp:7stop when you reach line 7
runstart the program
p nameprint a variable
nrun this line, stop on the next
ccarry on until the next breakpoint
btshow how you got here
quitleave

How did I get here

bt, short for backtrace, answers a question printing is bad at: which call led to this?

(lldb) bt
  * frame #0: buggy`total(numbers=size=3) at buggy.cpp:7:24
    frame #1: buggy`main at buggy.cpp:18:33

Read it top down as “I am in total, on line 7, and total was called from main on line 18”. With one caller that’s obvious. When a function is called from five places and misbehaves for one of them, bt is the fastest answer in the book.

When a program crashes rather than lying, this is the first thing to do: run it under the debugger, let it die, and type bt.

Exercise 1 · Break in and look around

Compile the buggy program above and start your debugger on it.

Put a breakpoint on the return sum; line and run. Print sum and numbers. Then bt to confirm you are inside total and main called you.

Now fix the loop to start at 0, recompile, and do it again. sum should be 60 when you reach the same line, which is what “proving it works” means. You saw the right value at the moment it mattered rather than inferring it from the end.

Check yourself

1. Why does -g need to be in the compile command?

Not quite. A different flag, and a different problem. -g is about what information is recorded, not how the code is generated.

Yes. Without it the debugger runs but has nothing of yours to show you, so it falls back to machine-level detail.

Not quite. Nothing finds bugs for you. The flag only makes the program describable while you look at it yourself.

2. The program stops at a breakpoint on line 7. Has line 7 run?

Yes. That is what makes it useful: you can look at the values going in. Type n to run it and see what changed.

Not quite. If it worked that way you could never inspect the inputs to a line, only its aftermath.

Not quite. It behaves the same everywhere. A breakpoint in a loop simply gets hit once per pass.

3. What does a debugger give you that printing does not?

Not quite. It shows you what is true. Working out why is still yours, and this chapter found the bug with human reasoning either way.

Yes. Printing needs you to know the question before you build. A breakpoint lets you stop first and decide what to ask once you are there.

Not quite. It debugs a built program, so a compile error has to be fixed first. Chapter 2 is the tool for those.

Project

Hunt the planted bug

Roughly 45 minutes

Type this in as stats.cpp. It reads five scores, reports the highest and the average, and gets one of them wrong.

#include <iostream>
#include <vector>

int highest(std::vector<int> numbers) {
    int best = 0;
    for (unsigned int i = 0; i < numbers.size(); ++i) {
        if (numbers[i] > best) {
            best = numbers[i];
        }
    }
    return best;
}

int average(std::vector<int> numbers) {
    int sum = 0;
    for (unsigned int i = 0; i < numbers.size(); ++i) {
        sum += numbers[i];
    }
    int count = numbers.size();
    return sum / count;
}

int main() {
    std::vector<int> scores;

    for (int i = 0; i < 5; ++i) {
        std::cout << "Score " << i + 1 << ": ";
        int score = 0;
        std::cin >> score;
        scores.push_back(score);
    }

    std::cout << "highest: " << highest(scores) << '\n';
    std::cout << "average: " << average(scores) << '\n';
}

Feed it 10 20 30 40 50 and both answers are right: highest 50, average 30. Feed it five negative numbers, -10 -20 -30 -40 -50, and you get an average of -30, which is correct, and a highest of 0, which is not a number you typed.

Do it in this order, because the point is comparing the two methods on the same bug:

  1. Predict. Which function is wrong, and what did you expect it to say?
  2. Find it by printing. Add lines inside the loop, run, read, remove them.
  3. Undo your prints. Find it again with a breakpoint, p, and n.
  4. Fix it, and prove the fix with the debugger rather than by rerunning.

The second way should take a fraction of the time, and that gap is the argument for learning it.

Stretch: the second bug. average has one too, and it does not show up on the numbers above. Feed it 1 1 1 1 2 and think about what integer division from chapter 6 does to the answer. Deciding whether that is a bug or the correct behaviour is your call to make, and worth making deliberately.