skip to content

Why is `return 1 + count(rest)` not a tail call, while `return count(rest, n + 1)` is?

level: juniorimportance: must knowfreq 60%

answer

  1. ask what happens after the call returns
  2. does the caller still need its own frame
  3. textually last is not last work
  4. one pending addition disqualifies it
  5. carry the running count in a parameter

basics

~20 s

A call is in tail position only when the caller returns its result directly, with nothing pending afterwards. The added one runs after the call returns, so its frame must survive; carrying the count as an argument removes that work.

solid answer

~40 s

Tail position means the recursive call's result becomes the enclosing call's result unchanged — the caller has nothing left to do once it returns. In `return 1 + count(rest)` the addition is scheduled to run *after* the call comes back, so the caller must be resumed and its frame stays alive; the call is an ordinary nested call that happens to be written last. The accumulator version moves that addition into the argument list, where it runs *before* control transfers, and returns the recursive result untouched — so the caller holds no state anyone will read again. The general rule: textually last is not the same as last work. Any operator, wrapper, comparison or still-open exception handler around the call disqualifies it.

code

pseudocode · 13 lines
pseudocode
COUNT-A(entries)
    if empty(entries)
        return 0
    return 1 + COUNT-A(rest(entries))      // addition pending: NOT a tail call

COUNT-B(entries, n)
    if empty(entries)
        return n
    return COUNT-B(rest(entries), n + 1)   // nothing pending: tail call

// call sites
total_a = COUNT-A(entries)
total_b = COUNT-B(entries, 0)

go deeper

for a junior

Be ready to point at a return statement and say whether the recursive call is in tail position, and to explain the accumulator rewrite that puts it there. Know that the test is what happens after the call returns.

for a middle

Explain why pending work forces the caller's frame to stay alive, and name the disqualifiers beyond arithmetic: wrapping the result, comparing it, or leaving an exception handler open over the call.

for a senior

Show that you treat tail position as fragile under maintenance — a log line or a wrapper added after the call removes it silently, and nothing in the build tells you.

for a principal

Own the framing that tail position is a static, checkable property of code and nothing more; any claim about stack safety needs a separate statement about what the target runtime does with it.

**Tail position, precisely.** A call is in *tail position* when the value it returns becomes the value of the enclosing call directly — the caller has nothing left to do once the callee returns. "Nothing left to do" is a strong claim. No arithmetic on the returned value, no wrapping it in a container or record, no comparison, no logging afterwards, no exception handler still covering the call, no scoped cleanup waiting to run. If any of that exists, control must come back to the caller, so the caller's activation record has to stay alive while the callee runs. **The disqualifier that hides in plain sight.** Counting the entries in a ledger segment the obvious way ends with `return 1 + COUNT(rest)`. Read left to right, the recursive call looks like the last thing the function does — and that reading is exactly what trips candidates up. *Textually last* is not *last work*. The `+ 1` is scheduled to run **after** the recursive call returns. So the call is an ordinary nested call, and every level of the recursion pins one activation record until the whole chain unwinds. The general shape of the mistake: the recursive result feeds an operator, and the **operator** is the tail, not the call. **The accumulator rewrite.** The standard repair moves the pending work *before* the call, into an argument: `return COUNT(rest, n + 1)`, seeded at the top with `COUNT(entries, 0)`. Now the addition happens while the argument list is being built, and the recursive call's result is returned unchanged. Once the arguments are evaluated the caller holds no state anyone needs afterwards, so *in principle* its frame could be reused instead of stacked. That "in principle" is carrying weight: whether the frame actually is reused is a question about the runtime, not about this rewrite. **A checklist for spotting non-tail recursion in a diff.** The call is not in tail position when its result is: - consumed by an operator — `return 1 + f(rest)`, `return n * f(n - 1)`, `return f(a) + f(b)` (here *neither* call is in tail position); - passed onward to another call on the way out — `return wrap(f(rest))`; - compared or tested — `return f(rest) > 0`; - assigned to a local that is returned after further statements run; - produced inside a protected region — a call inside a `try` whose `catch`/`finally` (or scoped-resource cleanup) still covers it has pending work by definition, because the handler must be able to run; - one of several operands of a short-circuit chain: in `return f(a) or g(b)`, `g(b)` can be in tail position but `f(a)` cannot, since its value must be tested first. **Self tail calls versus general tail calls.** A tail call does not have to be recursive at all. `return other(x)` as the final act of a function is a tail call to a different function, and two functions calling each other in tail position (mutual recursion) are tail calls too. This distinction matters because implementations that do reuse frames often handle only the *direct self* case — the compiler can turn a self tail call into "overwrite the parameters, jump back to the top", which is a purely local transformation, while a general tail call to an unknown target needs a calling convention that supports it. **Why the property is worth naming.** A call in tail position is the one case where the machine could hand the callee the caller's own frame: nothing in that frame will be read again. That is the entire basis of tail-call elimination, which turns a chain of n recursive calls into something that occupies constant stack space. Everything else about tail recursion — the accumulator style, the "recursion as a loop" idiom of some functional traditions — exists downstream of this single structural property. **What tail position does not promise.** It is a *static* property of the code: a reader, or a compiler front end, can decide it by inspection, with no idea how deep the recursion will go or how much memory the process has. It says nothing about whether the frame is actually reused at run time — that is a separate contract offered (or refused) by the compiler and runtime. Candidates who conflate the two say "I made it tail-recursive, so it can't overflow"; the accurate statement is "I made it eligible for elimination, if my runtime performs it". **A note on evaluation order.** Arguments are evaluated before the call, so `COUNT(rest, n + 1)` doing arithmetic *inside* the argument list does not disqualify it — that work finishes before control transfers. The test is always about what happens *after* the callee returns, never about how much work happens before it.

  • Does a recursive call inside a try block with a finally still count as a tail call?
    No. The cleanup or handler must be able to run when the callee returns, which is pending work by definition, so control has to come back to the caller and its frame stays alive. Wrapping a recursive body in an exception-handling or scoped-resource region is a common way to silently destroy tail position while the code still looks tail-recursive.
  • Is a tail call to a different function still a tail call, or must it be recursive?
    Still a tail call. Any call whose result is returned unchanged qualifies, including mutual recursion between two functions. The distinction matters because implementations that reuse frames often handle only the direct self case — that one is a local rewrite into a jump back to the top, whereas a general tail call to another target needs calling-convention support.
  • In `return f(a) + f(b)`, which call is in tail position?
    Neither. Both results feed the addition, which runs after both calls return, so the caller must be resumed twice. This shape is why tree recursion is never tail-recursive in its natural form: at least one branch's result is always pending while the other runs.

Tail position is handing the phone call off rather than putting the other person on hold: if you still have something to say after they answer, you have to stay on the line.

saying these in an interview costs you the question

  • Says any call on the last line is a tail call
  • Thinks a single pending addition is too cheap to matter
  • Believes tail position depends on how deep the recursion goes
  • Confuses tail position with having only one recursive call site
  • Assumes arithmetic inside the argument list disqualifies the call

context