Lambda Calculus
Computation from Functions Alone

Three rules. No numbers, no loops, no data types. And yet it can compute anything a computer can.

Syntax

Variables, λ-abstraction, application. That is the whole language.

Reduction

β runs a function. α renames. η tidies. Church–Rosser makes it sane.

Encodings

Booleans, numbers, pairs, lists and recursion, all built from functions.

Types

Simple types, strong normalization, Curry–Howard, Hindley–Milner.

Every Python snippet in this deck runs as shown. All outputs are copied from real runs with Python 3.11+.

Roadmap WHERE WE GO

  1. A short history: Church, Turing, Curry
  2. λ is just anonymous functions
  3. Syntax and the parsing rules
  4. Free and bound variables, with practice
  5. Capture-avoiding substitution
  6. β- and η-reduction, step-by-step traces
  7. A Python parser and reducer
  8. Reduction strategies and divergence (Ω)
  9. Church–Rosser, then a checkpoint
  10. Church booleans and pairs
  11. Church numerals and arithmetic
  12. Predecessor: Kleene's trick
  13. Encodings as real Python lambdas
  14. Lists as folds
  15. Fixed points: Y, and FACT unrolled
  16. Z: recursion under call-by-value
  17. SKI combinators and bracket abstraction
  18. Turing equivalence and undecidability
  19. de Bruijn indices
  20. Simply-typed λ and strong normalization
  21. Curry–Howard
  22. Hindley–Milner type inference
  23. Influence on Lisp, ML, Haskell, Python
  24. Check yourself (two rounds)
  25. Pitfalls, summary, glossary

Where it came from HISTORY

YearEvent
1928Hilbert asks the Entscheidungsproblem: is there a method to decide every math statement?
1932–35Alonzo Church proposes λ as a basis for logic (1932–33). Kleene and Rosser show that system is inconsistent (1935).
1936Church keeps the untyped λ-calculus as a pure model of computation. He proves the Entscheidungsproblem unsolvable.
1936Church & Rosser prove confluence. Turing publishes his machines, and shows they match λ.
1940Church adds simple types to fix the logic.
1958McCarthy's Lisp borrows LAMBDA.
1964–66Landin maps ALGOL onto λ (the SECD machine, ISWIM).
1969–78Scott builds math models. Hindley and Milner give type inference. ML is born.

Why study it today?

  • It is the smallest real programming language.
  • Closures, higher-order functions and currying come from here.
  • Type systems of ML, Haskell, Rust, TypeScript start here.
  • Proof assistants (Coq, Lean, Agda) are typed λ-calculi.
  • Compilers use it as a core IR (GHC Core, CPS).

The name

Church wrote x̂ (a hat over the x) for a bound variable. The printer moved the hat in front: ∧x. It then became the Greek λx. That is the usual story. Church himself later said it was just a letter he picked.

λ is just anonymous functions INTUITION FIRST

You already know λ-calculus. It is the lambda in Python, with everything else removed.

IdeaPythonJavaScriptλ-calculus
make a functionlambda x: xx => xλx. x
call itf(3)f(3)f 3 (no brackets needed)
two argumentslambda x: lambda y: xx => y => xλx y. x
call at once(lambda x: x * x)(5)(x => x * x)(5)(λx. x·x) 5 → 5·5
composelambda f, g: lambda x: f(g(x))(f, g) => x => f(g(x))λf g x. f (g x)
add = lambda x: lambda y: x + y      # curried: one argument at a time
inc = add(1)                         # partial application
print(inc(41), add(2)(3), (lambda x: x * x)(5))
42 5 25

Common mistake

Reading f 3 4 as f(3, 4). It means (f 3) 4: call f on 3, then call the result on 4.

What is removed

No numbers. No +. No if. No loops. No names for functions. Only three things remain: variables, making a function, and calling one.

The surprise of this deck: those three are enough. We will build numbers, booleans, lists and recursion out of functions alone.

On this slide, x·x and 5 are borrowed from ordinary math. From slide "Numerals" on, we build them from scratch.

Syntax: three kinds of term GRAMMAR

Terms. Given an infinite set of variables x, y, z, …

M, N ::= x   |   (λx. M)   |   (M N)

  • Variable x: a name.
  • Abstraction λx. M: a function with parameter x and body M.
  • Application M N: call M on argument N.

Conventions (to drop brackets)

  • Application is left-associative: f a b = (f a) b.
  • A body stretches as far right as it can: λx. x y = λx. (x y).
  • Nested λs merge: λx y. M = λx. λy. M.

Examples

TermReading
λx. xidentity, I
λx y. xconstant, K (keep first)
λf g x. f (g x)compose, B
λx. x xself-apply, ω
(λx. x x)(λx. x x)Ω, loops forever

Currying

There are only one-argument functions. A two-argument function returns a function: λx. λy. M. The idea is named after Haskell Curry, but Schönfinkel had it first (1924).

@ λx y x AST of (λx. x) y

Free and bound variables, α-conversion SCOPE

Free variables FV(M), by structure:

FV(x) = {x}
FV(λx. M) = FV(M) − {x}
FV(M N) = FV(M) ∪ FV(N)

A variable that is not free is bound by the nearest enclosing λ with its name. A term with no free variables is closed (a combinator).

α-conversion. Renaming a bound variable does not change meaning:

λx. M  =α  λy. M[x := y]   if y ∉ FV(M)

So λx. x and λz. z are the same term. We always work up to α.

def free(t):
    if isinstance(t, Var): return {t.name}
    if isinstance(t, Lam): return free(t.body) - {t.param}
    return free(t.fn) | free(t.arg)

