Lecture 8 — Tail recursion, stack and heap

Recursion is the workhorse of functional programming — but not all recursive declarations use machine resources equally well. In this lecture we make the cost of evaluation visible, and we meet the two standard techniques for taming it: accumulating parameters and continuations. On the way we look at how F# manages memory with a stack and a heap, because that is where the difference between a recursive and a tail-recursive function becomes concrete.

This matters beyond F#: when you review code — your own, a colleague's, or code produced by an AI assistant — "it compiles and the tests pass" says nothing about whether it survives a million-element list. Knowing why a recursive function overflows the stack, and how to see it in the shape of the code, is exactly the kind of invariant thinking this course is about.

What resources does a computation need?

Consider the factorial function:

let rec fact x =
    match x with
    | 0 -> 1
    | n -> n * fact (n - 1)

What resources are needed to compute \(\mathtt{fact}(N)\)?

Following the evaluation shows the problem. Each recursive call happens inside a multiplication, so every pending n * … has to be remembered:

fact N
~> N * fact (N-1)
~> N * ((N-1) * fact (N-2))
~> ...
~> N * ((N-1) * ((N-2) * (... (3 * (2 * 1)) ...)))
~> N!

Time and space are proportional to \(N\): the chain of pending multiplications grows as long as the recursion is deep. Is this satisfactory? For time, yes — \(N!\) needs \(N\) multiplications. For space: no, and we will see why it matters.

A worse example: naive reversal

let rec naiveRev lst =
    match lst with
    | [] -> []
    | x :: xs -> naiveRev xs @ [ x ]

Evaluation of naiveRev [x1; x2; ...; xn]:

naiveRev [x1; x2; ...; xn]
~> naiveRev [x2; ...; xn] @ [x1]
~> (naiveRev [x3; ...; xn] @ [x2]) @ [x1]
~> ...
~> ((...(([] @ [xn]) @ [xn-1]) @ ...) @ [x2]) @ [x1]

Space demand is proportional to \(n\) — satisfactory. But the running time of @ is linear in the length of its first argument, so the nested appends cost \(1 + 2 + \cdots + (n-1)\) steps: time is proportional to \(n^2\). Not satisfactory.

Accumulating parameters

Efficient solutions are obtained from more general functions, whose specifications carry an extra argument:

\[\mathtt{factA}(n, m) = n! \cdot m \quad\text{for } n \ge 0\]

\[\mathtt{revA}([x_1, \ldots, x_n],\, ys) = [x_n, \ldots, x_1]\ @\ ys\]

The original functions are the special cases \(n! = \mathtt{factA}(n, 1)\) and \(\mathtt{rev}\,[x_1,\ldots,x_n] = \mathtt{revA}([x_1,\ldots,x_n], [\,])\). The parameters \(m\) and \(ys\) are called accumulating parameters: they hold the temporary result during the evaluation.

let rec factA (x, m) =
    match x with
    | 0 -> m
    | n -> factA (n - 1, n * m)

let rec revA (lst, ys) =
    match lst with
    | [] -> ys
    | x :: xs -> revA (xs, x :: ys)

Now the evaluation stays flat — no pending operations pile up:

factA(5, 1) ~> factA(4, 5) ~> factA(3, 20) ~> ... ~> factA(0, 120) ~> 120

revA([1;2;3], []) ~> revA([2;3], [1]) ~> revA([3], [2;1])
                  ~> revA([], [3;2;1]) ~> [3;2;1]

factA runs in constant space; revA in time and space proportional to the length of the first list. The results agree with the originals:

let reversed = revA ([ 1; 2; 3 ], [])
[3; 2; 1]

Tail calls

The declarations of factA and revA are tail-recursive:

Compare the traces above with the fact trace: there is nothing to the left of the recursive call waiting to be finished.

Measuring: the #time toggle

F# Interactive has a #time toggle that reports wall-clock time, CPU time and garbage collections. Reversing a 20000-element list:

