We're still under construction!

And you just found out where. We're still working on this page. How did you get in here, anyway?

Writing a Lambda Calculus interpreter in Haskell

We write a rather small lambda calculus expression parser, evaluator, and repl in Haskell.

Lambda Calculus real quick

Lambda Calculus is a universal model of computation proposed by Alonzo Church in the 1930's, and it is an incredibly small one at that. It consists of simple expressions constructed out of 3 things:

  • variables
  • lambda abstractions
  • function applications

What are these? In friendly terms, a variable is just something that we don't know yet, and so we've given it a name instead. A lambda abstraction tells us how to replace a variable with it's value when we learn it. And a function application is the act of learning what a lambda abstraction's variable is.

How do we use this, say, to add two plus five? Well, it's easy! add five two, where add is a function taking two arguments.

Whereas Turing machines invoke clunky mechanisms, Church's calculus is a language, with a flexibility and flow not unlike that of the spoken language, and how each spoken word produces a sentence fragment that is awaiting the next word of the rest of the sentence.

This article isn't going to explain lambda calculus in depth, as it rather serves as a nice starting point for such explorations later. If you've been doing Haskell, you've been doing lambda calculus the whole time, anyway.

With that out of the way, let's get started! First off, we'll create a new cabal project file. You may prefer to use stack if you like. We're going to pull in a few libraries - containers for the usual suspects, parsec for parsing, and repline for interactive input - and set some extensions (nothing fancy, just FlexibleContexts, NoImplicitPrelude, and OverloadedStrings)

cabal project file - lc-simple.cabal

cabal-version:      3.0
name:               lc-simple
version:            0.0.1
license:            NONE
author:             Leo
maintainer:         leo@apotheca.io
build-type:         Simple
extra-source-files: README.md
library
    exposed-modules:
        Lambda.Simple
        Lambda.Simple.Eval
        Lambda.Simple.Expr
        Lambda.Simple.Parse
        Lambda.Simple.Repl
    default-extensions:
        FlexibleContexts
        NoImplicitPrelude
        OverloadedStrings
    build-depends:
        base >= 4 && < 5,
        containers,
        parsec,
        repline
    hs-source-dirs:   src
    default-language: Haskell2010

Lambda.Simple.Expr

Let us define our expression data type. As you can see, an expression is made up of variables, lambda abstractions, and function applications.

The most important part is this part:

data Expr
    = Var Name
    | Lam Name Expr
    | App Expr Expr
                    

Our definition here is a lot less threatening than our earlier descriptions, don't you think? As you can see, an Expr is either a Var which is just a Name, a Lam which is a Name and another Expr, or a App which is two Exprs.

In addition to our data type, we've added a few convenience functions for encoding our expressions to strings, and for pretty printing them - it helps to be able to do something with what we're building.

haskell - src/Lambda/Simple/Expr.hs

module Lambda.Simple.Expr where

import Prelude

type Name = String

-- The expression data type

data Expr
    = Var Name
    | Lam Name Expr
    | App Expr Expr
    deriving (Show, Eq)

-- Encoding to string

encode :: Expr -> String
encode = go where

    go (Var n) = n
    go (Lam n b@(Lam _ _)) = "\\ " ++ n ++ tail (go b)
    go (Lam n b) = "\\ " ++ n ++ " . " ++ go b
    go (App e@(App _ _) f) = go e ++ " " ++ wrap f
    go (App e f) = wrap e ++ " " ++ wrap f

    wrap x@(Var _) = go x
    wrap x@(Lam _ _) = "(" ++ go x ++ ")"
    wrap x@(App _ _) = "(" ++ go x ++ ")"

-- Pretty printing

putExpr :: Expr -> IO ()
putExpr = putStr . encode

putExprLn :: Expr -> IO ()
putExprLn = putStrLn . encode

Lambda.Simple.Parse

So how do we go about actually using this data type? We could construct it manually, of course - we could define the identity function id = Lam "x" (Var "x"). But that is rather tedious. It is better to build a parser!

