So far we have only universes and Π-types. They suffice for propositional logic, but mathematics also needs numbers to reason about and a way to state that two things are equal. Lean supplies both through inductive types. This chapter adds constants to the kernel, declares the natural numbers, and shows how the same mechanism supports computation and proof about them.
Constants and definitions
First we need a way to add named objects to the kernel. Terms gain constantsc:
t,A,B::=⋯∣c
A signatureΣ lists the constants. Each entry either declares a constant with its type, written c:C, or also gives it a body, written c:C:=t. Entries with a body are definitions. For example, Nat:Sort1 below is a declaration, and add later in this chapter is a definition. Before an entry is added, its type is checked to be a type in the empty context, and its body, if any, is checked against that type, as Lean does for each def.
The signature is used in two ways. First, a constant has the type the signature gives it:
Γ⊢c:C⊢Γ;ok(c:C)∈Σ;(T-Const)
where (c:C)∈Σ covers both declarations and definitions. Second, a definition can be unfolded: the constant is replaced by its body. This computation step is called delta-reduction:
c→δtif (c:C:=t)∈Σ.
For example, add21→δ(λ(m:Nat).λ(n:Nat).⋯)21, after which beta-reduction continues. A declared constant without a body, such as Nat or zero, never delta-reduces. Like beta, the step may be applied to any subterm.
Constants are not variables: they are never bound, so FV(c)=∅ and substitution leaves them unchanged. Reduction now consists of beta, delta, and the iota-rules introduced below, and definitional equality (Chapter 12) is taken with respect to all three.
Natural numbers
In Lean, the natural numbers are declared as
inductive Nat where
| zero : Nat
| succ (n : Nat) : Nat
The declaration says that every natural number is built in one of two ways: it is zero, or it is succn, the successor of a natural number n. For example, 2 is succ(succzero). In our kernel the declaration adds three constants without bodies:
Nat:Sort1,zero:Nat,succ:Nat→Nat.
zero and succ are called constructors. They build values but never compute: succ(succzero) is already in normal form, and it is the number 2. We write 0,1,2,… for zero, succzero, succ(succzero), and so on.
We now have numbers, but no way to compute with them: addition must distinguish zero from succn, while our terms can only pass an argument along, not inspect it.
Computing: addition
How should addition be defined? By recursion on the second argument:
addmzero=m,addm(succk)=succ(addmk).
Look at the shape of this definition. For a fixed m, the function addm is determined by two pieces of data: its value on zero, which is m, and a way to turn its value r on k into its value on succk, which is succr. Every function f:Nat→C defined by recursion of this kind, called structural recursion, has the same shape:
fzero=z,f(succk)=sk(fk),
for some z:C and s:Nat→C→C. The step function s receives both k and the result fk of the recursive call. So we add one constant that builds f from z and s, once and for all:
recC:C→(Nat→C→C)→Nat→C,
together with two computation rules, called iota-rules, which are the equations above:
recCzszero→ιz,recCzs(succn)→ιsn(recCzsn).
Addition is now an ordinary, non-recursive definition:
We write m+n for addmn. The kernel computes 2+1 with delta, beta, and iota steps only. The explorer below is the kernel doing that, one head step at a time, with the add definition loaded.
def add : Nat -> Nat -> Nat := fun (m : Nat) => fun (n : Nat) => Nat.rec1 (fun (_n : Nat) => Nat) m (fun (k : Nat) => fun (r : Nat) => Nat.succ r) n
Each row is one head step: the highlighted part is the redex, and the next row shows what it became. Change the numerals, or try add Nat.zero n with the context n : Nat to see the recursor get stuck.
Other functions follow the same pattern. Doubling, with double(succk)=succ(succ(doublek)), is
and double2→succ(succ(double1))→succ4(double0)→succ4zero=4.
No new recursion mechanism is needed. The constants of the inductive type, together with recC and its two rules, are enough to express every structurally recursive function on Nat. And unlike general recursion, this one always terminates: each iota-step removes one succ from the argument.
Proving: induction
Now consider proofs. To prove that a property Pn holds for every n, where P:Nat→Sort0, mathematics uses induction: prove the base casePzero, and the stepΠ(k:Nat).Pk→P(succk), whose argument Pk is the induction hypothesis.
Through Curry-Howard, a proof of Π(n:Nat).Pn is a function f that takes n and returns a proof of Pn. How do we build such an f from base:Pzero and step? For 3, the proof is step2(step1(step0base)), and in general
fzero=base,f(succk)=stepk(fk).
This is a structurally recursive function again, with exactly the shape of add above: z=base and s=step. Induction is recursion that returns proofs.
There is one difference: add has the same result type C for every n, whereas a proof has a type that depends on n, such as fzero:Pzero and f1:P1. Because the type of recC mentions a fixed C, it cannot express induction as it stands.
One recursor for both
Putting the two cases side by side shows what to generalize.
Recursion
Induction
result for n
C
Pn
case zero
z:C
base:Pzero
case succ
s:Π(k:Nat).C→C
step:Π(k:Nat).Pk→P(succk)
result
Nat→C
Π(n:Nat).Pn
The left column is the special case of the right column in which Pn is the same type C for every n. So we take the right column, with P replaced by an arbitrary function M:Nat→Sortu that gives the type of the result for each n. This function is called the motive. The result is the recursorNat.recu of Figure 7.1. Its arguments are the motive, one minor premise for each constructor, and finally the number n to recurse on, called the major premise. Its iota-rules are those of recC. As in Lean, where the recursor is Nat.rec.{u}, there is one recursor for each universe level; since we have no universe polymorphism, the level is a subscript.
Figure 7.1. The constants for natural numbers and the iota-rules of the recursor.
The one recursor does both jobs, depending on the motive.
Recursion. With the constant motive λ(_:Nat).C at level 1, the type of Nat.rec1(λ(_:Nat).C) is convertible to the type of recC. So recC is just Nat.rec1(λ(_:Nat).C), and from now on
which is the definition in examples/zero-add.leanmini.
Induction. With a predicate M:Nat→Sort0 at level 0, the type of Nat.rec0 is the induction principle
Mzero→(∀n.Mn→M(succn))→∀n.Mn.
Here the choice of imax in T-Pi is essential. The conclusion Π(n:Nat).Mn has type Sortimax(1,0)=Sort0, so "Mn for every n" is a proposition although Nat lives in Sort1. With max instead, it would live in Sort1, and statements about numbers would not be propositions at all.
The type of Nat.recu is not arbitrary: it can be read off the declaration of Nat. There is one minor premise per constructor. Each minor premise takes the constructor's arguments, plus one induction hypothesis for each argument whose type is Nat itself (a recursive argument), and returns the motive applied to the constructor. zero has no arguments, so its case is just Mzero; succ has one recursive argument n, so its case is Π(n:Nat).Mn→M(succn). Chapter 8 applies this recipe to more and more general declarations.
Try it
The kernel starts with Nat and its recursors already declared (Kernel::standard in src/kernel.rs). The script below defines addition and asks the kernel to confirm that 2+1 and 3 are definitionally equal, which rfl does by computing both sides. Try changing three to Nat.succ three and see the kernel refuse.
def add : Nat -> Nat -> Nat := fun (m : Nat) => fun (n : Nat) => Nat.rec1 (fun (_n : Nat) => Nat) m (fun (k : Nat) => fun (r : Nat) => Nat.succ r) n
def one : Nat := Nat.succ Nat.zero
def two : Nat := Nat.succ one
def three : Nat := Nat.succ two
# Both sides compute to succ (succ (succ zero)), so refl proves this.
goal Eq Nat (add two one) three
rfl
qed
The Eq in the goal is the equality type, declared in Chapter 9. For now, read Eq Nat a b as "a=b" and rfl as "both sides compute to the same thing".
Exercises
Define multiplication mulmn by recursion on n, using add, in the style of add above.
Solution
mulmzero=zero and mulm(succk)=addm(mulmk), so
mul:=λ(m:Nat).λ(n:Nat).Nat.rec1(λ(_:Nat).Nat)zero(λ(k:Nat).λ(r:Nat).addmr)n.
def mul : Nat -> Nat -> Nat := fun (m : Nat) => fun (n : Nat) => Nat.rec1 (fun (_n : Nat) => Nat) Nat.zero (fun (k : Nat) => fun (r : Nat) => add m r) n
Why can the kernel not accept a general fixed-point combinator fix:(C→C)→C with the rule fixg→g(fixg), which would make every recursive function definable?
Solution
With C=Π(P:Sort0).P and g=λ(x:C).x, the term fixg would have type Π(P:Sort0).P, a proof of everything, and the system would be inconsistent. It also reduces to itself forever, so IsDefEq would not terminate. Structural recursion avoids both problems because every iota-step consumes a constructor.
What to remember
Constants come from a signature Σ; definitions unfold by delta-reduction, declarations never do.
An inductive type adds a type former and constructors, which build values and do not compute.
Structural recursion on Nat is captured by one constant, the recursor, with one iota-rule per constructor.
Induction is the same recursor with a dependent motive M:Nat→Sort0. Recursion and induction are one operation.
The imax rule makes "for all n, Mn" a proposition.