Project 1: Pure Lambda Calculus Interpreter

Project 1: Pure Lambda Calculus Interpreter

Metadata

Attribute Value
Title Pure Lambda Calculus Interpreter
Difficulty Advanced
Main Language Haskell
Alternative Languages OCaml, Racket, Rust
Prerequisites Basic Haskell syntax, understanding of recursion, familiarity with algebraic data types
Estimated Time 2-3 weeks
Knowledge Area Programming Language Theory / Lambda Calculus
Main Book “Haskell Programming from First Principles” by Christopher Allen and Julie Moronuki

Learning Objectives

By completing this project, you will be able to:

  1. Explain the three fundamental constructs of lambda calculus (variables, abstraction, application) and demonstrate how they form a Turing-complete computational model.

  2. Implement a recursive descent parser for lambda expressions that correctly handles operator precedence, associativity, and nested structures.

  3. Distinguish between free and bound variables in any lambda expression and compute free variable sets algorithmically.

  4. Implement capture-avoiding substitution that correctly performs alpha-renaming when necessary to preserve variable bindings.

  5. Execute beta-reduction using multiple evaluation strategies (normal order, applicative order) and explain the tradeoffs between them.

  6. Demonstrate the Church-Rosser theorem practically by showing that different reduction paths converge to the same normal form.

  7. Identify divergent computations and explain why some lambda terms have no normal form.


Conceptual Foundation

The Birth of Computation Theory

Before there were computers, mathematicians asked a profound question: What does it mean to compute? In the 1930s, three brilliant minds independently answered this question with equivalent but superficially different formalisms:

  • Alan Turing proposed the Turing machine: a tape, a head, and state transitions
  • Alonzo Church proposed lambda calculus: anonymous functions and function application
  • Kurt Godel developed recursive function theory: primitive recursion and the mu operator

The remarkable fact is that all three systems have exactly the same computational power. Anything computable by a Turing machine can be computed in lambda calculus, and vice versa. This equivalence is the Church-Turing thesis.

Lambda calculus won the hearts of programming language theorists for a simple reason: it is elegant. While Turing machines require pages of tape descriptions to add two numbers, lambda calculus achieves the same with pure abstraction.

The Syntax: Three Constructs to Rule Them All

Lambda calculus has the smallest possible syntax that is still Turing-complete. Every lambda term is one of exactly three things:

1. Variables

A variable is just a name: x, y, foo, answer. Variables are placeholders that will eventually be filled with values (which are themselves lambda terms).

x
y
myVariable

2. Abstraction (Lambda Functions)

An abstraction creates a function. The syntax is lambda x . body or, using the Greek letter, (x. body). The variable x is called the bound variable or parameter, and body is any lambda term.

lambda x . x                    -- The identity function
lambda f . lambda x . f x       -- A function that applies f to x
lambda x . lambda y . x         -- A function that ignores its second argument

An abstraction is an anonymous function waiting to receive an argument. The term lambda x . x says: “Give me something, call it x, and I will return x.”

3. Application (Function Calls)

Application is how we “call” functions. The syntax is simply two terms next to each other: (f a) means “apply function f to argument a.”

(lambda x . x) y               -- Apply identity to y
(lambda x . x x) (lambda y . y) -- Apply self-application to identity

That is it. There are no numbers, no strings, no if-statements, no loops, no data structures. And yet, as Church proved, this is enough to express any computation.

Parentheses, Precedence, and Parsing Conventions

To reduce parentheses, we adopt conventions:

Application associates left:

f a b c    means    ((f a) b) c

Abstraction extends as far right as possible:

lambda x . lambda y . x y    means    lambda x . (lambda y . (x y))

Application has higher precedence than abstraction:

lambda x . f x    means    lambda x . (f x)    not    (lambda x . f) x

Free Variables vs Bound Variables

Understanding the distinction between free and bound variables is crucial for correct substitution.

A variable x is bound in a term if it appears inside a lambda x . ... that binds it. Otherwise, it is free.

lambda x . x           -- x is bound
lambda x . y           -- x is bound (unused), y is free
lambda x . x y         -- x is bound, y is free
(lambda x . x) x       -- the first x is bound, the second x is free!

The same variable name can appear both free and bound in the same term:

