Three rules. No numbers, no loops, no data types. And yet it can compute anything a computer can.
Variables, λ-abstraction, application. That is the whole language.
β runs a function. α renames. η tidies. Church–Rosser makes it sane.
Booleans, numbers, pairs, lists and recursion, all built from functions.
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+.
| Year | Event |
|---|---|
| 1928 | Hilbert asks the Entscheidungsproblem: is there a method to decide every math statement? |
| 1932–35 | Alonzo Church proposes λ as a basis for logic (1932–33). Kleene and Rosser show that system is inconsistent (1935). |
| 1936 | Church keeps the untyped λ-calculus as a pure model of computation. He proves the Entscheidungsproblem unsolvable. |
| 1936 | Church & Rosser prove confluence. Turing publishes his machines, and shows they match λ. |
| 1940 | Church adds simple types to fix the logic. |
| 1958 | McCarthy's Lisp borrows LAMBDA. |
| 1964–66 | Landin maps ALGOL onto λ (the SECD machine, ISWIM). |
| 1969–78 | Scott builds math models. Hindley and Milner give type inference. ML is born. |
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.
You already know λ-calculus. It is the lambda in Python, with everything else removed.
| Idea | Python | JavaScript | λ-calculus |
|---|---|---|---|
| make a function | lambda x: x | x => x | λx. x |
| call it | f(3) | f(3) | f 3 (no brackets needed) |
| two arguments | lambda x: lambda y: x | x => y => x | λx y. x |
| call at once | (lambda x: x * x)(5) | (x => x * x)(5) | (λx. x·x) 5 → 5·5 |
| compose | lambda 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
Reading f 3 4 as f(3, 4). It means (f 3) 4: call f on 3, then call the result on 4.
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.
Terms. Given an infinite set of variables x, y, z, …
M, N ::= x | (λx. M) | (M N)
| Term | Reading |
|---|---|
| λx. x | identity, I |
| λx y. x | constant, K (keep first) |
| λf g x. f (g x) | compose, B |
| λx. x x | self-apply, ω |
| (λx. x x)(λx. x x) | Ω, loops forever |
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).
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']
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
λ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.
λx. (λy. x y) y
Bracket fully, and list the free variables of λx. x (λy. y z) x.
(λx. ((x (λy. (y z))) x)). Only z is free. Both xs are bound by the outer λx.
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.
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′
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'
β-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.
(λ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
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)
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)"))
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.
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.
Substituting into every x, even under an inner λx. (λx. λx. x) a gives λx. x, not λx. a. The inner binder shadows.
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
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")
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.
| Strategy | Which redex | Under λ? | Argument evaluated | Used by |
|---|---|---|---|---|
| Normal order | leftmost-outermost | yes | never first | theory, proof checkers |
| Applicative order | leftmost-innermost | yes | always first | partial evaluators |
| Call-by-name | leftmost-outermost | no (stop at λ) | each time it is used | ALGOL 60 by-name |
| Call-by-value | leftmost-innermost | no | once, before the call | Python, ML, Scheme, JS |
| Call-by-need | like by-name | no | at most once, then cached | Haskell (lazy) |
(λ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.
Ω = (λ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
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 Ω.
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.
Theorem (Church & Rosser, 1936). If M →* N1 and M →* N2, then some P exists with N1 →* P and N2 →* P.
(λ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.
| Term | Normal form? | Why |
|---|---|---|
| λx. x x | yes, itself | no redex inside |
| (λx. x x)(λy. y) | λy. y | 2 steps |
| Ω | none | reduces to itself |
| (λx. z) Ω | z (normal order) | Ω is thrown away |
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.
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
(λ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.
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
| Op | Term | Why 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 b | compose b with itself e times |
| ISZERO | λn. n (λz. FALSE) TRUE | any 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.
Adding an f is easy. Removing one is not, because a numeral only knows how to apply f. Church thought PRED might be impossible.
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.
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.
PRED = λn f x. n (λg h. h (g f)) (λu. x) (λu. u)
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.
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
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.
lambda captures its free variables.IF(c)(a)(b) evaluates both a and b first. For a real branch, pass thunks: IF(c)(lambda: a)(lambda: b)().
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.
| Structure | Encoded as |
|---|---|
| Bool | 2-way choice |
| Nat | iterator |
| List | foldr |
| Tree | tree 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]
sum, map, length, append. Each is a single fold.tail. It needs the same pair trick as PRED.Work each one on paper. Then open the answer.
(λx. x x)(λy. y)
→ (λy. y)(λy. y) → λy. y. Two steps. Self-application is fine when the argument is harmless.
(λx y. x) y
λy'. y. It is a function that ignores its input and returns the free y. Writing λy. y is the classic error.
Show that AND FALSE q = FALSE for any q. Use AND = λp q. p q p.
AND FALSE q →→ FALSE q FALSE. FALSE picks its second argument, so the result is FALSE. q is never looked at. The reducer agrees.
Write twice, which applies f two times to x. What have you seen it called?
λf x. f (f x). It is the Church numeral 2. A numeral is "do it n times".
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) ∎
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.
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.
| n | FACT n | pure β-steps |
|---|---|---|
| 0 | 1 | 12 |
| 1 | 1 | 36 |
| 2 | 2 | 142 |
| 3 | 6 | 694 |
Counted with the deck's run and to_int. Unary numbers make costs grow fast.
Expanding Y F eagerly, "just to see". Every unfold makes another Y F, so you never finish. Unfold only when the IF needs it.
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]
fact and fib never refer to their own names. The recursion lives in Z.Python's recursion limit still applies. Each self(...) call adds a few stack frames.
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.
Theorem (Kleene 1936, Turing 1937). For f : ℕ → ℕ, these are equivalent:
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.
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.
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)
(λ 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.
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
ω, Ω 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.
| Logic | Programming |
|---|---|
| proposition A | type A |
| proof of A | term of type A |
| implication A ⇒ B | function A → B |
| conjunction A ∧ B | pair A × B |
| disjunction A ∨ B | tagged union A + B |
| true ⊤ | unit type |
| false ⊥ | empty type |
| ⇒-introduction | λ-abstraction |
| modus ponens (⇒-elim) | application |
| proof normalization | β-reduction |
| ∀ / ∃ | dependent Π / Σ types |
call/cc (Griffin, 1990).Curry saw it for combinators (1934). Howard wrote it for natural deduction (1969, published 1980).
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.
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.
(lambda (x) ...) taken straight from Church.eval is a λ interpreter.let-polymorphism.fix f = f (fix f) just works.lambda is a single expression only. Guido wanted to remove it in Python 3, but kept it.[lambda: i for i in range(3)] all return 2.functools.reduce, partial, map, and sorted(key=...) are λ ideas.Reduce (λx. z) Ω with normal order, then with applicative order.
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.
In one sentence: why does Y raise RecursionError, while Z works?
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.
Why can't λx. x x get a simple type?
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.
Write λx. λy. y (λz. x z) with de Bruijn indices (0 = nearest binder, as in the deck).
λ λ 0 (λ 2 0). Outside λz, y is 0. Inside it, z is 0 and x is 2. The same x would be 1 outside.
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.
λx. x y is λx. (x y), not (λx. x) y. And a b c is (a b) c.
Y in Python, JS or ML loops forever. Use Z, the η-expanded form.
Confluence says answers agree if you finish. It does not say every order finishes. (λx y. y) Ω is the counterexample.
Unary numerals are for theory. Real systems add native ints and use sharing (graph reduction, environments). Then λ is as fast as anything.
Simply-typed λ cannot loop, so it is not Turing-complete. That is a feature for proofs. Languages add fix back when they need it.
| Concept | Key fact |
|---|---|
| β | (λx.M)N → M[x:=N] |
| Church–Rosser | confluence ⇒ unique NF |
| Standardization | normal order is complete |
| Y / Z | Y F = F (Y F), Z for strict langs |
| SKI | variables are optional |
| STLC | strongly normalizing |
| HM | principal types, via unification |
| Term | Meaning |
|---|---|
| Abstraction | λx. M: a function with parameter x and body M. |
| Application | M N: call M on N. Groups to the left. |
| Free / bound | Bound: under a matching λ. Free: not. |
| Combinator | A closed term: no free variables. |
| α-conversion | Renaming a bound variable. Meaning does not change. |
| Capture | A free variable wrongly becomes bound during substitution. |
| Redex | A reducible spot: (λx. M) N. |
| β-reduction | (λx. M) N → M[x := N]. |
| η-reduction | λx. M x → M if x is not free in M. |
| Term | Meaning |
|---|---|
| Normal form | A term with no redex left. |
| Normal order | Always reduce the leftmost, outermost redex first. |
| Confluence | Any two reduction paths can be joined again (Church–Rosser). |
| Church numeral | n = λf x. fn x: "apply f n times". |
| Fixed point | X with F X = X. Y finds one for any F. |
| Currying | Turning a 2-argument function into nested 1-argument ones. |
| de Bruijn index | A number for "how many binders up" instead of a name. |
| Strong normalization | Every reduction path ends. True for simply-typed terms. |
| Curry–Howard | Types are propositions, and programs are proofs. |