print(sorted(free(parse(r"λx. x y z"))))
['y', 'z']

Shadowing

In λx. (λx. x) x the inner x refers to the inner binder. The last x refers to the outer one. It is exactly like nested scopes in Python:

f = lambda x: (lambda x: x)(x + 1)
print(f(1))   # inner x is 2

Output: 2

Worked: reading terms PRACTICE

Three rules, in this order

  1. Body. A λ body runs to the right until a closing bracket or the end.
  2. Apps. Group applications from the left: a b c = (a b) c.
  3. Binders. Link each variable to the nearest enclosing λ of the same name. No binder? It is free.
λx. x y z        => (λx. ((x y) z))        FV = ['y', 'z']
λx y. y x        => (λx. (λy. (y x)))      FV = []
(λx. x) y z      => (((λx. x) y) z)        FV = ['y', 'z']
λx. (λy. x y) y  => (λx. ((λy. (x y)) y))  FV = ['y']
x λy. y x        => (x (λy. (y x)))        FV = ['x']

Output of the deck's parse and free, printed with full brackets.

Look closely at line 4

λx. (λy. x y) y

  • The first y (inside x y) is bound by λy.
  • The last y is outside that bracket. No λy covers it. It is free.
  • Same name, two different variables. That is why we need α-renaming.

Try it

Bracket fully, and list the free variables of λx. x (λy. y z) x.

Answer

(λx. ((x (λy. (y z))) x)). Only z is free. Both xs are bound by the outer λx.

Common mistake

Reading line 5, x λy. y x, as (x λy. y) x. The body of λy swallows everything to its right, so the last x is inside it.

Capture-avoiding substitution THE HARD PART

M[x := N] replaces free x in M by N:

x[x := N] = N
y[x := N] = y   (y ≠ x)
(M1 M2)[x := N] = M1[x := N] M2[x := N]
(λx. M)[x := N] = λx. M   (shadowed)
(λy. M)[x := N] = λy. M[x := N]   if y ∉ FV(N)
(λy. M)[x := N] = λy′. M[y := y′][x := N]   else, fresh y′

Why renaming matters

Naive: (λy. x y)[x := y] → λy. y y. Wrong! The free y got captured by the binder. Correct: λy′. y y′.

def fresh(name, avoid):
    while name in avoid: name += "'"
    return name

def subst(t, x, s):
    """t[x := s], capture-avoiding."""
    if isinstance(t, Var):
        return s if t.name == x else t
    if isinstance(t, App):
        return App(subst(t.fn, x, s), subst(t.arg, x, s))
    if t.param == x:                   # x is shadowed: stop
        return t
    if t.param in free(s):             # would capture: rename first
        y = fresh(t.param, free(s) | free(t.body))
        t = Lam(y, subst(t.body, t.param, Var(y)))
    return Lam(t.param, subst(t.body, x, s))

print(show(subst(parse(r"λy. x y"), "x", Var("y"))))
λy'.y y'

β- and η-reduction COMPUTATION

β-reduction (run a function):

(λx. M) N  →β  M[x := N]

The left side is a redex (reducible expression). You may reduce any redex, anywhere, even under a λ.

η-reduction (drop a useless wrapper):

λx. M x  →η  M   if x ∉ FV(M)

This is extensionality: two functions that agree on every input are equal. In Python, lambda v: f(v) behaves like f.

Normal form: a term with no β-redex. →* means zero or more steps. =β is the equivalence it generates.

A worked reduction

(λx. λy. x) a b
= ((λx. λy. x) a) b
→β (λy. a) b
→β a

Two steps, normal form a. Our reducer agrees (next slides):

t, n = normalize(parse(r"(λx. λy. x) a b"))
print(show(t), n)
a 2
(λx.λy.x) a b β (λy. a) b β a

Worked: β-reduction step by step TRACES

SUCC 1 = 2

SUCC = λn f x. f (n f x) and 1 = λf x. f x.

0: (λn.λf.λx.f (n f x)) (λf.λx.f x)
1: λf.λx.f ((λf.λx.f x) f x)
2: λf.λx.f ((λx.f x) x)
3: λf.λx.f (f x)
  1. Step 1. The redex is the whole term. Put 1 in for n.
  2. Step 2. No redex at the top: it is a λ. So go under the binders. The leftmost redex is (λf.λx.f x) f. Put f in for f.
  3. Step 3. Now (λx.f x) x. Put x in for x. Done: two fs, the numeral 2.
def trace(t):                         # print every normal-order step
    n = 0; print(f"{n}: {show(t)}")
    while (t2 := step(t)) is not None:
        n += 1; t = t2; print(f"{n}: {show(t)}")
trace(parse(r"(λn f x. f (n f x)) (λf x. f x)"))

Capture, live

0: (λx.λy.x y) y
1: λy'.y y'

The argument y is free. The body has a binder λy. So subst renames it to y' first. The free y stays free.

Without renaming, you would get λy. y y: a totally different function.

Throwing an argument away

0: (λx.λy.x) (λz.z) w
1: (λy.λz.z) w
2: λz.z

K I w = I. The λy body never uses y, so w just vanishes.

Common mistake

Substituting into every x, even under an inner λx. (λx. λx. x) a gives λx. x, not λx. a. The inner binder shadows.

A term parser in Python CODE

from dataclasses import dataclass

@dataclass(frozen=True)
class Var:
    name: str

@dataclass(frozen=True)
class Lam:
    param: str
    body: object

@dataclass(frozen=True)
class App:
    fn: object
    arg: object

