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)
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:
dataExpr = VarName | LamNameExpr | AppExprExpr
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 whereimport PreludetypeName = String-- The expression data typedataExpr = VarName | LamNameExpr | AppExprExprderiving (Show, Eq)-- Encoding to stringencode :: Expr -> Stringencode = 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 printingputExpr :: Expr -> IO ()putExpr = putStr . encodeputExprLn :: 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 whereimport Preludeimport Data.Eitherimport Data.Functorimport Control.Monadimport Lambda.Simple.Exprimportqualified Text.Parsec as Parsecimport Text.Parsec hiding (parse, runParser)runParser str = Parsec.runParser (expr <* eof) () "" strparse str = case runParser str of (Right r) -> r (Left e) -> error $ show eexpr :: (Stream s m Char) => ParsecT s u m Exprexpr = 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 whereimport Preludeimport Data.Boolimport Data.Eitherimport Data.Set (Set)importqualified Data.Set as Setimport Control.Monadimport Lambda.Simple.Expr-- Name suppliestypeNameSupply = [Name]-- a0,b0,...,z0,a1,b1,...digitEndian :: NameSupplydigitEndian = zipWith (\i a -> a:(show $ div i 26)) [0..] . concat . repeat $ ['a'..'z']-- a,b,...,z,aa,ab,...,zz,aaa,...bigEndian :: NameSupplybigEndian = [1..] >>= flip replicateM ['a'..'z']-- a,b,...,z,aa,ba,...,za,ab,bb,...littleEndian :: NameSupplylittleEndian = [ v:vs | vs <- [] : littleEndian, v <- ['a'..'z'] ]-- Free VarstypeFreeVars = SetNamefreeIn :: Name -> Expr -> BoolfreeIn n (Var m) = n == mfreeIn n (Lam m b) | n == m = FalsefreeIn n (Lam m b) | n /= m = freeIn n bfreeIn n (App x y) = freeIn n x || freeIn n y-- NOTE: Observe how this mirrors freeInfreeVars :: Expr -> FreeVarsfreeVars (Var n) = Set.singleton nfreeVars (Lam n b) = Set.filter (/= n) (freeVars b)freeVars (App e f) = Set.union (freeVars e) (freeVars f)extendFree :: Name -> FreeVars -> FreeVarsextendFree = Set.insert freeVarList :: [Name] -> FreeVarsfreeVarList = Set.fromListisFree :: Name -> FreeVars -> BoolisFree = Set.memberisFreeIn :: FreeVars -> Name -> BoolisFreeIn = flip isFreefreeName :: NameSupply -> FreeVars -> NamefreeName supply vars = head $ filter (not . isFreeIn vars) supply-- Evaleval :: Expr -> Expreval (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 -> Exprsubst n e (Var m) | n == m = esubst 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 / reductionalpha :: SetName -> Expr -> Expralpha 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 reductionbeta :: Expr -> Expr -> Exprbeta (App f a) b = App (beta f a) bbeta (Lam n b) e = subst n e (alpha (freeVars e) b)beta f a = App f a-- HelpersisLam :: Expr -> BoolisLam (Lam _ _) = TrueisLam _ = FalseapplyWhen :: Bool -> (a -> a) -> a -> aapplyWhenTrue f x = f xapplyWhen _ _ 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 whereimport Prelude hiding (read, print)import System.IO hiding (read, print)import Lambda.Simple.Evalimport Lambda.Simple.Exprimport Lambda.Simple.Parseimport Control.Monad.IO.Classimport System.Console.Repline hiding (ReplOpts(..))-- The simple replrepl :: IO ()repl = do putStr "> " hFlush stdout input <- getLinecase input of (':':cmd) -> docase cmd of"q" -> return () _ -> do putStrLn $ "Unrecognized command: " ++ cmd hFlush stdout repl _ -> dolet e = parse input p = eval e putExprLn p repl-- The fancy repline interpretertypeRepl a = HaskelineTIO abanner :: MultiLine -> ReplStringbannerSingleLine = return "> "bannerMultiLine = return "| "command :: String -> Repl ()-- command x = liftIO $ putStrLn $ "Command was: " ++ xcommand input = liftIO $ putExprLn p where e = parse input p = eval eoptions :: [(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 :: MaybeCharsigil = Just ':'multiLineSigil :: MaybeStringmultiLineSigil = Nothingcompleter :: CompleterStyleIOcompleter = Word $ \ _ -> return []initializer :: Repl ()initializer = do liftIO $ putStrLn "Starting lci..." liftIO $ putStrLn "Enter ':?' for help. Press Ctrl-D to quit."finalizer :: ReplExitDecisionfinalizer = do liftIO $ putStrLn "Leaving lci." return Exitinterpreter :: 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