Chapter experiment

Functional data structures

How can a functional program build and transform data without changing existing values?

Lesson 1 of 11

Change the result, not the input

If a list cannot change, how can a program add an item to it?

In the previous lesson, we saw that pure functions do not cause side effects or change their inputs. A functional data structure follows that rule for its data: its operations use pure functions. Its values are immutable, meaning no operation can edit them in place. To add an item, an operation returns a new list.

Scala's enum lists a type's possible forms. For one concrete example, use an integer-only list with two forms: Nil means empty, and Cons(head, tail) is one cell holding a first value (head) and another list (tail). This lets a new cell point to an existing list.

Add a new first cell and keep the old list
scala
enum IntList:
  case Nil
  case Cons(head: Int, tail: IntList)

val old = IntList.Cons(2, IntList.Nil)
val next = IntList.Cons(1, old)

old still holds the list containing 2. next is one new cell for 1 followed by the exact list in old; the existing cell is not copied or changed. If a shared cell could be edited, both lists would reflect that edit. Because IntList cells are immutable, both versions remain valid. A functional update can reuse an unchanged tail without copying every element.

Lesson 2 of 11

Every list is empty or one item followed by a list

What is `List(1, 2, 3)` actually made of?

Last lesson showed that a new list can reuse an unchanged old list as its tail. To see what List(1, 2, 3) is made from, we’ll use this chapter’s custom List, not Scala’s standard-library List. A constructor is a named form for building a value. This type has two: Nil builds the empty list; Cons builds a nonempty list from one head (element) and a tail (another List).

In List[A], A is a type parameter, a placeholder for the element type. The same definition can describe List[Int] and List[String]; it is polymorphic, meaning it works with different element types.

One List definition for integer and string values
scala
enum List[+A]:
  case Nil
  case Cons(head: A, tail: List[A])

val numbers: List[Int] =
  List.Cons(1, List.Cons(2, List.Cons(3, List.Nil)))

val words: List[String] =
  List.Cons("one", List.Cons("two", List.Nil))

Read numbers from the outside in. The first Cons holds 1; its tail is another list starting with 2, then 3, then Nil. Each Cons stores one element and the next list, not every later element at once. Nil means the list is empty, not that a value is missing. Every list is either Nil or Cons(head, tail), where the tail is another list.

Lesson 3 of 11

Give each possible shape an answer

How does a function work with a value that can have two shapes?

The previous lesson showed that the custom List has two shapes: Nil, or Cons(head, tail). How can a function choose an answer for either shape? Pattern matching checks which constructor built a value and selects the matching case. A case pairs a pattern with an answer; a pattern describes the shape it checks. In Cons(head, tail), head and tail bind, meaning they receive the values stored in that Cons. A recursive function calls itself; for a list, it can continue with the shorter tail.

The empty-list case stops. The nonempty case adds one item and continues with its tail.
scala
def sum(xs: List[Int]): Int =
  xs match
    case List.Nil => 0
    case List.Cons(head, tail) => head + sum(tail)

Nil returns 0 and stops, so it is the base case, the branch that ends the recursion. The Cons branch is the recursive case, the branch that calls sum again. It adds the current head to sum(tail). Each tail is shorter than the list it came from, so repeated calls eventually reach Nil.

The custom List has only Nil and Cons, so these cases cover every shape. A match that covers every possible constructor is exhaustive. Scala checks cases in order and uses the first matching one. If none matches, Scala raises MatchError, an error meaning no case fit that value. Patterns read the list; they do not change it. Match every constructor, stop at Nil, and recurse only on smaller data.

Lesson 4 of 11

New lists can point to unchanged old pieces

Does every new immutable list copy every old item?

Last lesson, pattern matching let us read a nonempty list as Cons(head, tail). The answer to this lesson's question is no: adding one item need not copy the tail. Prepend means to put an item at the front.

