all_lessons/functional programming/12 · Recursion & persistencelesson 13 / 15

Recursion and persistent structures

Lesson 11 gave us typed failure — Option, Try, and Either — and a way to sequence fallible steps without null or exceptions. But every example so far has quietly leaned on library methods to walk over data. This lesson opens the box: how do you iterate at all when you have no var to increment? The answer is recursion, the FP loop. And recursion forces us back to the one worry Lesson 03 left dangling — "if data never changes in place, do I copy everything on every update?" The same chapter that teaches recursion answers it: persistent data structures share their unchanged parts, so an immutable update is cheap, not wasteful.

Book source
This lesson follows Widman's treatment of recursion as the functional substitute for imperative looping, and the companion discussion of why immutable collections are not the performance disaster they sound like. We develop structural recursion, tail recursion with @tailrec, and structural sharing (cons-lists and the wide-trie sketch behind Scala's Vector) in original prose and examples; the conceptual arc — recursion → stack safety → persistence → why sharing is safe — is the book's.
Linear position
Prerequisite: Lesson 03 (immutability, and its open "isn't copying wasteful?" worry) and Lesson 05 (functions as values, and fold as a higher-order function). Lesson 09's foldLeft/foldRight are recursion captured as a reusable HOF.
New capability: loop the FP way with structural and tail recursion, force stack-safety with @tailrec and the accumulator pattern, and explain how structural sharing makes immutable updates cheap — closing the loop Lesson 03 opened.
The plan
Seven moves. (1) Loops need a mutable counter, so FP loops by recursion; structural recursion over a cons-list. (2) The stack problem — naive recursion can overflow. (3) Tail recursion + the accumulator pattern + @tailrec, before/after. (4) Pivot: immutable does not mean deep-copy. (5) Cons-list sharing, drawn. (6) The wide-trie behind Vector/Map and the headline number. (7) Why sharing is safe — and it is only safe because of Lesson 03.

1 · Recursion is the FP loop

A while loop is built from mutation. var i = 0; while (i < n) { …; i = i + 1 } works only because i means a different value each pass — exactly the changing-state we spent Lesson 03 learning to avoid. Take away var and the loop has nothing to advance. So FP iterates a different way: a function calls itself on a smaller piece of the problem until there is nothing left.

Our running data shape is the cons-list — Scala's List. It is defined recursively: a list is either Nil (empty) or a head element followed by a tail list, written head :: tail. Because the data is defined by cases, the function over it is too. This mirror is called structural recursion: one branch per shape — a base case for Nil and a recursive case for head :: tail that does a little work and recurses on the strictly-smaller tail.

def length[A](xs: List[A]): Int = xs match {
  case Nil       => 0                    // base case: empty list has length 0
  case _ :: tail => 1 + length(tail)     // recursive case: 1 + length of the rest
}

def sum(xs: List[Int]): Int = xs match {
  case Nil       => 0                    // base case: identity for +
  case h :: tail => h + sum(tail)        // recursive case: head plus sum of rest
}

Look closely at sum: a base value (0) and a combine step (h + …). That is exactly a fold from Lesson 09 — xs.foldRight(0)(_ + _). A fold is structural recursion packaged as a higher-order function: you hand it the base case and the combine function, and it supplies the recursion. Whenever you find yourself writing this pattern by hand, a fold usually says it more clearly.

2 · The stack problem

Each unfinished call must be remembered: h + sum(tail) cannot finish until sum(tail) returns, so the runtime parks a stack frame holding h and "add when the rest comes back." For a list of length n, that is n parked frames before any addition happens. The call stack is a fixed, small region of memory; pile up enough frames and you get a StackOverflowError — typically somewhere in the tens of thousands of elements. A naive recursive walk is correct but not safe for large input.

The shape of the danger
sum(List(1,2,3,4)) expands to 1 + (2 + (3 + (4 + 0))). Note that nothing collapses until the innermost 0 is reached — every + is pending. The depth of pending work equals the length of the list. That growing tower of "do this after the recursive call returns" is the stack we can overflow.

3 · Tail recursion and the accumulator

