Learning Lean (Part 1)
I'm going to be starting a series of blog posts on Lean 4, a dependently typed functional programming language and theorem prover. If none of that sounds familiar, no problem, we'll get to that. First, Lean 4 has not been officially released as a 1.0, so breaking changes to the code in these posts may occur, but I'll try to update from time to time. The current stable release of Lean is Lean 3.
Let me also preface that these blog posts, although meant to be instructive, mirror my own learning of Lean. I've been playing with Lean off and on since April 2020 but I am currently not an expert at Lean or functional programming in general, so there are likely going to be instances of me writing non-idiomatic Lean and doing things in not the most efficient way. Also, my goal in learning Lean was mostly to learn formalizing mathematics rather than functional programming, so these blogs will hence be mostly focused on the mathematics use of Lean. In terms of pre-requisites if you want to be able to follow along, anyone with a background programming in some programming language and at least a high school mathematics background should be able to follow along.
Go ahead and get Lean installed by following the guide here: Lean Setup Guide.
There's also a lively online community for Lean where friendly individuals from around the world will answer your questions surprisingly quickly: https://leanprover.zulipchat.com/
What is Lean?
Lean is a programming language. More specifically, Lean is a functional programming language. Probably the most well-known (at least to me) functional programming language is Haskell. A functional programming language usually refers to a purely functional programming language. Many programming languages support functions as first-class citizens, allowing functions to be passed as arguments and returned, but a purely functional language imposes that all functions have a well-specified and static type signature and no other side effects can occur within a function (e.g. writing to disk, playing a sound file, etc.). These are called pure functions. Moreover, the only data these pure functions have access to is what gets explicitly passed as an argument. And every expression in Lean has a type, including types themselves.
The mandate that all functions are pure is quite onerous coming from typical imperative programming languages such as C or Python. It is no longer trivial in a pure functional language to do something like read an input string, do some computation, write to a log and return a value. The benefit is that pure functions are completely predictable at compile-time, preventing a whole class of bugs.
Lean is not only a functional programming language, but a dependently-typed functional programming language. This means that types can depend on other types and values. For example, in Lean, you can define a type of list of integers of length 3, and thus if you try to construct an instance of that type by giving it 4 integers, it will fail. In essence, dependent types give you extreme expressiveness and granularity when defining new types.
Why should you learn Learn?
I think Lean is going to be the next big thing in the functional programming world and hopefully the formal software verification and mathematics world. So if any of those things interest you, it may be worth learning Lean.
One goal for me in learning Lean is to learn proofs in mathematics. I do data analysis, statistics and machine learning so my understanding of mathematics is very applied and calculational. I want to get a flavor of pure mathematics and how mathematical structures are made and theorems are proved. Because Lean is a dependently typed pure functional programming language, it can be used to encode all of modern mathematics using its type theory in place of traditional set theory.
In any case, doing math in Lean is actually fun a lot of the time. It's like a programming and logic puzzle. So stop with the Sudoku and just prove theorems in Lean instead.
Natural Numbers
Before we can do much of anything in mathematics, we're going to need some numbers. Now Lean already has numbers defined, of course, but we will pretend it doesn't.
Let's define the natural numbers, that is, the set of numbers 0, 1, 2, 3 ..., or the counting numbers.
namespace Tutorial
inductive Nat : Type where
| zero : Nat
| succ : Nat → Nat
end Tutorial
The namespace section is what it sounds like and works similarly to how namespaces work in other programming languages. Outside the namespace you have to refer to identifiers inside the namespace by prefixing [namespace name].[identifier]
(without the brackets). We enclose our code in this namespace to avoid name clashes because we are defining types and functions that already exist in Lean with the same names.
First, the keyword inductive
means we are defining a new (inductive) type. In Lean's type theory, there are basically just two kinds of types, inductive types and function types, so we will use inductive a lot.
After the keyword inductive
comes the name of the type we are defining, in this case, we are calling it Nat
, for natural numbers. After the name of the type comes a colon and the keyword Type
.
Recall, every expression in Lean must have a type, even the new type we are defining must itself be assigned a type. So in general, new inductive types will by default be assigned the type Type
, which is an alias for Type 0
, because if you ask Lean what the type of Type
is, it must give you an answer, so it just creates an infinite hierarchy of types Type : Type 1, Type 1 : Type 2
etc., meaning that the type of Type
is Type 1
.
You can ask Lean what the type of an expression is by using the #check
command.
#check Nat
--Output: Nat : Type
#check Type
--Output: Type : Type 1
Now Lean tries to be smart and save you keystokes, so whenever possible, it will infer types when it can do so unambiguously. So it is fine to also declare our Nat
type without explicitly assigning its type:
-- This also works
inductive Nat where
| zero : Nat
| succ: Nat → Nat
And two dashes is how you start a comment line. Or a multiline comment can be delimited using
/-
Multi
line
comment
-/
Back to our new type.
inductive Nat : Type where
| zero : Nat
| succ : Nat → Nat
After assigning Nat
as being the type Type
is the keyword where
which is also optional, as this is also valid:
inductive Nat
| zero : Nat
| succ : Nat → Nat
I guess where
is mostly of descriptive use, as you can translate the type declaration into English as "We are making a new inductive type named Nat
of type Type
where zero
is declared to be of type Nat
and succ
is a function that maps values of type Nat
to values of type Nat
.
Back to the more verbose type declaration:
inductive Nat : Type where
| zero : Nat
| succ : Nat → Nat
So after the where
keyword, we start a new line beginning with the pipe |
character. Each line beginning with |
is called a constructor since these lines specify how to construct terms (aka elements, values or members) of the new inductive type.
First, we invent a name for a value of type Nat
, in this case I am declaring that the string of characters zero
is hereby defined to be a term (or value) of type Nat
.
We could stop here and we'd have a new (inductive) type with a single value, but that wouldn't help us create numbers, which we expect to be indefinite (infinite).
Next, we start a new line beginning with a pipe, and this time instead of declaring a new term of type Nat
, we declare the first function that operates on terms of type Nat
. We call this function succ
(short for successor). We know that succ
is a function and not a term because we assign it the type Nat → Nat
after the colon. Whenever you see the pattern some type → some type, you're looking at a function type.
Functions are programs that map terms from one type to another (or the same) type. In this case, succ
is a function that does not compute anything, in fact it doesn't do anything at all. All we can do with it is apply it.
#check Nat.succ (Nat.succ Nat.zero)
--- Output: Nat.succ (Nat.succ Nat.zero) : Nat
As you can see, we apply a function by putting the function name, a space, and then the term (value). To avoid ambiguity we must use parentheses when applying multiple times. Declaring the type Nat
creates a local namespace Nat
so we must prefix references to the value zero
or function succ
with Nat.
e.g. Nat.zero
We can save keystokes by opening the namespace.
open Nat
--- Now we can do this
#check succ (succ zero)
--- Output: succ (succ zero) : Nat
Now we have defined the natural numbers, 0, 1, 2, ... and so on, identified in our new type as zero, succ (zero), succ (succ (zero))
. Obviously writing numbers using function application is not as convenient as using our normal numerals 1, 2 etc. There is a way to map numerals to our more verbose natural numbers, but we will wait awhile before doing that.
When I first understood what was going on here in this very simple inductive type, it was quite profound. By declaring this empty function succ
and applying it to the only "real" term of type Nat
, we get an infinite new set of terms of type Nat
. Any string of characters that fits the pattern succ x
where x
is either zero
or also of the pattern succ (succ ... zero )
is also of type Nat
.
You can think of types as specifiying a pattern of characters, a type-checker as checking whether some expression matches a particular pattern, and a value or term is just an expression that matches a particular type pattern. So succ (succ zero)
is of type Nat
because that pattern of characters matches the pattern we defined as type Nat
.
Let's define our first function on our new Nat
type. In Lean, all functions are pure as we discussed earlier, and they are also total. A total function is one where every possible input term gets mapped to an output term. That means for a function of type Nat → Nat
, every natural number must get mapped to some output term that is also a natural number. You cannot have input terms that are left undefined. In mathematics, if we treat division as a function, we say that \(\frac{x}{0}\) is undefined. In Lean, that is not acceptable, even \(\frac{x}{0}\) is defined.
Total functions can sometimes be an onerous constraint when using Lean for non-mathematical purposes, especially as you have to prove to Lean that your function is total, so Lean does provide a way to define partial functions, but we will not address that yet.
Here's our first function:
def natId : Nat → Nat := fun x : Nat => x
First we define a new function using the def
keyword, then the name of our function, in this case we're calling it natId
, then we have a colon, which indicates we're going to be assigning a type and after the colon we have the type Nat → Nat
. Following that, we have the symbol :=
which is an assignment operator, and then we have the body of the function which is fun x : Nat => x
This last part is called an anonymous function (or lambda function). An anonymous function is a function expression without giving it a name. As is clear, an anonymous function is declared using the fun
keyword, following by an identifier (can be more than one) representing the input, then its type annotation, then the =>
symbol following by the function body, which in this case just returns the input x
, and hence this is defining the identity function that does nothing but return its input unadulterated.
One challenge when getting started with Lean is that Lean has a lot of syntactic sugar, so there are often multiple ways to express the same thing. Here are 3 other ways we could have defined the same identity function:
def natId2 : Nat → Nat :=
fun a =>
match a with
| a => a
And:
def natId3 : Nat → Nat
| a => a
And:
def natId4 (a : Nat) : Nat := a
The first of these alternatives also uses an anonymous function but then has a match ... with
pattern. As we discussed, inductive types are essentially defined as a set of base terms and then one or more functions that define a pattern for creating other terms of that type using the base terms. So since we construct types by defining base terms and patterns over those base terms, we also define functions on types by deconstructing types into their base terms and patterns over those base terms, and then map each deconstructed pattern into terms of another type.
Notice that the variable a
after fun
represents the input term and we can name it whatever we want. For multiple input functions we will have to introduce multiple input variables after fun
.
Also we can check how Lean actually defines types by using the #print
command. Let's check the last of these alternatives for idNat4
#print natId4
/-
Output:
def Tutorial.natId4 : Nat → Nat := fun a => a
-/
As you can see, even though Lean lets us omit the explicit anonymous function, behind the scenes it is filling in the anonymous function for us.
Okay, moving on. Let's write a simple function that just subtracts one from any natural number.
def subOne : Nat → Nat :=
fun a =>
match a with
| zero => zero
| succ b => b
We define a function called subOne
with the type Nat → Nat
. We implement the function by assigning it to an anonymous function that takes an input variable a
(which must be of type Nat
according to the function type signature) and matches it against the patterns that a term of type Nat
can have, namely it can either be the base term zero
or of the pattern succ b
where b
is just a placeholder for whatever is inside the succ
function. We could have also used a
in place of b
and Lean is smart enough to figure out what we mean based on the context.
If the input a
happens to be zero
then we just return zero
since natural numbers don't get any lower than zero
. If the input a
happens to be succ b
with b
being a term of type Nat
then we return b
, which effectively removes one application of succ
and thus decrements the number by one.
There are no other possible patterns that a term of type Nat
could be and since we covered them in our function pattern match, Lean is satisfied our function is total.
We can ask Lean to evaluate our function on the input zero.succ.succ
(the number 2) by using the #reduce
command.
#reduce subOne zero.succ.succ
--Output: succ zero
It works, if we subtract one from 2 we get 1. Notice that the expression zero.succ.succ
is equivalent to succ (succ zero)
but easier to read as it avoids parentheses. Again, this is one challenge in learning Lean; there are many ways to do the same thing. But ultimately these are ways to save keystrokes and improve readability, at the expense of taking longer to learn.
We can also write a function where we explicitly name the inputs and then pattern match on them:
def subOne (a : Nat) : Nat :=
match a with
| zero => zero
| succ b => b
In this style we name the inputs and annotate their types and then we give the output type after the last free colon.
Now let's define the addition function on natural numbers.
def add (a b : Nat) : Nat :=
match b with
| zero => a
| succ c => succ (add a c)
We define our function add
to take two inputs named a
and b
and both are of type Nat
so we include them together separated by a space. The output type of our function is also of type Nat
. We then match the pattern of input b
to define the computation of the function.
If the second input b
is zero, then that means we are dealing with a + 0
and that obviously just equals a
, so we return a
.
If b
is greater than 0, i.e. of the form succ c
where c
is another natural number, then we add together a
and c
(and c = b - 1
), then we apply succ
to the result, which is the same as adding one.
In other words, we are recursively doing 1 + (a + (b - 1))
. Because we are in a purely functional programming language, we do not have access to things like for
loops or while
loops. Any iterative computations must be done using recursive (self-referential) function calls.
When we compute 1 + (a + (b - 1))
, Lean will then call the add
function again, with input a
(the same as the original input a
), and the second input will be b - 1
. It keeps recursively calling itself until b - 1 = 0
and then we hit the base case where the second input is 0
and add
just returns the first input a
.
#reduce add zero.succ zero.succ
--Output: succ (succ zero)
As you can see, our function successfully computes 1 + 1 = 2
. Let's do it by hand to make sure we really understand what is going on.
First,
add zero.succ zero.succ
(again, this represents 1 + 1
)
will pattern match on the second argument b = zero.succ
, so it will return succ (add zero.succ zero)
since the pattern match will "pull off" a succ
from the input b
.
So now we're calling add
within itself, namely add zero.succ zero
(1 + 0
). Now again, we pattern match on the second input b = zero
and that matches the base case where we just return a
. So add zero.succ zero = zero.succ
.
Now we substitute that into the expression above, succ (add zero.succ zero)
, so we get succ (succ zero)
, which is the final answer. So the add
function works by recursively decrementing b
by 1 while adding 1 to a
until b = 0
.
In order for functions to be total (as described above), they need to be terminating. Lean has a component called a termination checker that makes sure every function you define will terminate in a finite number of steps. It does this by making sure that when you're recursively calling a function that the input arguments are structurally decreasing. In the case of the add
function, the second input b
will structurally decrease each recursive call of add
because a succ
is "pulled off" (i.e. b
becomes b - 1
each call). Once b = zero
in the recursive calls then the function terminates.
We'll end this post here, but we have a lot more to learn. In the next post we'll prove our first theorems about the natural numbers and learn a lot more Lean along the way.
PS: My goal with these Learning Lean posts are to assume as few pre-requisites as possible, so please leave a comment or email me if anything needs additional explanation and you meet the pre-requisites of knowing how to program in some language and having a high school math background.