Each new Cons cell uses the same existing xs as its tail.
scala
val xs: List[Int] = List.Cons(2, List.Cons(3, List.Nil))
val withOne: List[Int] = List.Cons(1, xs)
val withZero: List[Int] = List.Cons(0, xs)

Each Cons expression creates one new cell for its first value. Both new cells hold the same xs object as their tail, so they reuse the old list's cells. This reuse of unchanged parts is called structural sharing.

The original xs still contains 2 followed by 3, and both new lists remain usable. A list is persistent when an operation can make a new version while leaving earlier versions unchanged. Sharing is safe because no operation can edit the shared tail in place.

Lesson 5 of 11

Front operations can return a tail already there

How much work does it take to remove items from the front?

The previous lesson showed that two immutable lists can point to the same unchanged tail without either list changing. A Cons cell stores a head, the first item, and a tail, the remaining list; Nil means no items. Prepending means adding at the front. Here, drop(as, n) means return the list after skipping n items. Constant time means a fixed amount of work, no matter how many cells follow.

Using this chapter's List, Cons, and Nil definitions.
scala
def tail[A](as: List[A]): List[A] =
  as match
    case Nil => sys.error("tail of empty list")
    case Cons(_, rest) => rest

def drop[A](as: List[A], n: Int): List[A] =
  if n <= 0 then as
  else
    as match
      case Nil => Nil
      case Cons(_, rest) => drop(rest, n - 1)

val xs = Cons(1, Cons(2, Cons(3, Nil)))
val longer = Cons(0, xs)
val remaining = tail(longer) // xs
val afterTwo = drop(xs, 2) // Cons(3, Nil)

Prepending 0 creates one new Cons and points it at the existing xs. tail reads the first cell and returns its existing rest list; it does not traverse the rest of xs. Each operation does a fixed number of steps, so each is constant time.

  1. 1

    Skip the first item

    drop sees Cons(1, ...). It follows that cell's tail, so the list becomes Cons(2, Cons(3, Nil)) and n becomes 1.
  2. 2

    Skip the second item

    It follows one more tail link, reaching Cons(3, Nil) with n now 0.
  3. 3

    Return what is already there

    At n = 0, drop returns Cons(3, Nil), the third cell from the original list. It does not copy the unskipped tail.

drop takes time in proportion to the number of items it skips. If it reaches Nil first, it returns Nil. Each Cons here points to one next cell; a list with that shape is called singly linked. These costs follow from that shape, and another data structure may make different operations cheaper.

Lesson 7 of 11

Keep the walk, replace the job

How can foldRight replace repeated List recursion?

Last lesson, init rebuilt earlier Cons values because immutable lists cannot change their tails. A list is Nil (empty) or Cons(head, tail) (one item followed by the rest). A fold turns that shape into one result: a base result for Nil and a combining function for each Cons. Recursion means a function calls itself. foldRight follows each tail to Nil, then combines each head with the tail's result. foldRight is a higher-order function because it takes another function. A type names a kind of value. Here, A names items and B names results. Int holds whole numbers; String holds text. sum adds numbers, product multiplies them, and text joins their digits.

Each call chooses its result.
scala
def foldRight[A, B](items: List[A], base: B, combine: (A, B) => B): B =
  items match
    case List.Nil => base
    case List.Cons(head, tail) =>
      combine(head, foldRight(tail, base, combine))

val numbers: List[Int] = List.Cons(1, List.Cons(2, List.Nil))
val sum: Int = foldRight(numbers, 0, (number, rest) => number + rest)
val product: Int = foldRight(numbers, 1, (number, rest) => number * rest)
val text: String = foldRight(numbers, "", (number, rest) => number.toString + rest)
  1. 1

    Start at `Nil`

    Nil returns the base 0.
  2. 2

    Fold the last item

    At Cons(2, Nil), the tail gives 0; combining it with 2 yields 2.
  3. 3

    Return to the head

    The outer Cons gets 2; combining it with 1 yields sum = 3.

