AI Coding

How to Debug a Function You Did Not Write

The function arrives as a block of somebody else's decisions. Forty lines, maybe a hundred, a name that suggests one job and a body that does three. It sits in a file with a timestamp from eleven months ago and a comment at the top that reads, without irony, "temporary."

Debugging code written by another person has a bad reputation for a reason, though the reason is usually misidentified. The hard part is not that the author was smarter or the logic more tangled. The hard part is that a reader must reconstruct intent before they can judge behavior, and intent lives in a place the code does not show: the author's head, at the moment of writing, under whatever pressure existed that day.

There is a discipline to this that can be learned. It is closer to forensics than to programming, and it starts with refusing to touch the keyboard.

Reading for intent before reading for bugs

The first pass through unfamiliar code should produce no hypotheses about what is broken. It should produce a map of what the function believes about the world.

What does it assume about its inputs? Does it check them, or does it trust the caller? Does it assume a list has at least one element, a string is non-empty, a date is in the future? Every unchecked assumption is a place where the function quietly delegates responsibility to someone else, and that delegation is often where the bug lives.

What does it promise to return? This matters more than it sounds. A function that returns an empty list on failure and a populated list on success has two meanings packed into one type, and the caller has to guess which one it received. A function that returns null in three different situations, each meaning something different, is a function that will eventually be debugged by someone who did not write it. That someone may be reading this paragraph.

The map worth drawing has three columns: what goes in, what comes out, what changes along the way. Side effects belong in the third column, and they are frequently the culprit. A function that mutates its argument, writes to a global, appends to a log, or updates a cache has reach beyond its return value, and that reach is invisible at the call site.

The five-minute test that saves an hour

Before reading a single line closely, run the function. Give it an input whose correct output is known with certainty. A small list, a simple string, a date two days in the future. Watch what comes back.

If the output is correct, the bug is in a path the simple input did not exercise: an edge case, an error branch, a condition that only triggers with real data. If the output is wrong, the function is broken at a level that does not require understanding the whole of it, and the work shrinks considerably.

This step gets skipped constantly. Engineers open a file, start reading, form a theory, spend forty minutes confirming it, and discover the theory addressed a code path the failing input never touches. Five minutes of execution would have eliminated the theory on the first try. Reading is slower than running, and running is honest in a way that reading is not.

Print the intermediate values too. Drop a log statement at the function's midpoint, at the boundary of each branch, at the point where the return value gets assembled. The goal is a timeline: input enters, value transforms here, value transforms again, output exits. When the timeline diverges from what the code appears to say, the gap between appearance and behavior has been located.

Bisecting the body with breakpoints instead of guesses

A debugger turns a function into a sequence of observable states. Set a breakpoint at the first line, step through, and watch the variables change. The technique is old and unglamorous and it works because it replaces inference with observation.

The efficient version of this does not step through every line. It bisects. Place a breakpoint at the midpoint of the function and check whether the state there is what the code leading up to it should have produced. If it is, the bug lives in the second half. If it is not, the bug lives in the first half. Repeat. A hundred-line function falls in roughly seven checks.

Bisection works on any monotonic property. The state can be a variable, a flag, a list length, a return value built incrementally, anything that changes from the start of the function to the end. The only requirement is that the state at any point can be judged correct or incorrect independently of the rest of the function. That judgment is the actual skill, and it improves with practice the way reading a map improves.

When the state is wrong at the midpoint, the cause is in the first half, and the second half can be ignored entirely for now. When the state is right at the midpoint but wrong at the end, the cause is in the second half. The function shrinks by half at every check, and the shrinking is what makes unfamiliar code tractable.

Following the argument to its source

Many bugs in a function nobody wrote are not in the function at all. They are in what the caller passed in. A value that arrives malformed, a null that should have been an empty list, a string with trailing whitespace, a number stored as text. The function then does exactly what it was written to do with a bad input, which is usually something unhelpful.

The check is to log the arguments at the top of the function and compare them against what the function's author clearly expected. The comparison is often revealing. A function that calls .length on its argument expects an array; if the caller passes an object, the function was never going to work, and no amount of reading its body will fix that.

This is the point where a reader should be willing to blame the caller. Debugging unfamiliar code has a gravitational pull toward the unfamiliar, and the caller is often familiar code written by the person doing the debugging. That familiarity creates blind spots. The newest code in the chain is usually the least tested, which means the newest code is usually the suspect, and the newest code is often the caller, not the callee.

Treating the comment as evidence, not documentation

Comments in code written by someone else carry a specific kind of information: what the author believed at the time of writing. That belief may be accurate, outdated, aspirational, or a lie told to a code reviewer at 2 a.m. The comment is a witness statement, and witness statements are useful and unreliable in roughly equal measure.

A comment that says "handles null" is a hypothesis to test, not a fact to trust. A comment that says "this is slow, optimize later" is a pointer to a known weak spot. A comment that explains why a strange workaround exists is often the most valuable thing in the file, because the workaround looks like a bug and the comment explains that it is a defense against a worse one.

When a comment and the code disagree, the code is what runs. The comment tells the reader what the author intended; the code tells the reader what the author did. Debugging is the process of finding where those two things diverged, and the divergence is frequently the bug.

Writing the test that would have caught it

The moment the bug is found, before fixing it, write a test that fails for the same reason. The test is proof that the diagnosis is correct, and it is proof that the fix will keep the bug fixed. Without the test, the fix is a guess that happens to make the symptom disappear, which is a different and less useful thing.

This step also forces a reader to articulate the bug in a single sentence: given this input, this function returns that, when it should return the other. That sentence is the whole diagnosis, and if it cannot be written, the bug has not been understood well enough to fix.

The test belongs in the repository next to the function, not in a scratch file that gets deleted. The next person to debug this function will be someone else, and the test is the only durable record of what was learned.

Leaving a trail for the next reader

The final act of debugging a function someone else wrote is to make the function easier to debug the next time. A one-line comment at the top stating what the function assumes about its inputs. A named constant replacing the magic number that caused the confusion. A guard clause that rejects the bad input at the boundary instead of letting it corrupt state three calls deep.

These edits are small and they compound. A codebase where every function carries a rough map of its assumptions is a codebase where unfamiliar code is faster to read, because the reader no longer has to reconstruct intent from behavior alone. The map is the gift, and it costs about ninety seconds to write.

The function that arrived as a block of somebody else's decisions leaves as a block of decisions with a small, honest annotation attached. The next person opens the file, reads the note, runs the test, and finds the bug in fifteen minutes instead of an afternoon. That is the whole transaction, and it is worth making.

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button