def show(t):
    if isinstance(t, Var): return t.name
    if isinstance(t, Lam): return f"λ{t.param}.{show(t.body)}"
    f = show(t.fn) if not isinstance(t.fn, Lam) else f"({show(t.fn)})"
    a = show(t.arg) if isinstance(t.arg, Var) else f"({show(t.arg)})"
    return f"{f} {a}"

Frozen dataclasses give free structural equality and hashing.

print(show(parse(r"λx. λy. x y")))
print(show(parse(r"(λx. x x) (λx. x x)")))
λx.λy.x y
(λx.x x) (λx.x x)
import re

def parse(src):
    toks = re.findall(r"[λ\\.()]|[A-Za-z0-9_']+", src)
    pos = 0
    def peek(): return toks[pos] if pos < len(toks) else None
    def eat(t=None):
        nonlocal pos
        tok = toks[pos]; pos += 1
        assert t is None or tok == t, f"expected {t}, got {tok}"
        return tok
    def term():                        # term := λx y. term | application
        if peek() in ("λ", "\\"):
            eat(); names = []
            while peek() != ".": names.append(eat())
            eat(".")
            body = term()
            for n in reversed(names): body = Lam(n, body)
            return body
        t = atom()
        while peek() not in (None, ")"):   # application is left-assoc
            t = App(t, atom() if peek() not in ("λ", "\\") else term())
        return t
    def atom():
        if peek() == "(":
            eat("("); t = term(); eat(")"); return t
        return Var(eat())
    t = term(); assert pos == len(toks); return t

A normal-order reducer CODE

def step(t):
    """One leftmost-outermost β-step, or None if t is normal."""
    if isinstance(t, App):
        if isinstance(t.fn, Lam):
            return subst(t.fn.body, t.fn.param, t.arg)
        r = step(t.fn)
        if r is not None: return App(r, t.arg)
        r = step(t.arg)
        return None if r is None else App(t.fn, r)
    if isinstance(t, Lam):
        r = step(t.body)
        return None if r is None else Lam(t.param, r)
    return None

def normalize(t, limit=10_000):
    for n in range(limit):
        nxt = step(t)
        if nxt is None: return t, n
        t = nxt
    raise RuntimeError("no normal form within limit")

How it picks a redex

  1. If the whole term is a redex, fire it. This is the outermost choice.
  2. Else try the function part first. This is the leftmost choice.
  3. Then try the argument.
  4. Also reduce under λ. We want a full normal form, not just a value.

Standardization theorem (Curry & Feys, 1958). If a term has a normal form, the leftmost-outermost strategy finds it.

That is why normalize uses this order. Other orders can loop on terms that do have an answer. The next slide shows one.

The limit is required. Whether a term has a normal form is undecidable, so no reducer can always tell.

Reduction strategies ORDER MATTERS

StrategyWhich redexUnder λ?Argument evaluatedUsed by
Normal orderleftmost-outermostyesnever firsttheory, proof checkers
Applicative orderleftmost-innermostyesalways firstpartial evaluators
Call-by-nameleftmost-outermostno (stop at λ)each time it is usedALGOL 60 by-name
Call-by-valueleftmost-innermostnoonce, before the callPython, ML, Scheme, JS
Call-by-needlike by-namenoat most once, then cachedHaskell (lazy)

Same term, two orders

(λx. x x)((λy. y) z)

By-value: first (λy. y) z → z, then z z. Two steps.

By-name: ((λy. y) z)((λy. y) z), then two more. Three steps. The argument got copied and done twice.

The trade-off

  • By-value can do work that is never needed. It can even loop on it.
  • By-name never does unneeded work, but it may redo work.
  • By-need gets the best of both. The cost is sharing (thunks and graph reduction).

Ω and divergence NON-TERMINATION

Ω = (λx. x x)(λx. x x) reduces to itself:

Ω →β Ω →β Ω →β …

It has no normal form. So λ can loop, just like a real program.

omega = parse(r"(λx. x x) (λx. x x)")
print(show(step(omega)) == show(omega))
True

Throw-away argument

Now take K∗ = λx. λy. y. It ignores its first argument.

t, n = normalize(parse(r"(λx. λy. y) ((λx. x x) (λx. x x))"))
print(show(t), n)
λy.y 1

Normal order is done in one step. It never touches Ω.

(λx.λy.y) Ω normal order (outer redex) λy. y done, 1 step applicative (inner redex) (λx.λy.y) Ω Ω → Ω forever

Python is applicative

Python evaluates arguments first. So (lambda x: lambda y: y)(loop()) hangs before the call even starts. This is exactly why we need the Z combinator later instead of Y.

Church–Rosser: confluence THEOREM

Theorem (Church & Rosser, 1936). If M →* N1 and M →* N2, then some P exists with N1 →* P and N2 →* P.

Consequences

  • Normal forms are unique up to α. The order you pick can change whether you finish, never what you get.
  • M =β N exactly when both reduce to a common term.
  • The theory is consistent. λx y. x and λx y. y are different normal forms, so they are not equal.

Proof sketch (Tait & Martin-Löf)

  1. Plain →β lacks the one-step diamond. A step can copy a redex, which then needs two steps.
  2. Define parallel reduction ⇒. It fires any set of existing redexes at once.
  3. Show ⇒ has the diamond property. Use the complete development M*, which fires all redexes. Every M ⇒ N has N ⇒ M*.
  4. →β ⊆ ⇒ ⊆ →*, so both have the same closure. Diamond for ⇒ tiles into confluence.
M * * N₁ N₂ P * * solid: given dashed: exists

Example

(λx. x x)((λy. y) z) took 2 steps by value and 3 by name. Both paths end in z z, as the theorem promises.

Checkpoint: the machine is built RECAP