The fix: arrange the recursion so that the recursive call is the last thing the function does — there is no pending + waiting on its return. Such a call is tail-recursive. When the recursive call is in tail position, the current frame has no remaining work, so the runtime can reuse it instead of pushing a new one. The recursion runs in constant stack space — it has become a loop.

To get there we carry the running answer forward in an extra parameter, the accumulator, instead of leaving work pending on the way back up:

import scala.annotation.tailrec

def sum(xs: List[Int]): Int = {
  @tailrec
  def loop(rest: List[Int], acc: Int): Int = rest match {
    case Nil       => acc                 // base case: answer is fully built
    case h :: tail => loop(tail, acc + h) // recursive call is the LAST thing — tail position
  }
  loop(xs, 0)                             // start with accumulator = identity for +
}

The @tailrec annotation is the key discipline. It does not make the function tail-recursive; it asks the compiler to verify that it is, and to compile it into a loop. If the call is not actually in tail position, compilation fails with an error — so you can never accidentally ship the stack-overflowing version believing it was optimized. Always annotate the helper you intend to be stack-safe.

Naive (§2)Tail-recursive (this §)
work orderh + (recurse) — add after the call returnsrecurse(acc + h) — add before the call
pending framesone per element (grows with n)none — frame reused
stack usageO(n) → overflows on long listsO(1) → safe for any length
@tailrec accepts?no — compile erroryes

4 · Pivot: immutable does not mean deep-copy

Now the worry from Lesson 03. We said "to update, build a new value." Naively that sounds like every change copies the whole structure — change one element of a million-element list and pay a million-element copy. If that were true, immutability would be unaffordable. It is not true. The collections FP uses are persistent data structures: an "update" produces a new version while the old version remains fully valid, and the new version shares all the parts that did not change with the old one. You copy the small slice that differs and reuse the rest by reference.

5 · Cons-list sharing

The cons-list shows persistence in its simplest form. A node is a head cell plus a reference to its tail. Prepending builds one new node pointing at the existing list — the old list is neither copied nor touched:

val a = 2 :: 3 :: Nil   // a:  2 -> 3 -> Nil
val b = 1 :: a          // b prepends 1; b:  1 -> (all of a, reused)
// a is unchanged and still valid. b reuses every node of a.
┌───┐ ┌───┐ ┌───┐ a ───▶│ 2 │──▶│ 3 │──▶│Nil│ └───┘ └───┘ └───┘ ▲ ┌───┐ │ b = 1 :: a (new node, shared tail) │ 1 │───┘ └───┘ ▲ b

Prepend is therefore O(1): allocate one node, point it at the old head. Both a and b are real, independent lists that happen to share memory — and that is invisible and harmless, because neither can be mutated. Contrast the two directions: prepending shares the entire existing list; "updating an element in the middle" must rebuild only the path from the front down to that element (the part before it), and shares the unchanged suffix after it. Either way you copy a path, not the whole.

6 · Trees and the wide-trie sketch behind Vector

A list shares well at the front but is O(n) to reach the middle. Scala's Vector and immutable Map get fast arbitrary updates with the same trick applied to a tree. Picture a wide trie: a balanced tree where each internal node has up to ~32 children. A million elements fit in a tree only about four levels deep, because 32 levels multiply fast (32 4 ≈ 1,000,000). To update one element, you copy the path from the root down to the changed leaf — one new node per level — and point every other child reference at the existing, shared subtrees.

root' ← new root (copied) / | \ \ share share share node' ← copied (on path) / \ \ share share leaf' ← the one changed element (every "share" is the OLD subtree, reused by reference)
Worked number — the answer Lesson 03 promised
Update one element of a 1,000,000-entry Vector. The tree is ~4 levels deep, so the update copies ~4 small (≤32-wide) nodes — on the order of a hundred references — and shares the other ~999,996 elements untouched. The cost is O(log32 n) ≈ effectively constant for any realistic size, not O(n). This is precisely why "build a new value to update" is cheap: you rebuild a thin path, never the whole structure.

7 · Why sharing is safe — and only because of Lesson 03

