Elaboration: from syntax to Expr
Part 2 of Lean from the Inside. What you type is much shorter than
what the kernel checks. 1 + 1 leaves out the type, the addition instance and
how the numerals are interpreted, and Lean fills all of that in before the
kernel sees anything. That process is elaboration. This post follows a term
from text to kernel term, then looks at the main things the elaborator fills in:
metavariables and unification, implicit arguments, instances and coercions.
From text to Expr
Lean turns the text of a term into a kernel term in three stages:
-
The parser turns text into
Syntax, a tree that records which notation you used and nothing else. It knows no types. -
Macro expansion rewrites syntax into other syntax. Most notation, including
if, is a macro. -
The elaborator turns the remaining syntax into an
Expr, the data type the kernel checks. This is the stage that infers types, fills in implicit arguments, finds instances and inserts coercions.
You can watch each stage from inside Lean, because the parser, the macro
expander and the elaborator are ordinary Lean functions you can call. Here is
the parse tree of if 1 < 2 then 3 else 4:
open Lean Elab Command in
#eval show CommandElabM Unit from do
let stx ← `(term| if 1 < 2 then 3 else 4)
logInfo (toString stx.raw)
Each node is named after the notation that produced it (termIfThenElse,
term_<_), and the keywords are kept as plain strings. Nothing in the tree
says what < means or what type 3 has.
Macros run first
A macro maps syntax to syntax. Asking for one step of expansion of the same
term shows what if is notation for:
open Lean Elab Command in
#eval show CommandElabM Unit from do
let stx ← `(term| if 1 < 2 then 3 else 4)
let some stx' ← liftMacroM (Macro.expandMacro? stx) | logInfo "no macro"
logInfo m!"{stx'}"
I expected ite (1 < 2) 3 4, and the last step is that call. The two steps
before it are instructions to the elaborator: elaborate the condition into a
metavariable ?m (a hole to be filled later), and don't continue until its
type is known. The macro can't check the type itself, because types don't
exist at this stage. All it can do is emit syntax telling the elaborator what
to check. The ✝ marks names made hygienic by the macro system, so a local
variable you happen to call ite can't capture the call.
What the elaborator produces
Turning off notation in the pretty printer shows the term the elaborator built:
set_option pp.notation false in
#check if 1 < 2 then 3 else 4
ite also takes a Decidable (1 < 2) instance, which is how it can compute
with a proposition. That argument is in square brackets in the signature of
ite, so the printer leaves it out. Even this view hides a lot. pp.all
prints every implicit argument and instance. Here is 1 + 1:
set_option pp.all true in
#check 1 + 1
The three characters 1 + 1 became a call to HAdd.hAdd with three type
arguments, universe levels and an instance. Each 1 became OfNat.ofNat
applied to a type, a raw literal (nat_lit 1) and another instance. The
elaborator chose Nat for all of them because nothing else constrained the
type, a rule covered later in this post.
Expr is an ordinary inductive type
The result is a value of Lean.Expr, an inductive type with constructors for
constants, applications, lambdas, bound variables and a few more. Calling the
elaborator directly and printing the raw value shows them. (mkIdent stops the
quotation from making x hygienic, which would print as a long generated name.)
open Lean Elab Term in
#eval show TermElabM Unit from do
let x := mkIdent `x
let e ← elabTerm (← `(fun ($x : Nat) => $x)) none
logInfo m!"{repr e}"
Reading it from left to right: a lambda (lam) whose binder is named x, whose
binder type is the constant Nat with no universe arguments, whose body is
bvar 0, and whose binder is explicit (BinderInfo.default, as opposed to
{x} or [x]). The body refers to x as bvar 0, not by name. Bound
variables are de Bruijn indices: bvar 0 means "the nearest enclosing binder",
bvar 1 the one outside that, and so on. The name x is kept only for
printing, which is why renaming a bound variable never changes the term.
This is the whole interface between the elaborator and the kernel. The kernel
never sees Syntax, macros, implicit arguments or instances as such. It sees an
Expr in which all of those have already been written out, and it checks that
term. The rest of this post looks at how the elaborator fills in the parts you
left out.
Metavariables and unification
The elaborator rarely knows everything it needs when it reaches a subterm. When
it is missing a piece, such as a type, an implicit argument or the term behind a
_, it creates a metavariable: a named hole, printed ?m.4 or similar, that
it promises to fill before the definition is finished. The ?m in the if
macro above was one of these.
Holes get filled by unification. Whenever the elaborator needs two terms to
be equal, for example the type an argument has and the type the function
expects, it calls isDefEq on them. If one side contains a metavariable,
isDefEq may assign it to make the two sides equal. You can call it yourself:
open Lean Meta in
#eval show MetaM Unit from do
let m ← mkFreshExprMVar (mkConst ``Nat)
let ok ← isDefEq (mkApp (mkConst ``Nat.succ) m) (mkNatLit 5)
logInfo m!"{ok}: ?m := {← instantiateMVars m}"
Nat.succ ?m and the literal 5 have different shapes, but isDefEq knows
that a literal n + 1 is Nat.succ n, so it solved ?m := 4. Unification
is checking definitional equality while allowed to fill holes. It goes beyond
syntactic matching, because it can unfold definitions to make the two sides
line up.
An underscore is a metavariable
Writing _ asks the elaborator to make a metavariable and fill it by
unification. Here the witness of an existential is left out:
#check (⟨_, rfl⟩ : ∃ n : Nat, n + 2 = 5)
The anonymous constructor became Exists.intro ?w rfl. The expected type says
rfl must prove ?w + 2 = 5, and rfl proves a = a, so unification has to
solve ?w + 2 =?= 5. That's the same literal trick as before, one level
deeper, and it gives ?w := 3.
Unification finds a solution, not the value you might have in mind. If the hole is on the right of an equation, the obvious solution is to copy the left side:
#check (rfl : 2 + 3 = _)
The hole was filled with 2 + 3, not 5. Nothing asked for the sum to be
computed, and copying is the first solution isDefEq finds.
When a hole stays empty
If nothing constrains a metavariable, it's still unassigned when the elaborator finishes, and the definition is rejected:
def mystery := id _
There are three errors because there are three holes. id has the signature
{α : Sort u} → α → α, so id _ creates ?u for the universe, ?m.4 for α
and ?m.3 for the argument. The only constraint is that the argument has type
?m.4, which relates two holes without filling either. There is no expected
type either, since mystery has no type annotation. Adding one fixes α but not
the argument:
def mystery' : Nat := id _
Unification only ever solves an equation. It never invents a term out of nothing.
The elaborator does have a few tricks for holes that unification can't fill.
Instance resolution fills [...] arguments, coercions repair type mismatches,
and default instances choose a type for a bare numeral. The next sections
cover them.
Implicit arguments and instances
Implicit arguments are metavariables
An argument in curly braces is implicit. You don't write it. At each use,
the elaborator creates a metavariable for it and lets unification fill it in.
@ turns this off and shows the full signature, and a named argument fills one
implicit argument by hand:
#check id 5
#check @id
#check id (α := Int) 5
In id 5, the elaborator makes ?α, then elaborates 5 against the expected
type ?α. A numeral alone doesn't fix its type, so ?α becomes Nat by the
default rule covered at the end of this post. With (α := Int) the hole is
filled before 5 is
elaborated, so the same numeral becomes an Int. α is one argument, and
implicit only means the elaborator usually fills it in for you.
Instance arguments are filled by search
An argument in square brackets is an instance argument. It also becomes a
metavariable, but unification is the wrong tool for it. Nothing in 1 + 1
mentions an addition function, so no equation could determine it. Here is the
signature of the function behind +:
#check @HAdd.hAdd
The types of the two operands, α and β, are implicit and come from
unification with the operands. The instance [self : HAdd α β γ] can't come
from unification. The result type γ is marked outParam, which tells
instance resolution not to wait for γ to be known: it searches using α
and β alone, and the instance it finds decides γ.
For an argument like this, the elaborator runs instance resolution. It looks
for a declaration marked instance whose type matches the goal. #synth runs
the same search directly:
#synth HAdd Nat Nat Nat
That's the @instHAdd Nat instAddNat in the pp.all output earlier.
instHAdd itself has an instance argument, [Add α], so finding it started a
second search, for Add Nat, which instAddNat answers.
Watching the search
Instances that take instance arguments are how type classes compose, and the
search can be traced. Here is a small class, a base instance, and an instance
that builds a Shape for a pair out of Shape instances for its parts:
class Shape (α : Type) where
area : α → Float
structure Square where side : Float
structure Pair (α β : Type) where
fst : α
snd : β
instance : Shape Square := ⟨fun s => s.side * s.side⟩
instance [Shape α] [Shape β] : Shape (Pair α β) :=
⟨fun p => Shape.area p.fst + Shape.area p.snd⟩
set_option trace.Meta.synthInstance true in
#synth Shape (Pair Square Square)
On the page the trace starts collapsed; click its first line to expand it. Reading it from the top:
-
For the goal
Shape (Pair Square Square), only one instance has a matching type, the unnamed pair instance, which Lean namedinstShapePair. -
Applying it uses unification (
≟) to match its conclusionShape (Pair ?α ?β)against the goal, which sets?αand?βtoSquare. That leaves its two instance arguments as new goals, bothShape Square. -
instShapeSquareanswersShape Square. -
The answer is passed back up to both waiting arguments (
size: 1, thensize: 2), and with both filled the original goal is answered.
new goal Shape Square appears only once, although two arguments needed it.
The search stores every answer it finds and reuses it when the same goal comes
up again, so Shape Square was solved once.
When the search fails
If no instance matches, the elaborator reports the goal it couldn't solve:
#check fun (s : String) => s + s
The goal still has a metavariable in it: ?m.3 is the outParam result type,
which only a matching instance could have filled. Strings are joined
with ++ (HAppend), and there is no HAdd String instance. The second
message shows that elaboration doesn't stop at the first error. The failed
subterm is replaced by sorry and the rest of the term is still elaborated,
which is how one mistake doesn't hide every error after it.
Coercions and default instances
The last two tools fill in what neither unification nor an ordinary instance search can decide: what to do when two types don't match, and what type to give a numeral when nothing says.
Coercions repair a failed unification
When an argument's type doesn't unify with the type the function expects, the
elaborator doesn't give up straight away. It searches for a coercion from one
type to the other, using the Coe family of classes, and wraps the argument in
it:
def addInt (a b : Int) : Int := a + b
#check fun (n : Nat) => addInt n 1
set_option pp.coercions false in
#check fun (n : Nat) => addInt n 1
n : Nat failed to unify with Int, so the elaborator inserted a coercion,
printed ↑n. Turning off pp.coercions shows what's really in the term:
Nat.cast n. With the option off, numerals also print as OfNat.ofNat calls.
That's a printing difference only, and the term is the same. Note what didn't
change: 1 was elaborated as an Int from the start, because its expected
type was known, so it needed no coercion.
The coercion isn't kept as a call to Coe.coe. The elaborator unfolds the
instance and puts its body in the term. A user-defined coercion makes this
visible:
structure Meters where
val : Float
instance : Coe Nat Meters where
coe n := ⟨n.toFloat⟩
def twice (m : Meters) : Meters := ⟨2 * m.val⟩
#check twice (3 : Nat)
set_option pp.coercions false in
#check twice (3 : Nat)
There's no ↑ this time, even with coercion printing on. The instance body
⟨n.toFloat⟩ was put into the term, and nothing marks it as a coercion.
Nat.cast printed as ↑ because it is tagged @[coe], which tells the
printer to show it as an arrow. Wrapping the body in a tagged function brings
the arrow back:
@[coe] def Meters.ofNat (n : Nat) : Meters := ⟨n.toFloat⟩
instance : Coe Nat Meters := ⟨Meters.ofNat⟩
#check twice (3 : Nat)
There are now two Coe Nat Meters instances. When instances have the same
priority, the one declared later is tried first, so this one wins.
Because coercions are unfolded during elaboration, the kernel never sees anything special. It checks an ordinary function application.
Default instances choose a type for numerals
A numeral like 1 elaborates to OfNat.ofNat ?α 1, which needs an
OfNat ?α 1 instance. If nothing ever determines ?α, there is no type to
search for. For this case Lean has default instances. instOfNatNat is
marked @[default_instance], so when the elaborator runs out of other work
with an OfNat ?α n goal still open, it tries that instance, which sets
?α := Nat:
#check fun x => x + 1
Nothing said what x is. Its type and the type of 1 were both holes,
linked by +, and the default filled both with Nat.
A default is a last resort. It applies only after the other constraints have had their chance, even when the constraint comes later in the text:
#check (fun x => x) 5
#check (fun x => x + (1 : Int)) 5
In the second line, (1 : Int) inside the function fixes the type of x,
and so the type of 5, before any default is tried.
The default can surprise you, because Nat subtraction truncates at zero:
#eval 2 - 3
#eval (2 - 3 : Int)
Nothing in 2 - 3 asks for a type, so the default makes it a Nat
subtraction.
What I took away
-
The kernel only ever sees
Expr. Notation, macros, implicit arguments, instances and coercions are all gone by then, written out as ordinary terms. -
Most of the work is filling holes. The elaborator creates metavariables for everything you leave out and fills them by unification, instance search, coercions or, as a last resort, a default instance.
-
Unification solves equations and nothing more. It finds the first solution that works, which isn't always the one you meant, and it can't invent a term no equation mentions.
-
Instance search is a search over instance declarations, with answers stored and reused, and its trace can be read step by step.
-
Coercions and numeral types are decided during elaboration. Odd results such as
2 - 3 = 0come from the elaborator's choices, not the kernel's.
Sources
-
Lean language reference: the sections on elaboration, implicit arguments, type classes (including default instances and output parameters) and coercions.
-
Metaprogramming in Lean 4, chapters on
Syntax, macros,Expr,MetaMand elaboration, which cover theisDefEqandelabTermfunctions the probes call. -
Daniel Selsam, Sebastian Ullrich and Leonardo de Moura, Tabled Typeclass Resolution (2020), the algorithm behind instance search, including the stored answers seen in the trace above.