The sum trace explains the other calls. Product uses base 1 and multiplication. Text uses an empty-string base; toString converts each integer, and + joins it to the tail text, yielding "12". Thus the same List[Int] folds to String; B need not equal A. Folds can also build lists, leading to four common operations:

  • map applies a function to each item, with one output per input. Doubling [1, 2] gives [2, 4].
  • A predicate is a true-or-false test; filter keeps matching items. Keeping only 2 from [1, 2] gives [2].
  • flatMap lets each input produce zero, one, or many items, then joins them. Duplicating 1, 2 gives 1, 1, 2, 2.
  • zipWith combines matching positions and stops when either list ends. Adding [1, 2] and [10] gives [11].

foldRight keeps the Nil/Cons traversal while its base and combining function choose the result. The four operations package common output shapes.

Lesson 8 of 11

Reusable functions still have runtime costs

What runtime costs can a reusable list function hide?

Last lesson showed foldRight using one recursive list walk for different jobs: the empty list gives a starting result, and each cell combines its head with the folded tail. Now we trace the direct version at run time. Evaluation order is the order in which these calculations happen.

  1. 1

    The first item waits

    For a list with values 1, 2, and 3, the call for 1 waits while foldRight handles the tail.
  2. 2

    The next item waits

    The call for 2 also waits while foldRight handles 3.
  3. 3

    The empty list returns

    At the empty list, the result is 0. The additions then finish from the last item back to the first.

The call stack is memory that tracks function calls that have not finished. Each waiting call uses some of that space. A long list can need more space than available. The program then reports a stack overflow, an error caused by running out of call-stack space. This warning applies to the chapter's direct implementation, not every function named foldRight.

Composition means using one function's output as another's input. A pass is one complete visit through a list. If one function builds a temporary list for the next, that result is an intermediate list. The next function may make another pass. Early termination means stopping as soon as the answer is known. A chain that must finish a pass can delay that stop. A focused recursive function can help when extra passes or stopping early matter. So equal answers can still require different work at run time: this direct foldRight can grow the call stack, and composed functions can add passes or delay stopping.

Lesson 9 of 11

Carry the result as you go

How does foldLeft change the evaluation of a list fold?

Last lesson traced the direct foldRight: it reached the end before combining anything, leaving calls waiting. foldLeft starts with the first item and carries an accumulator, the result so far, into the next call. Its combining function, f, uses the accumulator and current item to produce the next result.

Each recursive call gets the updated accumulator.
scala
def foldLeft[A, B](as: List[A], acc: B, f: (B, A) => B): B =
  as match
    case Nil => acc
    case Cons(head, tail) =>
      foldLeft(tail, f(acc, head), f)
  1. 1

    Start with addition

    Let f add its two inputs and set acc to 0. For the first item, 1, f(0, 1) gives 1; the next call gets that result and the remaining List(2, 3).
  2. 2

    Carry the new result

    Apply f(1, 2) to get 3, then pass 3 into the call on List(3).
  3. 3

    Finish at Nil

    Apply f(3, 3) to get 6. The call receives Nil, so it returns the current accumulator, 6.

The call stack keeps track of unfinished function calls. A function is tail recursive when its last action is to call itself, with no work left afterward. In the Cons case, foldLeft passes the updated result straight to its next call. Scala can run this shape without keeping one unfinished call per item. A fold is stack safe when a long list does not overflow the call stack. This foldLeft can be stack safe; other folds are not automatically so.

An order-sensitive combining function produces a different result when the same inputs are combined in a different order. Subtraction is one example. With items 1, 2, 3 and starting result 0, foldLeft calculates ((0 - 1) - 2) - 3 = -6; foldRight calculates 1 - (2 - (3 - 0)) = 2. The folds differ because they combine in opposite orders.

Lesson 10 of 11

A tree changes the shapes, not the method

Was the Nil-or-Cons pattern a special trick for lists?

