← C++, End to End

C++, End to End

Debugging C++ Programs

Bugs in C++ split into three categories that need genuinely different tools to find, and misdiagnosing which one you have wastes time.

  • Compile errors: the code doesn't parse or type-check. The compiler always tells you, though not always where you'd expect (a missing semicolon on line 10 often shows up as an error on line 11, since that's where the parser finally gave up).
  • Logic errors: the code compiles and runs, but produces the wrong answer. The compiler cannot help you here by definition, since as far as it's concerned nothing is wrong.
  • Runtime errors: the code crashes, hangs, or triggers undefined behavior (out-of-bounds access, use of an uninitialized value, dereferencing a null or dangling pointer). Sometimes the OS kills the process outright (segmentation fault); sometimes, worse, it just silently produces garbage and keeps going.

Reading compiler errors and warnings

Compiler output reads bottom-to-top less often than you'd think for C++; usually the first error listed is the real one, and everything after it is the compiler getting confused by the fallout from that first error. Fix the first error, recompile, and see how much of the rest disappears before chasing the second one.

c++ · live, editable, runnableOpen in Compiler Explorer ↗

Run that: a missing semicolon after `int x = 5` produces a real error, but notice where the compiler reports it. It often points at the following line, since that's where it first found something that didn't fit its expectations.

Warnings deserve equal attention. -Wall -Wextra (on GCC and Clang) turn on the checks that catch the most common real bugs: comparing signed and unsigned integers, an unused variable, a switch statement missing a case, a function that might not return on every path. A codebase that compiles clean with -Wall -Wextra -Werror (the last flag turns warnings into hard errors) catches an enormous number of bugs before they ever run.

Every example in this course assumes -Wall -Wextra is on. Several examples later in this course are specifically designed to trigger a warning, because seeing the warning is half the point.

Assertions

An assertion states something you believe must always be true at that point in the program, and crashes immediately (with a message naming the file and line) if it isn't. assert lives in <cassert>.

c++ · live, editable, runnableOpen in Compiler Explorer ↗

An assertion is not error handling. It doesn't recover, it doesn't let the caller do anything, it just stops the program with a clear message pointing at the exact broken assumption. That's precisely the point: it's a tool for catching your own bugs during development, not for handling bad input a real user might legitimately provide (that's what actual error handling, covered in a later chapter, is for). In release builds, NDEBUG typically strips asserts out entirely, so never put code with side effects inside one; if the assert vanishes, that code vanishes with it.

Using a debugger

Every mainstream debugger (gdb, lldb, or whatever your IDE wraps around one of those) gives you the same handful of core tools, described here generically since the concepts transfer directly regardless of which one you use:

  • Breakpoints: mark a line where execution should pause so you can inspect the program's state at exactly that moment, instead of guessing from output.
  • Stepping: once paused, step into a function call to follow it line by line, step over one to run it as a black box and stop right after, or step out to finish the current function and pause back in its caller.
  • Watching variables: inspect (and in most debuggers, edit) the current value of any variable in scope, at the exact paused moment, rather than adding a print statement and rerunning.
  • The call stack: see the full chain of function calls that got you to the current paused line, which is often the fastest way to understand how you ended up somewhere unexpected.

The single highest-value habit: when something crashes, don't immediately start guessing. Set a breakpoint as close to the crash as you can, run it, and actually look at the values in scope. Most "impossible" bugs turn out to be a variable holding a value you didn't expect, and a debugger shows you that directly instead of you having to infer it.

Print debugging, done well

Debuggers aren't always available or convenient (a multi-threaded race condition, timing-sensitive code, a remote embedded target), and print debugging remains a completely legitimate tool. The difference between print debugging that wastes an afternoon and print debugging that finds the bug in ten minutes is usually just discipline:

  • Print the value AND a label. "5" tells you nothing an hour later; "x after the loop: 5" does.
  • Print at the boundary of the suspect region, not scattered everywhere. Bisect: if the value is right at the start of a function and wrong by the end, the bug is somewhere in that function, and you narrow further from there.
  • Remove the prints once you're done, or better, guard them behind a debug flag from the start so they don't linger in production output.

Try it yourself: write a function that's supposed to return the largest of three ints, deliberately introduce a bug (compare only two of the three), then find it two ways: once by adding print statements at each comparison, once by stepping through with a debugger if you have one available locally. Notice which one gets you there faster for this particular kind of bug.