The Y combinator - understanding recursion without recursion
October 30, 2019
Introduction
Recursion is central to functional programming, as a clearer alternative to loops as other control structures typical of imperative languages. Functional programming encourages programmers to study recursion in greater depths. I first encountered the Y combinator in the mind-bending penultimate chapter of the wonderful The Little Schemer, which explores recursion in great depth. In an effort to unbend my own mind on the subject, I decided to derive it for myself, so I could see how it worked, and gain an extra tool in dealing with recursion and closures.
Do you now know why Y works? Read this chapter just one more time and you will – The Little Schemer
The Y combinator was discovered by Haskell Curry in the 1940s. It allows recursion to be captured without functions needing to reference themselves by name. It provides some insight into the nature of recursion in the lambda calculus (where nothing has a name), and also demonstrates the power of closures.
We will conduct our explorations mostly in scheme, because it's expressive, concise and elegant, but we also give examples in Haskell and JavaScript at the end. The Haskell version is ludicrously simple and clear (relying on lazy evaluation), while the JavaScript version mirrors the Scheme version.
Further reading
The Little Schemer gives a great introduction to recursion, including a section on the Y combinator. The presentation here is a little different, since I wanted a more direct understanding of how the Y combinator worked.
Recursive functions as fixed points of (higher-order) functions
The Y combinator allows the programmer to pass in a function which isn't explicitly recursive (doesn't reference itself by name), but describes a step in a recursive process with a continuation, and provides back a new function which recursively applies that step using itself as the continuation.
Let's start by making the term "step in a recursive process with a continuation" more concrete, and clarify how the Y combinator acts on these steps.
To give us something specific to think about, let's examine the factorial function. The classic recursive definition of factorial is expressed in Scheme as follows
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
This definition references itself. We can view it as an equation in terms of factorial
, however. In fact, we can define
(define (factorialize f)
(lambda (n)
(if (= n 0)
1
(* n (f (- n 1))))))
and observe that (factorialize factorial)
(factorialize
applied to the factorial
function) is itself factorial
. The formal way to say this is that factorial
is a fixed-point of factorialize
.
Its important to understand that factorialize
operates on all functions from numbers to numbers. In terms of function, we can look at factorialize
as doing a single step in the factorial function, and then, instead of recursing, handing off the remainder of the work to a continuation which is passed in as a parameter. This is what is meant by a "step in a recursive process with a continuation". factorialize
itself is not recursive - it hands the recursion over to some continuation which is passed in.
The Y combinator turns these recursive steps into full-blown recursive functions. Applied to factorialize
it finds a fixed point (you can prove by induction that such a fixed point is necessarily the factorial
function). This means we can define the factorial function by
(define factorial
(Y factorialize))
where here, Y
is the Y combinator. How does it do this? In effect it passes factorialize
in as the continuation to factorialize
, so that the same recursive step is applied over-and-over, until we reach the base case.
Deriving the Y combinator
Capturing our own value
The fixed point perspective is a useful starting point, because we want the Y combinator applied to factorialize
to be an expression expr
which satisfies
expr = (factorialize expr)
One possible starting point is to ask, how can an expression capture it's own value? That is, can we write an expression, which, inside itself, has a handle on its own value.
The trick to doing this is to observe that applying the anonymous function (lambda (f) (f f))
to a function allows a function to receive itself as an argument. If we feed this function another function, (lambda (recur) ...)
, and try to evaluate it
((lambda (f) (f f))
(lambda (recur)
...))
Then inside the inner lambda, recur
will be bound to the (lambda (recur) ...)
. But then (recur recur)
is just the inner lambda (lambda (recur) ...)
applied to itself, which is the value of the expression we're trying to evaluate (that might take a few reads!).
In other words, if we try to evaluate the following
((lambda (f) (f f))
(lambda (recur)
(recur recur)))
by applying the outer function, we get back to where we started. If you try to evaluate this, it will just loop forever! In Haskell we could write this as
let x = x in x
Since we are looking for a function exp
with value (factorialize exp)
, and we know (recur recur)
has value exp
in the above, we can try inserting a call to factorialize:
((lambda (f) (f f))
(lambda (recur)
(factorialize (recur recur))))
By the same reasoning, if this has value v
, then by applying the outer lambda
, we see it also has value (factorialize v)
. Great! We've found a fixed point for factorialize
, and hence this must be the factorial
function. In fact, if we parameterize over factorialize
then we have the (formal) Y combinator!
Making it run
What happens when we try and evaluate this? Firing up a Scheme interpreter and plugging it in
((lambda (f) (f f))
(lambda (recur)
(factorialize (recur recur))))
;Aborting!: maximum recursion depth exceeded
Hmm. The issue here is that when trying to evaluate this procedure, (recur recur)
has to be fully evaluated before a call to factorialize
is made (scheme evaluates its arguments before calling functions). This means for our expression exp
to be evaluated, exp
((recur recur)
) must first be evaluated - this leads to an infinite loop!
To fix this, we want to delay the evaluation of (recur recur)
until it is needed (in other words evaluate it lazily). We can do this with the aid of a lambda:
((lambda (f) (f f))
(lambda (recur)
(factorialize (lambda (x) ((recur recur) x)))))
Let's try it:
(((lambda (f) (f f))
(lambda (recur)
(factorialize (lambda (x) ((recur recur) x))))) 0)
;Value: 1
(((lambda (f) (f f))
(lambda (recur)
(factorialize (lambda (x) ((recur recur) x))))) 5)
;Value: 120
Looking good! Notice that none of this has anything to do with factorialize
. We can parameterise and abstract:
(define (Y F)
((lambda (f) (f f))
(lambda (recur)
(F (lambda (x) ((recur recur) x))))))
Hello Y combinator!
Examples of the Y combinator in action
We started with the factorial function, so that ought to work as expected:
(define factorial
(Y
(lambda (recur)
(lambda (n)
(if (= n 0)
1
(* n (recur (- n 1))))))))
(factorial 5)
;Value: 120
Another easy example is defining the length of a list:
(define length
(Y
(lambda (recur)
(lambda (l)
(if (null? l)
0
(+ 1 (recur (cdr l))))))))
(length '(1 2 3))
;Value: 3
Multiple calls to recur also work just fine:
(define fibonacci
(Y
(lambda (recur)
(lambda (n)
(cond
((= n 0) 0)
((= n 1) 1)
(else (+ (recur (- n 1))
(recur (- n 2)))))))))
(map fibonacci (list 0 1 2 3 4 5 6 7 8))
;Value: (0 1 1 2 3 5 8 13 21)
We can also use the Y combinator's definition to write recursive lambdas inline. To give a contrived example using length of lists:
(map
((lambda (F)
((lambda (f) (f f))
(lambda (recur)
(F (lambda (x) ((recur recur) x))))))
(lambda (recur)
(lambda (l)
(if (null? l)
0
(+ 1 (recur (cdr l)))))))
'((1) (1 2) (1 2 3)))
;Value: (1 2 3)
Intuitions about Y as a limit
Let's try and get a different intuition for how Y works. Let's lean on our factorial
and factorialize
example some more.
One intuitive way to get factorial
out of factorialize
, is to pass factorialize
something like factorialize
as it's argument. In fact, each of the following gets closer and closer to factorial
(factorialize factorialize) ; Evaluates correctly for 0
(factorialize (factorialize factorialize)) ; Evaluates correctly for 0, 1
(factorialize (factorialize (factorialize factorialize))) ; Evaluates for correctly 0, 1, 2
...
One can think of factorial
as being something like
(factorialize (factorialize (factorialize ...)))
Notice that to evaluate factorial
on a given number, we only need finitely many of these calls to factorialize
.
In fact, if we expand, for example, ((Y factorialize 3))
we get
(factorialize
(factorialize
(factorialize
((factorialize _) 0))))
where the _
represents a lambda
which is never evaluated.
In some other languages
The Y combinator is not restricted to Lisps. Let's give examples of the Y combinator in Haskell and JavaScript.
The Y combinator in Haskell
Lazy evaluation means that in Haskell we don't need to work so hard, and we can just write down what i means to be a fixed point
y :: (a -> a) -> a
y f = let g = f g in g
factorial :: Integer -> Integer
factorial = y factorialize
where
factorialize _ 0 = 1
factorialize recur n = n * recur (n - 1)
This to me seems something close to magic, even though in many ways its simpler to think about than the Scheme version. y
is more often named fix
in the Haskell community, presumably because it is a definition by equation of a fixed-point. Haskell really is quite beautiful.
The Y combinator in JavaScript
JavaScript is not so beautiful. Lambdas and functions might be the only sensible parts of JavaScript, but that's an extraordinarily powerful part all the same. Another grace is that you can even try this snippet in the developer console of your browser.
const y = (f) => ((g) => g(g))(
(recur) => f((x) => recur(recur)(x))
);
const factorialize = (recur) => (n) => n == 0 ? 1 : n * recur (n - 1);
const factorial = y(factorialize);
Conclusions
Lambdas really are way more powerful than you would at first think! I found understanding how to derive the Y combinator gives me a new way to think. There really are good reasons why functional programming reveres the lambda so much - they are the Jedi weapon par excellence.
I can imagine many folks would balk at the code for the y combinator - it's hard to see what it does at a glance. It's nonetheless wonderfully abstract, and can be understood by its properties. Nonetheless, explicit recursion is probably clearer.