Last lesson, foldLeft followed List's Nil and Cons cases while carrying a result. Tree functions use the same case-by-case plan. A constructor is a named case that builds a value. ADT here means algebraic data type, not abstract data type. It fixes the allowed constructors and their stored data. The constructors form a closed set, so no other case belongs to Tree. Tree has two constructors: Leaf stores a value and has no children; Branch stores two child trees. With two children per branch, Tree is a binary tree. Recursion means a function calls itself on smaller pieces.

Scala's enum lists the allowed cases. A type parameter stands for a type; A here is the type of each Leaf value. A match checks which constructor made the current value. A method is a function attached to a type; inside it, this means the current tree. In a case, _ tells Scala to ignore a value. The size method counts each Leaf and Branch, so it can ignore a Leaf's value.

Define both Tree cases and count them.
scala
enum Tree[A]:
  case Leaf(value: A)
  case Branch(left: Tree[A], right: Tree[A])

  def size: Int = this match
    case Leaf(_) => 1
    case Branch(left, right) => 1 + left.size + right.size

this match checks which constructor made the current tree. A Leaf contributes 1. A Branch contributes 1 for itself, plus the sizes of left and right. Each name holds one child tree, so the method calls itself for both children. The calls stop at Leaf. Adding their results counts every Leaf and Branch.

Scala can write the same type with a trait, a parent type shared by its cases. sealed requires every case to be declared in the same source file, so another file cannot add a third case. extends Tree[A] marks Leaf and Branch as Tree cases.

Declare the same cases with a sealed trait.
scala
sealed trait Tree[A]
object Tree:
  case class Leaf[A](value: A) extends Tree[A]
  case class Branch[A](left: Tree[A], right: Tree[A]) extends Tree[A]

Both forms allow the same Leaf and Branch cases, with immutable stored values. Only Scala's way of writing them changes. Define the shapes first, then give each shape its part of the operation.

Lesson 11 of 11

Replace Leaf and Branch with the work you need

How can size, depth, maximum, and map share one tree traversal?

Last lesson introduced two tree shapes: Leaf(value) holds one value, and Branch(left, right) holds two child trees. Tree functions handle a leaf or recurse into both children of a branch. How can the same walk count leaves and branches (size), find the largest leaf value (maximum), measure the longest path from the top of the tree to a leaf (depth), or change each leaf value (map)? A tree fold keeps that walk but asks its caller for two functions. The leaf function turns each leaf value into a result. The branch function combines the results from the two child trees into a result for their branch.

The fold supplies the walk; each operation supplies its two rules.
scala
enum Tree[A]:
  case Leaf(value: A)
  case Branch(left: Tree[A], right: Tree[A])

  def fold[B](onLeaf: A => B)(onBranch: (B, B) => B): B =
    this match
      case Leaf(value) => onLeaf(value)
      case Branch(left, right) =>
        onBranch(left.fold(onLeaf)(onBranch), right.fold(onLeaf)(onBranch))

def size[A](tree: Tree[A]): Int =
  tree.fold(_ => 1)((left, right) => 1 + left + right)

def maximum(tree: Tree[Int]): Int =
  tree.fold(value => value)((left, right) => left.max(right))

def depth[A](tree: Tree[A]): Int =
  tree.fold(_ => 0)((left, right) => 1 + left.max(right))

def map[A, B](tree: Tree[A])(f: A => B): Tree[B] =
  tree.fold[Tree[B]](value => Tree.Leaf(f(value)))(
    (left, right) => Tree.Branch(left, right)
  )

fold calls onLeaf with a leaf's value. For a branch, it first folds each child, then gives both results to onBranch. size counts each leaf and branch; maximum starts with a leaf value and compares child results; map changes each leaf before rebuilding branches. For depth, a leaf starts at zero edges, and each branch adds one edge to the longer child path.

Each call to size, maximum, depth, or map still walks the tree; fold shares the recursion code. At each branch, a tree fold combines two child results. A list foldLeft carries one running result, called an accumulator.