What we have so far

  1. Syntax. Three forms: x, λx. M, M N. Bodies go right, apps group left.
  2. Scope. Free vs bound. Rename bound variables freely (α).
  3. Substitution. Replace free occurrences only. Rename to avoid capture.
  4. β. (λx. M) N → M[x := N]. That is the only rule of computation.
  5. Strategy. Normal order finds a normal form whenever one exists.
  6. Church–Rosser. Normal forms are unique. The order changes cost, not the answer.
TermNormal form?Why
λx. x xyes, itselfno redex inside
(λx. x x)(λy. y)λy. y2 steps
Ωnonereduces to itself
(λx. z) Ωz (normal order)Ω is thrown away

Quick self-test

Why does applicative order loop on (λx. z) Ω, while normal order stops in 1 step?

(Answer: applicative order reduces the argument Ω first, forever. Normal order reduces the outer redex first, and it drops the argument.)

Next: with only β, we will build booleans, numbers, pairs, lists and recursion.

Church booleans and pairs ENCODING

Idea. A boolean is a choice. It takes two options and returns one.

TRUE  = λt f. t
FALSE = λt f. f
IF    = λb x y. b x y
AND  = λp q. p q p
OR    = λp q. p p q
NOT  = λp. p FALSE TRUE

Pairs. A pair waits for a selector and hands it both parts.

PAIR = λa b s. s a b
FST  = λp. p TRUE    SND = λp. p FALSE

Check: AND TRUE FALSE

(λp q. p q p) TRUE FALSE
→* TRUE FALSE TRUE
→* FALSE

We run these in the reducer. A macro table DEFS expands names into terms before normalizing:

DEFS = {"TRUE": r"λt f. t", "FALSE": r"λt f. f",
        "AND": r"λp q. p q p", "NOT": r"λp. p FALSE TRUE",
        "PAIR": r"λa b s. s a b", "FST": r"λp. p TRUE", ...}

t, n = run("AND TRUE FALSE"); print(show(t))
t, n = run("NOT FALSE"); print(show(t))
t, n = run("FST (PAIR a b)"); print(show(t))
λt.λf.f
λt.λf.t
a

The full DEFS, expand and run are in the test file. run expands macros, then calls normalize.

Church numerals and arithmetic ENCODING

Numeral n = "apply f n times":

0 = λf x. x
1 = λf x. f x
2 = λf x. f (f x)
n = λf x. fn x

OpTermWhy it works
SUCCλn f x. f (n f x)one more f
PLUSλm n f x. m f (n f x)m f's after n f's
MULTλm n f. m (n f)repeat "n f's" m times
POWλb e. e bcompose b with itself e times
ISZEROλn. n (λz. FALSE) TRUEany f flips to FALSE
def church(k):
    body = Var("x")
    for _ in range(k): body = App(Var("f"), body)
    return Lam("f", Lam("x", body))

def to_int(t):
    """Read back λf.λx. f (f ... x)."""
    t, _ = normalize(t)
    f, x, body, k = t.param, t.body.param, t.body.body, 0
    while isinstance(body, App):
        assert body.fn == Var(f); body = body.arg; k += 1
    assert body == Var(x)
    return k

for expr in ["PLUS 2 3", "MULT 3 3", "POW 2 3", "PRED 5", "SUB 7 4"]:
    t, n = run(expr)
    print(f"{expr:<9} = {to_int(t)}   ({n} β-steps)")
PLUS 2 3  = 5   (6 β-steps)
MULT 3 3  = 9   (9 β-steps)
POW 2 3   = 8   (16 β-steps)
PRED 5    = 4   (15 β-steps)
SUB 7 4   = 3   (68 β-steps)

Numbers are unary, so costs grow with the value. SUB 7 4 runs PRED four times, which is why it is slow.

Predecessor: Kleene's trick CLEVER

Adding an f is easy. Removing one is not, because a numeral only knows how to apply f. Church thought PRED might be impossible.

The story

Kleene, a student in 1932, found PRED at the dentist. He was under laughing gas. The idea: count up with pairs and stay one step behind.

Pair version (easier to read)

STEP = λp. PAIR (SND p) (SUCC (SND p))
PRED = λn. FST (n STEP (PAIR 0 0))

Start at (0,0). Each step maps (a,b) ↦ (b,b+1). After n steps: (n−1, n). Take the first part. PRED 0 = 0.

The compact version we ran

PRED = λn f x. n (λg h. h (g f)) (λu. x) (λu. u)

  • λu. x is a "container" that skips the first f.
  • Each step wraps one more f around the contents.
  • λu. u opens the final container.

Subtraction is PRED repeated: SUB = λm n. n PRED m. It is truncated: SUB 2 5 = 0.

From the numerals slide: PRED 5 = 4 in 15 steps and SUB 7 4 = 3 in 68 steps.

Church numerals make PRED cost O(n). Other encodings (Scott, Parigot) make it O(1) and pay elsewhere.

The same encodings as Python lambdas RUNNABLE

TRUE  = lambda t: lambda f: t
FALSE = lambda t: lambda f: f
AND   = lambda p: lambda q: p(q)(p)
NOT   = lambda p: p(FALSE)(TRUE)
ZERO  = lambda f: lambda x: x
SUCC  = lambda n: lambda f: lambda x: f(n(f)(x))
PLUS  = lambda m: lambda n: lambda f: lambda x: m(f)(n(f)(x))
MULT  = lambda m: lambda n: lambda f: m(n(f))
POW   = lambda b: lambda e: e(b)
PRED  = lambda n: lambda f: lambda x: n(lambda g: lambda h: h(g(f)))(lambda u: x)(lambda u: u)

