Why recursion matters, part 2: lazy evaluation

In part 1, we saw how defining functions as sets of recursive equations lets us prove their correctness, using structural induction. In this article, we’ll examine the second big thing that recursion enables: performance. To get at how this is the case, let’s first look at special-purpose tools that use laziness to avoid unnecessary work.

A long time ago, in a registry far far away, there was a little compile-to-JavaScript language called CoffeeScript. Before JavaScript had modules and everything was built using Webpack, a common method for building client-side code was to run each file through the CoffeeScript compiler, and then concatenate all the resulting files. (Modern build tooling still essentially does this, it’s just substantially more complicated.) One could quite happily write a short shell script to build one’s project:

#!/bin/bash

mkdir -p compiled

for file in $(find src -name '*.coffee') ; do
  ./bin/coffee -co compiled $file
done

cat compiled/*.js > coffee-script.js

In keeping with our theme, the CoffeeScript compiler is itself written in CoffeeScript, and running this build process over its codebase on my computer takes about 7 seconds.

$ time ./build.sh

real    0m6.975s

Now, let’s write the same build process using Make. Here sources is a list of all the .coffee files in the src directory, and outputs is the result of replacing the pattern src/%.coffee with compiled/%.js in sources. We declare that coffee-script.js is derived from outputs by concatenating those files, and each compiled file compiled/%.js is derived from the corresponding src/%.coffee file by running the coffee compiler.

sources := $(shell find src -name '*.coffee')
outputs := $(sources:src/%.coffee=compiled/%.js)

coffee-script.js: $(outputs)
	cat $^ > $@

compiled/%.js: src/%.coffee compiled
	./bin/coffee -co $(dir $@) $<

compiled:
	mkdir -p $@

When we run this, it does all the same things the shell script does, and takes about the same amount of time.

$ time make
mkdir -p compiled
./bin/coffee -co compiled/ src/optparse.coffee
./bin/coffee -co compiled/ src/lexer.coffee
./bin/coffee -co compiled/ src/register.coffee
./bin/coffee -co compiled/ src/repl.coffee
./bin/coffee -co compiled/ src/nodes.coffee
./bin/coffee -co compiled/ src/cake.coffee
./bin/coffee -co compiled/ src/browser.coffee
./bin/coffee -co compiled/ src/coffeescript.coffee
./bin/coffee -co compiled/ src/index.coffee
./bin/coffee -co compiled/ src/rewriter.coffee
./bin/coffee -co compiled/ src/helpers.coffee
./bin/coffee -co compiled/ src/command.coffee
./bin/coffee -co compiled/ src/grammar.coffee
cat compiled/optparse.js compiled/lexer.js compiled/register.js \
    compiled/repl.js compiled/nodes.js compiled/cake.js compiled/browser.js \
    compiled/coffeescript.js compiled/index.js compiled/rewriter.js \
    compiled/helpers.js compiled/command.js compiled/grammar.js >
    coffee-script.js

real    0m7.092s

However, if we change one file, and run make again, then only that file is recompiled and concatenated with the existing compiled files, taking less than a second.

$ touch src/lexer.coffee

$ time make
./bin/coffee -co compiled/ src/lexer.coffee
cat compiled/optparse.js compiled/lexer.js compiled/register.js \
    compiled/repl.js compiled/nodes.js compiled/cake.js compiled/browser.js \
    compiled/coffeescript.js compiled/index.js compiled/rewriter.js \
    compiled/helpers.js compiled/command.js compiled/grammar.js >
    coffee-script.js

real    0m0.835s

If we change a few files, those are recompiled and concatenated with the existing files. But, note that Make compiles them sequentially, and it takes 1.9 seconds.

$ touch src/{grammar,helpers,lexer}.coffee

$ time make
./bin/coffee -co compiled/ src/lexer.coffee
./bin/coffee -co compiled/ src/helpers.coffee
./bin/coffee -co compiled/ src/grammar.coffee
cat compiled/optparse.js compiled/lexer.js compiled/register.js \
    compiled/repl.js compiled/nodes.js compiled/cake.js compiled/browser.js \
    compiled/coffeescript.js compiled/index.js compiled/rewriter.js \
    compiled/helpers.js compiled/command.js compiled/grammar.js >
    coffee-script.js

real    0m1.906s

However if we pass the option -j 3 (which means “run at most 3 concurrent jobs”) to make, the compilations run in parallel and when all of them are done, the results are concatenated. The parallelisation reduces the run time to 1.2 seconds.

$ touch src/{grammar,helpers,lexer}.coffee

$ time make -j 3
./bin/coffee -co compiled/ src/lexer.coffee
./bin/coffee -co compiled/ src/helpers.coffee
./bin/coffee -co compiled/ src/grammar.coffee
cat compiled/optparse.js compiled/lexer.js compiled/register.js \
    compiled/repl.js compiled/nodes.js compiled/cake.js compiled/browser.js \
    compiled/coffeescript.js compiled/index.js compiled/rewriter.js \
    compiled/helpers.js compiled/command.js compiled/grammar.js >
    coffee-script.js

real    0m1.245s

This happens because we’ve described the structure of the process to Make, rather than writing it as one opaque procedure. coffee-script.js, depends on all the files matching compiled/%.js, which themselves are each generated from a corresponding src/%.coffee file. This means Make can figure out which parts of the tree are out of date, based on their ancestors having been updated. It can also spot tasks that don’t depend on each other, like compiling each file, and automatically parallelises them, while making things that do depend on other tasks wait for those tasks to finish.

grammar.coffee    helpers.coffee    lexer.coffee
       |                |                 |
       |                |                 |
       v                v                 v
  grammar.js        helpers.js        lexer.js
       |                |                 |
       |                |                 |
       +----------------+-----------------+
                        |
                        v
                 coffee-script.js

Make is a special-purpose tool, tailor-made for generating files from other files. Some functional languages like Haskell apply this idea of laziness to general computation, and they do it by making use of recursion.

We’ve already seen the recursive definition of map, now we’re going to use two new functions: filter and !!. Filtering an empty list gives an empty list, and filtering any other list either keeps or drops the first element based on whether the predicate returns true. !! gets the nth element of a list; when called with 0 it returns the first element, and when called with any other integer it recurses by dropping the first element and reducing n by 1.

map               :: (a -> b) -> [a] -> [b]
map _ []          =  []
map f (x:xs)      =  f x : map f xs

filter            :: (a -> Bool) -> [a] -> [a]
filter _ []       =  []
filter p (x:xs)
    | p x         =  x : filter p xs
    | otherwise   =  filter p xs

(!!)              :: [a] -> Int -> a
(x:xs) !! 0       =  x
(x:xs) !! n       =  xs !! (n - 1)

Let’s say we want to get element number 2 of the list of squares of the even numbers. In JavaScript we might write this as:

numbers.filter((n) => n % 2 == 0).map((n) => n * n)

Now suppose that numbers is an infinite list. Haskell natively supports infinite lists, and in JavaScript one could simulate them using generators. In a language without lazy evaluation, the filter() call will first try to completely process its input, and it will never return as it loops trying to reach the end of an infinite list. However, the way Haskell defines functions lets it evaluate such an expression slightly differently.

The expression we want to evaluate is shown below. (^ 2) is the notation for a function that squares its input, even takes a number and returns True or False, and [1..] denotes the infinite list of integers counting from 1.

    (map (^ 2) (filter even [1..])) !! 2

As we did before, let’s use the function definitions as rewrite rules and start to transform this expression. The list can be decomposed into 1 paired with an infinite list beginning with 2:

    (map (^ 2) (filter even (1 : [2..]))) !! 2

Then, by the definition of filter, we drop the 1 because even 1 is False.

    (map (^ 2) (filter even [2..])) !! 2

The next list element is 2, and we can pull this out in front of filter because even 2 is True. That leaves [3..] inside the filter expression.

    (map (^ 2) (2 : filter even [3..])) !! 2

Now we have an expression in the form (map f (x : xs)), which we know transforms into (f x : map f xs). Notice we don’t square the 2 yet: we just pull the 2 through the map expression and retain the 2 ^ 2 expression, unevaluated.

    (2 ^ 2 : map (^ 2) (filter even [3..])) !! 2

We now have an expression of the form (x : xs) !! n, so by the definition of !!, we drop this term from the list and reduce n to 1. We never actually called the squaring function, and in fact if you trace the execution of this example you’ll see it won’t get called on the first element.

    (map (^ 2) (filter even [3..])) !! 1

Running this process again, we drop the 3, keep the 4, pull 4 ^ 2 out of the map, then drop it and reduce n to 0.

    (map (^ 2) (filter even [3..])) !! 1
  = (map (^ 2) (filter even (3 : [4..]))) !! 1
  = (map (^ 2) (filter even [4..])) !! 1
  = (map (^ 2) (4 : filter even [5..])) !! 1
  = (4 ^ 2 : map (^ 2) (filter even [5..])) !! 1
  = (map (^ 2) (filter even [5..])) !! 0

One last time, we drop the 5, keep the 6, square it, and then by the definition of !! this time we keep the first element, rather than the rest of the list. Now we evaluate this element since we actually want to use it, getting 36.

    (map (^ 2) (filter even [5..])) !! 0
  = (map (^ 2) (filter even (5 : [6..]))) !! 0
  = (map (^ 2) (filter even [6..])) !! 0
  = (map (^ 2) (6 : filter even [7..])) !! 0
  = (6 ^ 2 : map (^ 2) (filter even [7..])) !! 0
  = 6 ^ 2
  = 36

Two interesting things happened here: we managed to process data from an infinite list without iterating over the whole thing, and we avoiding evaluating things we weren’t going to use. Haskell can do this because its functions are expressed as recursive structures, rather than procedures that must run to completion before returning. Each time we transform an expression we’re taking one step through an iteration, and then seeing if any other transformations can be applied. We’re not running an whole loop to completion inside one function before doing any other work.

In the previous article we saw the function composition law, which says:

    map f (map g list) == map (f . g) list

This is a useful result because it tells us we can iterate over the list just once, rather than once for each function. But this isn’t a special case: all lazy list operations work this way. Rather than filter iterating over the whole list, and then map doing the same thing, the two functions work in tandem and there is only one iteration that steps through the expression until it finds the result we asked for. Anything that’s not necessary to produce that result is discarded.

Lazy constructs do exist in other languages. Ruby has lazy enumerators, Rust’s Iterator is lazy, and Node.js streams can be used to implement laziness (see my talk, Practical functional programming). However, in those languages it is only those constructs that are lazy. For example, take this Rust program:

fn main() {
    let mut squares = (1..).map(|n| {
        println!("squaring {}", n);
        n * n
    });

    println!("{:?}", squares.nth(20).unwrap());
}

Before printing the final result of 441, this will print squaring 1, squaring 2, squaring 3 and so on, all the way up to squaring 21. The map function is lazy as it only processes enough elements from 1.. to satisfy the nth call, and it can collapse chained map and filter calls into a single iteration, but the closure passed to map is called for every item, even though all but the last result is not used. In contrast, take this Haskell program:

import Debug.Trace

square n =
    trace ("squaring " ++ show n) n * n

main = do
    let squares = map square [1..]
    print (squares !! 20)

The entire output of this program is:

squaring 21
441

It only runs the square function for the list element the program is actually going to use. In Haskell, laziness applies to all computations, and this is possible because it defines functions as recursive equations. Just as Make uses its knowledge of your build structure to make sure it only rebuilds things that have changed, Haskell uses the structure of these equations to work backwards from your program’s output instructions to find the expressions that are essential to produce that output. It means each function, especially functions that work with lists or trees, can be evaluated just for the next element of the list, rather than for the entire list, before further work can be done on the expression, and this is an essential component in evaluating functional programs efficiently.

In the next and final part of this series we’ll see how recursive definitions let us better express the meaning of our programs, and we’ll meet logic programming and the idea of running functions backwards.