(lambda x . x) x
     ^----^  bound
             ^  free

We compute free variables recursively:

  • FV(x) = {x} - a variable is free in itself
  • FV(lambda x . e) = FV(e) - {x} - abstraction binds x
  • FV(e1 e2) = FV(e1) + FV(e2) - union of free variables

The Heart of Computation: Beta Reduction

Beta reduction is how computation happens in lambda calculus. When you apply a function to an argument, you substitute the argument for the parameter in the function body:

(lambda x . body) arg    -->    body[x := arg]

The notation body[x := arg] means “replace every free occurrence of x in body with arg.”

Example:

(lambda x . x) y
  --> y[x := y]      -- substitute y for x in x
  --> y

(lambda x . x x) y
  --> (x x)[x := y]   -- substitute y for x in (x x)
  --> y y

(lambda f . lambda x . f (f x)) g
  --> (lambda x . f (f x))[f := g]
  --> lambda x . g (g x)

A term that can be beta-reduced is called a redex (reducible expression). Specifically, a redex is any subterm of the form (lambda x . body) arg.

The Trap: Variable Capture

Naive substitution is wrong. Consider:

(lambda x . lambda y . x) y

This function takes an argument and returns a function that always returns that argument, ignoring its own parameter. If we apply it to y:

Naive (WRONG) substitution:

(lambda x . lambda y . x) y
  --> (lambda y . x)[x := y]
  --> lambda y . y          -- WRONG! This is the identity function!

The problem: the free variable y in the argument got captured by the lambda y in the body. The result is nonsense.

Correct substitution: Before substituting, we rename the bound variable to avoid capture:

(lambda x . lambda y . x) y
  --> (lambda x . lambda z . x) y    -- alpha-rename y to z
  --> (lambda z . x)[x := y]
  --> lambda z . y                   -- Correct! Returns y regardless of input

This renaming is called alpha conversion or alpha renaming. Two terms that differ only by the names of their bound variables are considered alpha-equivalent:

lambda x . x    =alpha=    lambda y . y    =alpha=    lambda foo . foo

Substitution: The Complete Algorithm

Here is the complete, capture-avoiding substitution algorithm:

x[x := s]           = s                          -- Replace variable with argument
y[x := s]           = y        (when y != x)     -- Leave other variables alone

(e1 e2)[x := s]     = (e1[x := s]) (e2[x := s])  -- Substitute in both parts

(lambda x . e)[x := s] = lambda x . e            -- x is shadowed, no substitution

(lambda y . e)[x := s] where y != x:
  if y not in FV(s):
    = lambda y . (e[x := s])                      -- Safe to substitute
  else:
    let z = fresh variable not in FV(s) or FV(e)
    = lambda z . ((e[y := z])[x := s])            -- Alpha-rename first!

The key insight: we only need to alpha-rename when the bound variable would capture a free variable in the argument.

Reduction Strategies: Which Redex First?

When a term has multiple redexes, which do we reduce first? Different strategies give different behaviors:

Normal Order (Leftmost-Outermost First)

Always reduce the leftmost redex that is not inside another redex. This is “lazy” evaluation - arguments are substituted unevaluated.

(lambda x . lambda y . y) ((lambda z . z z) (lambda w . w w))

Redexes:
1. (lambda x . ...) (...)  <-- leftmost-outermost
2. (lambda z . z z) (...)  <-- inside #1

Normal order reduces #1 first:
--> lambda y . y

Applicative Order (Leftmost-Innermost First)

Always reduce the innermost redex first. This is “eager” evaluation - arguments are fully evaluated before substitution.

(lambda x . lambda y . y) ((lambda z . z z) (lambda w . w w))

Applicative order reduces #2 first:
--> (lambda x . lambda y . y) ((lambda w . w w) (lambda w . w w))
--> (lambda x . lambda y . y) ((lambda w . w w) (lambda w . w w))
--> ... (infinite loop!)

The Church-Rosser Theorem

One of the most beautiful results in lambda calculus:

Theorem (Church-Rosser): If a term has a normal form (a term with no redexes), all reduction strategies that terminate will reach the same normal form.

Furthermore, normal order reduction will find the normal form if one exists.

This is why Haskell uses lazy evaluation (based on normal order): it is more likely to terminate.