to_py  = lambda n: n(lambda k: k + 1)(0)
to_bool = lambda b: b(True)(False)
ONE, TWO = SUCC(ZERO), SUCC(SUCC(ZERO))
THREE = PLUS(ONE)(TWO)

print(to_py(MULT(THREE)(THREE)), to_py(POW(TWO)(THREE)), to_py(PRED(THREE)))
print(to_bool(AND(TRUE)(NOT(FALSE))))
9 8 2
True

Reading back

to_py applies a numeral to real +1 and real 0. So the numeral is a loop counter. to_bool hands a boolean the real True and False.

Why this works in Python

  • Python has first-class closures. A lambda captures its free variables.
  • Curried, one-argument lambdas match λ exactly.
  • Python is call-by-value. That is fine here, since nothing diverges.

Watch out

IF(c)(a)(b) evaluates both a and b first. For a real branch, pass thunks: IF(c)(lambda: a)(lambda: b)().

Lists as their own fold DATA = BEHAVIOUR

Church list. A list is "what you get by folding it". It takes a cons handler c and a nil value n:

NIL  = λc n. n
CONS = λh t c n. c h (t c n)
[1,2,3] = λc n. c 1 (c 2 (c 3 n))

Compare numerals: 3 = λf x. f (f (f x)). A numeral is a list with no payload. Both are instances of Böhm–Berarducci encoding. Any algebraic data type can be written as its own fold.

StructureEncoded as
Bool2-way choice
Natiterator
Listfoldr
Treetree fold
NIL    = lambda c: lambda n: n
CONS   = lambda h: lambda t: lambda c: lambda n: c(h)(t(c)(n))
FOLD   = lambda xs: lambda f: lambda z: xs(f)(z)
xs = CONS(1)(CONS(2)(CONS(3)(NIL)))
print(FOLD(xs)(lambda h: lambda acc: h + acc)(0))
print(FOLD(xs)(lambda h: lambda acc: [h] + acc)([]))
6
[1, 2, 3]

What is easy, what is hard

  • Easy: sum, map, length, append. Each is a single fold.
  • Hard: tail. It needs the same pair trick as PRED.
  • Hard: general recursion. Folds always stop. For that we need a fixed point.

Check yourself (1) EXERCISES

Work each one on paper. Then open the answer.

1. Reduce to normal form

(λx. x x)(λy. y)

Answer

→ (λy. y)(λy. y) → λy. y. Two steps. Self-application is fine when the argument is harmless.

2. Watch the capture

(λx y. x) y

Answer

λy'. y. It is a function that ignores its input and returns the free y. Writing λy. y is the classic error.

3. Booleans

Show that AND FALSE q = FALSE for any q. Use AND = λp q. p q p.

Answer

AND FALSE q →→ FALSE q FALSE. FALSE picks its second argument, so the result is FALSE. q is never looked at. The reducer agrees.

4. Spot the numeral

Write twice, which applies f two times to x. What have you seen it called?

Answer

λf x. f (f x). It is the Church numeral 2. A numeral is "do it n times".

Recursion without names: the Y combinator FIXED POINTS

We want FACT = λn. IF (ISZERO n) 1 (MULT n (FACT (PRED n))). But a term cannot mention itself. So we abstract the self-reference away:

F = λr n. IF (ISZERO n) 1 (MULT n (r (PRED n)))

Now FACT must satisfy FACT = F FACT. FACT is a fixed point of F.

Curry's Y combinator:

Y = λf. (λx. f (x x)) (λx. f (x x))

Fixed point theorem. For every F, Y F =β F (Y F).

Y F → (λx. F (x x))(λx. F (x x))
    → F ((λx. F (x x))(λx. F (x x)))
    =β F (Y F)   ∎

Y in the pure reducer

DEFS["Y"] = r"λf. (λx. f (x x)) (λx. f (x x))"
DEFS["FACT"] = r"Y (λr n. IF (ISZERO n) 1 (MULT n (r (PRED n))))"
t, n = run("FACT 3")
print("FACT 3 =", to_int(t), f"({n} β-steps)")
FACT 3 = 6 (694 β-steps)

This works because normal order unfolds Y F only when the IF needs it. At n = 0, the recursive branch is simply thrown away.

Other fixed-point combinators

  • Turing's Θ = (λx y. y (x x y))(λx y. y (x x y)). Here Θ F →* F (Θ F) by real reduction, not just =β.
  • There are infinitely many. Every one gives recursion.

Worked: FACT 2, unrolled Y IN ACTION

F = λr n. IF (ISZERO n) 1 (MULT n (r (PRED n))) and FACT = Y F. Use Y F = F (Y F) as a macro step:

   FACT 2
=  Y F 2
=  F (Y F) 2                          unfold Y once
-> IF (ISZERO 2) 1 (MULT 2 (Y F (PRED 2)))
-> MULT 2 (Y F 1)                     ISZERO 2 = FALSE
=  MULT 2 (F (Y F) 1)                 unfold Y again
-> MULT 2 (MULT 1 (Y F 0))            ISZERO 1 = FALSE
=  MULT 2 (MULT 1 (F (Y F) 0))        unfold Y a third time
-> MULT 2 (MULT 1 1)                  ISZERO 0 = TRUE: stop
-> 2

Each "unfold" is Y F =β F (Y F). Each "→" hides several β-steps.

What to notice

  • Y F is unfolded only when the IF picks the recursive branch.
  • At n = 0, the IF picks 1. The copy of Y F in the other branch is dropped, never run.
  • So recursion stops for the same reason (λx. z) Ω stops: normal order throws unused arguments away.
nFACT npure β-steps
0112
1136
22142
36694