Here is the load-bearing point. b reuses a's nodes; the updated Vector reuses the old one's subtrees. Sharing memory between two "different" values would be a catastrophe in a mutable world — change the shared part through one alias and the other value silently mutates underneath you (exactly the aliasing bug of Lesson 03). It is safe here only because the shared parts are immutable: no one holds a way to mutate a shared subtree, so two values can point at the same memory forever without ever interfering. Immutability is not the tax that makes persistence necessary; it is the permission that makes sharing — and therefore cheap persistence — possible at all.

Worked example — tail-recursive sum traced + the sharing it leans on
Trace sum(List(1,2,3,4)) from §3, watching the accumulator:
loop(1::2::3::4::Nil, 0)   // acc starts at identity 0
loop(  2::3::4::Nil, 1)    // acc = 0 + 1
loop(     3::4::Nil, 3)    // acc = 1 + 2
loop(        4::Nil, 6)    // acc = 3 + 3
loop(           Nil, 10)   // acc = 6 + 4
=> 10                       // base case returns the finished accumulator
No pending additions: each step fully consumes the head into acc before recursing, so one reused frame suffices — constant stack. And notice the rest argument: 2::3::4::Nil is not a fresh copy — it is literally the tail node of the original list, shared, never rebuilt. The traversal allocates nothing; persistence (§5) is what makes "pass the tail" free.

Common mistakes / failure modes

Forgetting the accumulator
Writing h + recurse(tail) leaves the + pending, so the call is not in tail position and the stack grows with n. The accumulator is what moves the work before the recursive call. @tailrec will reject the non-tail version at compile time — use it.
Assuming foldRight is stack-safe
On a plain List, foldRight recurses to the end before combining (§2's shape) and can overflow. foldLeft is the tail-recursive one — it threads an accumulator left-to-right. Reach for foldLeft on long lists.
"Immutable updates deep-copy"
The belief that drove the Lesson 03 worry. Persistent structures copy only the path to the change and share everything else (§5–6): O(1) prepend, O(log32 n) Vector update — not O(n).
Wrong base case / non-shrinking recursion
If the recursive case does not move strictly toward the base (e.g. recursing on xs not tail), it never terminates. Every recursive case must consume structure.

Checkpoint exercise

Try it
(a) Write a tail-recursive reverse[A](xs: List[A]): List[A] using an accumulator and the @tailrec annotation. Hint: the accumulator is a List[A] that starts Nil, and each step prepends the current head onto it (loop(tail, h :: acc)) — note that this prepend is the O(1), fully-sharing operation from §5, so reversing an n-element list allocates exactly n new cons-nodes and copies nothing. Confirm the compiler accepts your @tailrec. (b) In one or two sentences, explain how many nodes a single-element update of a 1,000,000-entry Vector copies, and why that small number is safe — i.e. why two versions can share the rest of the tree without interfering.

Where this points next

Recursion gave us iteration without mutation; persistence made immutable updates cheap; immutability made sharing safe. Lesson 13 (laziness, streams, and concurrency) spends all three at once. Recursion plus immutable data lets us describe a computation — even a possibly-infinite one, like "all the natural numbers" — as a value, then run only as much of it as we ask for, on demand. The same property that made sharing safe in §7 (nothing mutates) is what makes that description safe to evaluate later, and what makes running pure computations on many threads free of data races. Description-now / execution-later is the bridge from this lesson's structures to the next lesson's LazyList and Future.

Takeaway
FP loops by recursion: structural recursion mirrors a recursive data type with a base case (Nil) and a recursive case (head :: tail), and fold is that pattern as a HOF (Lesson 09). Naive recursion parks one stack frame per element and overflows on long input; moving the work into an accumulator puts the recursive call in tail position, and @tailrec makes the compiler verify it and compile it to a constant-space loop. On the data side, immutable does not mean deep-copy: persistent structures share unchanged parts — cons prepend is O(1) and fully shares the tail; Vector/Map are ~32-way tries where one update copies only the ~4-node root-to-leaf path of a million-element structure and shares the rest, giving effectively-constant O(log32 n) updates. Sharing is safe only because the data is immutable (Lesson 03) — which is why that lesson's "isn't copying wasteful?" worry was never real.

Interview prompts