The term (lambda z . z z) (lambda w . w w) is called omega. It has no normal form:

(lambda z . z z) (lambda w . w w)
--> (lambda w . w w) (lambda w . w w)
--> (lambda w . w w) (lambda w . w w)
--> ...

Omega diverges under any reduction strategy. Some terms simply do not halt.

Normal Forms and Termination

A term is in normal form if it contains no redexes. Normal forms are “final answers” - there is nothing left to compute.

Not every term has a normal form. The simplest example is omega (self-application of self-application). But some terms reach a normal form under normal order but diverge under applicative order:

(lambda x . lambda y . y) omega

Under normal order: ignores the omega, returns lambda y . y. Under applicative order: tries to evaluate omega first, loops forever.

This is the power of laziness: it can avoid infinite loops by not evaluating unused arguments.

De Bruijn Indices: An Alternative Representation

Working with names is error-prone. An elegant alternative is de Bruijn indices: instead of names, use numbers indicating how many lambda-binders to skip.

lambda x . x           becomes    lambda . 0
lambda x . lambda y . x becomes   lambda . lambda . 1
lambda x . lambda y . y becomes   lambda . lambda . 0

The index counts how many lambdas away the binding site is. This eliminates alpha-equivalence issues entirely - alpha-equivalent terms have identical de Bruijn representations.

Substitution becomes index shifting, which is mechanical but can be tricky to implement correctly.

Connection to Haskell

Haskell is essentially typed lambda calculus with syntax sugar and built-in types. Every Haskell function is a lambda abstraction:

-- Haskell
add x y = x + y

-- Desugars to
add = \x -> \y -> x + y

-- Which is lambda calculus with + as a primitive

Haskell’s pattern matching desugars to case expressions, which desugar to Church encodings (conceptually). Haskell’s evaluation is call-by-need, a more efficient version of normal order that avoids re-evaluating the same expression.

Why This Project Matters

Implementing a lambda calculus interpreter teaches you:

  1. The essence of computation: Functions and function application are sufficient for any computation.

  2. Variable binding: The concepts of scope, free variables, and capture that appear in every programming language.

  3. Evaluation strategies: The difference between eager and lazy evaluation, which affects performance and termination.

  4. Parsing recursive structures: The term structure of lambda calculus mirrors the AST of real languages.

  5. Semantic correctness: The subtleties of substitution that cause real bugs in real compilers.


Project Specification

Core Requirements

Build a command-line interpreter for the untyped lambda calculus with the following features:

1. Parser

  • Parse lambda expressions with standard syntax: \x. body or lambda x. body
  • Handle nested abstractions: \x. \y. x y
  • Handle nested applications with left-associativity: f a b = (f a) b
  • Support parentheses for grouping
  • Provide clear error messages for syntax errors

2. Pretty Printer

  • Convert AST back to readable string representation
  • Minimize parentheses while preserving semantics
  • Support both symbolic (\) and spelled-out (lambda) notation

3. Free Variable Analysis

  • Compute the set of free variables in any term
  • Used for capture-avoiding substitution

4. Capture-Avoiding Substitution

  • Implement the full substitution algorithm
  • Correctly alpha-rename when necessary
  • Generate fresh variable names that do not conflict

5. Beta Reduction

  • Single-step reduction: find a redex and reduce it
  • Multi-step reduction: reduce until normal form or step limit
  • Detect divergence (same term appears twice)

6. Multiple Reduction Strategies

  • Normal order: leftmost-outermost first
  • Applicative order: leftmost-innermost first
  • User can select strategy

7. Reduction Trace

  • Show each step of reduction
  • Indicate which redex was reduced
  • Show the substitution being performed

Input/Output Format

Lambda> (\x. x) y
Parsed: App (Lam "x" (Var "x")) (Var "y")
Normal form: y
Steps: 1

Lambda> (\x. \y. x) a b
Parsed: App (App (Lam "x" (Lam "y" (Var "x"))) (Var "a")) (Var "b")
Reduction trace:
  (\x. \y. x) a b
  -> (\y. a) b        [x := a]
  -> a                [y := b]
Normal form: a
Steps: 2

Lambda> :strategy applicative
Strategy set to: applicative order

Lambda> :free \x. y x
Free variables: {y}

