Chapter 2
The Language of Terms
Before a kernel can check anything, it needs a precise language of terms. Ours has five kinds of term, and that is enough to express propositional logic with quantification over propositions. This chapter fixes the grammar, the notational conventions used for the rest of the book, and the Rust type that represents terms.
Five kinds of terms
The core language is a small dependent type theory. Its terms are generated by the grammar
where is a natural number and is a variable name. Each form has a role.
- is a universe: a type whose elements are themselves types. The number is its level. is the universe of propositions. Chapter 13 explains why there is a whole hierarchy rather than a single universe.
- is a variable. Its meaning comes from the context in which it appears, either a or binder enclosing it or an assumption in the typing context.
- is a -type, the dependent function type from Chapter 1. It is the type of functions that take an and return something of type , where may mention .
- is a -abstraction, a function that takes an argument of type and returns .
- is application of to .
The grammar makes no distinction between types and terms. The proposition and its proof are both terms built from the same five constructors:
The same two terms, seen as trees, make the shared grammar visible. Every node is one of the five constructors; the name on a or node is the variable it binds. Click a node to see the subterm it stands for, or type another term.
Whether a term is a valid type is not a syntactic property. It is decided by the type checker: a term is a type exactly when its own type is a universe (Chapter 5).
Conventions
The book uses the following letters consistently.
| Letters | Stand for |
|---|---|
| and (possibly primed or subscripted) | terms |
| and any name bound by or | variable names |
| universe levels | |
| typing contexts (Chapter 3) | |
| the signature of constants (Chapter 7) |
Three abbreviations keep terms readable.
Arrows. When does not mention , we write for . Ordinary function types are thus a special case of dependent function types, and implication is a special case of universal quantification. In the implementation the binder of an arrow is the reserved name _.
Associativity. Application associates to the left and arrows to the right:
Currying. A function of two arguments is a function that takes the first argument and returns a function waiting for the second. For example, has type , and applying it to and is written
The two associativity conventions are designed to fit together: a curried function of type applied as needs no parentheses on either side.
Binding and scope
In and , the variable is bound, and its scope is the body or . The annotation is outside the scope: in the in the annotation refers to something outside the term, while the in the body refers to the argument. A variable occurrence that is not inside the scope of a binder for it is free. For example, in the variables , , and are free and is bound.
Two facts about bound variables matter throughout the book, and both are formalized in Chapter 11.
- The name of a bound variable carries no meaning: and are the same function. Terms that differ only in the names of bound variables are called alpha-equivalent, and a kernel must treat them as equal.
- Replacing a variable by a term, which the typing rules and computation both require, must not accidentally bring a free variable under a binder of the same name. Getting this right is called capture-avoiding substitution.
Both facts are easy to state and both have caused real bugs in real proof checkers, which is why they get their own chapter.
In the code
The same file provides small constructor functions so that terms can be written without nested Box::new calls: var("x"), const_term("Nat"), app(f, a), pi("x", a, b), lam("x", a, b), and arrow(a, b), which builds pi("_", a, b). Three helpers encode the conventions of this chapter directly.
/// f a_1 ... a_n (left-associated application)
pub fn apps(f: Term, args: impl IntoIterator<Item = Term>) -> Term {
args.into_iter().fold(f, app)
}
/// Pi (x_1 : A_1) ... Pi (x_k : A_k). tail (a telescope of binders)
pub fn telescope(binders: &[(String, Term)], tail: Term) -> Term {
binders.iter().rev().fold(tail, |body, (name, ty)| pi(name, ty.clone(), body))
}
/// Decomposes t into its spine: a head that is not an application, and its arguments.
/// f a b becomes (f, [a, b]); a non-application becomes (t, []).
pub fn spine(t: &Term) -> (Term, Vec<Term>) {
match t {
Term::App(f, a) => {
let (head, mut args) = spine(f);
args.push((**a).clone());
(head, args)
}
_ => (t.clone(), vec![]),
}
}
apps and spine are inverses of each other: apps(h, args) builds the left-nested application , and spine takes it apart again. The spine view is how the reduction engine of Chapter 12 looks at a term, so get comfortable with it now. The nested term has head and arguments ; walking down the function side of each application reaches the head, and the arguments collected on the way, read from the inside out, are in the right order.
The surface syntax of the .leanmini scripts used throughout the book writes the same terms as Sort 0, forall (x : A), B, A -> B, fun (x : A) => b, and juxtaposition for application. Appendix E gives the full grammar.
Exercises
Write the proposition "for all propositions and , implies implies " as a term, and give a proof term for it.
Solution
The proposition is , which unfolds to . A proof is . In leanmini syntax:
goal forall (P : Sort 0), forall (Q : Sort 0), P -> Q -> P
exact fun (P : Sort 0) => fun (Q : Sort 0) => fun (p : P) => fun (q : Q) => p
qed
Fully parenthesize and , then give the spine of the first term.
Solution
, with head and arguments . Note that stays a single argument because of the parentheses. , which is .
In , which occurrences of refer to which binder? Which variables are free?
Solution
The in the annotation of refers to the outer binder , because the inner has not been entered yet. The innermost is bound by the inner binder but never used. The only free variable is . This term reappears in Chapter 11, where it breaks a type checker that handles shadowing carelessly.
What to remember
- Terms are universes , variables, -types, -abstractions, and applications. Types and terms share one grammar.
- abbreviates a -type whose body ignores its variable. Application is left-associative, arrows are right-associative, and functions are curried.
- Bound variable names are arbitrary, and substitution must avoid capture. Both are handled in Chapter 11.
- In Rust,
Termis an enum with one variant per form, andspine/appsconvert between nested applications and a head with an argument list.