Lecture 15 — 2017-10-24
Lambda calculus: encodings
This lecture is written in literate Haskell; you can download the raw source.
After defining the lambda calculus, I made the claim that the lambda calculus is a universal model of computation, just like Turing machines and Gödel numbers. To back up this claim, I started defining the various elements we might expect in a programming language.
The key idea behind all of the encodings is to operationalize things: since the lambda calculus only has functions, there is no “data” per se.
Booleans
Booleans are functions that make a binary choice. Arbitrarily, we’ll say true
takes the first of two choices.
true = lambda a b. a
false = lambda a b. b
not = lambda bl. lambda a b. bl b a
and = lambda b1 b2. b1 b2 false
or = lambda b1 b2. b1 true b2
To convince yourself that these definitions are correct, try to derive the following equalities. (Again, try to mark when you use alpha and when you use beta.)
not true = false
not (not true) = true
or false false = false
Once we have these booleans, we can encode conditionals as follows: to write if e1 then e2 else e3
, simple write e1 e2 e3
. If e1
is true, we’ll get e2
back; if it’s false, we’ll get e3
—just like a conditional!
Naturals
The booleans were easy: there are only two possibilities. Naturals presented a greater difficulty, since there are infinitely many. The idea here is to think back to the idea of natural numbers from number theory, where a natural number is either (a) zero, or (b) the successor of some other natural number. We could do it in Haskell easily:
data Nat = Zero | Succ Nat
We can’t explicitly represent such data structures in the lambda calculus, though, since there’s only functions, no data. But we can operationalize the idea: the natural number n
can be represented as a function of two arguments: we apply the first argument n
times to the second. Natural numbers represented this way are called Church numerals, in honor of Alonzo Church.
zero = lambda s z. z
one = lambda s z. s z
two = lambda s z. s (s z)
three = lambda s z. s (s (s z))
four = lambda s z. s (s (s (s z)))
While we can follow this pattern in principle to write down any natural number—just apply the first argument, s
, some number of times—we can also define the successor function itself.
succ = lambda n. lambda s z. s (n s z)
Verify that succ zero = one
and succ (succ two) = four
.
We can go on to define other numeric operations, using the intuition that m + n = 1 + 1 + ... m times ... + n
and m * n = n + n + ... m times ... + 0
to define these operations.
plus = lambda m n. m succ n
times = lambda m n. m (plus n) zero
isZero = lambda n. n (lambda x. false) true
Verify that times two two = four
and isZero (times three zero) = true
.
In order to encode the predecessor function, we had to make a detour through pairs.
Pairs
We defined pairs using what amounts to a “callback” strategy: a pair is a function that takes a single callback, which it calls with the first and second elements of the pair, respectively.
pair = lambda a b. lambda c. c a b
fst = lambda p. p (lambda f t. f)
snd = lambda p. p (lambda f t. t)
We can then observe that fst (pair a b) = a
and snd (pair a b) = b
.
Predecessor
The predecessor function is trickily partial: in the naturals, the predecessor of zero is undefined. Our function will have to do something, so let’s just say it returns zero.
The trick to writing the predecessor function is similar to what we did when we wrote folds that computed more than one value: we’ll use pairs to simultaneously compute our current number and one less than that number.
pred = lambda n. snd (n (lambda p. pair (succ (fst p)) (fst p)) (pair zero zero))
How does pred
work? Given a number, it does some computation on pairs and takes the second element. To understand the computation, let’s break it into parts:
pred_zero = pair zero zero
pred_succ = lambda p. pair (succ (fst p)) (fst p)
pred = lambda n. snd (n pred_succ pred_zero)
So pred n
is equal to snd (pred_succ (pred_succ ... n times ... pred_zero))
. We can compute:
pred zero = snd pred_zero = zero
Which is just the behavior we specified. We can also see:
pred three = snd (pred_succ (pred_succ (pred_succ pred_zero)))
= snd (pred_succ (pred_succ (pred_succ (pair zero zero))))
= snd (pred_succ (pred_succ (pair one zero)))
= snd (pred_succ (pair two one))
= snd (pair three two)
= two
Note how, as we work through the number, the first element of the pair is always our “current” number and the second is the predecessor of our “current” number (except for when we’re just starting at zero). Try to prove to yourself that pred (succ e) = e
.
Once we have predecessor, we can use the intuition that m - n = m - 1 - 1 - ... n times ... -1
to define subtraction:
minus = lambda m n. n pred m
Once we have minus, we can define other standard predicates on the naturals:
lte = lambda m n. isZero (minus m n)
gte = lambda m n. lte n m
lt = lambda m n. lte (succ m) n
gt = lambda m n. lt n m
equal = lambda m n. and (lte m n) (lte n m)