Lambda> (\x. x x) (\x. x x)
Parsed: App (Lam "x" (App (Var "x") (Var "x"))) (Lam "x" (App (Var "x") (Var "x")))
WARNING: Diverges (step limit reached)
Reduction trace:
  (\x. x x) (\x. x x)
  -> (\x. x x) (\x. x x)
  -> (\x. x x) (\x. x x)
  ... (loops)

Commands

Command Description
<expr> Parse and reduce expression to normal form
:parse <expr> Show AST without reducing
:step <expr> Single-step reduction
:trace <expr> Show full reduction trace
:free <expr> Show free variables
:strategy normal Use normal order (default)
:strategy applicative Use applicative order
:limit <n> Set step limit (default 1000)
:help Show help
:quit Exit

Solution Architecture

Data Structures

Lambda Term AST

data Term
  = Var String           -- Variable reference
  | Lam String Term      -- Lambda abstraction: \x. body
  | App Term Term        -- Application: f x
  deriving (Eq, Show)

Reduction Strategy

data Strategy = NormalOrder | ApplicativeOrder

Reduction Result

data ReductionResult
  = NormalForm Term              -- Reached normal form
  | Diverges [Term]              -- Detected loop or hit limit
  | StepResult Term [Term]       -- Intermediate result with history

Module Organization

src/
  Lambda/
    Syntax.hs        -- Term data type, pretty printing
    Parser.hs        -- Parsing lambda expressions
    FreeVars.hs      -- Free variable computation
    Substitution.hs  -- Capture-avoiding substitution
    Reduction.hs     -- Beta reduction, strategies
    Interpreter.hs   -- REPL, commands
  Main.hs            -- Entry point
test/
  Lambda/
    ParserSpec.hs
    SubstitutionSpec.hs
    ReductionSpec.hs

Key Algorithms

Free Variables

freeVars :: Term -> Set String
freeVars (Var x) = singleton x
freeVars (Lam x body) = delete x (freeVars body)
freeVars (App f a) = freeVars f `union` freeVars a

Fresh Variable Generation

-- Generate x', x'', x''' etc. until finding one not in the avoid set
freshVar :: String -> Set String -> String
freshVar base avoid = head [v | v <- candidates, v `notMember` avoid]
  where candidates = base : [base ++ replicate n '\'' | n <- [1..]]

Capture-Avoiding Substitution

subst :: String -> Term -> Term -> Term
-- subst x s term = term[x := s]

subst x s (Var y)
  | x == y    = s
  | otherwise = Var y

subst x s (App f a) = App (subst x s f) (subst x s a)

subst x s (Lam y body)
  | x == y = Lam y body  -- x is shadowed
  | y `notMember` freeVars s = Lam y (subst x s body)  -- safe
  | otherwise =
      let y' = freshVar y (freeVars s `union` freeVars body `union` singleton x)
      in Lam y' (subst x s (subst y (Var y') body))

Finding Redexes

-- A path through the term tree
data Path = Here | LeftOf Path | RightOf Path | BodyOf Path

-- Find all redexes with their paths
findRedexes :: Term -> [(Path, Term)]

Reduction Strategies

-- Normal order: reduce leftmost-outermost redex
normalOrderStep :: Term -> Maybe Term

-- Applicative order: reduce leftmost-innermost redex
applicativeOrderStep :: Term -> Maybe Term

-- Reduce to normal form
reduce :: Strategy -> Int -> Term -> ReductionResult

Implementation Guide

Phase 1: Data Types and Pretty Printing (Days 1-2)

Milestone: Define the Term type and implement show/prettyPrint.

  1. Create Syntax.hs with the Term data type
  2. Implement Show instance for debugging
  3. Implement prettyPrint with minimal parentheses
  4. Test: prettyPrint (App (App (Var "f") (Var "x")) (Var "y")) = "f x y"

Hint Layer 1: Use a precedence parameter in pretty printing Hint Layer 2: Application is left-associative, so f x y needs no parens Hint Layer 3: Lambda extends right, so \x. \y. x needs no parens Hint Layer 4: Only add parens when a lower-precedence construct is inside a higher one

Phase 2: Parser (Days 3-5)