Having a parser serves a few purposes: first, it lets us use a better format for our lambda calculus code (\ x y . x is easier to read and write than Lam "x" (Lam "y" (Var "x"))) and secondly, it lets us write lambda calculus outside of Haskell, which comes in handy when we're building our interpreter later.

We will use the parsec library that we pulled in earlier. parsec is a parser-combinator library, which lets you use smaller parsers to build up a larger parser, and can result in parsers with similar shapes to the data type they are parsing. Since our Expr type is so small, it isn't hard to knock together a parser.

First, we build a parser for parsing Names, which is just captures an alphanumeric string. Then, a Var parser just wraps a constructor around a Name. Lam is a tad more complex, as it looks for one or more Names between a \ and a . followed by an Expr.

The parser for App is the harder bit to unravel. Naively, we could just say that an App is an Expr followed by a space and another Expr, and then use that to build our final parser with something like expr = var | lam | app, which looks rather like our data type. However, if we do it that way, our parser will loop infinitely, because to parse an App, we start by parsing an Expr, which could be an App that needs more parsing... Yikes! Rather than let this happen, we cut the loops, and handle App separately from Var and Lam. We do this by using chainl1 which lets us say that we have a series of Expr with App between them.

As you can see, our expression parser itself fits in just 14 lines - the rest is imports, and a few convenience functions for running our parser. Despite the small size, it parses parenthesis and spaces properly, and even handles multiple lambda arguments (so we can say \ x y z . ... instead of \ x . \ y . \ z . ...). Not bad at all.

With the parser out of the way, we should be able to run parse "\\ x y . x" and get Lam "x" (Lam "y" (Var "x")). (Note the double-slash - because it is a quoted haskell String that uses backslash to escape, we need to use double backslash with the first escaping the second.)

Neat! Now we can build expressions; that means it's time to evaluate them.

haskell - src/Lambda/Simple/Parse.hs

module Lambda.Simple.Parse where

import Prelude

import Data.Either
import Data.Functor

import Control.Monad

import Lambda.Simple.Expr

import qualified Text.Parsec as Parsec
import Text.Parsec hiding (parse, runParser)

runParser str = Parsec.runParser (expr <* eof) () "" str

parse str = case runParser str of
    (Right r)   -> r
    (Left e)    -> error $ show e

expr :: (Stream s m Char) => ParsecT s u m Expr
expr = chainl1 (nonApp <* spaces) (pure App) <?> "expr" where
    name = do
        head <- letter
        tail <- many (letter <|> digit <|> char '\'')
        (return (head:tail)) <?> "name"
    var = (Var <$> name) <?> "var"
    lam = do
        ns <- char '\\' >> spaces >> sepEndBy1 name spaces
        e <- char '.' >> spaces >> expr
        (return (foldl1 (.) (fmap Lam ns) e)) <?> "lam"
    nonApp = (parens expr <|> lam <|> var) <?> "non-app expr"
    parens = between
        (char '(' >> spaces)
        (char ')' >> spaces)

Lambda.Simple.Eval

So far, we've had to do any evaluation manually, just as we had to do the building of expressions manually before we constructed our parser. We know that the evaluation of (\ x y . x) first second yields first, and that the evaluation of (\ x y . y) first second yields second. We're just going to implement this logic to do this automatically.

To start, we've added a few NameSupply definitions. We use this when we perform alpha conversion, which is what we do if we have a naming collision because we accidentally used the same variable name for two different things.

Then we have a bunch of stuff about FreeVars. A variable is either bound - that is, named in a lambda abstraction - or it is free, meaning it references. A lambda expression can only be evaluated if it contains no free vars, or equivalently, if we can provide the matching variables. So in the expression \ x y . x z, both x and y are bound (even though only x is used), while z is free. We can turn a free variable into a bound one by wrapping the full expression in a lambda abstraction with a matching name. This is where we might have naming collisions, because the free-variable-value's expression might contain variable names that are already in use in the full expression. All this FreeVars stuff helps us detect and ameliorate this.

