This chapter introduces several additional proof strategies
and tactics that will allow us to begin proving more interesting
properties of functional programs.
We will see:
how to reason about data constructors -- in particular, how to
use the fact that they are injective and disjoint;
more on how to reason by case analysis;
how to use auxiliary lemmas in both "forward-" and
"backward-style" proofs; and
how to strengthen an induction hypothesis, and when such
strengthening is required.
By this definition, every number has exactly one of
two forms: either it is the constructor 0 or it is built by
applying the constructor .succ to another number.
There are two important consequences of this definition:
The constructor .succ is injective (or one-to-one).
That is, if n + 1 = m + 1, it must also be that n = m.
The constructors 0 and .succ are disjoint. That is, 0 is not
equal to n + 1 for any n.
Similar principles apply to every inductively defined type:
all constructors are injective, and the values built from distinct
constructors are never equal. For lists, the List.cons constructor
is injective and the empty list List.nil is different from every
non-empty list. For booleans, true and false are different.
(Since true and false take no arguments, their injectivity is
neither here nor there.) And so on.
We can prove the injectivity of Nat.succ by using the Nat.pred function:
example(nm:Nat)(h:n+1=m+1):n=m:=n:Natm:Nath:n+1=m+1⊢ n=mn:Natm:Nath:n+1=m+1this:n=(n+1).pred⊢ n=m/- The hypothesis name defaults to `this` when unspecified. -/n:Natm:Nath:n+1=m+1this:n=(n+1).pred⊢ (m+1).pred=mrw[Nat.pred_succn:Natm:Nath:n+1=m+1this:n=(n+1).pred⊢ m=m]All goals completed! 🐙
This technique for proving injectivity can be generalized to any constructor
by writing the equivalent of Nat.pred — i.e., writing a function that
"undoes" one application of the constructor.
As a more convenient alternative, Lean provides a tactic called
injection that allows us to directly exploit the injectivity of any
constructor. Here is an alternate proof of the above theorem
using injection:
Writing injection h directs Lean to generate all equations
that follow from h using the injectivity of constructors,
adding them to the context.
In the present example, Lean can infer that n = m from n + 1 = m + 1.
When a generated equation matches the goal, as is the case here,
injection automatically closes the goal.
When the generated equations do not immediately close the goal, the equations are added to the context instead; adding with allows us to explicitly name the equations
(otherwise Lean generates names for us).
There is also a related tactic, injections, that applies the injection
tactic to all hypotheses, repeatedly. Using it simplifies the proof
of the above example.
Note that both injection and injections will simplify
a hypothesis before applying injectivity. Thus we could also use them
to solve the following example, which requires simplifying the ++ and List.reverse expressions:
So much for injectivity of constructors. What about disjointness?
The principle of disjointness says that two terms beginning
with different constructors (like 0 and Nat.succ, or true and false)
can never be equal. Therefore, any time we find ourselves
in a context where we've assumed that two such terms are equal,
we are justified in concluding anything we want, since the
assumption is nonsensical.
The contradiction tactic embodies this principle. If the context
contains a contradictory hypothesis, such as false=true,
contradiction solves the current goal immediately. Some examples:
These examples are instances of a logical principle known as the
principle of explosion, which asserts that a contradictory
hypothesis entails anything — even manifestly false things!
In the above example, n + 1 is shorthand for a constructor application Nat.succ n
so contradiction applies to it directly.
Sometimes you
need to do a little work to expose a contradictory hypothesis involving
constructors.
For example, recall that Nat.add recurses on its
second argument, so deriving a contradiction from 1 + n = 0 is not direct.
example(n:Nat)(h:1+n=0):2+2=5:=byn:Nath:1+n=0⊢ 2+2=5Tactic `contradiction` failedn:Nath:1+n=0⊢ 2+2=5contradictionn:Nath:1+n=0⊢ 2+2=5-- doesn't work because `1 + n` doesn't reduce to `n.succ`.
To fix it, rewriting with Nat.one_add changes the
hypothesis from 1 + n = 0 to n.succ = 0.
Then Lean can immediately recognize this as impossible.
If you find the principle of explosion confusing, remember
that these proofs are not simply showing that the conclusion of the
statement holds. Rather, they are showing that, if the
nonsensical situation described by the premise did somehow hold,
then the nonsensical conclusion would hold too (because we'd be
living in an inconsistent universe where every statement is true).
We'll explore the principle of explosion in more detail in the
next chapter.
Tactic `injection` failed: equality of constructor applications expectedxy:Nath:1+x=1+y⊢ y=x
The addition in 1 + x (and 1 + y) is blocked by the variable in the second argument.
Therefore it doesn't reduce to x.succ, so injectivity of constructors can't be used directly.
There is a tactic named congr that can
prove such goals directly. Given a goal of the form
f a₁ ... aₙ = g b₁ ... bₙ, writing congr will produce subgoals
of the form f = g, a₁ = b₁, ..., aₙ = bₙ.
If any of these subgoals that are simple enough to be discharged automatically (e.g., immediately
provable by rfl), they will disappear.
The congr tactic accepts an optional numerical argument,
which tells Lean how deeply to decompose the goal.
So, given a goal like ((a, b), (c, d)) = ((e, f), (g, h)),
congr 1 only applies congr just once to the goal to produce
two subgoals: (a, b) = (e, f) and (c, d) = (g, h), while
congr 2 would apply congr again to
both these subgoals and produce four subgoals: a = e, b = f,
c = g and d = h. Using congr without an argument
decomposes the goal as deeply as possible.
Why might we want this level of control? Because, depending
on what we are trying to prove, deeper applications
of congr may sometimes make our goal unprovable. Consider:
example(abcd:Nat)(h1:a=b+1)(h2:d=c+1):(a+c,true)=(b+d,true):=bya:Natb:Natc:Natd:Nath1:a=b+1h2:d=c+1⊢ (a+c,true)=(b+d,true)/- Using `congr` shallowly allows us to complete the proof -/congr1e_fsta:Natb:Natc:Natd:Nath1:a=b+1h2:d=c+1⊢ a+c=b+drw[h1,e_fsta:Natb:Natc:Natd:Nath1:a=b+1h2:d=c+1⊢ b+1+c=b+dh2,e_fsta:Natb:Natc:Natd:Nath1:a=b+1h2:d=c+1⊢ b+1+c=b+(c+1)Nat.add_assoc,e_fsta:Natb:Natc:Natd:Nath1:a=b+1h2:d=c+1⊢ b+(1+c)=b+(c+1)Nat.add_comm1ce_fsta:Natb:Natc:Natd:Nath1:a=b+1h2:d=c+1⊢ b+(c+1)=b+(c+1)]All goals completed! 🐙
We've seen many examples where the cases tactic is
used to perform case analysis of the value of some variable, such
as one of type Bool or Nat.
Sometimes we need to reason by cases on the result of some expression.
We can do so with cases, directly. Here is an example:
After unfolding chooseIf in the above proof, we find that
we are stuck on (bif test x then x else x) = x. But either
test x is true or it isn't,
so we can use cases (test x) to let us reason about the two cases.
In general, the cases tactic can perform case analysis on
the results of arbitrary computations. If e has an inductively
defined type T, then cases e generates one subgoal for each
constructor of T, specializing the goal to that constructor. It does not
necessarily rewrite occurrences of e in hypotheses; when that
information is needed, we can save an equation as described below.
The cases tactic is useful when we are dealing with values
that can be one of a list of things (a Bool is either a false or a true,
a Nat is either 0 or succ n, etc.). When we want more information about a
value that is a tuple of multiple things, we instead
want a way to extract the pieces of that value.
If we have a value v : α × β in our context, we can
extract the first and second components of v and give them names using this tactic:
let ⟨a, b⟩ := v
Exercise★★★(zip_unzip')
Recall the unzip function from chapter Poly;
copy your implementation from that chapter and paste it below:
Prove that unzip' and zip are inverses in the following sense.
Remember that you can use dsimp only to simplify expressions involving
pairs and fst and snd.
When using cases, we can specify to Lean that it should
remember an equality between a compound expression and what we are
decomposing it into, using cases h : ... syntax. This step is sometimes critical: if we leave it out, we might lack
information we need to complete a proof.
For example, suppose we define a function keepIf like this:
Now suppose that we want to prove that, if keepIf returns a result of the form some y, then x = y. If we start the proof like
this (with no h : ⋯ on the cases)...
... then we are stuck because the context does
not contain enough information to prove the goal.
Because test x appears in the hypothesis rather than the
goal, cases (test x) does not automatically replace the expression
with false or true like it did during the proof of chooseIf.
We want to add an equation to the context that records which case we are in.
This is precisely what the
h : ⋯ qualifier does.
theoremkeepIf_some{α:Type}(test:α→Bool)(xy:α)(h:keepIftestx=somey):x=y:=byα:Typetest:α→Boolx:αy:αh:keepIftestx=somey⊢ x=yrw[keepIfα:Typetest:α→Boolx:αy:αh:(biftestxthensomexelsenone)=somey⊢ x=y]athα:Typetest:α→Boolx:αy:αh:(biftestxthensomexelsenone)=somey⊢ x=y-- Now we have the same state as at the point where we got stuck-- above, except that the context contains an extra equality-- assumption, which is exactly what we need to make progress.caseshTest:testxwith|false=>falseα:Typetest:α→Boolx:αy:αh:(biftestxthensomexelsenone)=someyhTest:testx=false⊢ x=yrw[hTestfalseα:Typetest:α→Boolx:αy:αh:(biffalsethensomexelsenone)=someyhTest:testx=false⊢ x=y]athfalseα:Typetest:α→Boolx:αy:αh:(biffalsethensomexelsenone)=someyhTest:testx=false⊢ x=ycontradictionAll goals completed! 🐙|true=>trueα:Typetest:α→Boolx:αy:αh:(biftestxthensomexelsenone)=someyhTest:testx=true⊢ x=yrw[hTesttrueα:Typetest:α→Boolx:αy:αh:(biftruethensomexelsenone)=someyhTest:testx=true⊢ x=y]athtrueα:Typetest:α→Boolx:αy:αh:(biftruethensomexelsenone)=someyhTest:testx=true⊢ x=yinjectionsAll goals completed! 🐙
We often encounter situations where the goal to be proved is
exactly the same as some hypothesis in the context or some
previously proved lemma.
The apply tactic is useful when the goal is instead the
conclusion of an implication.
If the conclusion of the implication matches the current goal,
its premises become new subgoals to be proved.
For example, suppose we have a hypothesis
h : p → q and our goal is q. We can use apply h to replace the
goal q with the premise p:
This process is called backward reasoning. We are trying to prove some
goal ⊢ b and we know some fact h : a → b. So we work backwards by
applying that fact, which replaces the goal with ⊢ a.
When we use apply h, Lean tries to match the conclusion of the type
of h with the current goal. Here h : n = m → [n, o] = [m, p] has conclusion
[n, o] = [m, p], which matches the current goal. Lean replaces the goal
with the premise n = m. Then we close the goal with exact hnm.
Even more generally, the type of a theorem or hypothesis used with apply may also have
universally quantified variables and premises. Lean tries to match its conclusion with
the current goal to determine appropriate values for the quantified variables.
To use apply, the conclusion of the fact
being applied must match the goal. For example, apply will not work if the left
and right sides of the equality are swapped.
example(nm:Nat)(h:n=0→n=m)(hn:n=0):m=n:=byn:Natm:Nath:n=0→n=mhn:n=0⊢ m=n/- Here we cannot use `apply` directly...
...but we can use the `symm` tactic, which switches the left
and right sides of an equality in the goal. -/symmn:Natm:Nath:n=0→n=mhn:n=0⊢ n=mapplyhn:Natm:Nath:n=0→n=mhn:n=0⊢ n=0exacthnAll goals completed! 🐙
Exercise★★(apply_exercise1)
The apply tactic can be used with previously defined theorems, not
just hypotheses in the context. For this exercise, use a
previously-defined theorem about rev from chapter Poly as part of your (fairly short) solution. You do not need induction.
Notice that in Lean's version, the arguments a, b, and c are implicit.
This is because they can usually be inferred from the equality hypotheses and the goal.
Now let's use our trans_eq to prove the example above.
If we simply write apply trans_eq, Lean can infer some arguments from the goal,
but not the intermediate list or the hypotheses needed for the lemma's premises.
Recall that trans_eq has five arguments.
From the goal, Lean can infer the endpoints a and c,
namely [u, v] and [y, z]. But it still needs the intermediate term b.
We want to prove [u, v] = [y, z].
By transitivity, it's enough to prove [u, v] = ?b and ?b = [y, z], for some intermediate list ?b.
Here ?b is a metavariable: a placeholder for a value Lean has not yet determined.
Before we provide the hypothesis h₂, Lean doesn't know that this intermediate list should be [w, x].
One way to make progress is to supply the arguments and hypotheses explicitly:
Here, we had to specify the a and c arguments
to trans_eq before we could supply [w, x] for b or h₁ and h₂ for
the premises. However, we just said that Lean was able to infer these arguments, so it's
a bit redundant (and wordy) for us to do it.
Thankfully, Lean allows us to use _s for positional arguments that it can infer.
Alternatively, if we know the name of the argument we are supplying (in this case b), we can
name it directly and avoid typing any _s. Such named arguments can be used in function applications generally, not just with apply.
When fully applying another theorem or hypothesis to conclude a proof,
it is good practice to use the exact tactic instead of apply. Doing so signals to
a reader that the proof is solved exactly by this fact, and nothing more.
A final alternative for this situation is the calc tactic we saw in the UsingLean chapter.
It works by chaining equalities together using transitivity,
serving the same purpose here as applying trans_eq.
We can also use the apply tactic to rewrite hypotheses.
The tactic apply t at h matches an implication t
(say, of the form a → b) against a hypothesis h in the local
context. Unlike ordinary apply, which matches the goal against b
and replaces it with the subgoal a, apply t at h matches the type of h
against a and, if successful, replaces h with a hypothesis of type b.
In other words, apply t at h is a form of forward
reasoning from the hypotheses toward the goal.
By contrast, ordinary apply t is backward reasoning: given t : a → b
and a goal ⊢ b, it replaces the goal with ⊢ a.
Here is a proof that uses forward reasoning rather than backward reasoning:
Forward reasoning begins with what is already known — premises and
previously proven theorems — and derives new facts from
them until the goal is reached. Backward reasoning begins with
the goal and works backward through implications that would prove
it, until the remaining goals are facts that are already known or assumed.
Informal proofs in mathematics and computer science often
use forward reasoning. In Lean, backward reasoning is generally more
idiomatic, though forward reasoning can sometimes be easier to follow or more natural for
particular proofs.
You may be interested to know that the apply ... at ... tactic
is not part of Lean's core set of tactics. Lean makes it
very easy for users to define new tactics that suit their
particular proof style, and so the developers of the Mathlib library
defined the apply ... at ... tactic to
better support forward reasoning. Mathlib is a very large development,
so we do not import the whole thing in this book, but we do import apply ... at ... because it is particularly useful.
We've already seen how we can use have to do
forward reasoning, by letting us state and prove useful facts
that get us closer to the main goal we're trying to prove. Often,
though, these facts are just special cases of more general hypotheses
we already have.
If h is a quantified hypothesis in the current context — i.e.,
h : ∀ (x : α), P x — then we can use have to obtain a special
case of h by supplying a value for x.
In other words, have h := h e
introduces a new h which x has been instantiated with e.
Specializing a hypothesis in this way is common enough that Lean provides a separate
specialize tactic for it. For example,
specialize h 1 is a more concise way of writing replace h := h 1:
Tactics like have and replace can also be used with lemmas and
theorems we've already proven, not just things in the immediate proof context.
Using these tactics before apply gives us yet another way to
control where apply does its work.
example(uvwxyz:Nat)(h₁:[u,v]=[w,x])(h₂:[w,x]=[y,z]):[u,v]=[y,z]:=byu:Natv:Natw:Natx:Naty:Natz:Nath₁:[u,v]=[w,x]h₂:[w,x]=[y,z]⊢ [u,v]=[y,z]haveh:=trans_eq(b:=[w,x])u:Natv:Natw:Natx:Naty:Natz:Nath₁:[u,v]=[w,x]h₂:[w,x]=[y,z]h:∀(ac:ListNat),a=[w,x]→[w,x]=c→a=c⊢ [u,v]=[y,z]applyhau:Natv:Natw:Natx:Naty:Natz:Nath₁:[u,v]=[w,x]h₂:[w,x]=[y,z]h:∀(ac:ListNat),a=[w,x]→[w,x]=c→a=c⊢ [u,v]=[w,x]au:Natv:Natw:Natx:Naty:Natz:Nath₁:[u,v]=[w,x]h₂:[w,x]=[y,z]h:∀(ac:ListNat),a=[w,x]→[w,x]=c→a=c⊢ [w,x]=[y,z]/- This tactic closes a goal if it appears anywhere in the context.
In this case we could also write `exact h₁` ... -/assumptionau:Natv:Natw:Natx:Naty:Natz:Nath₁:[u,v]=[w,x]h₂:[w,x]=[y,z]h:∀(ac:ListNat),a=[w,x]→[w,x]=c→a=c⊢ [w,x]=[y,z]/- .. and here we could also write `exact h₂` -/assumptionAll goals completed! 🐙
Sometimes induction gives us an induction hypothesis that is too specific to be useful.
This can happen when another variable in the theorem is fixed during the induction but
the induction step needs to use it at different values.
For example, suppose we want to show that Nat.double is injective —
i.e., that it maps different arguments to different results:
...we get stuck: m is fixed during whole the induction because it was already in the context when we applied the induction tactic.
In the successor case, the induction hypothesis ih is specialized to the current value of m.
After the case split, that value is m'+1, and the induction hypothesis has the form:
ih : n'.double = (m' + 1).double → n' = m' + 1
From h, using the definition of Nat.double we can obtain
The problem is that m is already in the context when we invoke
induction n. Since m is an ordinary argument of the theorem,
this is exactly what we normally want — we are considering some particular
n and m, together with the hypothesis n.double=m.double and trying
to prove n = m.
The claim itself makes perfect sense, but keeping m fixed during induction
causes trouble: we are proving, for alln, the proposition
But knowing Q doesn't give us any help at all with proving R.
If we tried to prove R from Q, we would start with something
like "Suppose (n+1).double=10..." but then we would be stuck:
the induction hypothesis Q only tells us what happens if n.double=10,
whereas our assumption says (n+1).double=10, so Q is useless here.
This is exactly what we saw in the proof state.
Trying to carry out this proof by induction on n with m fixed
doesn't work, because we are then trying to
prove a statement involving everyn but just a particularm.
A successful proof of double_injective needs to generalizem when carrying out the induction on n,
so that the induction hypothesis holds for every m,
rather than for just the particular m in the context.
That is, we want an induction hypothesis like this:
ih : ∀ m, n'.double = m.double → n' = m
We can obtain this generalized induction hypothesis by writing
Let's look at an informal proof of this theorem. Notice that
the induction hypothesis is generalized over m, corresponding to
the use of generalizing m.
Theorem: For any natural numbers n and m, if n.double=m.double, then
n=m.
Proof: We prove by induction on n that, for anym,
if n.double=m.double then n=m.
First, suppose n=0. We must show that, for any m, if
Nat.double0=m.double, then 0=m.
There are two cases to consider for m:
If m=0, we are done.
Otherwise if m=m'+1 for some m', then by definition of Nat.double
we have Nat.double 0 = 0 and (m' + 1).double = m'.double + 2.
Clearly 0 cannot equal m'.double+2, so this case is impossible.
Second, suppose n=n'+1. The induction hypothesis says that, for every m,
if n'.double=m.double then n'=m. Again there are two cases
to consider for m:
If m=0, then by the definition of Nat.double our assumption says
n'.double+2=0, which is impossible.
Otherwise suppose m=m'+1.
Our assumption is then that (n'+1).double=(m'+1).double.
By the definition of Nat.double, this gives n'.double + 2 = m'.double + 2.
By injectivity of Nat.succ, we obtain n'.double=m'.double.
We can now instantiate the induction hypothesis with m', obtaining n' = m'.
Now we can conclude n' + 1 = m' + 1, which is exactly what we wanted to show.
Qed.
The thing to take away from all this is that you need to be
careful, when using induction, that your induction hypothesis
is not too specific. When proving a proposition quantified over
variables n and m by induction on n, it is sometimes crucial
to generalizem, so that the induction hypothesis applies to every m
rather than just the particular m in the context.
Exercise★★★(add_self_injective)
The following theorem follows the same pattern as double_injective.
Give a careful informal proof of add_self_injective, stating the induction
hypothesis explicitly and being as explicit as possible about
quantifiers, everywhere.
Rewriting with a conditional theorem is similar in spirit to backward
reasoning with apply, introduced earlier in this chapter: both let
a theorem's premises become new subgoals. Here, though, it is rw
doing the work, using the theorem's conclusion to transform the goal rather
than closing it outright.
Suppose that we know two numbers have the same double, and we want to
use this fact to rewrite one of them into the other. Recall the theorem
double_injective from the previous section:
The use of rw here is a little different from the examples we have seen so far.
The theorem double_injective says n=mprovided thatn.double=m.double, not just n=m.
When we write rw [double_injective n m], Lean uses the conclusion n=m to rewrite
the goal, and then asks us to prove the hypothesis needed by double_injective.
Thus we get two goals: the updated main goal, m + p = q, and the
condition from double_injective, n.double=m.double.
These goals follow by assumption from hm and h, respectively.
If we rewrite with a conditional statement of the form
P → a = b, then Lean tries to rewrite with a = b, and then
asks us to prove P in a new subgoal. If the statement has more
than one assumption, then we get one subgoal for each assumption.
We've now talked about many of Lean's most fundamental tactics.
We'll introduce a few more in the coming chapters, and later on
we'll see some more powerful automation tactics that make Lean
help us with low-level details. But basically we've got what we
need to get work done.
Here are the tactics we've seen so far.
Managing goals and hypotheses:
intro h: move an assumption/quantified variable from the goal into the local context
apply thm: use a theorem, hypothesis, or constructor whose conclusion matches the goal;
its premises become new goals
apply thm at h: use a theorem on a hypothesis in the context, replacing h by the resulting
fact (forward reasoning)
specialize h ...: instantiate quantified variables in a hypothesis, modifying h in place
replace h := ...: replace a hypothesis with a newly proved fact
have h : P := ...: prove a local fact P and add it to the context with the name h
contradiction: close the current goal when the context contains contradictory assumptions
Equality, rewriting, and unfolding:
rfl: close an equality that holds by reflexivity (possibly after computation)
rw [h]: rewrite the goal using an equality hypothesis or theorem
rw [d]: unfold a definition in the goal
rw [h] at h': rewrite a hypothesis using an equality hypothesis or theorem
rw [d] at h': unfold a definition in a hypothesis
symm: reverse an equality goal, changing t = u to u = t
symm at h: reverse an equality hypothesis
calc: prove a goal about equality or another transitive relation by
giving a sequence of intermediate steps
congr: use congruence to reduce an equality between expressions with the same outer form;
for example, a goal f x = f y may be reduced to x = y
injection h with ...: use injectivity of constructors to extract equalities from equations
between constructor applications
injections: repeatedly use constructor injectivity on suitable equalities in the context
Case analysis:
cases x: reason separately about the possible constructors of an inductively defined value
cases h : e: perform case analysis on an expression e and add an equation named h
recording the result of the case analysis
Induction:
induction x: prove the goal by induction on an inductively defined value
induction x generalizing y: induction on x while generalizing the listed local variables,
giving a more general induction hypothesis
We proved in zip_unzip' that zipping the result of unzip'
recovers the original list.
What about the other direction? Complete and prove the following unzip'_zip:
theorem unzip'_zip {α β : Type}
{l₁ : List α} {l₂ : List β}
/- add appropriate parameters and hypotheses here -/ :
unzip' (zip l₁ l₂) = (l₁, l₂) := sorry
Hint: Take a look at the definition of zip in Poly.
Your definition will need to account for the behavior of zip
in its base cases, which possibly drop some list elements.
-- FILL IN HERE-- FILL IN HEREunexpected end of input