Module 08.3: Self-Application, Recursion, and Fixed Points¶
In Module 08.2, you encoded Booleans, pairs, lists, and numbers as functions. One thing is still missing before the lambda calculus can do everything an ordinary programming language can: recursion. Every function in the lambda calculus is anonymous, and a function with no name has no obvious way to call itself.
This module solves that puzzle. The route there takes a detour through a famous paradox in set theory, because the paradox turns out to contain exactly the trick we need: an expression applied to itself. From that trick we'll get fixed points, then the Y combinator, and finally a recipe for turning any recursive definition into one that never mentions its own name.
By the end of this module, you should be able to represent sets as functions, explain Russell's paradox and how it appears as self-application in the lambda calculus, say what a fixed point of a function is, follow the reduction showing that Y F is a fixed point of F, and rewrite a recursive function as a nonrecursive generator whose fixed point is the original function.
How to use this module
When you see a box labeled Gradescope question, answer that question in the Module 8.3 Completion assignment on Gradescope.
This module's worked example is factorial. It won't solve any of the HW 08 problems; those will be yours.
A Recursion Puzzle¶
Here's factorial in Haskell:
fact n = if n == 0 then 1 else n * fact (n - 1)
Haskell lets a definition refer to the name being defined. Now try writing the same function as an anonymous lambda, without any names:
\n -> if n == 0 then 1 else n * ??? (n - 1)
What can go in place of ???? In the named version, the body calls the function through its name, fact. The anonymous version has no name, so its body has no way to refer to the function it belongs to. The only names available inside the body are the function's parameters (here, just n), and n is a number, not the function. The lambda calculus has the same problem, only worse: every function is anonymous, and parameters are the only names there are.
You might hope that lc's abbreviations could get around this:
Fact = (λn.(((IsZero n) 1) ((Times n) (Fact (Pred n)))))
They can't. An abbreviation is only shorthand for the expression it stands for. Replacing Fact with its definition produces an expression that contains Fact again, and replacing that one produces another, so the expansion never ends. That means there is no actual lambda expression for Fact to stand for. Abbreviations are a convenience for us, not part of the lambda calculus. They only save typing, so anything written with abbreviations has to be something we could also write out in full without them.
So the puzzle is: how can a purely anonymous function be recursive? The answer comes from an unexpected place, so we'll take a detour through sets first.
Sets as Functions¶
Module 08.2 encoded data by asking what we need to do with it. What do we do with a set? Mostly, we ask whether something is in it. So represent a set by its membership test: a function that takes a value and returns True if the value is in the set and False if it isn't.
For example, if the set s is {3, 5, 10}, then s 5 should be True and s 8 should be False. Applying a set to a value is asking whether the value is in the set.
Here are the basic sets and set operations in Haskell. Put them in a file (say Sets.hs), load it with ghci Sets.hs, and try the examples below as you read.
-- A set of a's is its membership test.
type Set a = a -> Bool
empty :: Set a
empty x = False -- nothing is in the empty set
universe :: Set a
universe x = True -- everything is in the universal set
singleton :: Eq a => a -> Set a
singleton x y = x == y -- y is in {x} exactly when y equals x
complement :: Set a -> Set a
complement s x = not (s x) -- x is in the complement when it's not in s
union :: Set a -> Set a -> Set a
union s t x = s x || t x -- x is in s ∪ t when it's in s or in t
empty and universe ignore their argument. They give the same answer whatever x is.
singleton is a nice example of currying. singleton 7 is a function still waiting for y, so it is the set {7}. You might first write its body as if x == y then True else False, but that conditional just returns whatever x == y already returns, so the body can be x == y on its own.
complement and union take sets (functions) and return a new set (another function). Their extra argument x is the value being tested.
Reading the code as set notation¶
Every application below is a membership question:
| Haskell | Set notation | Result |
|---|---|---|
empty 8 |
8 ∈ ∅ | False |
universe 8 |
8 ∈ U | True |
(singleton 7) 7 |
7 ∈ {7} | True |
(singleton 7) 8 |
8 ∈ {7} | False |
complement (singleton 7) 8 |
8 ∈ complement of {7}, i.e. 8 ∉ {7} | True |
union (singleton 3) (singleton 5) 5 |
5 ∈ {3} ∪ {5} | True |
Running a few by hand¶
Each definition says what to replace a call with, so we can evaluate by substitution:
singleton 7 8
= 7 == 8
= False
complement (singleton 7) 8
= not (singleton 7 8)
= not (7 == 8)
= not False
= True
union (singleton 3) (singleton 5) 5
= singleton 3 5 || singleton 5 5
= (3 == 5) || (5 == 5)
= False || True
= True
Notice that union doesn't build a list of elements anywhere. It passes the question "is 5 in here?" along to each of the two sets and combines their answers.
Building a set from the empty set¶
Start with empty and add one element at a time with singleton and union. Here is {3, 5, 10}:
s :: Set Integer
s = union (singleton 3) (union (singleton 5) (union (singleton 10) empty))
Testing it in ghci:
ghci> s 5
True
ghci> s 10
True
ghci> s 8
False
ghci> complement s 8
True
s 8 is False because the question works its way through each layer: 8 isn't 3, isn't 5, isn't 10, and empty 8 is False.
ghci can't print s itself. A set here is a function, and the only way to find out what's in it is to ask about particular values.
The same definitions work in the lambda calculus, using the encoded Booleans from 08.2:
Empty = (λx.False)
Universe = (λx.True)
Complement = (λs.(λx.(Not (s x))))
Union = (λs.(λt.(λx.((Or (s x)) (t x)))))
Since sets are functions, nothing stops us from asking whether a set is a member of itself. That means applying the set to itself. (Universe Universe) reduces to True, since the universal set contains everything. (Empty Empty) reduces to False, since the empty set contains nothing. Both are reasonable answers to reasonable questions. Self-application looks strange at first, but it's an ordinary application.
Russell's Paradox: The Catalog of Catalogs¶
Suppose every library keeps a catalog of all its books, and each catalog is itself a book. The Harvey Mudd librarian decides that, since the catalog is a book in the library, it should list itself, and writes "Harvey Mudd Library Catalog" inside the Harvey Mudd Library Catalog. The Denison librarian decides that a catalog is a different kind of thing from the books it lists, and leaves it out. Both choices are fine.
Now the Library of Congress wants a complete record of every book in the country, catalogs included. Collecting all the library catalogs, the librarian notices that the ones that don't list themselves will be missed. So they make a new catalog, R: the catalog of all catalogs that do not list themselves. Denison's catalog goes into R. Harvey Mudd's doesn't.
But R is a catalog too. Should R list itself?
- If R doesn't list itself, then it's a catalog that doesn't list itself, so by its own rule it must be added.
- Once R does list itself, it's a catalog that lists itself, so by its own rule it must be removed.
The librarian is stuck adding R and removing it forever.
Bertrand Russell found the same problem in set theory around 1901. Define
R = { x | x ∉ x } -- the set of all sets that are not members of themselves
and ask whether R ∈ R. Each answer forces the opposite answer. At the time, set theory was supposed to be the foundation for all of mathematics, and it allowed any describable collection to be a set. Russell's paradox showed that this freedom leads to a contradiction, and set theory had to be rebuilt with rules about which collections count as sets.
Russell's set in the lambda calculus¶
With sets as membership tests, R is easy to write down. Given x, ask whether x is a member of itself, and negate the answer:
R = (λx.(Not (x x)))
On ordinary inputs, it behaves the way Russell's set should:
(R Empty) →β (Not (Empty Empty)) →* (Not False) →* True
(R Universe) →β (Not (Universe Universe)) →* (Not True) →* False
The empty set doesn't contain itself, so it belongs to R. The universal set contains itself, so it doesn't.
Now ask the paradoxical question, (R R). Expand only the first R and take one β-step, substituting R for x:
(R R)
= ((λx.(Not (x x))) R)
→β (Not (R R))
The question (R R) has turned into Not applied to the same question. Do it again inside, and we get (Not (Not (R R))), then (Not (Not (Not (R R)))), with no end. This is the librarian adding R, then removing it, then adding it again. The expression has no normal form.
The two situations differ in an important way. In naive set theory, the paradox is a genuine contradiction: the theory proves both a statement and its opposite. In the lambda calculus, nothing contradictory happens. (R R) is a perfectly legal expression whose reduction just never finishes. The lambda calculus can express the self-referential question. It just never produces an answer.
Gradescope question: The self-reference problem
Submit this response on Gradescope.
In your own words, explain why the catalog R, "the catalog of all catalogs that do not list themselves," can neither list itself nor leave itself out.
Then explain what part self-application plays in the lambda-calculus expression (R R). Why does reducing it never finish?
The Pattern Inside (R R)¶
Let's look more closely at why (R R) keeps producing Nots. R is a function whose body does two things: it applies its argument to itself, and it wraps the result in Not. When R is given itself as its argument, the self-application inside rebuilds (R R), and the wrapper adds one more Not around it.
Not isn't special here. Swap it for any other expression f:
S = (λx.(f (x x)))
Exactly the same reasoning gives:
(S S)
= ((λx.(f (x x))) S)
→β (f (S S))
→β (f (f (S S)))
→β (f (f (f (S S))))
...
(In each later step, it's the innermost (S S) that gets reduced.)
It works for anything in that position: (λx.(g (x x))) produces gs, and for any expression M, (λx.(M (x x))) produces (M (M (M ...))). The template is always the same: put whatever you want repeated in front of (x x), then apply the function to itself.
Ω from Module 08.1 is the same template with nothing in front of (x x): ((λx.(x x)) (λx.(x x))) reproduces itself and adds nothing.
This is the ingredient recursion needs. (S S) is an expression that, when reduced, hands f a copy of itself. A recursive function needs exactly that: access to itself, to call on a smaller problem.
Gradescope question: Self-application
Submit this response on Gradescope.
Let
A = (λx.(g (x x)))
Show the result of one β-reduction of (A A). You may write A instead of expanding it in your answer.
Which subexpression of the result is the same as the expression you started with? Why does that matter?
Fixed Points¶
Look again at the reduction (S S) →β (f (S S)). The left side is (S S). The right side is f applied to that same (S S). Since one reduces to the other, they are the same value: (f (S S)) and (S S) are equal, up to reduction. So applying f to (S S) doesn't change it. Mathematics has a name for a value that a function leaves unchanged.
Definition: fixed point
A fixed point of a function g is a value x such that g(x) = x.
Fixed points are less exotic than they sound. Take
g(x) = 20 - x²
Then g(4) = 20 - 16 = 4, so 4 is a fixed point of g. It isn't the only one: g(-5) = 20 - 25 = -5. Some functions have several fixed points, some have exactly one, and some have none at all. g(x) = x + 1 never returns its input.
Because 4 = g(4), we can substitute g(4) for the 4 on the right as many times as we like:
4 = g(4) = g(g(4)) = g(g(g(4))) = ...
Compare that with what (S S) did:
(S S) = f (S S) = f (f (S S)) = f (f (f (S S))) = ...
Here = means "equal up to β-reduction." It's the same shape: (S S) is a fixed point of f. The self-application trick from Russell's paradox is a machine for building fixed points.
Fixed points of functions on functions¶
The functions we care about in this module take functions as arguments and return functions. A fixed point of such a function is itself a function. If F is a higher-order function, then a fixed point of F is a function h with
F h = h
That is, handing h to F gives you back something that behaves exactly like h. In the rest of this module, we'll see that recursive functions are precisely fixed points of this kind.
Definition: fixed-point combinator
A fixed-point combinator is an expression that, given any function F, produces a fixed point of F.
Gradescope question: Fixed points
Submit this response on Gradescope.
Suppose g(x) = 8 - x. Give a fixed point of g and show why it is one.
Then, in a sentence or two, explain what it would mean for a function h to be a fixed point of a higher-order function F.
The Y Combinator¶
We already know how to build a fixed point of a particular f: write S = (λx.(f (x x))) and form (S S). To get a fixed point of any function, take f as a parameter:
Y = (λf.((λx.(f (x x))) (λx.(f (x x)))))
This is the famous Y combinator. It looks dense, but it's just the S template written out twice, once as the function and once as the argument, with f abstracted out front. In words: "given f, build the self-applying expression for f."
Why Y F is a fixed point of F¶
Let's apply Y to an arbitrary function F and reduce.
Step 1. Substitute F for f:
(Y F)
= ((λf.((λx.(f (x x))) (λx.(f (x x))))) F) -- expand Y
→β ((λx.(F (x x))) (λx.(F (x x)))) -- substitute F for f
To keep this readable, name the repeated piece A = (λx.(F (x x))). Then the result of step 1 is just (A A).
Step 2. Reduce the self-application, exactly as in the previous section:
(A A)
→β (F (A A))
Step 3. Step 1 showed that (Y F) reduces to (A A). So the (A A) inside (F (A A)) is the same thing, up to reduction, as (Y F). Putting the steps together:
Y F =β F (Y F)
Read =β as "β-equivalent": the two expressions can be connected by β-steps, though the strings aren't literally identical. The equation says that Y F is a fixed point of F, for any F at all.
Just as 4 = g(4) = g(g(4)), the equation can be unfolded as far as you like:
Y F =β F (Y F) =β F (F (Y F)) =β F (F (F (Y F))) ...
This is what makes Y useful. Y F gives F a copy of Y F to use, and that copy can give F another copy, as many times as the computation asks for.
Gradescope question: What does Y accomplish?
Submit this response on Gradescope.
In the derivation above, why is it important that (A A) shows up again inside (F (A A))?
In your own words, what does the equation Y F =β F (Y F) say?
Recursion as a Fixed Point¶
Back to factorial. Here's the named definition again, written so that fact stands alone on the left:
fact = \n -> if n == 0 then 1 else n * fact (n - 1)
This is an equation with fact on both sides, like x = 20 - x². In algebra, you'd rearrange an equation to isolate the unknown. Let's do the same here. Pull the fact on the right out into a parameter, and then apply the result to fact:
fact = (\rec -> \n -> if n == 0 then 1 else n * rec (n - 1)) fact
That's still a true equation: one β-step puts fact back where rec is. Now give the function in parentheses a name:
factorialStep :: (Integer -> Integer) -> (Integer -> Integer)
factorialStep rec n = if n == 0 then 1 else n * rec (n - 1)
factorialStep is not recursive. It never mentions itself or fact. It describes one layer of factorial: handle the base case, and for anything bigger, multiply by whatever the function rec returns on n - 1. And the rearranged equation now reads
fact = factorialStep fact
Factorial is a fixed point of factorialStep. We already have something that builds fixed points.
Trying it in Haskell¶
Haskell can express the fixed-point equation directly. Its standard library defines fix in Data.Function as
fix f = f (fix f)
which is the equation Y F = F (Y F) written as a Haskell definition. Laziness makes this work: fix f unfolds into another f only when the computation asks for one. In GHCi:
ghci> import Data.Function (fix)
ghci> factorialStep rec n = if n == 0 then 1 else n * rec (n - 1)
ghci> fix factorialStep 5
120
The expression fix factorialStep never mentions factorial, yet it computes factorial.
Haskell's fix cheats in one way: it's defined recursively by name, which the lambda calculus can't do. You also can't write the real Y as a Haskell lambda, because the type checker rejects the self-application x x: if x has type t, then x would also need the type t -> t2, and no type can be both. (Try :t \x -> x x in GHCi and you'll get a type error.) In the untyped lambda calculus, neither problem exists.
The same thing in the lambda calculus¶
Translate factorialStep into the encodings from 08.2. IsZero, Times, and Pred will all be provided in HW 08's starter file, along with Y:
FactorialStep = (λrec.(λn.(((IsZero n) 1) ((Times n) (rec (Pred n))))))
Then factorial is (Y FactorialStep). Let's trace ((Y FactorialStep) 2) to see the recursion unfold and then stop. To keep it readable, write Fact for (Y FactorialStep), FS for FactorialStep, and simplify (Pred 2) to 1 and (Pred 1) to 0 as we go:
(Fact 2)
=β ((FS Fact) 2) -- Y F =β F (Y F): unfold once
→* (((IsZero 2) 1) ((Times 2) (Fact 1))) -- substitute Fact for rec, 2 for n
→* ((Times 2) (Fact 1)) -- IsZero 2 is False: take the else branch
=β ((Times 2) ((FS Fact) 1)) -- unfold again
→* ((Times 2) ((Times 1) (Fact 0))) -- IsZero 1 is False
=β ((Times 2) ((Times 1) ((FS Fact) 0))) -- unfold again
→* ((Times 2) ((Times 1) 1)) -- IsZero 0 is True: take the then branch
→* 2
The important step is the last unfolding. When n reaches 0, IsZero chooses the branch 1, which doesn't mention Fact, and the other branch, along with the copy of Fact it contained, is thrown away unreduced. That's how the recursion stops. As in 08.2, choosing a branch only skips the other one because the leftmost, outermost redex is reduced first.
Once you have HW 08's starter file, you can confirm this in lc. With its definitions and FactorialStep loaded, ((Y FactorialStep) 2) reaches (λf.(λb.(f (f b)))), the numeral 2, after 249 β-steps, and 3! takes 1627. The lambda calculus doesn't do this efficiently, but it does compute factorial.
The recipe¶
Any recursive definition can be handled the same way:
- Write the function the usual way, calling itself by name.
- Replace the recursive call with a new parameter, such as
rec. The result is a nonrecursive generator that describes one layer of the computation. - Apply
Yto the generator. The fixed point it produces is the recursive function.
This means recursion doesn't have to be a primitive feature of a language. It can be derived from function application alone.
Y and evaluation order
Y works with the leftmost-outermost order that lc uses, where an argument is only reduced when it's needed. In a language that reduces every argument before calling a function, (Y F) would keep reducing the self-application (x x) forever before F ever ran. Such languages use a variant, often called the Z combinator, that wraps the self-application in an extra lambda so it isn't reduced until it's called. The recipe above stays the same.
Gradescope question: A recursion recipe
Submit this response on Gradescope.
Here's a recursive Haskell function that adds up the numbers from 0 to n:
sumTo n = if n == 0 then 0 else n + sumTo (n - 1)
Write a nonrecursive generator sumToStep rec n for it, following the recipe above. Then run fix sumToStep 4 in GHCi (after import Data.Function (fix)). What does it print, and why is that the right answer?
Why This Matters¶
Look at what we've built across Modules 08.1–08.3, using nothing but variables, abstraction, and application:
- choosing between alternatives (Booleans),
- storing and retrieving data (pairs and lists),
- counting and arithmetic (Church numerals),
- and now recursion (fixed points).
That's everything a general-purpose language needs. This is why the lambda calculus counts as a model of computation alongside Turing machines: in 1937, Turing proved that the two can compute exactly the same functions. Nobody would want to write everyday programs this way, and the 249 steps for 2! show why. But the lambda calculus is small enough that you can study what computation is, separated from the conveniences of any particular language. Its ideas run through functional programming, language semantics, type systems, and compilers.
Preparing for HW 08¶
HW 08 will ask you to design more encodings and operations, some by repeating a function (as Church numerals do) and some by recursion. The starter file will give you Y, so you won't need to derive it again. What you will need is the recipe: find the recursive call, turn it into a parameter, and hand the generator to Y.
When you get stuck on an encoding, go back to the question from 08.2: what does this data need to do, and which functions I already have can do it?
What to Carry Forward¶
- Self-application lets an expression rebuild itself during reduction.
(λx.(f (x x)))applied to itself producesfapplied to a copy of the same self-application. - A fixed point of
Fis somethingFleaves unchanged. For a higher-orderF, a fixed point is a function. - The Y combinator builds fixed points of any function:
Y F =β F (Y F). - A recursive function is a fixed point of its nonrecursive generator, so
Yapplied to the generator gives the recursive function without any named self-reference. - The recursion stops because a Church Boolean throws away the branch it doesn't choose, which only works when that branch isn't reduced first.
Finish Module 08.3¶
Check that you submitted the five Gradescope questions encountered above:
- The self-reference problem
- Self-application
- Fixed points
- What Y accomplishes
- A recursion recipe
Gradescope question: Time spent
Submit this response on Gradescope.
Approximately how much time did you spend on this module?
- less than 30 minutes
- 30 - 45 minutes
- 45 - 60 minutes
- 60 - 75 minutes
- 75 - 90 minutes
- more than 90 minutes
Gradescope question: Lingering questions
Submit this response on Gradescope.
What lingering questions or thoughts do you have about this module?