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:
- Print the name as well as the value.
std::cout << sumgives you a number floating in space.sum =in front of it means you can still read your own output ten lines later. - Print inside the loop, not after it. The interesting thing about a loop is how the values change; one print at the end shows you only the wreckage.
- Indent debug output, or mark it somehow. Two leading spaces is enough to tell your temporary lines from the program’s real output at a glance.
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:
| Platform | Command |
|---|---|
| macOS | lldb ./buggy |
| Windows (MSYS2) and Linux | gdb ./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:
| Command | What it does |
|---|---|
b file.cpp:7 | stop when you reach line 7 |
run | start the program |
p name | print a variable |
n | run this line, stop on the next |
c | carry on until the next breakpoint |
bt | show how you got here |
quit | leave |
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
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:
- Predict. Which function is wrong, and what did you expect it to say?
- Find it by printing. Add lines inside the loop, run, read, remove them.
- Undo your prints. Find it again with a breakpoint,
p, andn. - 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.