Once we get to the eval function, it is rather small. It just recurses, beta reducing and calling itself until it stops. So what is beta reduction?

Beta reduction is where, if we have a function application with a lambda abstraction as the first of its two expressions, we can go ahead and give the lambda abstraction the second expression as its argument, substituting the expression for the matching variable in the expression. We use alpha conversion in the case of the earlier mentioned naming conflict. And so we have functions subst alpha and beta.

That's it! Really! We can test it out. A simple test of eval . parse $ "(\ x y . x) first second" should return Var first

If we take the church numerals 2 = \ s z . s (s z) and 3 = \ s z . s (s (s z)), and supply them as arguments to the function add = \ m n s z . m s (n s z), we get (\ m n s z . m s (n s z)) (\ s z . s (s z)) (\ s z . s (s (s z))) which when evaluated repeatedly to normal form yields \ s z . s (s (s (s (s z)))) which when provided one and zero as arguments, results in what is obviously five: one (one (one (one (one zero))))

Note: We'll take care of that normal form stuff in the future revision of this article. For now we'll allow this limitation in the name of a shorter article.

All together, ignoring whitespace, comments, and type hints, the evaluator is just shy of 50 lines of code, with the core of it being the 17 lines for eval subst alpha and beta.

haskell - src/Lambda/Simple/Eval.hs

module Lambda.Simple.Eval where

import Prelude
import Data.Bool
import Data.Either

import Data.Set (Set)
import qualified Data.Set as Set

import Control.Monad

import Lambda.Simple.Expr

-- Name supplies

type NameSupply = [Name]

-- a0,b0,...,z0,a1,b1,...
digitEndian :: NameSupply
digitEndian = zipWith (\i a -> a:(show $ div i 26)) [0..] . concat . repeat $ ['a'..'z']

-- a,b,...,z,aa,ab,...,zz,aaa,...
bigEndian :: NameSupply
bigEndian = [1..] >>= flip replicateM ['a'..'z']

-- a,b,...,z,aa,ba,...,za,ab,bb,...
littleEndian :: NameSupply
littleEndian = [ v:vs | vs <- [] : littleEndian, v <- ['a'..'z'] ]

-- Free Vars

type FreeVars = Set Name

freeIn :: Name -> Expr -> Bool
freeIn n (Var m)                  = n == m
freeIn n (Lam m b)    | n == m    = False
freeIn n (Lam m b)    | n /= m    = freeIn n b
freeIn n (App x y)                = freeIn n x || freeIn n y

-- NOTE: Observe how this mirrors freeIn
freeVars :: Expr -> FreeVars
freeVars (Var n)    = Set.singleton n
freeVars (Lam n b)  = Set.filter (/= n) (freeVars b)
freeVars (App e f)  = Set.union (freeVars e) (freeVars f)

extendFree :: Name -> FreeVars -> FreeVars
extendFree = Set.insert 

freeVarList :: [Name] -> FreeVars
freeVarList = Set.fromList

isFree :: Name -> FreeVars -> Bool
isFree = Set.member

isFreeIn :: FreeVars -> Name -> Bool
isFreeIn = flip isFree

freeName :: NameSupply -> FreeVars -> Name
freeName supply vars = head $ filter (not . isFreeIn vars) supply

-- Eval

eval :: Expr -> Expr
eval (Lam n e) = Lam n (eval e)
eval (App f a) = applyWhen (isLam f) eval $ beta (eval f) (eval a)
eval e = e

-- Substitute (n)ame with (e)xpr in (b)ody [of Lam]
--  subst n E B ~> B[n := E]
subst :: Name -> Expr -> Expr -> Expr
subst n e (Var m)   | n == m    = e
subst n e (Lam m b) | n /= m    = Lam m (subst n e b)
subst n e (App f a)             = App (subst n e f) (subst n e a)
subst _ _ b                     = b