> naiveRev xs20000;;
Real: 00:00:07.624, CPU: 00:00:07.597, GC gen0: 825, gen1: 253, gen2: 0
val it: int list = [20000; 19999; 19998; ...]

> revA (xs20000, []);;
Real: 00:00:00.001, CPU: 00:00:00.000, GC gen0: 0, gen1: 0, gen2: 0
val it: int list = [20000; 19999; 19998; ...]

Seven seconds versus one millisecond — and note the GC columns: the naive version made the garbage collector reclaim over a thousand generations of dead cells; revA reclaimed none. Replacing append (@) by cons (::) is what did it. To see why, we need the memory model.

Memory management: stack and heap

let xs = [ 5; 6; 7 ]
let ys = 3 :: 4 :: xs
let zs = xs @ ys
let n = 27
xsyszs27nstack frame5 6 7 3 4 5 6 7 stackheap

Reading the picture (from the bindings above):

  1. ys = 3 :: 4 :: xs did not copy the cells of xs — its chain simply continues into them. Cons reuses; this is safe because lists are immutable.
  2. zs = xs @ ys had to make fresh cells for the elements of xs (the copies of 5, 6, 7 at z1z3), and the last one links to the ys chain. Append copies its first argument — that is exactly why its running time is linear in the first argument's length.

Stack operations: push and pop

Evaluating a let-expression with local declarations pushes a new stack frame:

let zs = let xs = [1; 2]
         let ys = [3; 4]
         xs @ ys

The evaluation of the local declarations is initiated by pushing a new stack frame sf1 holding xs, ys and the auxiliary entry result for the value of the whole let-expression:

xsysresultsf1?zssf01 2 3 4 1 2 stackheap

When the evaluation completes, the top frame is popped and zs in sf0 receives the result:

zssf01 † 2 † 3 4 1 2 stackheap

The cells marked with † are now obsolete — nothing on the stack can reach them.

Sharing across calls

The same mechanics explain why passing lists around and "modifying" them is cheap. Consider a caller with ws = [6; 7] invoking

let addFive zs = 5 :: zs

While addFive runs, its frame sf1 holds the parameter zs — pointing at the same cells as the caller's ws — and the result: one fresh cell containing 5, linking straight into the existing chain:

zsresultsf1ws?vssf06 7 5 stackheap

Nothing was copied. Where imperative runtimes need tricks like copy-on-write to make sharing safe, immutable lists get the same effect for free: sharing is always safe because no one can write. When sf1 is popped, the caller's vs refers to [5; 6; 7] — two of whose cells it already owned as part of ws.

Garbage collection

The memory management system reclaims obsolete cells behind the scenes. The .NET garbage collector partitions the heap into three generationsgen0, gen1, gen2 — by age, with gen0 the youngest. The design bet is that objects die young, and the #time output above shows it paying off: naiveRev's churn happened almost entirely in gen0/gen1.

The limits of the stack and the heap

The stack is big:

> let rec bigList n = if n = 0 then [] else 1 :: bigList (n - 1);;
> bigList 120000;;
val it: int list = [1; 1; 1; 1; ...]
> bigList 130000;;
Process is terminated due to StackOverflowException.

The heap is much bigger:

> let rec bigListA n xs = if n = 0 then xs else bigListA (n - 1) (1 :: xs);;
> let xsVeryBig = bigListA 12_000_000 [];;
val xsVeryBig: int list = [1; 1; 1; 1; ...]
> let xsTooBig = bigListA 13_000_000 [];;
System.OutOfMemoryException: ...

About \(1.2 \cdot 10^5\) stack frames versus \(1.2 \cdot 10^7\) heap cells. The iterative bigListA does not exhaust the stack — its recursive call is a tail call, so the same frame is reused. This is the concrete payoff of tail recursion, and the reason the compiler can turn it into a loop.

Iterative functions in general

Tail-recursive functions are also called iterative functions: the evaluation just iterates a step function. The general shape can itself be written as a (higher-order, tail-recursive) function:

