Before getting started on this chapter, we need to import
all of our definitions from the previous chapter:
import LF.Basics
For this import to work, Lean needs to be able to find a
compiled version of the previous chapter (Basics.lean). This
compiled version, called Basics.olean, is analogous to the
.class files compiled from .java source files and the .o
files compiled from .c files.
When using Lake (Lean's build system), the file lakefile.toml
specifies dependencies and build configuration. Running lake build
will compile all necessary files in the correct order.
If you are using VS Code with the Lean 4 extension, compilation
happens automatically in the background. When you open a file, the
extension compiles its dependencies as needed.
Troubleshooting:
If you get complaints about missing imports, make sure you have
run lake build from the project root directory in a terminal, at least once.
If you modify Basics.lean, VS Code will automatically
recompile it when you save. You may need to reopen this file
or wait for recompilation to finish.
If you get errors that seem inconsistent with the source, try
running lake clean followed by lake build to recompile
everything from scratch.
(If you are using the Lean 4 extension for VS Code,
you can also restart the extension on the current file
via the Restart File button in the InfoView. The extension
should prompt you to do this if you change things upstream
in the dependency tree.)
We reopen the namespace from the previous chapter to group this chapter's
definitions and theorems with the custom natural-number development and keep
their names distinct from the standard library.
Now let's review what we learned in Basics using some
quiz questions and an exercise.
namespaceNatPlayground.Nat
Quiz
Recall the definition of or, which has notation || and is not
marked @[irreducible]:
def or (b1 : Bool) (b2 : Bool) : Bool :=
match b1 with
| true => true
| false => b2
To prove the following theorem, which tactics will we need besides
rfl?
We will introduce proofs by induction on natural numbers, first motivating
why induction is needed, and then explaining what it is and how you do it
in Lean.
For the add_zero simplification rule, we were able to prove that zero is a
neutral element for + on the right using just rfl.
theorem add_zero : ∀ (n : Nat), n + zero = n := by
intro n
rfl
This worked because n + zero reduces to n by definition.
What if we wanted to prove a rule that zero is also a neutral element
on the left? Just applying rfl doesn't
work, since the n in zero + n is an arbitrary unknown number, so
the match in the definition of + can't be reduced.
example(n:Nat):zero+n=n:=byn:Nat⊢ zero+n=nTactic `rfl` failed: The left-hand sidezero+nis not definitionally equal to the right-hand sidenn:Nat⊢ zero+n=nrfln:Nat⊢ zero+n=n-- doesn't work here!
Tactic `rfl` failed: The left-hand sidezero+nis not definitionally equal to the right-hand sidenn:Nat⊢ zero+n=n
And reasoning by cases using cases on n doesn't get us much
further: the branch of the case analysis where we assume n = zero
goes through just fine, but in the branch where n = n' + 1 for
some n' we get stuck in exactly the same way.
To prove interesting facts about numbers, lists, and other
inductively defined sets, we often need a more powerful reasoning
principle: induction.
Recall (from a discrete math course, probably) the principle of
induction over natural numbers: If P(n) is some proposition
involving a natural number n and we want to show that P holds for
all numbers n, we can reason like this:
show that P(zero) holds;
show that, for any n', if P(n') holds, then so does
P(succ n');
conclude that P(n) holds for all n.
In Lean, the steps are the same: we begin with the goal of proving
P(n) for all n and use the induction tactic to break it down
into two separate subgoals: one where we must show P(zero) and another
where we must show P(n') → P(succ n'). Here's how this works for
the theorem at hand...
Like cases, the induction tactic takes a with clause
that specifies the names of the variables to be introduced in the
subgoals. Since there are two subgoals (for zero and succ),
the with clause has two branches.
In the first subgoal, n is replaced by zero. The goal becomes
zero+zero=zero, which follows by rewrite [add_zero] and rfl.
In the second subgoal, n is replaced by succ n', and the
induction hypothesis ih : zero + n' = n' is added to the context.
The goal becomes zero + (succ n') = succ n'. add_succ tells
us that a + (succ b) = succ (a + b), so rewrite [add_succ]
transforms the goal to succ (zero + n') = succ n'. Then rewrite [ih]
rewrites zero + n' to n', and the goal becomes succ n' = succ n',
which closes with reflexivity.
Here's another theorem to try, this time involving equality on
natural numbers.
As you've probably noticed, a common pattern in Lean proofs is rewrite [...]
followed by rfl. Lean also provides a tactic that combines these two steps: rw [...]
will automatically close the goal if the rewrite makes the goal true by
definition. For example, instead of
rewrite [double_zero]; rfl
we could write this:
rw [double_zero]
One small caveat: rw [...] only performs a quick reflexivity check
after rewriting; it does not unfold every definition. So, in some
cases, rw may leave a goal that can actually be solved immediately by rfl.
For example, rw does not unfold the definition of aliasOfTwo in the following
example, and thus needs an explicit rfl.
In Lean, as in informal mathematics, large proofs are often
broken into sequences of theorems, with later proofs referring to
earlier theorems. But sometimes a proof will involve some
miscellaneous fact that is too trivial and of too little general
interest to bother giving it its own top-level name. In such
cases, it is convenient to simply state and prove the
required fact "in place." The have tactic allows us to do this.
The have tactic introduces a local lemma into the proof.
We prove it immediately, and it's available as a hypothesis
for the rest of the proof.
As another example, suppose we want to prove that
(n + m) + (p + q) = (m + n) + (p + q). The only difference between
the two sides of the = is that the arguments m and n to the
first inner + are swapped, so it seems we should be able to use
the commutativity of addition (add_comm) to rewrite one into the
other. However, the rw tactic is not very smart about where
it applies the rewrite. There are three uses of + here, and
rw [add_comm] may choose the wrong one...
example(nmpq:Nat):(n+m)+(p+q)=(m+n)+(p+q):=unsolved goalsnmpq:Nat⊢ p+q+(n+m)=m+n+(p+q)byn:Natm:Natp:Natq:Nat⊢ n+m+(p+q)=m+n+(p+q)/-
We just need to swap (n + m) for (m + n)... seems
like add_comm should do the trick!
But `rw [add_comm]` might rewrite the wrong `+`!
-/rw[add_commn:Natm:Natp:Natq:Nat⊢ p+q+(n+m)=m+n+(p+q)]n:Natm:Natp:Natq:Nat⊢ p+q+(n+m)=m+n+(p+q)
To use add_comm at the point where we need it, we can supply
explicit arguments: rw [add_comm n m] tells Lean exactly which
+ to rewrite. (We can also use have to establish the specific
equation we want, then rewrite with it.)
"Informal proofs are algorithms; formal proofs are code."
What constitutes a successful proof of a mathematical claim?
The question has challenged philosophers for millennia, but a
rough and ready answer could be this: A proof of a mathematical
proposition P is a text that instills in the
reader the certainty that P is true.
That is, a proof is an act of communication.
Acts of communication may involve different sorts of readers. On
one hand, the reader can be a program like Lean, in which case
the "belief" that is instilled is that P can be mechanically
derived from a certain set of formal logical rules, and the proof
is a recipe that guides the program in checking this fact. Such
recipes are formal proofs.
Alternatively, the reader can be a human being, in which case the
proof will probably be written in English or some other natural
language and will thus necessarily be informal. Here, the
criteria for success are less clearly specified. A "valid" proof
is one that makes the reader believe P. But the same proof may
be read by many different readers, some of whom may be convinced
by a particular way of phrasing the argument, while others may not
be. Some readers may be unfamiliar with the area and need the
argument spelled out in detail. Other readers, more
familiar with the area,
may find that extra detail makes it harder to follow the
argument; all they want is to be told the
main ideas, since it is easier for them to fill in the details for
themselves than to wade through a written presentation of them.
Ultimately, there is no universal standard, because there is no
single way of writing an informal proof that will convince every
conceivable reader.
In practice, mathematicians have developed a rich set of
conventions and idioms for writing about complex mathematical
objects that — at least within a certain community — make
communication pretty reliable. The conventions of this stylized
form of communication give a reasonably clear standard for judging
proofs good or bad.
Because we are using Lean in this course, we will be working
heavily with formal proofs. But this doesn't mean we can
completely forget about informal ones! Formal proofs are useful
in many ways, but they are typically not the most efficient ways of
communicating ideas between human beings.
For example, here is a proof that addition is associative
(you might have written something like it yourself, recently...):
Lean is perfectly happy with this. For a human, however, it
is difficult to make much sense of it. We can
pass arguments to the add_succ theorem to show the structure more clearly...
... and if you're used to Lean you might be able to step
through the tactics one after the other in your mind and imagine
the state of the context and goal stack at each point, but, if the
proof were even a little bit more complicated, this would be next
to impossible.
On paper, a (somewhat pedantic) mathematician might write the proof like
this:
Theorem: For any n, m, and p,
n + (m + p) = (n + m) + p.
Proof: By induction on p.
First, suppose p = zero. We must show that
n + (m + zero) = (n + m) + zero.
This follows directly from the definition of +
(since x + zero = x for any x).
Next, suppose p = p' + 1 (i.e., p = succ p'), where
n + (m + p') = (n + m) + p'.
We must now show that
n + (m + (p' + 1)) = (n + m) + (p' + 1).
By definition of +, both sides rewrite (via add_succ) to
(n + (m + p')) + 1 and ((n + m) + p') + 1
respectively, which are equal by the induction hypothesis.
QED.
The overall form of the formal and informal proofs is basically similar, and of
course this is no accident: Lean has been designed so that its
induction tactic generates the same sub-goals, in the same
order, as the bullet points that a mathematician would usually
write. But there are significant differences of detail: the
formal proof is much more explicit in some ways (e.g., the sequence
of rewrites) and less explicit in others. In particular, the
"proof state" at any given point in the Lean proof is completely
implicit, whereas the informal proof reminds the reader several
times where things stand.
Write an informal proof of the following theorem, using the
informal proof of add_assoc as a model. Don't just
paraphrase the Lean tactics into English!
Theorem: (n == n) = true for any n.
Proof:
3.6. Aside: Using Code Actions to Generate Match Skeletons🔗
Lean's language server can suggest code actions, which are
small editor commands that modify the source code.
In VS Code, a lightbulb icon appears on the left when a code action is available at your cursor.
You can click the icon or open the code action menu with Ctrl + .
on Windows/Linux or Command + . on macOS.
For more information, see the
Lean 4 VSCode extension manual.
For example, code actions can generate the explicit branches needed for pattern
matching. This can be especially useful when working with match expressions
or with tactics such as cases and induction,
which we saw earlier in the book.
Let's look at a code action for induction.
Suppose we start with the following incomplete proof:
Put your cursor on induction n and open the code action menu.
You should see
"Generate an explicit pattern match for 'induction'." in the list.
If you choose this action,
Lean adds an explicit branch for each constructor:
The same trick also works for match expressions. For example, suppose we start with
defisZero(n:Nat):Bool:=matchnunexpected end of input; expected 'with'
Lean can generate the missing branches:
defisZero(n:Nat):Bool:=matchnwith|.zero=>don't know how to synthesize placeholdercontext:n:Nat⊢ Bool_|.succn=>don't know how to synthesize placeholdercontext:n✝n:Nat⊢ Bool_
Now you just have to replace the holes _ with your definition.
You can use code actions freely to fill out induction,
case, and match branches while working with this book.
One note: Sometimes the variables the code action chooses are not ideal,
so you might want to change them.
For example, here is what we get
from the code action for add_comm
theoremdeclaration uses `sorry`add_comm'(nm:Nat):n+m=m+n:=byn:Natm:Nat⊢ n+m=m+ninductionmwith|zero=>zeron:Nat⊢ n+zero=zero+nsorryAll goals completed! 🐙|succnih=>succn✝:Natn:Natih:n✝+n=n+n✝⊢ n✝+succn=succn+n✝sorryAll goals completed! 🐙-- bad choice of variable `n`, want `m` or `m'` !
Notice that the action chose n for the succ case, even though we are
inducting on m. Manually updating this variable to either m or m'
will make your proof easier to read.
By default, rewrite and rw rewrite left to right, i.e.,
they transform the goal (or a hypothesis) from the form on
the left side of the equality to the right side. To rewrite from
right to left, use rewrite [← h] or rw [← h], where ← is entered
as \l or \<-.
Take a piece of paper. For each of the following theorems, first
think about whether (a) it can be proved using only
simplification and rewriting, (b) it also requires case
analysis (cases), or (c) it also requires induction. Write
down your prediction. Then fill in the proof. (There is no need
to turn in your piece of paper; this is just to encourage you to
reflect before you hack!)
Before moving on to the next batch of exercises, let's introduce a
simple tactic combinator. A tactic combinator combines tactics to form
a larger tactic.
If t₁ and t₂ are tactics, then t₁ <;> t₂ means: first run t₁, then
run t₂ on every subgoal produced by t₁.
This is useful when the first tactic splits the goal into several subgoals
and all of them can be finished by the second.
We can also chain <;>s. In the next example, cases on b creates two
goals; in each of them, cases on c splits the goal again; then rfl
solves all four remaining goals.
For the moment, you should use <;> only when the generated subgoals really do have the same proof.
If different branches need different arguments, it is usually clearer
to write the cases explicitly. We'll discuss some other tactic combinators
in the Automation chapter.
Before you start working on the next exercise, replace the stub
definitions of incr and binToNat, below, with your solution
from Basics, so that this file can be graded
on its own.
In Basics, we did some unit testing of binToNat, but we
didn't prove its correctness. Now we'll do so.
Exercise★★★(binary_commute)
Prove that the following diagram commutes — that is, incrementing a binary number and
then converting it to a (standard, unary) natural number yields the same result as first converting
it to a natural number and then incrementing:
incr
Bin ------------------------> Bin
| |
binToNat | | binToNat
| |
v v
Nat ------------------------> Nat
succ
If you want to change your previous definitions of incr or binToNat
to make the property easier to prove, feel free!
Write a function to convert natural numbers to binary numbers.
Also write some simplification lemmas for it.
defdeclaration uses `sorry`natToBin(n:Nat):Bin:=sorry-- FILL IN HERE-- FILL IN HEREunexpected end of input
Prove that, if we start with any Nat, convert it to Bin, and
convert it back, we get the Nat that we started with.
Hint: This proof should go through smoothly using the previous
exercise about incr as a lemma. If not, revisit your definitions
of the functions involved and consider whether they are more
complicated than necessary: the shape of a proof by induction will
match the recursive structure of the program being verified, so
make the recursion as simple as possible.
The opposite direction — starting with a Bin, converting to Nat,
then converting back to Bin — turns out to be problematic: the expected "theorem" does not hold.
Let's explore why it fails and how to prove a modified
version of it. We'll start with some lemmas that might seem
unrelated but will turn out to be relevant.
Exercise★★(double_bin) (Advanced)
Prove this lemma about double, which we defined earlier in the
chapter.
The theorem fails because there are some Bins for which we won't
necessarily get back to the originalBin, but instead to an
"equivalent" Bin. (We deliberately leave this notion informal
here so that you can think about it.)
Explain in a comment, below, why this failure occurs. Your
explanation will not be graded, but it's important that you get it
clear in your mind before going on to the next part. If you're
stuck on this, think about alternative implementations of
doubleBin that might have failed to satisfy double_bin_zero
yet otherwise seem correct.
To solve this problem, we can introduce a normalization function
that selects the simplest Bin out of all the equivalent
Bins. Then we can prove that the conversion from Bin to Nat and
back again produces that normalized, simplest Bin.
Exercise★★★★(bin_nat_bin) (Advanced)
Define normalize. Keep its definition as simple
as possible so that later proofs go through smoothly. Do not use
binToNat or natToBin, but do use doubleBin.
Hint: Structure the recursion such that it always reaches the
end of the Bin and only processes each bit once. Do not
try to "look ahead" at future bits, as this will complicate the proof.
Also specify the characterizing lemmas for this definition:
-- FILL IN HERE-- FILL IN HEREunexpected end of input
Next, it would be a good idea to do some example proofs to check that your
definition of normalize works the way you intend before you
proceed. They won't be graded, but do fill in a few below.
-- FILL IN HERE-- FILL IN HEREunexpected end of input
Now that we have defined all of our functions and their characterizing lemmas,
we mark the definitions irreducible as usual. From here on, proofs about these definitions
should use rewrite or rw, not rfl.
Finally, prove the main theorem. The inductive cases could be a
bit tricky.
Hint: Start by trying to prove the main statement, see where you
get stuck, and see if you can find a lemma — perhaps requiring
its own inductive proof — that will allow the main proof to make
progress. We have one lemma for the b0 case (which also makes
use of double_incr_bin) and another for the b1 case.
-- FILL IN HERE-- FILL IN HEREtheoremdeclaration uses `sorry`bin_nat_bin(b:Bin):natToBin(binToNatb)=normalizeb:=byb:Bin⊢ natToBin(binToNatb)=normalizebsorryAll goals completed! 🐙
endNatToBinendNatPlayground.Nat
Source revision: 9cc9a7b, committed 2026-09-28 19:09 UTC