Debugging

This is a printer-friendly version. It omits exercises, optional topics (i.e., four-star topics), and other extra content such as learning outcomes.

What

The debugging topics of this textbook draw substantially on The Debugging Book by Andreas Zeller et al., in particular its terminology of defects, infections, and failures, and its treatment of debugging as an application of the scientific method. Further material was adapted from the debugging readings of MIT 6.102, UC Berkeley CS61B, Stanford CS107, UW CSE 332, and CMU 15-213.

Debugging is the process of finding the cause of a known problem in a program, and fixing it. It starts after you know something is wrong — whether a test, a user, or monitoring exposed it; finding that the problem exists is a separate activity. The hard part is usually the diagnosis rather than the correction: once you understand why a program misbehaves, the edit itself is often a single character — though choosing which edit is a decision in its own right.

To debug well, distinguish four things that beginners tend to lump together as 'the bug':

  • A mistake is the human (or AI) act that started it all.
    Example You misremembered that list indices start at 1.
  • A defect is the resulting error in the code. This is what most people mean by 'a bug'.
    Example A loop that starts counting from the wrong index.
  • An infection is the resulting error in the program state at run time. When the defective line executes, some variable now holds a wrong value.
  • A failure is the externally visible wrong behavior.
    Example A total shown to the user that is too small, or a crash.

These four form a chain, and each link can be far from the next:

mistake → defect → infection → infection → ... → failure
          (code)   (state)      (state)          (behavior)

Debugging is therefore a search, not a lookup: you observe the failure but must fix the defect. The infection spreads as the wrong value is passed on, stored in a field, or used to compute another wrong value, so where the program crashed is usually not where the mistake was made. The chain also explains why bugs hide: a defect infects the state only when that line executes, and an infection becomes a failure only if it propagates out to something observable. A defect can sit in daily-executed code for months unnoticed.

The chain assumes the fault lies in code — the common case, but not the only one. A failure can equally originate in configuration, in data left by an earlier version, in a dependency, in the deployment, or in a requirement that was wrong to begin with. Sometimes the program is right while the test is wrong — the test may hold its own defect, or the expectation it encodes may never have been correct. Only an error in the code is a defect; where the fault lies elsewhere, call it the cause. Either way the search is the same: locate whatever has to change.

Example A running example, reused throughout the related debugging topics of this textbook. A shopping cart prints the correct total, but then appears empty.

class Cart {
    private final List<Item> items = new ArrayList<>();

    void add(Item item) {
        items.add(item);
    }

    List<Item> getItems() {
        return items;
    }

    int computeTotal() {
        int total = 0;
        List<Item> pending = getItems();
        while (!pending.isEmpty()) {
            total += pending.remove(0).price();
        }
        return total;
    }
}

Take the cart's intended contracts to be these: computing a total must not change the cart, and getItems() lets callers read the items without owning the list.

  • Mistake: the programmer assumed getItems() hands back a copy.
  • Defect: getItems() hands out the live internal list, which computeTotal() then empties — breaking both contracts at once.
  • Infection: after computeTotal() returns, the cart's own items list is empty.
  • Failure: the next attempt to display the cart shows nothing.

A debugger stopped at the failure would be pointing at the display code, which is correct.

Why debugging is hard

Debugging consumes a large share of real development effort, and a single stubborn defect routinely costs more than writing the code it hides in. Beginners tend to read time spent debugging as evidence that they are bad at programming. It is not; debugging is a distinct and learnable engineering skill.

Three things make it hard:

  • The distance between defect and failure: the crash site is not the crime scene, so the instinct to study the code around the error message is often the least productive move available.
  • You cannot inspect everything — a running program holds an enormous amount of state, changing at every step, and choosing which small part to look at is most of the skill.
  • Your mental model of the code is exactly the thing that is wrong: had you understood it correctly you would not have written the defect, so re-reading with the same assumptions reproduces the same blind spot. Hence debugging must be driven by evidence from the running program, not by reasoning alone.

Debugging time is therefore not proportional to the size of the fix. A one-character correction can cost an afternoon. The cost lives in the search, and every debugging technique aims at making that search cheaper.

How not to debug

Most unproductive debugging comes from having no method, rather than from using the wrong tool.

  • Bad Stare and hope — reading the code and waiting for the bug to reveal itself. This inspects the code but not the state, using the mental model that wrote the defect. Fine as a 30-second first try; a poor plan for the next two hours.
  • Bad Shotgun debugging — changing whatever looks suspicious and re-running to see whether it helped. Each run teaches you nothing: a change that does not fix the problem has neither confirmed nor eliminated any explanation, and unrelated edits accumulate.
  • Bad Debugging into existence — mutating the code until the symptom disappears. The symptom often vanishes because a second defect cancels the first, leaving two bugs and a harder problem later. The difference from shotgun debugging is the stopping rule: here you stop when the symptom goes away, having never identified a cause.
  • Bad Fixing the symptom instead of the cause. The failure goes away and the defect stays.
    Example Special-casing the input that fails, or wrapping the crash in an empty catch block.
  • Bad Keeping no record of what you tried. Without notes you will re-test explanations you already eliminated, lose your place when interrupted, and be unable to hand the problem over.

Adding temporary print statements is not necessarily bad; doing so without a hypothesis is — that is shotgun debugging in another form. A few prints chosen to answer a specific question are legitimate, and in production, embedded, or concurrent settings they are sometimes the only tool available.

What these have in common is that they produce activity without producing information. A productive debugging step is one that rules something out.