let rec iterate p f h z =
    if p z then iterate p f h (f z) else h z

For suitable predicate p, step function f and finishing function h, an iterative function repeats f until p says stop:

iterate p f h v ~> iterate p f h (f v) ~> iterate p f h (f (f v))
                ~> ... ~> h (f^n v)        (when p (f^n v) = false)

Two desirable properties: after \(n\) steps there have been \(n\) recursive calls, at most one binding of z is active at any stage — and one stack frame suffices. factA and revA are instances:

let factA' n = iterate (fun (x, _) -> x <> 0) (fun (x, m) -> (x - 1, x * m)) snd (n, 1)

let revA' xs =
    iterate
        (fun (l, _) -> not (List.isEmpty l))
        (fun (l, ys) -> (List.tail l, List.head l :: ys))
        snd
        (xs, [])

Iteration versus while loops

Every while loop corresponds to a tail-recursive function, and vice versa. The imperative counterpart of factA, in modern F# style with a mutable local (the older ref/!/:= style is deprecated):

let factW n =
    let mutable ni = n
    let mutable r = 1
    while ni > 0 do
        r <- r * ni
        ni <- ni - 1
    r

Measurements show the tail-recursive version is not slower:

> for i in 1 .. 1000000 do factA (16, 1) |> ignore;;
Real: 00:00:00.024, CPU: 00:00:00.031, GC gen0: 0, gen1: 0, gen2: 0

> for i in 1 .. 1000000 do factW 16 |> ignore;;
Real: 00:00:00.048, CPU: 00:00:00.046, GC gen0: 9, gen1: 0, gen2: 0

There is no performance argument for writing the loop imperatively — use whichever states the algorithm more clearly, and prefer the version with fewer moving parts.

Example: Fibonacci numbers

The declaration straight from the mathematical definition is correct — and hopeless:

let rec fib x =
    match x with
    | 0 -> 0
    | 1 -> 1
    | n -> fib (n - 1) + fib (n - 2)
fib 4 ~> fib 3 + fib 2
      ~> (fib 2 + fib 1) + fib 2
      ~> ((fib 1 + fib 0) + fib 1) + fib 2
      ~> ...

The two recursive calls recompute each other's work; fib 44 needs around \(10^9\) base-case evaluations. An iterative version carries two accumulators — the last two Fibonacci numbers:

let rec itfib (n, a, b) =
    if n <> 0 then itfib (n - 1, a + b, a) else a

let fib30 = itfib (30, 0, 1)
832040

The invariant is worth writing down (types and invariants are the course theme, after all): after \(k\) steps the arguments are \((n-k,\, F_k,\, F_{k-1})\), so when the counter reaches \(0\) the first accumulator holds \(F_n\). That one-line argument is the correctness proof.

Limits of accumulating parameters

Accumulating parameters do not make every recursion tail-recursive. Consider counting the nodes of a binary tree:

type BinTree<'a> =
    | Leaf
    | Node of BinTree<'a> * 'a * BinTree<'a>

let rec count t =
    match t with
    | Leaf -> 0
    | Node (tl, _, tr) -> count tl + count tr + 1

Whatever accumulator you add, the body must still make two recursive calls, and one of them will not be in tail position. Something more general is needed.

Continuations

A continuation is a function standing for the rest of the computation. Instead of returning a result, a continuation-based function passes the result on:

let rec bigListC n c =
    if n = 0 then c []
    else bigListC (n - 1) (fun res -> c (1 :: res))

Both the recursive call and the calls of c are tail calls — the stack does not grow. The pending work that used to sit in stack frames now sits in closures on the heap: continuations trade stack for heap.

> bigListC 16_000_000 id;;
Real: 00:00:08.586, CPU: 00:00:08.314, GC gen0: 80, gen1: 60, gen2: 3
val it: int list = [1; 1; 1; 1; ...]

Slower than the plain version — but it can build lists the plain version cannot.

For the tree, one continuation per recursive call does the trick:

