Video Coming Soon...

Created by Zed A. Shaw Updated 2026-04-18 04:15:27

26: The Way of the Print

The term "debugging" means to identify and resolve defects in your code. The style of debugging I use is called "print debugging." Print debugging is where you resolve bugs by printing data with simple println calls. To find a bug, you figure out where to look, print variables, use that information to find possible clues, and continue until you find the bug.

Some programmers look down on print debugging as if it's low class, but print debugging has many advantages over their fancy tools:

  1. It works, all the time. There's many situations where you can't use a fancy debugger but print debugging will always be by your side.
  2. Anyone can use it. Some debuggers are incredibly weird, dificult to use, have strange UIs, and require specific compiler configurations to work.
  3. It works on every OS. Do you want to use RADDebugger? Good luck if you're on Linux. Hell, good luck if you're not using MSVC++ in the exact configuration required.
  4. It works with every language. Every programming language has some way of printing data to the screen, so you'll always have a debugger.
  5. It is persistent. This is a huge advantage over a visual debugger. When you add a print line it stays until you remove it. Visual debuggers are notorious for "forgetting" all of your settings each time you run them.
  6. It's a log, not a snapshot. Visual debugging is a snapshot of your code at a point in time, but typically you need a history of events leading to a defect to find it. Defects are rarely found in isolation.

There are a few situations where print debugging is not the best solution:

  1. If you have a crashing program with no clue about the cause then it's generally easier to attach a visual debugger to it and observe the crash. This also works on servers in production and uses minimal resources.
  2. Getting a stack trace from C++. It's way easier to run your code under gdb like normal and when it does get a trace.

Other than that, good old easy to use always viable println will be the winner.

Debugging is Impossible to Teach

Before we begin I need to stress that it's almost impossible to teach debugging. Debugging is a weirdly situational activity that is usually unique to every situation. You do learn to spot common mistakes, patterns, and clues over time, but the only way you really learn these is through experience. You have to debug a ton of terrible code to build the skill.

Thankfully, you are going to be great at making a ton of really terrible code you'll have to debug. All you need to do is keep programming, keep debugging your code, and eventually you'll get better at debugging and also at writing code that avoids common defects.

How to Print Debug

Print debugging is simply using println or a similar logging system to log variables and functions at specific points in your code. Your goal is to gather information about the code to figure out why you have the defect, or in many cases to even figure out what the defect is. For example, if I have this code:

void count_things(int i) {
    // some possibly bad code
}

Then I would want to add a print for the entry to the function and exit like this:

void count_things(int i) {
    fmt::println(">>> count_things i={}", i);
    /// some possible bad code
    fmt::println("<<< count_things");
}

When you run this you'll be able to see the following:

  1. When count_things enters and exits. This is a good way to narrow down where the bug may be. Let's say you believe the code crashes in the /// some possible bad code part. Then if you have these bounding log messages you can know that true. If you still see these logs then you know it's not crashing there and can move on to another location.
  2. What count_things is receiving. Almost always your defects are caused by passing a bad value to a function or using those values incorrectly. This will help you figure out both of these problems by showing you what the count_things is receiving for i.

Doing this you can adopt a process of elimination that's effectively the Scientific Method:

  1. You observe how the program is failing and try to gather information about possible causes.
  2. You pick one possible cause as a hypothesis to test. Just guess where it is and what might be happening.
  3. You sprinkle println calls at the places you believe have the bug to gather more information to either support or refute your hypothesis.
  4. If your data supports the hypothesis then you change your code to fix it. If that works then job done.
  5. If the data doesn't support your hypothesis then you can remove it and start at 1 again.
  6. Sometimes you're also keeping these prints so you can slowly narrow down where the bug is in the code. Think of it like trying to corner a spider so you can drop a cup on it to take outside. You use print in successively smaller places until you figure out where the bug is and then it's pretty easy to fix from there.

The way I think about this is I'm constantly asking, "What's happening?" Then making a guess as to where that may be in the code, and slowly adding prints until I narrow it down to one location. If you ever watch me code you'll see that I keep repeating the problem while I think. I keep saying, "It crashes when I call it with 6." Over and over, but I'm actually running through all the things that can make that happen, then I get an idea, put some println there, and see if I'm right.

One additional thing you do is to follow Sherlock Holmes, "When you have eliminated the impossible, whatever remains, however improbable, must be the truth." If I've gone through and shown that it can't be all obvious or impossible causes then I start to look at really weird ones. Strangely it actually does sometimes end up being something totally stupid like that, so if you've exhausted everything check for strange things like...you forgot to save the file. Or, you misspelled one word.

Trust me, it's many times something as dumb as that.

The Code for This Lesson

You'll use this code in this exercise and the next. Get it to work but don't study it too much as it does have a bug. We'll try to find the bug using print debugging next.

View Source file ex26_crasher.cpp Only

#include <fmt/core.h>
#include <vector>

void print_number_at(const std::vector<int>& numbers, size_t at) {
  fmt::println("number: {}", numbers[at]);
}

void print_numbers(const std::vector<int>& numbers) {
  for(size_t i = 0; i != numbers.size(); i+=3) {
    print_number_at(numbers, i);
  }
}

int main() {
  std::vector numbers{1,2,3,4};
  print_numbers(numbers);
}

Running the Code

When you run this code you'll see it crash like this:

number: 1
number: 4
/usr/include/c++/15/bits/stl_vector.h:1282: constexpr std::vector<_Tp, _Alloc>::const_reference std::vector<_Tp, _Alloc>::operator[](size_type) const [with _Tp = int; _Alloc = std::allocator<int>; const_reference = const int&; size_type = long unsigned int]: Assertion '__n < this->size()' failed.
Aborted (core dumped)

