Untyped λ-calculus via de Bruijn indices
Table of Contents
1. Review
We've begun to encode untyped λ-terms using de Bruijn indices, meaning that rather than writing, e.g., the identity function as \[λx.x\] we write it instead as \[λ0\] Here, the variable \(0\) is bound by the first \(λ\) prefixed to a λ-term in which it occurs; i.e., \(0\) itself. Likewise, if we want to encode the function that takes two arguments and just gives back the first one, we would normally write it as \[λx, y.x\] but using de Bruijn indices, we'd write it instead as \[λλ1\] Here, the \(1\) is bound by the second \(λ\) prefixed to a λ-term in which it occurs; i.e.; the \(λ\) prefixed to \(λ1\).
1.1. Syntax
Technically, we can specify the set of λ-terms in terms of de Bruijn notation as \[\def\Coloneqq{::=}\def\divd{\ |\ } M, N \Coloneqq n \divd (M N) \divd (λM)\] where \(n ∈ \{0, 1, 2, ...\}\).
In Haskell, we can give this encoding of λ-terms in terms of an algebraic data type. First, we should define a type for natural numbers:
data Nat = Zero | Succ Nat
λ-terms may then be encoded in a way that matches the definition given above:
data Lambda = Var Nat | App Lambda Lambda | Lam Lambda
1.2. Substitution
Now that we are using de Bruijn indices, substitution becomes a bit tricky. But we can tame it by dividing the task up into parts that are manageable to do on their own.
First, let's define a very general function for doing arbitrary substitutions of variables by λ-terms. This function should have the following type:
subst :: (Nat -> Lambda) -> Lambda -> Lambda
It takes a function of type Nat -> Lambda
determining how the relevant
substitution (whatever that is) should be performed, and it provides a
general protocol for managing this substitution for different kinds of
λ-terms. For example, on variables, subst
just performs the substution:
subst f (Var i) = f i
On applications, it does the substitution to both the function and the argument:
subst f (App t u) = App (subst f t) (subst f u)
Finally, on abstractions, something special has to be done. Because we don't
want to mess around with variables which are bound by the abstraction, we
need to instead do substitution using another function g
just like the
function f
, but which is different in two ways. The first way it is different
is that it maps Zero
onto Var Zero
, ensuring that it remains bound by the
enclosing Lam
. The second way it is different is that it maps any Succ i
onto
f i
, since the Succ
was only there in order to ``look past'' the enclosing
Lam
- it should really just be an i
for the purpose of performing the
substitution - and it then renames the result of performing the substitution
f i
by incrementing all free variables inside f i
with a Succ
, using a
function rename
. This last part helps to avoid the problem of variable
capture, i.e., by making sure any free j
inside f i
still looks like a j
outside of the enclosing Lam
, which mean that it has to be a Succ j
inside
the enclosing Lam
.
subst f (Lam t) = Lam (subst g t) where g :: Nat -> Lambda g Zero = Var Zero g (Succ i) = rename Succ (f i)
How exactly does rename
work? It should take some function from Nat
onto Nat
and use this function to rename free variables inside a λ-term. Thus its type
is
rename :: (Nat -> Nat) -> Lambda -> Lambda
Given some function h
of type Nat -> Nat
(e.g., Succ
, as above), it renames
variables by adjusting the natural number they correspond to using h
:
rename h (Var i) = Var (h i)
On applications, as you might expect, it performs renaming on both the function and the argument:
rename h (App t u) = App (rename h t) (rename h u)
And on abstractions, it performs renaming on the body of the abstraction,
but (as above) keeping bound variables intact and taking into account any
extra Succ
that might be lying around by using h'
instead of h
:
rename h (Lam t) = Lam (rename h' t) where h' :: Nat -> Nat h' Zero = Zero h' (Succ i) = Succ (h i)
That's it for the definition of subst
! We can now use it to define a more
immediately useful function
subst0 :: Lambda -> Lambda -> Lambda
which substitutes a λ-term t
for Var Zero
inside a λ-term u
whose Lam
has
just been taken off. That is, we want to do a substitution inside a u
whose
free occurrences of Var Zero
used to be bound by an enclosing Lam
in the term
Lam u
. This yields the following definition:
subst0 t = subst f where f :: Nat -> Lambda f Zero = t f (Succ i) = Var i
1.3. β-reduction
Now that we have a definition of substitution, we want to use it to define a function which takes any given λ-term into its β-normal form, assuming it has one. This function should just take a λ-term onto a λ-term, where the latter is in normal form; thus it should have the following type:
betaNormal :: Lambda -> Lambda
Again, there are three kinds of λ-terms we need to consider as the argument to this function. First, variables.
betaNormal v@(Var _) = v
Variables are already in β-normal form, so nothing needs to be done to them. Next, let's consider abstractions.
betaNormal (Lam t) = Lam (betaNormal t)
Here, we only need to look underneath the binder Lam
and normalize the body
of the abstraction. The reasoning is as follows. Let's say u
is in β-normal
form; does turning u
into Lam u
create a β-redex somewhere? No! So as long as
we have the body in normal form, adding a Lam
won't make a difference. Next,
the more tricky case: applications.
betaNormal (App t u) = case betaNormal t of Lam t' -> betaNormal (subst0 (betaNormal u) t') t' -> App t' (betaNormal u)
If taking the function t
into β-normal form results in an abstraction, we
need to perform a β-reduction step between the function and the argument
(which we normalize). The result may not be in normal form, so we have to
call betaNormal
again at the end. If taking t
into β-normal form does not
result in an abstraction, than we can just take u
onto its normal form and
put the two back together into an application. The reasoning here is that
putting them back together can't possibly create any new β-redices.
2. Exercises: pretty printing
We now have an encoding of the untyped λ-calculus using de Bruijn indices, as well as an encoding of β-reduction that lets us take any λ-term into its β-normal form, assuming it has one.
But de Bruijn indies are ugly to look at, so it would nice to have a way of printing λ-terms out using the usual notation. These exercises aim to guide you in writing a pretty printer for our de Bruijn notation above, which renders it in terms of normal-looking λ-terms (yay!).
To be able to print out arbitrary large λ-terms, we'll need an infinite supply of variable names. One neat thing you can do in Haskell is create infinite lists (though don't try to print them out!). Indeed, the following list is infinite and has some nice names for variables:
freshVars :: [String] freshVars = concat (map (\s -> map (\c -> c:s) "xyzuvw") appendMe) where appendMe :: [String] appendMe = "" : map show ints ints :: [Integer] ints = 1 : map (\x -> x + 1) ints
If you took, e.g., the first 50 elements of freshVars
, you'd get the
following:
ghci> take 50 freshVars ["x","y","z","u","v","w","x1","y1","z1","u1","v1","w1","x2","y2","z2","u2","v2","w2","x3","y3","z3","u3","v3","w3","x4","y4","z4","u4","v4","w4","x5","y5","z5","u5","v5","w5","x6","y6","z6","u6","v6","w6","x7","y7","z7","u7","v7","w7","x8","y8"]
Note where the infinite structure of freshVars
comes from: the definition of
ints
, which is just an infinite list of positive integers, starting from 1
.
The goal in writing a pretty printer for λ-terms is to have a function
printLambda :: Lambda -> String
which takes a lambda term encoded using the algebraic data type Lambda
onto a
string representing that λ-term using the usual notation for λ-terms. This
function should have the following behavior, for example:
ghci> putStrLn (printLambda (Lam (Var Zero))) (λx.x) ghci> putStrLn (printLambda (App (Lam (Var Zero)) (Lam (Lam (Var (Succ Zero)))))) ((λx.x) (λx.(λy.x))) ghci> putStrLn (printLambda (Lam (Lam (Lam (Var (Succ Zero)))))) (λx.(λy.(λz.y)))
2.1. Part 1
To define printLambda
, we'll start by defining a function
printLambda' :: [String] -> (Nat -> String) -> Lambda -> String
which takes a list of strings providing us with fresh variable names, along
with a function of type Nat -> String
that should actually use this list of
names in some way. Given these two ingredients, it takes a Lambda
onto a
String
.
We can define this function by first defining its behavior on variables:
printLambda' _ f (Var i) = f i
How should printLambda'
be defined on applications? That is, fill out the
rest of the following branch by filling in the _
:
printLambda' freshVs f (App t u) = _
2.2. Part 2
The third branch, which involves abstractions, is the trickiest. Here, the list of fresh variable names actually needs to be used. This branch would look as follows:
printLambda' (fresh:vs) f (Lam t) = "(λ" ++ fresh ++ "." ++ printLambda f' vs t ++ ")" where f' :: Nat -> String
Fill out the rest of the definition of this branch by saying what f'
does!
2.3. Part 3
Now that all three branches of printLambda'
are defined, give a definition of
printLambda
using printLambda'
by feeding it the appropriate pieces.