Foundations: what an inductive type gives you
Part 1 of Lean from the Inside. Almost everything in Lean, from Nat
to And to Eq, is an inductive type, and almost every function and proof
about those types ends up as a call to a single function the kernel generates:
the recursor. This post works out what that means. It covers what inductive
adds to the environment, how universes and Prop constrain the recursor, and
how a match you write becomes a recursor call.
Types have types
Every term has a type, and every type is itself a term with a type. The types
of types are the universes Sort u:
#check Nat
#check Type
#check Prop
Prop is Sort 0, Type is Sort 1, and Type u is Sort (u + 1). The
hierarchy exists because Type : Type would be inconsistent (Girard's
paradox), so each universe lives in the next one up. Definitions can be
universe polymorphic: List takes a universe parameter, so List Nat and
List Type are both fine.
#check @List
#check List Type
Prop is the special case. A term p : Prop is a proposition, and a term
h : p is a proof of it. Two facts about Prop matter for everything below:
-
Impredicativity. A
∀over any type still lands inProp. The statement "for every typeα,α → αis inhabited" quantifies overType, but it is itself aProp, not aType 1. -
Proof irrelevance. Any two proofs of the same proposition are definitionally equal. The kernel never distinguishes them, so
rflproves it.
#check ∀ (α : Type), α → α
example (p : Prop) (h₁ h₂ : p) : h₁ = h₂ := rfl
That output was a surprise worth recording. Lean prints the statement back as a
dependent function type, (α : Type) → α → α, and puts it in Type 1, not
Prop. The body α → α is a Type, so this is a type of polymorphic
functions, not a proposition. Impredicativity applies only when the body is a
proposition:
#check ∀ (α : Type), α = α
The rule is that (x : A) → B lives in Sort (imax u v), where A : Sort u
and B : Sort v. imax u 0 = 0, so a Prop body always gives a Prop.
Otherwise imax u v = max u v.
What inductive adds
Here is a small inductive type, declared in its own namespace so it does not clash with the standard library:
namespace Demo
inductive Tree where
| leaf : Tree
| node : Tree → Nat → Tree → Tree
end Demo
That declaration adds much more than Tree, Tree.leaf and Tree.node. On
Lean 4.32 it adds 24 constants. I listed them with a small metaprogram that
filters the environment for names starting with Tree. Only four come from the
kernel: the type, its two constructors and the recursor Tree.rec. Lean's
front end derives the rest from the recursor:
-
Tree.casesOnandTree.recOn: case analysis without inductive hypotheses, and the recursor with the tree argument moved first. -
Tree.noConfusion,Tree.node.injandTree.node.injEq: constructors are distinct and injective. The kernel does not know this. It is proved from the recursor. -
Tree.belowandTree.brecOn: used to compile structural recursion (see the last section). -
Tree._sizeOf_1and thesizeOf_speclemmas: a size measure used by well-founded recursion. -
Tree.ctorIdx,Tree.ctorElimand the per-constructorelimfunctions, used by the code generator and by tactics.
The recursor is the important one. Its type is the induction principle:
#check @Demo.Tree.rec
Read it left to right:
-
The
motivesays what we are computing or proving for each tree. Because it returnsSort u_1for an arbitrary universeu_1, the same recursor builds both data (motive := fun _ => Nat) and proofs (motive := fun t => P t). -
There is one minor premise per constructor. The
nodecase gets the constructor's arguments plus one inductive hypothesis for each recursive argument:motive aandmotive a_2. -
The result is a function from every tree
ttomotive t.
The recursor also comes with computation rules, which the kernel uses when it
reduces terms. Tree.rec l n Tree.leaf reduces to l, and
Tree.rec l n (Tree.node a x b) reduces to
n a x b (Tree.rec l n a) (Tree.rec l n b). That is all the kernel knows about
Tree.
Using the recursor by hand
Any function on trees can be written as a recursor application. Here is the sum
of the labels, with no match and no recursion:
def Demo.Tree.sumRecBad : Demo.Tree → Nat :=
Demo.Tree.rec (motive := fun _ => Nat) 0 (fun _ x _ ihl ihr => ihl + x + ihr)
My first draft of this post said this would just run. It does not. The kernel
accepts the definition, but Lean's compiler, which produces the code #eval
runs, has no implementation of recursors. The definition has to be marked
noncomputable, which tells Lean to type-check it but not compile it:
namespace Demo
noncomputable def Tree.sumRec : Tree → Nat :=
Tree.rec (motive := fun _ => Nat) 0 (fun _ x _ ihl ihr => ihl + x + ihr)
def example1 : Tree := .node (.node .leaf 1 .leaf) 2 (.node .leaf 3 .leaf)
example : example1.sumRec = 6 := rfl
end Demo
#eval is out, but rfl still works: the kernel unfolds sumRec, applies the
recursor's computation rules, and gets 6. This is the first sign of a split
that comes up again and again in Lean. The kernel and the compiler are separate
programs with separate views of a definition.
The kernel also did not ask for a termination proof. It did not need one, because a recursor only ever recurses on the immediate subtrees, so it always terminates.
The same recursor proves things. With motive := fun t => 0 ≤ t.sumRec it
would prove a (trivial) fact about every tree. With the induction tactic, Lean
picks the motive for you from the goal.
Prop restricts the recursor
Inductive types can also live in Prop. Or is one:
#check @Or.rec
(The ⋯ hides proof terms, here Or.inl h and Or.inr h. Lean elides proofs
in output by default; set_option pp.proofs true shows them.)
Compare this to Tree.rec. The motive returns Prop, not Sort u_1. You can
use a proof of a ∨ b to prove another proposition, but not to compute data.
If you could, you could write a function that returns true for proofs made
with Or.inl and false for proofs made with Or.inr. Proof irrelevance says
any two proofs of True ∨ True are equal, so that function would have to
return the same value on both, and Lean would be inconsistent.
Lean enforces this when you try:
def whichSide (h : True ∨ True) : Bool :=
match h with
| .inl _ => true
| .inr _ => false
The important line is the second one. The pattern-match compiler tried to build
the function from Or.casesOn, whose motive must return Prop, but Bool is
a Type.
The exception is subsingleton elimination. An inductive proposition may
eliminate into any Sort when it has at most one constructor and every
argument of that constructor is either a proof or appears in the result type.
And, True, False and Eq qualify. False.rec eliminating into any type
is what makes absurd possible, and Eq.rec eliminating into any type is what
lets you transport data along an equation:
#check @Eq.rec
#check @False.rec
Note the motive of Eq.rec. It depends on the right-hand side and on the
proof of equality. Proofs about Eq that look trivial (Eq.symm, Eq.subst,
congruence) are all applications of this one recursor.
How match becomes a recursor
Most functions are written with pattern matching and recursion:
namespace Demo
def Tree.sum : Tree → Nat
| .leaf => 0
| .node l x r => l.sum + x + r.sum
end Demo
The kernel has no match and no recursion, so the elaborator has to translate
this into something like sumRec. It does that in two steps.
First, the pattern match is compiled into an auxiliary matcher function,
Demo.Tree.sum.match_1, built from casesOn.
Second, the recursion has to be justified. Lean first tries structural
recursion: every recursive call must be on a direct subterm of the argument.
Here l and r are subterms of .node l x r, so it succeeds. #print shows
the definition the kernel actually checked:
#print Demo.Tree.sum
There is no recursion left. Tree.brecOn is a variant of the recursor that
hands each case a table, Tree.below, holding the results for every smaller
subtree. The body you wrote became the helper Demo.Tree.sum._f, and each
recursive call l.sum became a lookup in that table. That is why inductive
generated below and brecOn.
The compiler, by contrast, compiles your match and recursive calls directly,
which is why #eval Demo.example1.sum works when #eval of sumRec did not:
#eval Demo.example1.sum
Both definitions reduce in the kernel on concrete inputs:
example : Demo.example1.sum = 6 := rfl
example : Demo.example1.sum = Demo.example1.sumRec := rfl
The second line is worth pausing on. The two functions were written
differently, one with match and one with Tree.rec directly, but on a
concrete tree they reduce to the same numeral, so rfl proves them equal. To
prove them equal for every tree, you need induction, which means using the
recursor:
theorem Demo.Tree.sum_eq_sumRec (t : Demo.Tree) : t.sum = t.sumRec := t:Tree⊢ t.sum = t.sumRec
induction t with
⊢ leaf.sum = leaf.sumRec All goals completed! 🐙
l:Treex:Natr:Treeihl:l.sum = l.sumRecihr:r.sum = r.sumRec⊢ (l.node x r).sum = (l.node x r).sumRec
l:Treex:Natr:Treeihl:l.sum = l.sumRecihr:r.sum = r.sumRec⊢ l.sum + x + r.sum = l.sumRec + x + r.sumRec
All goals completed! 🐙
The show step works because both sides of the node case unfold, by the
computation rules, to exactly the expression written there. induction chose
the motive fun t => t.sum = t.sumRec and handed us the two minor premises,
with ihl and ihr as the inductive hypotheses.
When structural recursion fails, for example when the recursive call is on
n / 2, Lean falls back to well-founded recursion. That builds the function
from WellFounded.fix and needs a termination proof. Such definitions do not
reduce by rfl the way structural ones do. That is the subject of the
Programming part of the series.
What I took away
-
The kernel's view of an inductive type is small: the type, its constructors, one recursor and its computation rules. Everything else is derived.
-
Universes stop
Type : Type, andimaxis the rule that makes a∀with aPropbody aProp. -
A proof of an inductive proposition can only be used to prove other propositions, unless the type qualifies for subsingleton elimination.
-
matchand recursion are elaborator features. They compile tocasesOnandbrecOn, which are built from the recursor. -
The kernel and the compiler are separate. A recursor application type-checks and reduces, but it does not compile.
Sources
-
Theorem Proving in Lean 4, chapters on inductive types and on induction and recursion.
-
Lean language reference: the sections on universes and on inductive types, including the rules for subsingleton elimination.
-
Mario Carneiro, The Type Theory of Lean (master's thesis, 2019), for the formal rules behind
imaxand the recursor.