Milestone: Parse lambda expressions from strings.

  1. Create Parser.hs using Text.Parsec or write a recursive descent parser
  2. Parse variables: identifiers starting with lowercase
  3. Parse abstractions: \x. or lambda x. followed by term
  4. Parse applications: terms next to each other (left-associative)
  5. Handle parentheses
  6. Write comprehensive parser tests

Hint Layer 1: Define parseVar, parseLam, parseApp, parseTerm Hint Layer 2: Handle left-associativity with chainl1 or a loop Hint Layer 3: The grammar is: term = atom | term atom where atom = var | lam | (term) Hint Layer 4: Be careful with infinite recursion; factor out atom and application

Phase 3: Free Variables (Day 6)

Milestone: Compute free variables for any term.

  1. Create FreeVars.hs
  2. Implement freeVars :: Term -> Set String
  3. Test on examples with both free and bound variables

Hint Layer 1: Use Data.Set for efficient set operations Hint Layer 2: The recursive structure matches the term structure exactly Hint Layer 3: Remember: Lam x body binds x, so remove it from freeVars body

Phase 4: Substitution (Days 7-9)

Milestone: Implement capture-avoiding substitution.

  1. Create Substitution.hs
  2. Implement freshVar for generating fresh variable names
  3. Implement subst :: String -> Term -> Term -> Term
  4. Test extensively, especially capture cases!

Critical test cases:

-- Should NOT capture y:
subst "x" (Var "y") (Lam "y" (Var "x"))
-- Expected: Lam "y'" (Var "y") or similar, NOT Lam "y" (Var "y")

-- Shadowing - should not substitute:
subst "x" (Var "z") (Lam "x" (Var "x"))
-- Expected: Lam "x" (Var "x")

Hint Layer 1: Handle the shadowing case first (when bound var equals subst var) Hint Layer 2: Check if bound var is free in the substituted term Hint Layer 3: If capture would occur, alpha-rename before substituting Hint Layer 4: The fresh variable must not be in FV(s), FV(body), or equal to x

Phase 5: Beta Reduction (Days 10-12)

Milestone: Implement single-step and multi-step reduction.

  1. Create Reduction.hs
  2. Implement findRedexes :: Term -> [(Path, Term)]
  3. Implement reduceAt :: Path -> Term -> Term
  4. Implement normalOrderStep :: Term -> Maybe Term
  5. Implement applicativeOrderStep :: Term -> Maybe Term
  6. Implement reduce :: Strategy -> Int -> Term -> ReductionResult

Hint Layer 1: A redex is exactly App (Lam x body) arg Hint Layer 2: Use the Path type to track positions and reduce at specific locations Hint Layer 3: Normal order: first redex in list (naturally leftmost-outermost if you traverse correctly) Hint Layer 4: Detect divergence by keeping history and checking for repeats

Phase 6: REPL (Days 13-14)

Milestone: Build an interactive interpreter.

  1. Create Interpreter.hs
  2. Implement command parsing (:parse, :step, etc.)
  3. Implement the main REPL loop
  4. Format output nicely with reduction traces
  5. Handle errors gracefully

Hint Layer 1: Use haskeline for line editing and history Hint Layer 2: Pattern match on input to detect commands vs expressions Hint Layer 3: Store current strategy and step limit in a state record


Testing Strategy

Unit Tests

Parser Tests:

testParseVar = parse "x" `shouldBe` Right (Var "x")
testParseId = parse "\\x. x" `shouldBe` Right (Lam "x" (Var "x"))
testParseApp = parse "f x y" `shouldBe` Right (App (App (Var "f") (Var "x")) (Var "y"))
testParseNested = parse "\\x. \\y. x" `shouldBe` Right (Lam "x" (Lam "y" (Var "x")))
testParseParens = parse "(\\x. x) y" `shouldBe` Right (App (Lam "x" (Var "x")) (Var "y"))

Free Variables Tests:

testFV1 = freeVars (Var "x") `shouldBe` Set.singleton "x"
testFV2 = freeVars (Lam "x" (Var "x")) `shouldBe` Set.empty
testFV3 = freeVars (Lam "x" (Var "y")) `shouldBe` Set.singleton "y"
testFV4 = freeVars (App (Var "f") (Lam "x" (Var "x"))) `shouldBe` Set.singleton "f"