-- Alpha conversion / reduction
alpha :: Set Name -> Expr -> Expr
alpha vars (Lam n b) | isFreeIn vars n = Lam m b'' where
    m   = freeName digitEndian vars
    b'  = subst n (Var m) b
    b'' = (alpha (extendFree m vars) b')
alpha vars (Lam n b) = Lam n (alpha vars b)
alpha _ e = e

-- Beta reduction
beta :: Expr -> Expr -> Expr
beta (App f a) b = App (beta f a) b
beta (Lam n b) e = subst n e (alpha (freeVars e) b)
beta f a = App f a

-- Helpers

isLam :: Expr -> Bool
isLam (Lam _ _) = True
isLam _         = False

applyWhen :: Bool -> (a -> a) -> a -> a
applyWhen True f x = f x
applyWhen _    _ x = x

Lambda.Simple.Repl

Up to this point, we've been building our expression evaluator while using cabal repl / ghci to handle input and output. This is great for development, but to run our interpreter outside of cabal repl / ghci, we'll have to supply our own repl. Thankfully, this is not that difficult. We're going to first build our own standard READ-EVAL-PRINT-LOOP, just to show how, and then we'll quickly replace it with one using the repline package, which provides a nice shell experience with almost no work.

haskell - src/Lambda/Simple/Repl.hs

module Lambda.Simple.Repl where

import Prelude hiding (read, print)
import System.IO hiding (read, print)

import Lambda.Simple.Eval
import Lambda.Simple.Expr
import Lambda.Simple.Parse

import Control.Monad.IO.Class

import System.Console.Repline hiding (ReplOpts(..))

-- The simple repl

repl :: IO ()
repl = do
    putStr "> "
    hFlush stdout
    input <- getLine
    case input of
        (':':cmd) -> do
            case cmd of
                "q" -> return ()
                _   -> do
                    putStrLn $ "Unrecognized command: " ++ cmd
                    hFlush stdout
                    repl
        _ -> do
            let e = parse input
                p = eval e
            putExprLn p
            repl

-- The fancy repline interpreter

type Repl a = HaskelineT IO a

banner :: MultiLine -> Repl String
banner SingleLine = return "> "
banner MultiLine  = return "| "

command :: String -> Repl ()
-- command x = liftIO $ putStrLn $ "Command was: " ++ x
command input = liftIO $ putExprLn p where
    e = parse input
    p = eval e

options :: [(String, String -> Repl ())] 
options =
    [   ("r", const $ liftIO $ putStrLn "Reloading...")
    ,   ("q", const $ abort)
    ,   ("?", const $ liftIO $ putStrLn
            "lci - lambda calculus interpreter\n\
            \Usage\n\
            \   Enter lambda calculus terms to evaluate them.\n\
            \   Example:\n\
            \       > (\\ x y . x) first second\n\
            \       first\n\
            \Option:\n\
            \   :r - reload\n\
            \   :q - quit\n\
            \   :? - display this help\n\
            \Done."
        )
    ]

sigil :: Maybe Char
sigil = Just ':'

multiLineSigil :: Maybe String
multiLineSigil = Nothing

completer :: CompleterStyle IO
completer = Word $ \ _ -> return []

initializer :: Repl ()
initializer = do
    liftIO $ putStrLn "Starting lci..."
    liftIO $ putStrLn "Enter ':?' for help. Press Ctrl-D to quit."

finalizer :: Repl ExitDecision
finalizer = do
    liftIO $ putStrLn "Leaving lci."
    return Exit

interpreter :: IO ()
interpreter = evalRepl
    banner
    command
    options
    sigil
    multiLineSigil
    completer
    initializer
    finalizer

And that's it!

Stay tuned! We'll expand more on the lambda calculus interpreter in future articles

TODO: Add repo / source download links.


- Leo D., Undated, revised / edited / published Aug 2022