namespace lecture_03_04
Note: These notes are not yet complete. They current extend the material presented at the end of lecture 3, August 29, 2023. This section will be extended as we go forward.
Generalization and Specialization
Consider the following three functions.
def id_nat: Nat → Nat
id_nat : Nat: Type
Nat → Nat: Type
Nat
| n: Nat
n => n: Nat
n
def id_string: String → String
id_string : String: Type
String → String: Type
String
| n: String
n => n: String
n
def id_bool: Bool → Bool
id_bool : Bool: Type
Bool → Bool: Type
Bool
| n: Bool
n => n: Bool
n
Each one returns the value of its single argument. We call such function identity functions. We thus have identity functions for arguments of types Nat, String, and Bool, respectively. Here are example applications.
id_natid_nat: Nat → Nat77: Natid_stringid_string: String → String"Hello""Hello": Stringid_boolid_bool: Bool → Booltruetrue: Bool
Beyond having different names, these functions vary only in the types of their argument and return values.
We wouldn't want to have to write one such function for each of hundreds of types. We can avoid such repetition by "factoring out" the varying part of the definition into a parameter (argument).
Parametric Polymorphism
A key idea throughout computer science and mathematics is that we can generalize families of definitions by turning aspects that vary into parameters. Then by giving specific values for parameters, we recover the specialied versions.
In the cases above, the main aspect that varies is the type of objects that are being handled: Bools, Strings, Nats. The code for each implementation is identical so we should really only have to write it once, in a general way, using the idea of generalization.
To do this, we introduce a new argument: one that can take on any type value whatsoever. We could call this argument, T : Type, but in Lean it's conventional to use lower-case Greek letters to name type-valued arguments, so we'll call it α : Type. Here's the code we want.
def id_poly: (α : Type) → α → α
id_poly : (α: Type
α : Type: Type 1
Type) → α: Type
α → α: Type
α
| _, v: x✝
v => v: x✝
v
def id_poly': (α : Type) → α → α
id_poly' (α: Type
α : Type: Type 1
Type) : α: Type
α → α: Type
α
| v: α
v => v: α
v
The key idea in play here is that we bind a name, α, to the value of the (first) type parameter, and, having done that, we then express the rest of the function type in terms of α. In more detail, here are the elements of the whole function definition:
- def is the keyword for giving a definition
- id_poly is the name of the function being defined
- (α : Type) binds the name α to the first (type) argument
- in this context, the rest of the function type is α → α
- the | gives the pattern matching rule for this function
- the names α and v bind to the first and the second arguments
- => separates the pattern on the left from the return value on the right
- v, bound to the second argument, is the return value of this function
- the name α is unused after the => and so can be replaced by _
-- And we can see that it works!(id_polyid_poly: (α : Type) → α → αString)String: Type"Hello!""Hello!": String(id_polyid_poly: (α : Type) → α → αNat)Nat: Type77: Nat(id_polyid_poly: (α : Type) → α → αBool)Bool: Typetruetrue: Bool
Specialization by (partial) application
For example, if α is Nat, the rest of the function is of type Nat → Nat. In the single pattern matching rule, we bind v to the first unnamed argument, a Nat, and the function then returns the value of v. If α is String, v will be bound to a String given as a second argument, and the function will return that value.
(id_poly) -- generalized definitionid_poly: (α : Type) → α → α(id_polyid_poly: (α : Type) → α → αNat) -- specialization to NatNat: Type(id_polyid_poly: (α : Type) → α → αBool) -- specialization to BoolBool: Type(id_polyid_poly: (α : Type) → α → αString) -- specialization to StringString: Type
We can specialize the generalized function to specific types by applying it only to a first type argument.
defid_nat' :=id_nat': Nat → Natid_polyid_poly: (α : Type) → α → αNat -- same as id_nat above defNat: Typeid_string' :=id_string': String → Stringid_polyid_poly: (α : Type) → α → αString -- same as id_string above defString: Typeid_bool' :=id_bool': Bool → Boolid_polyid_poly: (α : Type) → α → αBool -- same as id_bool aboveBool: Typeid_nat'id_nat': Nat → Nat77: Natid_string'id_string': String → String"Hello""Hello": Stringid_bool'id_bool': Bool → Booltruetrue: Bool
What we see here is an example of what, in programming, is called parametric polymorphism. We have one function definition that can take arguments of many different types. Here the types of the second argument and return value are given by the value (a type!) of the first argument.
Lean detects type errors in such expressions. For example, if we pass Bool as the first argument but 7 as the second, Lean will report an error. Let's try.
id_polyid_poly: (α : Type) → α → αBoolBool: Type-- Lean can't convert 7 into a Boolid_polyid_poly: (α : Type) → α → αBoolBool: Typetruetrue: Boolid_polyid_poly: (α : Type) → α → αNatNat: Type77: Natid_polyid_poly: (α : Type) → α → αStringString: Type"Hello""Hello": String
Implicit Arguments
You might have noticed that in principle Lean can always infer the type value of the first argument to the id_poly function from the data value passed as the second argument. For example, if the second argument is "Hello!", the first argument just has to be String. If the second argument is 7, the first has to be Nat. If the second is true, the first has to be Bool.
In these cases, you can ask Lean to silently fill in argument values when it knows what they must be, so that you don't have to write them explicitly. To tell Lean you want it to infer the value of the first type argument to id_poly, you specify it as an argument when defining the function not using (α : Type) but using curly braces instead: {α : Type}. Let's define the function again (with the name id_poly') to see this idea in action.
def id_poly'': {α : Type} → α → α
id_poly'' : {α: Type
α : Type: Type 1
Type} → α: Type
α → α: Type
α -- α is an implicit argument
| _, v: x✝
v => v: x✝
v
Now we can write applications of id_poly' without giving the first (type) argument explicitly. It's there, but we don't have to write it. Instead, Lean infers what it's value must be from context: specifically from the type of the value we pass as the second argument. The resulting code is beautifully simple and evidently polymorphic. It also eliminates possible type mismatches between the first and second arguments, as the type in question is inferred automatically from the value to be returned.
id_poly''id_poly'': {α : Type} → α → α7 -- α = Nat, inferred!7: Natid_poly''id_poly'': {α : Type} → α → α"Hello!" -- α = String, inferred!"Hello!": Stringid_poly''id_poly'': {α : Type} → α → αtrue -- α = Bool, inferred! #evaltrue: Bool-- error #eval-- error #eval-- error
Sometimes we will have to give type arguments explicitly, even when they're declared to be implicit. In these cases, we disable implicit argument inference, In Lean, by writing an @ before the given expression. Note that in the following examples we once again can, and must, give the type argument values explicitly.
@id_poly''id_poly'': {α : Type} → α → αNatNat: Type7 -- α = Nat, inferred!7: Nat@id_poly''id_poly'': {α : Type} → α → αStringString: Type"Hello!" -- α = String, inferred!"Hello!": String@id_poly''id_poly'': {α : Type} → α → αBoolBool: Typetrue -- α = Bool, inferred!true: Bool
Extended Example: A polymorphic apply2 function
We'll now work up to defining a polymorphic function, apply2, that takes as its arguments a function, f, and a value, a, and that returns the result of applying f to a twice: that it, it returns the value of f (f a).
A Natty Example
We'll define apply2_nat as a function that takes a function, f, and an argument, a, to that function as its arguments, and that then returns the result of applying the function f to the argument a twice. That is, apply will return the value of f (f a).
As an example, if f is the function, Nat.succ, that returns one more than a given natural number a, the result of "applying f twice to 0" is 2.
Let's write this apply2_nat function where the function and its argument values are Natty. We define apply2_nat that takes (1) a function, f : Nat → Nat, and (2) a second argument, a : Nat, and that returns a result of applying f twice to a: namely f (f a), also a Nat.
-- This apply2 version is specialized for "Natty" values f a
def apply2_nat: (Nat → Nat) → Nat → Nat
apply2_nat : (Nat: Type
Nat → Nat: Type
Nat) → Nat: Type
Nat → Nat: Type
Nat
| f: Nat → Nat
f, a: Nat
a => f: Nat → Nat
f (f: Nat → Nat
f a: Nat
a)
Let's apply this function to some arguments to see what we get. First we need some specific function, f, taking and returning a Nat. The Nat.succ function will work. This is the successor function, which returns 1 more than any natural number given as an argument.
(Nat.succ) -- Nat → NatNat.succ: Nat → NatNat.succNat.succ: Nat → Nat0 -- 10: Natapply2_natapply2_nat: (Nat → Nat) → Nat → NatNat.succNat.succ: Nat → Nat0 -- expect 20: Natapply2_natapply2_nat: (Nat → Nat) → Nat → NatNat.succNat.succ: Nat → Nat3 -- expect 53: Nat
Yay, it seems to work. It gets more interesting when we see that we can use any function of type Nat → Nat as a first argument to this function. Here are a few little puzzles for you to complete by defining simple functions.
First, define a function, double : Nat → Nat that returns twice the argument to which it's applied. So for example, double 4 should reduce to 8.
defdouble :double: Nat → NatNat →Nat: TypeNat |Nat: Typen =>n: Nat2 *2: Natnn: Natapply2_natapply2_nat: (Nat → Nat) → Nat → Natdoubledouble: Nat → Nat4 -- expect 16 (2 * (2 * 4))4: Natapply2_natapply2_nat: (Nat → Nat) → Nat → Natdoubledouble: Nat → Nat10 -- expect 40 (2 * (2 * 10))10: Nat
Second, define a function, square : Nat → Nat, that reduces to its argument value squared. Then check to see that apply2_nat works when you give square as the first argument? For example squaring 5 gives 25, and squaring 25 gives 625, so apply2_nat square 5 should reduce to 625. Write both the function definition and test cases for a few inputs, including 5. Give your answer here:
#A. define the square function here:
-- here: defsquare :square: Nat → NatNat →Nat: TypeNat |Nat: Typen =>n: Natn^n: Nat22: Natsquaresquare: Nat → Nat4 -- expect 164: Nat
Write test cases for apply2_nat square for several values, including 5, and use them to develop confidence that your function definition appears to be working more generally.
apply2_natapply2_nat: (Nat → Nat) → Nat → Natsquaresquare: Nat → Nat1 -- expect 11: Natapply2_natapply2_nat: (Nat → Nat) → Nat → Natsquaresquare: Nat → Nat2 -- expect 162: Natapply2_natapply2_nat: (Nat → Nat) → Nat → Natsquaresquare: Nat → Nat3 -- expect 813: Natapply2_natapply2_nat: (Nat → Nat) → Nat → Natsquaresquare: Nat → Nat4 -- expect 2564: Nat
A Stringy Example
Now if you think about it, we should be able to write an apply2 function that does the analogous thing but with Stringy things. Given a function, f, from String to String, and an argument, a : String, we can always compute f (f a ).
Your new puzzle is to write apply2_string; then give examples of applying this function to two different function arguments, and for each of those, to several string argument values.
You can make up your own String → String functions. For example, a function, exclaim : String → String, applied to a string, s, could return (append s "!"). There is an infix notation: s ++ "!".
defexclaim :exclaim: String → StringString →String: TypeString |String: Types =>s: Strings ++s: String"!""!": Stringexclaimexclaim: String → String"Hello" -- apply it once"Hello": Stringexclaim (exclaim: String → Stringexclaimexclaim: String → String"Hello") -- apply it twice"Hello": String
Now you can use this function, exclaim, as a first argument to apply2_string. Defining this function is easy, as it's the same as apply2_nat except for the type of objects being handled: String not Nat.
defapply2_string : (apply2_string: (String → String) → String → StringString →String: TypeString) →String: TypeString →String: TypeString |String: Typef,f: String → Stringa =>a: Stringf (f: String → Stringff: String → Stringa)a: Stringapply2_stringapply2_string: (String → String) → String → Stringexclaimexclaim: String → String"Hello" -- expect "Hello!!""Hello": String
It works!
Generalizing the Type of Objects Handled
At this point it should be clear, by analogy with earlier material, that we can generalize from the specific Nat and String types, in the previous examples, to write a version of apply2 that can handle objects of any type, α. The trick, as usual, is to handle the variation in object types by adding a type parameter.
defapply2' : (apply2': (α : Type) → (α → α) → α → αα :α: TypeType) → (Type: Type 1α →α: Typeα) →α: Typeα →α: Typeα | _,α: Typef,f: x✝ → x✝a =>a: x✝f (f: x✝ → x✝ff: x✝ → x✝a) /- Let's explain this function in detail: - def is the keyword for binding names to values - apply2' is the name of our new function - the type of the function is give after the : - the function takes three arguments: - a type value, α, such as Nat or String - a function of type α → α, such as exclaim - a value of type α - next is rule for computing the result - first we match all three arguments - the type value (we don't have to name it) - the function (we name it f) - the argument (we name it a) - after the => is the expression for the result We can now try it out to see that it works! -/a: x✝apply2'apply2': (α : Type) → (α → α) → α → αNatNat: TypeNat.succNat.succ: Nat → Nat0 -- expect 20: Natapply2'apply2': (α : Type) → (α → α) → α → αNatNat: Typedoubledouble: Nat → Nat1 -- expect 41: Natapply2'apply2': (α : Type) → (α → α) → α → αNatNat: Typesquaresquare: Nat → Nat2 -- expect 162: Natapply2'apply2': (α : Type) → (α → α) → α → αStringString: Typeexclaimexclaim: String → String"Hello" -- "Hello!!""Hello": String
Type Inference and Implicit Arguments
As a final exercise in good notation, redefine apply2 (calling it apply2') so that the first argument, the type value, is implicit. Write the definition so that Lean infers the value of α (the first, type, argument) from the values of the remaining arguments. When you get it right, the following test cases should work.
-- Answer: defapply2 : {apply2: {α : Type} → (α → α) → α → αα :α: TypeType } → (Type: Type 1α →α: Typeα) →α: Typeα →α: Typeα | _,α: Typef,f: x✝ → x✝a =>a: x✝f (f: x✝ → x✝ff: x✝ → x✝a) -- Now the type arguments are implicit!a: x✝apply2apply2: {α : Type} → (α → α) → α → αNat.succNat.succ: Nat → Nat0 -- expect 20: Natapply2apply2: {α : Type} → (α → α) → α → αdoubledouble: Nat → Nat1 -- expect 41: Natapply2apply2: {α : Type} → (α → α) → α → αsquaresquare: Nat → Nat2 -- expect 162: Natapply2apply2: {α : Type} → (α → α) → α → αexclaimexclaim: String → String"Hello" -- Hello!!"Hello": String
This example is an important achievement. It exhibits the following fundamental concepts:
- every value has a type
- types are values too; their type is Type
- types parameters make definitions polymorphic
- type arguments can be implicit and inferred
- functions are values, too, and can be arguments
With all the work required to get to this point now in hand, we're ready to introduce a new and important concept in mathematics.
Note: The concept is introduced as a homework assignment, then reviewed in class. Once it's done, this lecture then continues to completion.
end lecture_03_04