Skip to main content

Trace it before you run it

Level 5: Level 5: Judging Code You Did Not Writemedium22 mincode readingtracingboundary analysisverification

Read unfamiliar code for its contract, hand-trace one ordinary input, then hand-trace the boundary.

The reflex to unlearn

Someone hands you a function. It might come from a pull request, a teammate, a Stack Overflow answer, or an assistant that produced it in two seconds. The reflex most people have is to run it on the example they already have and, when the output looks right, move on.

That reflex is exactly backwards for generated code, because generated code is fitted to the example you described. The example is the one input it is almost guaranteed to handle. Running it proves the thing you already believed.

The habit that finds real bugs is: read the contract, hand-trace one ordinary input, then hand-trace the boundary input, and only then run it. Tracing costs you thirty seconds and it is the only part of the process where you build a model of what the code actually does rather than what it was supposed to do.

Step 1: recover the contract

Before you read a single line of the body, write down what the function is supposed to return, in one sentence, including what happens for the awkward inputs. The name and the docstring are claims, not facts, so state the contract in your own words.

def page_count(total_items, per_page):
    """How many pages are needed to display total_items items."""
    return total_items // per_page + 1

Contract in your own words: given a number of items and a page size, return the number of pages needed to show all of them. Zero items needs zero pages. Ten items at ten per page needs one page.

Notice that you just wrote down two boundary claims that the docstring never made. That is the point of the exercise.

Step 2: trace one ordinary input

Pick an input the author clearly had in mind and walk the body line by line, writing down the value of every name as it changes. For page_count(25, 10): 25 // 10 is 2, plus 1 is 3. Three pages for twenty-five items at ten per page is right.

So far the function looks correct, which is the normal outcome of tracing an ordinary input. Ordinary inputs are not where code fails.

Step 3: trace the boundary

Now pick the inputs at the edges of the contract you wrote down. For anything that divides, the interesting boundaries are: exactly divisible, one below, one above, and zero.

page_count(9, 10)    # 9 // 10 is 0, plus 1 is 1.   One page. Correct.
page_count(10, 10)   # 10 // 10 is 1, plus 1 is 2.  Two pages. WRONG.
page_count(11, 10)   # 11 // 10 is 1, plus 1 is 2.  Two pages. Correct.
page_count(0, 10)    # 0 // 10 is 0, plus 1 is 1.   One page for nothing. WRONG.

The + 1 is a fix for the common case that quietly breaks the exact-multiple case. This is the single most common shape of bug in generated code: a correction that is right for most inputs and wrong at the boundary it was meant to handle.

Check yourself
For page_count(total_items, per_page) above with per_page = 4, what is the smallest total_items of 1 or more where the function disagrees with the contract?

The trace table

For anything with a loop, a table beats squinting. One column per name, one row per iteration. Write the row before you look at the next line, not after.

def running_total(amounts):
    total = 0
    seen = []
    for amount in amounts:
        total += amount
        seen.append(total)
    return seen

For amounts = [5, -2, 7]:

iterationamounttotalseen
start0[]
155[5]
2-23[5, 3]
3710[5, 3, 10]

Three rows and you know the function accumulates rather than replaces, that negative amounts are handled, and that an empty input returns an empty list rather than crashing. None of that was obvious from the name.

Check yourself
An assistant wrote this: def running_peak(values): best = 0; out = []; for v in values: best = max(best, v); out.append(best); return out. Which input first shows that it disagrees with 'out[i] is the largest value in values[:i+1]'?

What to write down while you read

Four notes, every time, before you run anything:

  1. The contract, in your own words, including the awkward inputs.
  2. The types each parameter is assumed to be. Generated code often assumes a list where a string could arrive, or an int where a float could.
  3. The boundaries the body implies: any division, any index, any comparison, any accumulator seeded with a literal.
  4. What is mutated. A function that sorts its argument in place changes the caller's data.

Then run it, on the boundary inputs first.

Check yourself

Each line below appears in a function you are reviewing. Sort each one by the boundary it forces you to check first.

return sum(values) / len(values)
return values[0]
return total // size + 1
return max(counts) / min(counts)

Interview nuance: when an interviewer pastes code and asks "what does this do," they are not testing whether you can run it. They are watching whether you name the contract first, then reach for the boundary rather than the example. Saying "this looks like a ceiling division, so I want to see what it does when the numbers divide exactly" is a stronger answer than any correct output you could recite.

Check yourself
You are reviewing a generated function and you have thirty seconds. What is the highest-value thing to do with them?
Worked example (Python)
def page_count(total_items, per_page):
    """How many pages are needed to display total_items items."""
    return total_items // per_page + 1


# Trace the boundary before you trust it.
for total in [9, 10, 11, 0]:
    print(total, "items ->", page_count(total, 10), "pages")

Apply

Your turn

The task this lesson builds to.

Write a function smallest_broken_total(per_page) that returns the smallest total_items of 1 or more for which the page_count function in the starter disagrees with its contract.

The contract: page_count(total_items, per_page) must return the number of pages of per_page items needed to show total_items items, so 9 items at 10 per page is 1 page and 10 items at 10 per page is also 1 page.

Do not fix page_count. Your job is to find the input that exposes it. Keep smallest_broken_total as the last function in the file.

3 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Your team's habit tracker reports the longest run of consecutive days a user logged an activity, and an assistant wrote the longest_streak function in the starter. It passed the two examples in the ticket, so it shipped. Support is now getting reports that some streaks read low.

Write a function first_failing_case(cases) that takes a list of day-lists and returns the index of the first day-list on which longest_streak disagrees with its contract, or -1 if it handles all of them.

The contract: longest_streak(days) returns the length of the longest run of consecutive True values in days, and 0 for a list with no True in it. Keep first_failing_case as the last function in the file.

3 hints and 4 automated checks are waiting in the workspace.