This is on Linux but it'll be similar on Windows and OSX...if you're lucky. Sometimes programs simply crash and do nothing useful. We have two choices at this point:

  1. Use gdb to get a stack trace to see where we need to look.
  2. Make a guess at the cause and start printing there.

First we'll try to track down the bug by making a guess and printing until we find it.

Making Educated Guesses to Start

Remember that this is all fake. In actual debugging situations you'll have to make several guesses and investigate multiple possible causes before you can even get started.

The first thing you notice that it prints number: two times for number 1 and 4, and then crashes. You can then make a few guesses for the cause of the crash:

  1. The print_numbers function is passing the wrong values to print_number_at.
  2. The print_number_at function is using those values incorrectly.

Most of your problems will be one of these two, so tracing how functions receive and use their parameters is a good start.

Trying the first guess, you can change print_numbers to this:

void print_numbers(const std::vector<int>& numbers) {
  for(size_t i = 0; i != numbers.size(); i+=3) {
    fmt::println("#### print_numbers: numbers.size={} i={}", numbers.size(), i);
    print_number_at(numbers, i);
  }
}

With this change we these messages right before the crash:

### print_numbers: numbers.size=4 i=0
number: 1
#### print_numbers: numbers.size=4 i=3
number: 4
#### print_numbers: numbers.size=4 i=6
...CRASH...

It looks like we're passing i correctly, but is i the right size? It's obvious that we're using i wrong here, bet let's pretend you believe that print_number_at should be able to handle i when it's outside of numbers.size(). Since it still crashes, and you're passing the "right" index i it must be the other function.

Change print_number_at to this:

void print_number_at(const std::vector<int>& numbers, size_t at) {
  fmt::println(">>>>> print_number_at: numbers.size={} at={}", numbers.size(), at);
  fmt::println("number: {}", numbers[at]);
  fmt::println("<<<<<< print_number_at");
}

Here you are adding a message that has >>>>> and <<<<< so you can see the entry and exit of the function clearly to see if, maybe it does actually exit and something else is going on. If you rerun this and don't see that <<<<< print_number_at then it's crashing in print_number_at.

If we run this version we see this:

#### print_numbers: numbers.size=4 i=0
>>>>> print_number_at: numbers.size=4 at=0
number: 1
<<<<<< print_number_at
#### print_numbers: numbers.size=4 i=3
>>>>> print_number_at: numbers.size=4 at=3
number: 4
<<<<<< print_number_at
#### print_numbers: numbers.size=4 i=6
>>>>> print_number_at: numbers.size=4 at=6
...CRASH...

Now we can trace this easily, and it says:

  1. print_numbers runs fine.
  2. print_numbers passed i of 0, then 3, then 6 to print_number_at.
  3. When i is 0 or 3, it's fine. You see the entry and exit for each.
  4. It's when i (aka at in print_number_at) that you get the crash. You can also see that it doesn't exit since there's no <<<<<< print_number_at at the end.
  5. You can also clearly see that numbers.size() is always 4, so you can rule out any problems with it being wrong.

This means your assumption about print_number_at preventing bad input is wrong. It's getting i=6, that's setting at=6, and since numbers only has 4 elements you get a crash.

Now you can fix it.

Using gdb to Start

The above process is the basics, but it's way more helpful to have a stack trace when the program crashes. In most languages getting a stack trace is trivial, but in C++ you have to use a debugger to get a good one. No matter how good print debugging is it simply can't fix how programs crash at the OS level.

To get a stack trace from your code with gdb run it like this:

gdb --nx --batch --ex run --ex bt --ex q --args ex26_crasher

When you do you'll get this output after the crash:

Program received signal SIGABRT, Aborted.
....
#6  0x0 in std::vector<int, std::allocator<int> >::operator[] (this=0x7fffffffd7f0, __n=6) at /usr/include/c++/15/bits/stl_vector.h:1282
#7  0x0 in print_number_at (numbers=std::vector of length 4, capacity 4 = {...}, at=6) at ../ex26_crasher.cpp:5
#8  0x0 in print_numbers (numbers=std::vector of length 4, capacity 4 = {...}) at ../ex26_crasher.cpp:10
#9  0x0 in main () at ../ex26_crasher.cpp:16
A debugging session is active.

I cut out everything above (after) __glibcxx_assert_fail as that's when the stack heads into the C++ library. From this you can see that it crashes in this path:

You can also see what's passed to print_number_at with this part:

print_number_at (numbers=std::vector of length 4, capacity 4 = {...}, at=6)

This is saying that numbers is a vector of length 4, and at=6 so right away you can see you're passing the wrong value.

With this information you can jump right to printing out the parameters to print_number_at to see how it's being used and make the fix from there. This saves you a lot of time if you can use it.

Why Not Use gdb All The Time?

You can, and in the next exercise I'll show you how, but it's really annoying. It's best used for things like this. Getting a stack trace is really easy. Pausing your code at a crash spot to see what the variables are is also really easy with gdb. It's a good tool to get more information before you start logging what's going on.

Where gdb falls apart is after that when you have to see how everything is being called, how one function goes into another, and how variables are changing over time. This is the information that print debugging excels at, so use both. You need all the help you can get after all.

Previous Lesson Next Lesson

Register for Learn C++ the Hard Way

Register to gain access to additional videos which demonstrate each exercise. Videos are priced to cover the cost of hosting.