In this chapter we continue our development of basic
concepts of functional programming. The critical new ideas are
polymorphism (abstracting functions over the types of the data
they manipulate) and higher-order functions (treating functions
as data). We begin with polymorphism.
In the last chapter, we worked with lists containing just
numbers. Obviously, interesting programs also need to be able to
manipulate lists with elements from other types — lists of
booleans, lists of lists, etc. We could just define a new
inductive datatype for each of these, for example...
... but this would quickly become tedious: not only would we
have to make up different constructor names for each datatype, but —
even worse — we would also need to define new versions of all
the list manipulating functions (length, ++, reverse,
etc.) and all their properties (length_reverse, append_assoc, etc.)
for each new definition.
To avoid this repetition, we can make the element type itself an
argument to the definition. Lean calls such definitions
polymorphic. Here is a polymorphic list type:
This is exactly like the definition of Natlist from the
Lists chapter, except that a type parameter
α has been added to the header on the first line,
the Nat argument to the cons
constructor has been replaced by this arbitrary type α,
and the occurrences of Natlist in the types of the constructors
have been replaced by MyListα. We can now write MyListNat
instead of a dedicated NatList type.
What sort of thing is MyList itself? A good way to think about it
is as a type constructor — that is, a function from Types to
Types. For any particular type α,
the type MyListα is the inductively defined type of lists whose
elements are of type α.
Note to developers (Yipeng Liu @berberman)
A trick used below: parenthesizing the declaration makes it a term —
Lean elaborates it and prints its inferred function type,
instead of the declaration signature: MyList (α : Type) : Type.
The α in the definition of MyList automatically becomes a
parameter to the constructors nil and cons — that is, nil
and cons are now polymorphic constructors. In Lean, the type
parameter is implicit by default: Lean will infer it from context.
For example, MyList.nil is the empty list, and Lean figures out
the element type from how it is used.
Notice the use of curly braces in {α : Type}, rather than normal
parentheses (as in (α : Type)); this is Lean telling us that α is an implicit parameter.
The MyList.cons constructor also adds an element of type Nat to a
list of type MyListNat. Here is an example of forming a list
containing just the natural number 3.
What is the full type of MyList.nil? We can read off the
result type MyListα from the definition,
but to state the full type we must also bind α.
Since the type argument to the constructor is implicit,
it is presented with curly braces.
Having to supply a type argument for every single use of a
list constructor would be rather burdensome. Fortunately, when a type
argument is implicit, Lean will try to automatically infer it from context.
We can now go back and make polymorphic versions of all the
list-processing functions that we wrote before. Here is replicate,
for example:
From now on, we'll use Lean's built-in List type and its
associated notation.
The built-in List is defined just like
our MyList above, but with notation [] for List.nil,
:: for List.cons, and [1,2,3] for list literals.
The ++ operator is list append. The type arguments to the list constructors are implicit.
Using Lean's built-in list notations, we can now write lists
in the natural way:
example:ListNat:=[1,2,3]
6.1.1.1. Implicit Type Arguments and Argument Synthesis🔗
In our replicate function above we wrote the type parameter (α : Type) explicitly, with normal parentheses.
Doing so means that we need to pass in type argument explicitly, in addition to its other arguments.
We see this in its own recursive call, which must
pass along the type α. But since the second argument to
replicate is an element of α, it seems entirely obvious that the
first argument can only be α — why should we have to write it
explicitly?
Fortunately, Lean permits us to avoid this kind of redundancy. In
place of any type argument we can write a "hole" _, which can be
read as "Please try to figure out for yourself what belongs here."
More precisely, when Lean encounters a _, it will attempt to
unify all locally available information — the type of the
function being applied, the types of the other arguments, and the
type expected by the context in which the application appears —
to determine what concrete type should replace the _.
Using holes, the replicate function can be rewritten like this:
Alternatively, and more typically for Lean, we can declare an argument to be implicit
when defining the function itself, by surrounding it in curly
braces instead of parentheses.
By making the type argument implicit, we no longer need to provide α
to the recursive call to replicate''.
For each implicit parameter, Lean automatically inserts a hidden
hole _ argument for us, which is then inferred as usual.
One small problem with implicit arguments is that, once in a
while, Lean does not have enough local information to determine
a type argument; in such cases, we need to tell Lean the type
explicitly. For example, the following definition fails because
Lean can't figure out the type of the empty list:
defFailed to infer type of definition `mynil`mynil:=don't know how to synthesize implicit argument `α`@List.nil?m.3context:αβγ:Typex:αy:β⊢ Type u_1[]
Failed to infer type of definition `mynil`
We can fix this with an explicit type annotation.
We use the @ prefix when we want to supply the type
argument explicitly. The @ makes all implicit arguments
of a function explicit:
Here are a few simple exercises, just like ones in the Lists chapter,
for practice with polymorphism. Complete the proofs below.
You will find the following
characterizing lemmas for List.append in Lean standard library to be useful:
Like inductives, structures can also be made polymorphic.
If we generalize the definition NatProd of pairs of natural numbers from last chapter,
we get polymorphic pairs, often called products:
structureMyProd(αβ:Type)wherefst:αsnd:β
Lean's built-in product type Prod provides a Prod.mk constructor,
and Prod.fst and Prod.snd functions for accessing the first and
second components of the pair. It also has special syntax for creating products:
Lean writes the product type Prodαβ as α×β.
In VS Code you can type \times or \x to enter the × symbol.
The dsimp only tactic can be used to simplify (x, y).fst into x and
(x, y).snd into y.
It is easy at first to get (x,y) and α×β confused.
Remember that (x,y) is a value built from two other values,
while α×β is a type built from two other types. If x has
type α and y has type β, then (x,y) has type α×β.
The following function takes two lists and combines them
into a list of pairs.
Notice that the simplification lemmas zip_nil_left and zip_nil_right are not proofs by rfl.
The reason is that l₁ and l₂ are variables, and matching on a variable usually gets stuck, like we have seen before in Induction when proving the zero_add theorem.
To overcome this, we destruct the list so that the match knows which branch to take during the computation done by the rfl tactic.
Exercise★(zip_checks) (Optional, Manually graded)
Try answering the following questions on paper and
checking your answers in Lean:
What is the type of zip (i.e., what does #check @zip print?)
What does
#eval zip [1, 2] [false, false, true, true]
print?
Exercise★★★(unzip) (Manually graded)
The function unzip goes in the other direction from zip: it takes a list of pairs and returns a pair of lists.
Fill in the definition of unzip below and write simplification rules that characterize it.
Make sure it that passes the given unit test.
Prove unzip_test_fst and unzip_test_snd by rewriting with your simplification lemmas instead of using rfl directly. Remember that you can use dsimp only to simplify expressions accessing the fst
or snd elements of a pair.
defunzip{α:Type}{β:Type}(l:List(α×β)):Listα×Listβ:=solution!(matchlwith|[]=>([],[])|(x,y)::l'=>let(l₁,l₂):=unzipl'(x::l₁,y::l₂))-- This is a must have. One has to explicitly specify the types of the empty lists, which-- can be done in two equivalent waystheoremunzip_nil{αβ:Type}:unzip[]=(([],[]):Listα×Listβ):=byα:Typeβ:Type⊢ unzip[]=([],[])solution!rflAll goals completed! 🐙theoremunzip_nil'{αβ:Type}:unzip([]:List(α×β))=([],[]):=byα:Typeβ:Type⊢ unzip[]=([],[])solution!rflAll goals completed! 🐙-- To characterize the cons branch, we can introduce a single `unzip_cons`...theoremunzip_cons{αβ:Type}{x:α}{y:β}{l:List(α×β)}:(unzip((x,y)::l))=(x::(unzipl).fst,y::(unzipl).snd):=byα:Typeβ:Typex:αy:βl:List(α×β)⊢ unzip((x,y)::l)=(x::(unzipl).fst,y::(unzipl).snd)solution!rflAll goals completed! 🐙-- ... or introduce lemmas `unzip_cons_fst/snd` which individually give both sides of `unzip_cons`theoremunzip_cons_fst{αβ:Type}{x:α}{y:β}{l:List(α×β)}:(unzip((x,y)::l)).fst=x::(unzipl).fst:=byα:Typeβ:Typex:αy:βl:List(α×β)⊢ (unzip((x,y)::l)).fst=x::(unzipl).fstsolution!rflAll goals completed! 🐙theoremunzip_cons_snd{αβ:Type}{x:α}{y:β}{l:List(α×β)}:(unzip((x,y)::l)).snd=y::(unzipl).snd:=byα:Typeβ:Typex:αy:βl:List(α×β)⊢ (unzip((x,y)::l)).snd=y::(unzipl).sndsolution!rflAll goals completed! 🐙theoremunzip_test1:unzip[(1,false),(2,true)]=([1,2],[false,true]):=by⊢ unzip[(1,false),(2,true)]=([1,2],[false,true])solution!rflAll goals completed! 🐙theoremunzip_test_fst:(unzip[(1,false),(2,true)]).fst=[1,2]:=by⊢ (unzip[(1,false),(2,true)]).fst=[1,2]solution!rw[unzip_cons_fst,⊢ 1::(unzip[(2,true)]).fst=[1,2]unzip_cons_fst,⊢ 1::2::(unzip[]).fst=[1,2]unzip_nil⊢ 1::2::([],[]).fst=[1,2]]All goals completed! 🐙theoremunzip_test_snd:(unzip[(1,false),(2,true)]).snd=[false,true]:=by⊢ (unzip[(1,false),(2,true)]).snd=[false,true]solution!·⊢ (unzip[(1,false),(2,true)]).snd=[false,true]rw[unzip_cons_snd,⊢ false::(unzip[(2,true)]).snd=[false,true]unzip_cons_snd,⊢ false::true::(unzip[]).snd=[false,true]unzip_nil⊢ false::true::([],[]).snd=[false,true]]All goals completed! 🐙-- These are the same tests but with `unzip_cons` insteadtheoremunzip_test_fst':(unzip[(1,false),(2,true)]).fst=[1,2]:=by⊢ (unzip[(1,false),(2,true)]).fst=[1,2]rw[unzip_cons,⊢ (1::(unzip[(2,true)]).fst,false::(unzip[(2,true)]).snd).fst=[1,2]unzip_cons,⊢ (1::(2::(unzip[]).fst,true::(unzip[]).snd).fst,false::(2::(unzip[]).fst,true::(unzip[]).snd).snd).fst=[1,2]unzip_nil⊢ (1::(2::([],[]).fst,true::([],[]).snd).fst,false::(2::([],[]).fst,true::([],[]).snd).snd).fst=[1,2]]All goals completed! 🐙theoremunzip_test_snd':(unzip[(1,false),(2,true)]).snd=[false,true]:=by⊢ (unzip[(1,false),(2,true)]).snd=[false,true]rw[unzip_cons,⊢ (1::(unzip[(2,true)]).fst,false::(unzip[(2,true)]).snd).snd=[false,true]unzip_cons,⊢ (1::(2::(unzip[]).fst,true::(unzip[]).snd).fst,false::(2::(unzip[]).fst,true::(unzip[]).snd).snd).snd=[false,true]unzip_nil⊢ (1::(2::([],[]).fst,true::([],[]).snd).fst,false::(2::([],[]).fst,true::([],[]).snd).snd).snd=[false,true]]All goals completed! 🐙
Our last polymorphic type for now is polymorphic options.
Lean's standard library provides Optionα, with constructors
none and some. (We already saw NatOption in the Lists chapter.)
Let's briefly look at the definition:
Like most modern programming languages — especially other
"functional" languages, including OCaml, Haskell, Racket, Scala,
and Clojure, among others — Lean treats functions as first-class citizens,
allowing them to be passed as arguments to other functions,
returned as results, stored in data structures, etc.
Here is a more useful higher-order function, taking a list
of αs and a predicate on α (a function from α to Bool)
and "filtering" the list to yield a new list containing just
those elements for which the predicate returns true.
You might have noticed that filter_cons_of_pos and filter_cons_of_neg
have implicit parameters, such as x and l, that do not have type Type like α does.
As it turns out, Lean allows any parameter to be implicit, not just those of type Type.
This is a standard Lean convention for lemmas that are likely to be used by rw
when their values can be inferred from the context.
For example, suppose you were using theorem filter_cons_of_pos to rewrite filter Nat.even (3 :: rest).
Matching the latter expression against the theorem's left-hand side filter test (x :: l)
establishes that test = Nat.even, x = 3, l = rest, and
α=Nat.
If arguments α, test, x, and l were not implicit, you'd
have to write rewrite [filter_cons_of_pos Nat Nat.even 3 rest] in your proof.
Since the arguments are implicit, Lean automatically inserts
a hole _ for each of them when you apply the theorem, just as with implicit parameters
of type Type, so they can be inferred from the context.
Thus you can write rewrite [filter_cons_of_pos] instead.
Note that h : test x is not implicit, it's explicit. That's because it cannot be
solved by unification, i.e., Lean can't prove that Nat.even 3 = true that way.
It's a general proof obligation.
We'll follow the Lean standard convention from now on.
We can use filter to give a concise version of the
countOddMembers function from the Lists chapter.
It is arguably a little sad, in the example just above, to
be forced to define the function isLength1 and give it a name
just to be able to pass it as an argument to filter, since we
will probably never use it again. Indeed, when using higher-order
functions, we often want to pass as arguments "one-off"
functions that we will never use again; having to give each of
these functions a name would be tedious.
Fortunately, there is a better way. We can construct a function
"on the fly" without declaring it at the top level or giving it a
name. Lean provides two syntaxes for anonymous functions:
fun n => n * n — traditional lambda syntax
(· * ·) — "term with holes" syntax, where · marks arguments
Use filter (instead of a recursive def) to write a Lean function
filterEvenGt7 that takes a list of natural numbers as input
and returns a list of just those that are even and greater than 7.
Use filter to write a Lean function partition that, given a
type α, a predicate of type α→Bool and a Listα, should
return a pair of lists. The first member of the pair is the sublist
of the original list containing the elements that satisfy the test,
and the second is the sublist containing those that fail the test.
The order of elements in the two sublists should be the same as
their order in the original list.
It takes a function f and a list l = [n1, n2, n3, ...]
and returns the list [f n1, f n2, f n3, ...], where f has
been applied to each element of l in turn. For example:
The element types of the input and output lists need not be
the same, since map takes two type arguments, α and β; it
can thus be applied to a list of numbers and a function from
numbers to booleans to yield a list of booleans:
The function map maps a Listα to a Listβ using a function
of type α→β. We can define a similar function, flatMap,
which maps a Listα to a Listβ using a function f of type
α→Listβ. Your definition should work by 'flattening' the
results of f, like so:
flatMap (fun n => [n, n + 1, n + 2]) [1, 5, 10]
= [1, 2, 3, 5, 6, 7, 10, 11, 12]
The definitions and uses of filter and map use implicit
arguments in many places. Replace the curly braces around the
implicit arguments with explicit parentheses, and then fill in
explicit type parameters where necessary and use Lean to check that
you've done so correctly. (This exercise is not to be turned in;
it is probably easiest to do it on a copy of this file that you
can throw away afterwards.)
An even more powerful higher-order function is
fold. It is the inspiration for the "reduce"
operation that lies at the heart of Google's map/reduce
distributed programming framework.
Intuitively, the behavior of the fold operation is to
insert a given binary operator f between every pair of elements
in a given list. For example, fold (· + ·) [1, 2, 3, 4]
intuitively means 1 + 2 + 3 + 4. To make this precise, we also
need a "starting element" that serves as the initial second input
to f. So, for example,
Observe that the type of fold is parameterized by two type
variables, α and β, and the parameter f is a binary operator
that takes an α and a β and returns a β.
The examples above show one instance where it is useful for α
and β to be different. Can you think of any others?
Show solution
There are many. For example, we could use fold to count the
number of true elements in a list of booleans. Here α would
be Bool and β would be Nat.
Most of the higher-order functions we have talked about so
far take functions as arguments. Let's look at some examples that
involve returning functions as the results of other functions.
To begin, here is a function that takes a value x (drawn from
some type α) and returns a function from Nat to α that
yields x whenever it is called, ignoring its Nat argument.
What's happening here is called partial application. In
Lean, the type constructor → is right-associative, meaning a
function type like α→β→γ is parsed like α→(β→γ),
or "a function from α to a function from β to γ."
We can think of fold not as a three-argument function, but as a
one-argument function that:
Takes an argument f of type α→β→β
Returns a function of type Listα→β→β that "remembers" f
When we write fold (· + ·), we're giving fold its first argument,
(· + ·), and getting back a specialized function that can sum up
the elements of any list of numbers. This new function still expects
two more arguments: a list and a starting value.
The type α→β→γ can be read as describing functions that
take two arguments, one of type α and another of type β, and
return an output of type γ. Recall from our discussion
of partial application that this type is written α→(β→γ)
when fully parenthesized. That is, if we have f : α → β → γ,
and we give f an input of type α, it will give us as output
a function of type β→γ. If we then give that function an
input of type β, it will return an output of type γ. That
is, every function in Lean takes only one input, but some
functions return a function as output. This is precisely
what enables partial application, as we saw above with plus3.
By contrast, functions of type α×β→γ — which when fully
parenthesized is written (α × β) → γ — require their single
input to be a pair. Both arguments must be given at once; there
is no possibility of partial application.
It is possible to convert a function between these two types.
Converting from α×β→γ to α→β→γ is called
currying, in honor of the logician Haskell Curry. Converting
from α→β→γ to α×β→γ is called uncurrying.
Write a careful informal proof of the following theorem:
∀ (l : List α) (n : Nat), l.length = n → nth? l n = none
Make sure to state the induction hypothesis explicitly.
Theorem: For all types α, lists l, and natural numbers n,
if l.length = n then nth? l n = none.
Proof: By induction on l. There are two cases to consider:
If l = [], we must show nth? [] n = none. This follows
immediately from the definition of nth?.
Otherwise, l = x :: l' for some x and l', and the
induction hypothesis tells us that
l'.length = n' → nth? l' n' = none, for any n'.
Let n be the length of l. We must show that
nth? (x :: l') n = none.
But we know that n = l.length = (x :: l').length = l'.length + 1.
So it's enough to show nth? l' l'.length = none, which
follows directly from the induction hypothesis, picking l'.length
for n'.
The following exercises explore an alternative way of defining
natural numbers using the Church numerals, which are named after
their inventor, the mathematician Alonzo Church. We can represent
a natural number n as a function that takes a function f as a
parameter and returns f iterated n times.
namespaceChurchdefCNat:=∀(α:Type),(α→α)→α→α
Let's see how to write some numbers with this notation. Iterating
a function once should be the same as just applying it. Thus:
More generally, a number n can be written as
fun α f x => f (f ... (f x) ...), with n occurrences of f.
Let's informally notate that as fun α f x => f^n x, with the
convention that f^0 x is just x. Note how the doIt3Times
function we've defined previously is actually just the Church
representation of 3.
So n α f x represents "do it n times", where n is a Church
numeral and "it" means applying f starting with x.
Another way to think about the Church representation is that
function f represents the successor operation on α, and value
x represents the zero element of α. We could even rewrite
with those names to make it clearer:
One very interesting implication of the Church numerals is that we
don't strictly need the natural numbers to be built-in to a
functional programming language, or even to be definable with an
inductive data type. It's possible to represent them purely (if
not efficiently) with functions.
Of course, it's not enough just to "represent" numerals; we need
to be able to do arithmetic with the representation. Show that we
can by completing the definitions of the following functions. Make
sure that the corresponding unit tests pass by proving them with
rfl.
Exercise★★(church_scc) (Advanced)
Define a function that computes the successor of a Church numeral.
Given a Church numeral n, its successor scc n should iterate
its function argument once more than n. That is, given
fun X f x => f^n x as input, scc should produce
fun X f x => f^(n+1) x as output.
In other words, do it n times, then do it once more.
Define a function that computes the addition of two Church
numerals. Given fun X f x => f^n x and fun X f x => f^m x
as input, plus should produce fun X f x => f^(n + m) x as
output. In other words, do it n times, then do it m more times.
Hint: the "zero" argument to a Church numeral need not be just x.
Define a function that computes the multiplication of two Church
numerals.
Hint: the "successor" argument to a Church numeral need not be
just f.
Warning: Lean will not let you pass CNat itself as the type α
argument to a Church numeral; you will get a "sort mismatch"
error between Type1 and Type2. Don't worry too much
about what this means right now, but know that
this is Lean's way of preventing a paradox in
which a type contains itself. So leave the type argument
unchanged.