Counted with the deck's run and to_int. Unary numbers make costs grow fast.

Common mistake

Expanding Y F eagerly, "just to see". Every unfold makes another Y F, so you never finish. Unfold only when the IF needs it.

Z: recursion under call-by-value PYTHON

Y in Python loops forever

Y = lambda f: (lambda x: f(x(x)))(lambda x: f(x(x)))
try:
    Y(lambda self: lambda n: 1 if n == 0 else n * self(n - 1))
except RecursionError:
    print("Y: RecursionError (Python is call-by-value)")
Y: RecursionError (Python is call-by-value)

Python evaluates x(x) before it calls f. That gives another x(x), and so on. The stack blows up before any number is used.

Fix: η-expand the self-application. x x becomes λv. x x v. A lambda is already a value, so it is not evaluated until it is called.

Z = λf. (λx. f (λv. x x v)) (λx. f (λv. x x v))

Z = lambda f: (lambda x: f(lambda v: x(x)(v)))(lambda x: f(lambda v: x(x)(v)))
fact = Z(lambda self: lambda n: 1 if n == 0 else n * self(n - 1))
fib  = Z(lambda self: lambda n: n if n < 2 else self(n - 1) + self(n - 2))
print(fact(10), [fib(i) for i in range(10)])
3628800 [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

What Z teaches

  • fact and fib never refer to their own names. The recursion lives in Z.
  • Y and Z are η-equal. The strategy is the only thing that tells them apart.
  • This is how a language with only closures still gets loops.

Python's recursion limit still applies. Each self(...) call adds a few stack frames.

SKI combinators: no variables at all COMBINATORY LOGIC

Three combinators (Schönfinkel 1924, Curry 1930):

I x      → x
K x y   → x
S x y z → x z (y z)

In λ: S = λx y z. x z (y z). I is not even needed: S K K x → K x (K x) → x.

Bracket abstraction [x]M removes variable x:

[x] x = I
[x] M = K M    if x ∉ FV(M)
[x] (M N) = S ([x]M) ([x]N)

Then ([x]M) N →* M[x := N]. Apply it inside-out and every λ disappears.

The output blows up. Naive translation can be exponential in term size. Turner's extra combinators B and C (1979) fix most of that. His SASL and Miranda ran on SKI graph reduction.

def abstract(x, t):
    if t == Var(x): return C("I")
    if not occurs(x, t): return App(C("K"), t)
    return App(App(C("S"), abstract(x, t.fn)), abstract(x, t.arg))

def bracket(t):
    """Compile a λ-term to S, K, I by bracket abstraction."""
    if isinstance(t, (Var, C)): return t
    if isinstance(t, App): return App(bracket(t.fn), bracket(t.arg))
    return abstract(t.param, bracket(t.body))

for src in [r"λx. x", r"λx y. x", r"λx y. y x", r"λf x. f (f x)"]:
    print(f"{src:<15} ⇒  {sshow(bracket(parse(src)))}")
swap = bracket(parse(r"λx y. y x"))
print(sshow(ski_norm(App(App(swap, Var("a")), Var("b")))))
λx. x           ⇒  I
λx y. x         ⇒  S (K K) I
λx y. y x       ⇒  S (K (S I)) (S (K K) I)
λf x. f (f x)   ⇒  S (S (K S) (S (K K) I)) (S (S (K S) (S (K K) I)) (K I))
b a

S (K K) I is η-equal to K. Add the rule [x](M x) = M when x ∉ FV(M) to get K directly. C, occurs, sshow and the S/K/I step function ski_norm are in the test file.

Turing equivalence and undecidability POWER

Theorem (Kleene 1936, Turing 1937). For f : ℕ → ℕ, these are equivalent:

  1. f is λ-definable on Church numerals.
  2. f is μ-recursive.
  3. f is Turing-computable.

Sketch: μ-recursive ⇒ λ

  • Zero, successor, projections: λx. 0, SUCC, λx1…xk. xi.
  • Composition: plain application.
  • Primitive recursion: iterate a pair, as with PRED.
  • μ (unbounded search): use Y to loop until a test is zero.

Sketch: λ ⇒ Turing machine

Write terms on the tape as strings. A TM can find the leftmost redex and do substitution. Our step function is exactly such a program.

Church–Turing thesis

Every "effectively computable" function is λ-definable. This is a claim about the world, not a theorem. The matches above are the evidence for it.

Church's theorem (1936). No λ-term can decide if a term has a normal form.

Sketch. Suppose H ⌊M⌋ = TRUE iff M has a normal form. Here ⌊M⌋ is a numeral coding M. Two helpers are λ-definable: APP ⌊M⌋ ⌊N⌋ = ⌊M N⌋ and QUOTE ⌊M⌋ = ⌊⌊M⌋⌋. Build D = λx. IF (H (APP x (QUOTE x))) Ω I. Then D ⌊D⌋ → IF (H ⌊D ⌊D⌋⌋) Ω I. If D ⌊D⌋ has a normal form, it becomes Ω. If not, it becomes I. Both cases contradict. This is the halting problem, a year before Turing.

Scott–Curry theorem: any nontrivial set of terms closed under =β is undecidable. It is the λ version of Rice's theorem.

de Bruijn indices: names are a nuisance NAMELESS

Idea (N. G. de Bruijn, 1972). Replace a variable by a number. The number says how many λs up its binder is, starting at 0.

λx. x        → λ 0
λx y. x     → λ λ 1
λf x. f (f x) → λ λ 1 (1 0)

  • α-equivalence becomes plain equality. No renaming, no capture.
  • Cost: β must shift indices of free variables when moving under a binder.
  • Used inside Coq, Lean, Agda, and many compilers. Humans still read named syntax.

(λ M) N → ↑−1( M[0 := ↑1N] )

def debruijn(t, ctx=()):
    if isinstance(t, Var):
        return str(ctx.index(t.name)) if t.name in ctx else t.name
    if isinstance(t, Lam):
        return "λ " + debruijn(t.body, (t.param,) + ctx)
    f = debruijn(t.fn, ctx); a = debruijn(t.arg, ctx)
    f = f"({f})" if isinstance(t.fn, Lam) else f
    a = f"({a})" if not isinstance(t.arg, Var) else a
    return f"{f} {a}"

for src in [r"λx. x", r"λx y. x", r"λz w. z", r"λf x. f (f x)", r"λx. λy. x (λz. z y)"]:
    print(f"{src:<22} {debruijn(parse(src))}")
λx. x                  λ 0
λx y. x                λ λ 1
λz w. z                λ λ 1
λf x. f (f x)          λ λ 1 (1 0)
λx. λy. x (λz. z y)    λ λ 1 (λ 0 1)

ctx.index finds the nearest binder, since new names go on the front. Note the same variable y is 1 inside the inner λ but would be 0 outside it.

Simply-typed λ-calculus TYPES

Types τ ::= A | τ → τ (base types and arrows). Arrows associate right.

Typing rules. Γ maps variables to types:

x : τ ∈ Γ
Γ ⊢ x : τ
Γ, x:σ ⊢ M : τ
Γ ⊢ λx:σ. M : σ → τ
Γ ⊢ M : σ→τ   Γ ⊢ N : σ
Γ ⊢ M N : τ

Strong normalization (Tait, 1967). Every well-typed term reaches a normal form, under every strategy.

Sketch. Plain induction on terms fails, because β can make terms bigger. Tait defines reducible sets by type. At base type, "reducible" means strongly normalizing. At σ→τ, it means it sends reducible inputs to reducible outputs. Then show every typed term is reducible, and reducible implies SN.

def typeof(t, env={}):
    if isinstance(t, Var):
        return env[t.name]
    if isinstance(t, TLam):
        return Arrow(t.ty, typeof(t.body, {**env, t.param: t.ty}))
    fn, arg = typeof(t.fn, env), typeof(t.arg, env)
    if not isinstance(fn, Arrow) or fn.a != arg:
        raise TypeError(f"cannot apply {tshow(fn)} to {tshow(arg)}")
    return fn.b

print(tshow(typeof(K)))          # λx:A. λy:B. x
print(tshow(typeof(compose)))    # λf:B→C. λg:A→B. λx:A. f (g x)
try:
    typeof(TLam("x", A, App(Var("x"), Var("x"))))
except TypeError as e:
    print("TypeError:", e)
A → B → A
(B → C) → (A → B) → A → C
TypeError: cannot apply A to A

The price

ω, Ω and Y have no simple type, since x x needs τ = τ → σ. So typed λ always halts and is not Turing-complete. Real languages add fix or recursive types back on purpose.

Curry–Howard: proofs are programs CORRESPONDENCE

LogicProgramming
proposition Atype A
proof of Aterm of type A
implication A ⇒ Bfunction A → B
conjunction A ∧ Bpair A × B
disjunction A ∨ Btagged union A + B
true ⊤unit type
false ⊥empty type
⇒-introductionλ-abstraction
modus ponens (⇒-elim)application
proof normalizationβ-reduction
∀ / ∃dependent Π / Σ types

Read the types we just checked

  • K : A → B → A proves "if A, then (if B then A)".
  • compose : (B→C) → (A→B) → A → C proves that implication is transitive.
  • S : (A→B→C) → (A→B) → A → C. With K, these are the two axioms of Hilbert-style logic. So SKI is a Hilbert proof system.

What it buys you

  • Strong normalization means the logic is consistent. No closed term has type ⊥.
  • Coq, Lean and Agda check proofs by type-checking terms.
  • The logic is intuitionistic. A ∨ ¬A has no term. Classical logic matches control operators like call/cc (Griffin, 1990).

Curry saw it for combinators (1934). Howard wrote it for natural deduction (1969, published 1980).

Hindley–Milner type inference NO ANNOTATIONS

Leave out every type. Can we still find one? Yes. Give each unknown a type variable, gather equations, and solve them by unification (Robinson, 1965).

Principal types (Hindley 1969, Milner 1978, Damas 1982). If a term is typable, it has a most general type. Every other type is an instance of it. Algorithm W finds it.

  • Var: look up its type.
  • λx. M: new variable a for x. The type is a → type(M).
  • M N: new r. Unify type(M) with type(N) → r.
  • Occurs check: a = a → b has no finite answer, so reject it.
  • let-polymorphism: let gets ∀ types. It is left out here for size.

Worst case is exponential (nested lets). It is near-linear in practice. ML, OCaml, Haskell, F#, Elm and Rust's local inference all build on it.

def infer(t, env, s):
    if isinstance(t, Var): return env[t.name], s
    if isinstance(t, Lam):
        a = tv(); b, s = infer(t.body, {**env, t.param: a}, s)
        return Arrow(a, b), s
    f, s = infer(t.fn, env, s); a, s = infer(t.arg, env, s)
    r = tv(); s = unify(f, Arrow(a, r), s)
    return r, s

for src in [r"λx. x", r"λx y. x", r"λf g x. f (g x)",
            r"λf x. f (f x)", r"λx. x x"]:
    try:
        ty, s = infer(parse(src), {}, {})
        print(f"{src:<18} : {pretty(resolve(ty, s))}")
    except TypeError as e:
        print(f"{src:<18} : TypeError: {e}")
λx. x              : a → a
λx y. x            : a → b → a
λf g x. f (g x)    : (a → b) → (c → a) → c → b
λf x. f (f x)      : (a → a) → a → a
λx. x x            : TypeError: infinite type

tv, unify (with occurs check), resolve and pretty are in the test file, about 30 lines. Note the type of Church numeral 2: (a→a)→a→a.

Influence on real languages LEGACY

Lisp (1958)

  • (lambda (x) ...) taken straight from Church.
  • Early Lisp used dynamic scope, which is not λ. Scheme (1975) fixed it with lexical closures.
  • Code as data. eval is a λ interpreter.

ML (1973)

  • "Meta Language" for the LCF prover.
  • Typed λ plus Hindley–Milner plus let-polymorphism.
  • Call-by-value. Leads to OCaml, F#, Standard ML, and Rust's type ideas.

Haskell (1990)

  • Lazy (call-by-need). fix f = f (fix f) just works.
  • GHC compiles to Core, a typed λ-calculus (System FC).
  • Monads and type classes sit on top.

Python

  • lambda is a single expression only. Guido wanted to remove it in Python 3, but kept it.
  • Closures capture variables, not values. Classic bug: [lambda: i for i in range(3)] all return 2.
  • functools.reduce, partial, map, and sorted(key=...) are λ ideas.
  • No tail calls and strict evaluation. So use Z, not Y, and keep recursion shallow.

Everywhere else

  • JavaScript arrow functions. Java 8 and C++11 lambdas. C# LINQ.
  • CPS and ANF compiler IRs. Closure conversion. Lambda lifting.
  • Proof assistants: Coq (CIC), Lean, Agda, Idris.
  • Even "AWS Lambda" borrows the name for a stateless function.

Check yourself (2) EXERCISES

5. Strategy matters

Reduce (λx. z) Ω with normal order, then with applicative order.

Answer

Normal order: z in 1 step. Applicative order reduces Ω first and never stops. Church–Rosser is not broken: it says normal forms are unique, not that every strategy finds one.

6. Why Y fails in Python

In one sentence: why does Y raise RecursionError, while Z works?

Answer

Python evaluates the argument x(x) before calling f, so it unfolds forever. Z wraps it as lambda v: x(x)(v), which waits until it is called.

7. Types

Why can't λx. x x get a simple type?

Answer

x would need type A and also A → B. So A = A → B, which has no finite solution. That is why STLC has no Y and every typed term halts.

8. de Bruijn

Write λx. λy. y (λz. x z) with de Bruijn indices (0 = nearest binder, as in the deck).

Answer

λ λ 0 (λ 2 0). Outside λz, y is 0. Inside it, z is 0 and x is 2. The same x would be 1 outside.

Pitfalls and misconceptions WATCH OUT

Naive substitution

Forgetting to rename causes variable capture. Bugs show up only with name clashes, so tests can miss them. Use fresh names or de Bruijn indices.

Wrong parse

λx. x y is λx. (x y), not (λx. x) y. And a b c is (a b) c.

Y under call-by-value

Y in Python, JS or ML loops forever. Use Z, the η-expanded form.

"Any order is fine"

Confluence says answers agree if you finish. It does not say every order finishes. (λx y. y) Ω is the counterexample.

"λ is slow, so useless"

Unary numerals are for theory. Real systems add native ints and use sharing (graph reduction, environments). Then λ is as fast as anything.

Typed = weaker?

Simply-typed λ cannot loop, so it is not Turing-complete. That is a feature for proofs. Languages add fix back when they need it.

Summary TAKEAWAYS

  • Three constructs: variable, λ, application.
  • β runs code. α renames. Substitution must avoid capture.
  • Church–Rosser: at most one normal form. Normal order finds it.
  • Data is behaviour: booleans choose, numerals iterate, lists fold.
  • Fixed points (Y, Z) give recursion. So λ is Turing-complete.
  • Types buy termination and logic, through Curry–Howard.
ConceptKey fact
β(λx.M)N → M[x:=N]
Church–Rosserconfluence ⇒ unique NF
Standardizationnormal order is complete
Y / ZY F = F (Y F), Z for strict langs
SKIvariables are optional
STLCstrongly normalizing
HMprincipal types, via unification

Further reading

  • Barendregt, The Lambda Calculus: Its Syntax and Semantics (1984)
  • Pierce, Types and Programming Languages (2002)
  • Hindley & Seldin, Lambda-Calculus and Combinators (2008)
  • Sørensen & Urzyczyn, Lectures on the Curry–Howard Isomorphism

Glossary REFERENCE

TermMeaning
Abstractionλx. M: a function with parameter x and body M.
ApplicationM N: call M on N. Groups to the left.
Free / boundBound: under a matching λ. Free: not.
CombinatorA closed term: no free variables.
α-conversionRenaming a bound variable. Meaning does not change.
CaptureA free variable wrongly becomes bound during substitution.
RedexA reducible spot: (λx. M) N.
β-reduction(λx. M) N → M[x := N].
η-reductionλx. M x → M if x is not free in M.
TermMeaning
Normal formA term with no redex left.
Normal orderAlways reduce the leftmost, outermost redex first.
ConfluenceAny two reduction paths can be joined again (Church–Rosser).
Church numeraln = λf x. fn x: "apply f n times".
Fixed pointX with F X = X. Y finds one for any F.
CurryingTurning a 2-argument function into nested 1-argument ones.
de Bruijn indexA number for "how many binders up" instead of a name.
Strong normalizationEvery reduction path ends. True for simply-typed terms.
Curry–HowardTypes are propositions, and programs are proofs.