let rec countC t c =
    match t with
    | Leaf -> c 0
    | Node (tl, _, tr) ->
        countC tl (fun vl -> countC tr (fun vr -> c (vl + vr + 1)))

let countedNodes =
    countC (Node(Node(Leaf, 1, Leaf), 2, Node(Leaf, 3, Leaf))) id
3

Both calls of countC and the call of c are tail calls, so the stack does not grow: countC handles trees that overflow count — at the price of allocating closures, so count remains faster on trees that fit.

Summary and recommendations

Reading: Hansen & Rischel, chapter 9. Exercise 9.8 there asks you to declare countA : int -> BinTree<'a> -> int with countA n t = n + count t using an accumulating parameter — do it, and see exactly which of the two recursive calls still refuses to be a tail call. That discovery is the whole argument for continuations.

namespace Itt8060
module Diagrams from Itt8060
val figMemory: Svg
val stackHeap: frames: Frame list -> rows: HeapCell list list -> Svg
 Snapshot of stack frames and heap cells. Each stack frame is a
 *horizontal* row of slots (variable names underneath), and frames are
 stacked on top of each other — the last frame in the list is the top of
 the stack, drawn topmost. The heap is given as *rows*, one per chain, so
 links between chains are drawn as clearly separated elbow arrows.
 Pointers (slot -> cell head, cell -> cell) are drawn automatically;
 point slots at the head (leftmost cell) of a row.
val frame: name: string -> slots: Slot list -> Frame
union case Slot.Ptr: name: string * heapId: string -> Slot
union case Slot.Val: name: string * value: string -> Slot
val cell: id: string -> head: string -> next: string option -> HeapCell
union case Option.Some: Value: 'T -> Option<'T>
union case Option.None: Option<'T>
val figPush: Svg
val figPop: Svg
val figShare: Svg
val save: path: string -> Svg -> unit
 Save a diagram for use from slide decks: `Diagrams.save "img/list567.svg" (consCells ...)`.
 Paths are relative to the lecture directory when evaluated by the build.
val fact: x: int -> int
val x: int
val n: int
val naiveRev: lst: 'a list -> 'a list
val lst: 'a list
val x: 'a
val xs: 'a list
val factA: x: int * m: int -> int
val m: int
val revA: lst: 'a list * ys: 'a list -> 'a list
val ys: 'a list
val reversed: int list
val xs: int list
val ys: int list
val zs: int list
val addFive: zs: int list -> int list
val iterate: p: ('a -> bool) -> f: ('a -> 'a) -> h: ('a -> 'b) -> z: 'a -> 'b
val p: ('a -> bool)
val f: ('a -> 'a)
val h: ('a -> 'b)
val z: 'a
val factA': n: int -> int
val snd: tuple: ('T1 * 'T2) -> 'T2
val revA': xs: 'a list -> 'a list
val l: 'a list
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
val isEmpty: list: 'T list -> bool
val tail: list: 'T list -> 'T list
val head: list: 'T list -> 'T
val factW: n: int -> int
val mutable ni: int
val mutable r: int
val fib: x: int -> int
val itfib: n: int * a: int * b: int -> int
val a: int
val b: int
val fib30: int
type BinTree<'a> = | Leaf | Node of BinTree<'a> * 'a * BinTree<'a>
'a
union case Tree.Node: label: string * children: Tree list -> Tree
val count: t: BinTree<'a> -> int
val t: BinTree<'a>
union case BinTree.Leaf: BinTree<'a>
union case BinTree.Node: BinTree<'a> * 'a * BinTree<'a> -> BinTree<'a>
val tl: BinTree<'a>
val tr: BinTree<'a>
val bigListC: n: int -> c: (int list -> 'a) -> 'a
val c: (int list -> 'a)
val res: int list
val countC: t: BinTree<'a> -> c: (int -> 'b) -> 'b
val c: (int -> 'b)
val vl: int
val vr: int
val countedNodes: int
val id: x: 'T -> 'T