If the root node is a free variable or a lambda, then there is nothing to do. Otherwise, the root node is an App node, and we recursively evaluate the left child.

If the left child evaluates to anything but a lambda, then we stop, as a free variable got in the way somewhere.

Otherwise, we perform beta reduction as follows. Let the left child be \(\lambda v . M\). We traverse the right subtree of the root node, and replace every occurrence of \(v\) with the term \(M\).

While doing so, we must handle a potential complication. A reduction such as (\y -> \x -> y)x to \x -> x is incorrect. To prevent this, we rename the first x and find (\y -> \x1 -> y)x reduces to \x1 -> x.

More precisely, a variable v is bound if it appears in the right subtree of a lambda abstraction node whose left child is v. Otherwise v is free. If a substitution would cause a free variable to become bound, then we rename all free occurrences of that variable before proceeding. The new name must differ from all other free variables.

We store the let definitions in an associative list named env, and perform lookups on demand to see if a given string is a variable or shorthand for another term.

These on-demand lookups and the way we update env means recursive let definitions are possible. Thus our interpreter actually runs more than plain lambda calculus; a true lambda calculus term is unable to refer to itself. (Haskell similarly permits unrestricted recursion via let expressions.)

The quote business is a special feature that will be explained later.

eval env ( App ( Var "quote" ) t ) = quote env t eval env ( App m a ) | Lam v f <- eval env m = eval env $ beta env ( v , a ) f eval env ( Var v ) | Just x <- lookup v env = eval env x eval _ term = term beta env ( v , a ) t = case t of Var s | s == v -> a | otherwise -> Var s Lam s m | s == v -> Lam s m | s ` elem ` fvs -> Lam s1 $ rec $ rename s s1 m | otherwise -> Lam s ( rec m ) where s1 = newName s $ v : fvs ` union ` fv env [] m App m n -> App ( rec m ) ( rec n ) where rec = beta env ( v , a ) fvs = fv env [] a fv env vs ( Var s ) | s ` elem ` vs = [] -- Handle free variables in let definitions. -- Avoid repeatedly following recursive lets. | Just x <- lookup s env = fv env ( s : vs ) x | otherwise = [ s ] fv env vs ( App x y ) = fv env vs x ` union ` fv env vs y fv env vs ( Lam s f ) = fv env ( s : vs ) f

To pick a new name, we increment the number at the end of the name (or append "1" if there is no number) until we’ve avoided all the given names.

newName :: String -> [ String ] -> String newName x ys = head $ filter (` notElem ` ys ) $ ( s ++ ) . show <$> [ 1 .. ] where s = dropWhileEnd isDigit x

Renaming a free variable is a tree traversal that skips lambda abstractions that bind them:

rename :: String -> String -> Term -> Term rename x x1 term = case term of Var s | s == x -> Var x1 | otherwise -> term Lam s b | s == x -> term | otherwise -> Lam s ( rec b ) App a b -> App ( rec a ) ( rec b ) where rec = rename x x1

Our eval function terminates once no more top-level function applications (beta reductions) are possible. We recursively call eval on child nodes to reduce other function applications throughout the tree, resulting in the normal form of the lambda term. The normal form is unique up to variable renaming (which is called alpha-conversion).

norm :: [( String , Term )] -> Term -> Term norm env term = case eval env term of Var v -> Var v Lam v m -> Lam v ( rec m ) App m n -> App ( rec m ) ( rec n ) where rec = norm env

A term with no free variables is called a closed lambda expression or combinator. When given such a term, our function’s output contains no App nodes.

That is, if it ever outputs something. There’s no guarantee that our recursion terminates. For example, it is impossible to reduce all the App nodes of:

omega = (\x -> x x)(\x -> x x)

In such cases, we say the lambda term has no normal form. We could limit the number of reductions to prevent our code looping forever; we leave this as an exercise for the reader.

In an application App m n, the function eval tries to reduce m first. This is called a normal-order evaluation strategy. What if we reduced n first, a strategy known as applicative order? More generally, instead of starting at the top level, what if we picked some sub-expression to reduce first? Does it matter?

Yes and no. On the one hand, the Church-Rosser theorem states that the order of evaluation is unimportant in that if terms \(b\) and \(c\) are both derived from term \(a\), then there exists a term \(d\) to which both \(b\) and \(c\) can be reduced. In particular, if we reach a term where no further reductions are possible, then it must be the normal form we defined above.

On the other hand, some strategies may loop forever instead of normalizing a term that does in fact possess a normal form. It turns out this never happens with normal-order evaluation: it always reduces a term to its normal form if it exists, hence its name.