Substitution Tests:

testSubstSimple = subst "x" (Var "y") (Var "x") `shouldBe` Var "y"
testSubstNotFree = subst "x" (Var "y") (Var "z") `shouldBe` Var "z"
testSubstShadow = subst "x" (Var "y") (Lam "x" (Var "x")) `shouldBe` Lam "x" (Var "x")
testSubstCapture = -- subst "x" (Var "y") (Lam "y" (Var "x"))
  -- Should alpha-rename y to avoid capture
  let result = subst "x" (Var "y") (Lam "y" (Var "x"))
  in freeVars result `shouldBe` Set.singleton "y"

Reduction Tests:

testBetaId = reduce NormalOrder 100 (App (Lam "x" (Var "x")) (Var "y"))
  `shouldBe` NormalForm (Var "y")

testBetaConst = reduce NormalOrder 100 (App (App (Lam "x" (Lam "y" (Var "x"))) (Var "a")) (Var "b"))
  `shouldBe` NormalForm (Var "a")

testDiverges = case reduce NormalOrder 10 omega of
  Diverges _ -> True
  _ -> False
  where omega = App (Lam "x" (App (Var "x") (Var "x"))) (Lam "x" (App (Var "x") (Var "x")))

Property-Based Tests

-- Round-trip: parse . prettyPrint = id
prop_parseRoundTrip :: Term -> Bool
prop_parseRoundTrip t = parse (prettyPrint t) == Right t

-- Free variables subset: FV(M[x:=N]) subset of FV(M) union FV(N)
prop_freeVarsSubst :: String -> Term -> Term -> Bool
prop_freeVarsSubst x n m =
  freeVars (subst x n m) `Set.isSubsetOf` (freeVars m `Set.union` freeVars n)

-- Church-Rosser: if M reduces to both N1 and N2 in normal form, N1 = N2 (up to alpha)
prop_churchRosser :: Term -> Bool
prop_churchRosser m = case (normalReduce m, applicativeReduce m) of
  (NormalForm n1, NormalForm n2) -> alphaEquiv n1 n2
  _ -> True  -- Divergence is okay

Integration Tests

Test full sessions:

testSession = do
  repl ":strategy normal"
  result <- repl "(\\x. \\y. y) omega"
  result `shouldContain` "lambda y . y"

  repl ":strategy applicative"
  result <- repl "(\\x. \\y. y) omega"
  result `shouldContain` "Diverges"

Common Pitfalls

1. Forgetting to Handle Capture

Symptom: (\x. \y. x) y reduces to \y. y instead of \z. y

Cause: Substitution does not alpha-rename before substituting.

Fix: Check if the bound variable is free in the argument; if so, rename it.

2. Incorrect Associativity in Parser

Symptom: f x y parses as f (x y) instead of (f x) y

Cause: Recursive descent naturally right-associates.

Fix: Use a loop or chainl1 to enforce left associativity for application.

3. Infinite Loop in Substitution

Symptom: subst hangs on certain inputs

Cause: Recursive call with unchanged arguments, or generating “fresh” variables that are not actually fresh.

Fix: Ensure freshVar truly generates unused variables; ensure termination by structural recursion.

4. Confusing Strategies

Symptom: Normal order does not find normal form when it should

Cause: Reducing inner redexes before outer ones.

Fix: Normal order must find the leftmost redex that is NOT inside another redex. Traverse top-down.

5. Alpha-Equivalence in Comparisons

Symptom: \x. x and \y. y compare as not equal, breaking divergence detection.

Cause: Using structural equality instead of alpha-equivalence.

Fix: Implement alphaEquiv or use de Bruijn indices for comparisons.

6. Memory Blowup on Large Reductions

Symptom: Reduction of complex terms runs out of memory

Cause: Keeping entire reduction history, or non-sharing substitution.

Fix: Limit history size; consider using a graph representation with sharing.


Extensions and Challenges

Extension 1: De Bruijn Indices

Implement an alternative representation using de Bruijn indices:

  • Convert named terms to de Bruijn terms
  • Implement substitution as index shifting
  • Convert back for display

Extension 2: Named Definitions

Add a :let name = expr command to define reusable terms:

Lambda> :let id = \x. x
Lambda> :let const = \x. \y. x
Lambda> const id id
\y. y

Extension 3: Multiple Steps Per Line

Show intermediate steps inline:

(\x. x) ((\y. y) z) -> (\x. x) z -> z

Extension 4: Call-by-Need Reduction

Implement call-by-need (lazy) evaluation that shares reduced subterms to avoid redundant computation.

Extension 5: Web Interface

Build a web UI (using Elm or PureScript for consistency with FP) that shows:

  • The AST as a tree
  • Redexes highlighted in color
  • Click on a redex to reduce it

Extension 6: Normal Form Detection

Implement algorithms to detect:

  • Head normal form
  • Weak head normal form
  • Strong normalization (for simply-typed terms)

Real-World Connections

Haskell’s Evaluation Model

Haskell uses call-by-need evaluation, an optimized version of normal order:

  • Arguments are not evaluated until needed (like normal order)
  • Once evaluated, the result is memoized (unlike normal order)

Understanding normal order is essential to understanding Haskell’s performance characteristics.

Compilation Techniques

Real functional language compilers transform source code through multiple intermediate representations:

  • Core (GHC): essentially typed lambda calculus
  • STG (Spineless Tagless G-machine): lower-level lambda calculus with explicit allocation

The substitution and reduction you implement are simplified versions of what compilers do.

Type Theory and Proof Assistants

Lambda calculus is the foundation of:

  • Simply typed lambda calculus - the Curry-Howard isomorphism
  • System F - polymorphic lambda calculus (Haskell’s type system)
  • Calculus of Constructions - the basis of Coq and Lean

Your interpreter is step one toward building a proof assistant.

Program Analysis

Techniques you use here appear in:

  • Closure conversion: tracking free variables for compilation
  • Inlining: essentially beta reduction in an optimizer
  • Defunctionalization: converting higher-order programs to first-order

Interview Questions

After completing this project, you should be able to answer:

  1. What are the three constructs of lambda calculus? Why are they sufficient for universal computation?

  2. Explain the difference between free and bound variables. Give an example where the same variable name appears both free and bound.

  3. What is variable capture, and why is it a problem? How does alpha-renaming solve it?

  4. What is the difference between normal order and applicative order reduction? When does the choice matter?

  5. State the Church-Rosser theorem. What does it tell us about the uniqueness of normal forms?

  6. Give an example of a lambda term that has no normal form. Why does it diverge?

  7. How does lazy evaluation in Haskell relate to normal order reduction in lambda calculus? What optimization does call-by-need add?


Self-Assessment Checklist

You have mastered this project when you can:

  • Write any lambda expression in standard notation and parse it correctly
  • Identify all free and bound variables in a complex lambda term
  • Perform capture-avoiding substitution by hand, knowing when to rename
  • Trace beta-reduction step-by-step using both normal and applicative order
  • Predict whether a term will diverge or reach a normal form
  • Explain why normal order is “safer” for finding normal forms
  • Describe how your interpreter relates to Haskell’s evaluation
  • Implement all core features without looking at reference code
  • Write property-based tests that reveal subtle substitution bugs
  • Extend your interpreter with new features independently

Resources

Primary References

Resource Use For Specific Chapters
“Types and Programming Languages” by Pierce Rigorous theory of lambda calculus Chapters 5-7
“Haskell Programming from First Principles” Haskell implementation skills Chapters 1-3, 11
“Programming in Haskell” by Hutton Parsing in Haskell Chapter 13 (Parsing)

Papers and Tutorials

Resource Description
A Tutorial Introduction to the Lambda Calculus Excellent 10-page intro by Raul Rojas
Stanford CS 242: Lambda Calculus Clear lecture notes with examples
Lambda Calculus and Combinators Hindley & Seldin - comprehensive textbook

Online Tools

Tool Use For
Lambda Calculator Compare your results
Lambda Viewer Visualize reductions

Video Lectures

Lecture Platform
“Lambda Calculus - Fundamentals of Lambda Calculus” by Derek Banas YouTube
“CS 152: Programming Languages” by Adam Chlipala (MIT) MIT OpenCourseWare
“Category Theory for Programmers” by Bartosz Milewski YouTube

“The lambda calculus… is the smallest universal programming language in the world.” - Peter Landin