Data Types: Sum, Unit, and Empty Types
Whereas a product type contains both a value of some type, α, and a value of some type β, a sum type contains either a value of some type, α, or a value of some type, β. A sum type thus has two constructors, each taking a single argument, one taking an α value, the other taking a β value. We'll use asd constructor names inl and inr, where inl takes an argument of type α and inr takes an argument of type β. So, if (a : α), then inl a will be an object of a sum type; and if (b : β) then inr b will also be a value of a sum type.
The bulk of this chapter will deal with sum types, but then we'll address two very simple types, one with a single constant constructor, and one with no constuctors, and thus no values, at all. We will call these the unit and empty types.
Brief Review
Last time we saw defined polymorphic types that we called Box α and Prod α β, where α and β are type parameters. Here are their types.
namespace cs2120 inductiveBox (Box: Type → Typeα :α: TypeType) :Type: Type 1Type |Type: Type 1put (put: {α : Type} → α → Box αa :a: αα)α: Type(@Box.put) defBox.put: {α : Type} → α → Box αfoo := (foo: Box StringBox.putBox.put: {α : Type} → α → Box α"Hello")"Hello": Stringfoofoo: Box String
Here we've renamed the constructor from pair to mk to be consistent with Lean's built-in definition of the Prod type builder.
inductive Prod: Type → Type → Type
Prod (α: Type
α : Type: Type 1
Type) (β: Type
β : Type: Type 1
Type)
| mk: {α β : Type} → α → β → Prod α β
mk (a: α
a : α: Type
α) (b: β
b : β: Type
β)
Let's focus on the Box α type. It has one constructor, put (a : α). This constructor takes an implicit type argument, α, because Box is polymorphic, as well as an explicit argument value of type α. We can see the full type of put using @.
(@Box.put) defBox.put: {α : Type} → α → Box αjack_in_a_box := @jack_in_a_box: Box StringBox.putBox.put: {α : Type} → α → Box αStringString: Type"Jack!""Jack!": String
Leaving implicit arguments enabled, we can leave out the explicit type argument.
def jack_in_a_box': Box String
jack_in_a_box' := Box.put: {α : Type} → α → Box α
Box.put "Jack!": String
"Jack!"
It's important to understand that the constructor, put, doesn't compute anything: it just "wraps" its arguments into a term, here, Box.put "Jack!". You can visualize this as a box, with the label Box.put, and the contents "Jack!". The term Box.put "Jack!" is a value of type Box String.
Finally, we saw that we can get the (string) value from inside a term by eliminating the surrounding structure, giving a name to the string it contains, and returning the string value by that name. The key idea is that this is done by pattern matching.
Take the term, *Box.put "Jack!", as an example, if we match this term with the pattern, "Box.put s", then, (1) it matches, (2) the name s is bound to the string, "Jack!", and we can return that string by returning s. We'll write a get function to do this, and we might as well make it polymorphic.
defget {get: {α : Type} → Box α → αα :α: TypeType}:Type: Type 1BoxBox: Type → Typeα →α: Typeα | (α: TypeBox.putBox.put: {α : Type} → α → Box αs) =>s: αss: αget (get: {α : Type} → Box α → αBox.putBox.put: {α : Type} → α → Box α"Jack!") def"Jack!": Stringsquare (square: Nat → Natn :n: NatNat) :Nat: TypeNat :=Nat: Typen*n: Natnn: Nat
The Prod type builder is analogous except it puts two values, of possibly two different types,into a box, and so we need two "elimination functions" to get those values, called fst and snd in Lean. In Lean the constructor is called Prod.mk, but it's best to use ordered pair notation for that.
end cs2120(ProdProd: Type → Type → TypeNatNat: TypeBool) -- a typeBool: Type(Prod.mkProd.mk: {α β : Type} → α → β → α × β33: Nattrue) -- a value (term)true: Bool(3,3: Nattrue) -- outfix notation -- aka *projection functions*true: BoolProd.fst (Prod.fst: {α β : Type} → α × β → α3,3: Nattrue)true: BoolProd.snd (Prod.snd: {α β : Type} → α × β → β3,3: Nattrue)true: Bool(3,3: Nattrue).true: Bool1 -- postfix notation1: {α β : Type} → α × β → α(3,3: Nattrue).true: Bool2 -- postfix notation2: {α β : Type} → α × β → β
Sum Types
We can call such a type a sum type. We will again give a slightly simplified definition and then explain how to use the concept with Lean's build-in definitions. Here are the key ideas:
- Sum will be polymorphic with two type arguments
- It will have two constructors
- The first (inl) takes (a : α) to construct a value with an α value
- The second (inr) take (b : β) to construct a value with a β value
- To use a value of a sum type we have to be able to handle either case
namespace cs2120
inductive Sum: Type → Type → Type
Sum (α: Type
α β: Type
β : Type: Type 1
Type) : Type: Type 1
Type
| inl: {α β : Type} → α → Sum α β
inl (a: α
a : α: Type
α)
| inr: {α β : Type} → β → Sum α β
inr (b: β
b : β: Type
β)
Constructors
def a_sum1: Sum Nat Bool
a_sum1 : Sum: Type → Type → Type
Sum Nat: Type
Nat Bool: Type
Bool := Sum.inl: {α β : Type} → α → Sum α β
Sum.inl 1: Nat
1
def b_sum1: Sum Nat Bool
b_sum1 : Sum: Type → Type → Type
Sum Nat: Type
Nat Bool: Type
Bool := Sum.inr: {α β : Type} → β → Sum α β
Sum.inr true: Bool
true
These definitions assign (1) to a_sum1 a Sum object capable of holding a Nat OR a Bool, and that contains the Nat value, 1; and (2) to b_sum1, the same type of object but now holding the Bool value, true.
By contrast, the following definition assigns to a_sum2 an object capable of holding a Nat or a String, and holding the Nat value, 1. The value, 1, is the same as in the earlier example, but it's held in a different type of object: one of type Sum Nat String rather than of type Sum Nat Bool.
def a_sum2: Sum Nat String
a_sum2 : Sum: Type → Type → Type
Sum Nat: Type
Nat String: Type
String := Sum.inl: {α β : Type} → α → Sum α β
Sum.inl 1: Nat
1
Eliminator
A value of type Prod α β always contains both an α AND a β value, so given an object of this type we can always return an α value and we can always return a β value. The fst and snd functions serve these purposes.
By contrast, if all we're given an arbitrary value of type Sum α β, while we can be assured that it contains a value of type α OR a value of type β, but we can't be assured that we'll always have a value of type α to return or a value of type β. So we aren't able to define elimination functions like those for Prod α β.
To make good use of an arbitrary value of type Sum α β we need to have a little more machinery lying around. In particular, suppose we have two functions, one to convert any value of type α into, a String (or more generally into any type γ), and that we also have a funtion to convert any value of type β into a String (or more generally a value of that same type γ). The key is is that when given any value of type Sum α β, we can return a String (or more generally a value of some type γ) in either case.
Here's a concrete example.
def elim_sum1: Sum Nat Bool → String
elim_sum1 : Sum: Type → Type → Type
Sum Nat: Type
Nat Bool: Type
Bool → String: Type
String
| (Sum.inl: {α β : Type} → α → Sum α β
Sum.inl _) => "It's a Nat": String
"It's a Nat"
| (Sum.inr: {α β : Type} → β → Sum α β
Sum.inr _) => "It's a Bool": String
"It's a Bool"
We can make this elimination function more general by passing in and using two functions, one that converts any Nat to a String and one that converts and Bool to a string. Here's what that looks like.
def elim_sum2: Sum Nat Bool → (Nat → String) → (Bool → String) → String
elim_sum2 :
(Sum: Type → Type → Type
Sum Nat: Type
Nat Bool: Type
Bool) →
(Nat: Type
Nat → String: Type
String) →
(Bool: Type
Bool → String: Type
String) →
String: Type
String
| (Sum.inl: {α β : Type} → α → Sum α β
Sum.inl n: Nat
n), n2s: Nat → String
n2s, _ => n2s: Nat → String
n2s n: Nat
n
| (Sum.inr: {α β : Type} → β → Sum α β
Sum.inr b: Bool
b), _, b2s: Bool → String
b2s => b2s: Bool → String
b2s b: Bool
b
Let's analyze that. It takes arguments as expected, including Nat-to-String and Bool-to-String conversion functions. It then uses pattern matching to match the two possible forms of the given (Sum Nat Bool) value. If it was constructed using inl with a Nat, then it applies the Nat to String converter to the Nat to get the String to return.
Let's see it in action. We'll define two very simple functions to convert Nats and Bools to strings: each will take an argument and just return the same string we used in the example above.
defnat_to_string (nat_to_string: Nat → String:Nat) :=Nat: Type"It's a Nat" -- argument unused def"It's a Nat": Stringbool_to_string (bool_to_string: Bool → String:Bool) :=Bool: Type"It's a Bool""It's a Bool": String
Now we can apply the elimination function we defined.
elim_sum2elim_sum2: Sum Nat Bool → (Nat → String) → (Bool → String) → Stringa_sum1a_sum1: Sum Nat Boolnat_to_stringnat_to_string: Nat → Stringbool_to_stringbool_to_string: Bool → Stringelim_sum2elim_sum2: Sum Nat Bool → (Nat → String) → (Bool → String) → Stringb_sum1b_sum1: Sum Nat Boolnat_to_stringnat_to_string: Nat → Stringbool_to_stringbool_to_string: Bool → String
We're now in a position to define a general-purpose elimination function for Sum type values. Given three arbitrary types, α, β, and γ, it will take a value, s, of type (Sum α β), a function α2γ : α → γ, and a function, β2γ : β → γ, and will return a value of type γ. The function doesn't can't know ahead of time whether a given s will contain an α or a β value, but it can handle either case.
defelim_sum {elim_sum: {α β γ : Type} → Sum α β → (α → γ) → (β → γ) → γαα: Typeββ: Typeγ :γ: TypeType} : (Type: Type 1SumSum: Type → Type → Typeαα: Typeβ) → (β: Typeα →α: Typeγ) → (γ: Typeβ →β: Typeγ) →γ: Typeγ | (γ: TypeSum.inlSum.inl: {α β : Type} → α → Sum α βa),a: αα2γ, _ =>α2γ: α → γα2γα2γ: α → γa | (a: αSum.inrSum.inr: {α β : Type} → β → Sum α βb), _,b: ββ2γ =>β2γ: β → γβ2γβ2γ: β → γbb: βelim_sumelim_sum: {α β γ : Type} → Sum α β → (α → γ) → (β → γ) → γa_sum1a_sum1: Sum Nat Boolnat_to_stringnat_to_string: Nat → Stringbool_to_stringbool_to_string: Bool → Stringelim_sumelim_sum: {α β γ : Type} → Sum α β → (α → γ) → (β → γ) → γb_sum1b_sum1: Sum Nat Boolnat_to_stringnat_to_string: Nat → Stringbool_to_stringbool_to_string: Bool → String
Sum Types in Everyday Programming
Understanding what it takes, and how, to deal with objects of sum types is another big achievement in this class. It will make you a better programmer, and it's deeply related to logic, and in particular to reasoning from proofs of OR propositions.
Take programming. First, classes in Java and Python are basically product types: an object of a given type has values for all of the fields defined by it class. These languages simply don't have sum types. You can fake them, but it's complicated. Think about it. How would you define a Java class whose objects have either a cat field or a dog field? You can't.
On the other hand, industrial languages such as Rust and Swift, as well as functional languages such as Haskell and OCaml, do support sum types directly. You now have the basic pattern for programming with sum-type values: you have to have a way to handle each case.
end cs2120
The Sum Type in Lean
Given any two types, α and β, you can form the type, Sum α β, with notation α ⊕ β. You create values of this type using the Sum.inl and Sum.inr constructors. Note that if all you give to, say, inl, is a value of type α, Lean won't be able to infer the missing type β. You will have to give an explicit sum type to the value you're defining.
defss: Nat ⊕ ?m.26264-- don't know how to synthesize implicit argument def s1 : Sum Nat Bool := Sum.inl 1 def s2 : Sum Nat Bool := Sum.inr trues1s1: Nat ⊕ Bools2 defs2: Nat ⊕ Boolwhich :which: Nat ⊕ Bool → StringSumSum: Type → Type → TypeNatNat: TypeBool →Bool: TypeString | (String: TypeSum.inl _) =>Sum.inl: {α : Type ?u.26339} → {β : Type ?u.26338} → α → α ⊕ β"Left" | ("Left": StringSum.inr _) =>Sum.inr: {α : Type ?u.26360} → {β : Type ?u.26359} → β → α ⊕ β"Right""Right": Stringwhichwhich: Nat ⊕ Bool → Strings1s1: Nat ⊕ Boolwhichwhich: Nat ⊕ Bool → Strings2s2: Nat ⊕ Bool
Unit Type
The type, Bool, defines a set of two possible values. A variable of this type carries one bit of information, and thus distinguishes between two possibiities.
What about a type with just one value? We can certainly define such a type, and we'll call it the Unit type.
namespace cs2120
We'll present an only slightly simplified version of Lean's Unit type here. This will be all you'll need to use the built-in type.
The type definition is exactly what you'd expect. Unit is a type with one constant (parameterless) constructor, unit. Thus unit is the only value of the Unit type.
inductive Unit: Type
Unit : Type: Type 1
Type
| unit: Unit
unit
open Unit
The Lean libraries define () as a notation for unit. We can do the same with our own types, by the way.
notation "()" =>unitunit: Unit()(): Unit
So how much information does a value of this type carry? Imagine a function that takes some parameter and returns a value of this type. Here's one. It takes a Nat value and returns a Unit value.
defuseless (useless: Nat → Unit:Nat) :Nat: TypeUnit :=Unit: Type()(): Unituselessuseless: Nat → Unit00: Nat
How much do you learn about n from the return value of this operation? How much information does it give you? The answer is, nothing at all. You can of course also pass a value of the Unit type to a function, but it gives the function no useful additional information and so you might just as well leave it out.
def silly: Unit → Nat
silly : Unit: Type
Unit → Nat: Type
Nat
| () => 5: Nat
5
This silly function can't use the value of its argument to decide even between two possible return values, so it only has one possible course of action, here it returns 5. In pratice you'd never write code like this because it's unnecessarily complex and without harm simplifies to just dropping the argument and "returning" the 5.
def silly': Nat
silly' := 5: Nat
5
Now you might think that Unit is a type you've never seen before, but it practice it's omnipresent in code written in such languages as C, C++, Java, etc. It's the type of value returned by a function that "doesn't return anything useful." You know it as void.
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
Here, the main method returns void. The function really doesn't return nothing, it returns a value but one that's useless, and so can be ignored (a compiler can thus optimize it away in compiled code).
What you see in this example is that this type is used in cases where a procedure does something useful that does not include returning a useful result. Here the useful action is printing a message on the console! We call such actions side effects.
Lean4, like other useful functional languages such as Haskell, is capable of expressing operations that have side effects, such as sending output to the console. Here's Hello World in Lean4.
end cs2120 defmain :main: IO UnitIOIO: Type → TypeUnit :=Unit: TypeIO.printlnIO.println: {α : Type} → [inst : ToString α] → α → IO Unit"Hello, World!" -- Lean can run this code for us using #eval"Hello, World!": Stringmainmain: IO Unit
The procedure name is main. It "returns" a result of (built-in) type, IO Unit. IO is a polymorphic monadic type. This code basically says "run the side-effecting println routine in an isolated monad that returns Unit (nothing) when it's done."
You can actually write this LEAN code in a file, e.g., HelloWorld.lean, and compile it like a C++ or Java program, then run it, just as you would a compiled Java program.
So now you understand the Unit type in Lean. It's a data type with just one value. It communicates no information, and is useful mainly as a return value of an operation that computes nothing but rather is useful for its side effects, here input/output.
-- Here's Lean's version
Unit.unitUnit.unit: Unit
Empty Type
Just as there's a type, Unit, with just one value, we can define a type, we'll call it empty, with no values at all. It sounds useless. We won't find it useful in programming but it plays a vital role in constructive logic. For now we'll see what we can learn by programming with it.
namespace cs2120
inductive Empty: Type
Empty
That it: no constructors, no values. The Empty type.
inductive empty: Type
empty
What kinds of functions can we write with arguments or return values of the Empty type? Let's look at three possibilities:
- A function that takes Nat and returns Empty
- A function that takes Empty and returns Nat
- A function that takes Empty and returns Empty
defnat2empty :nat2empty: Nat → EmptyNat →Nat: TypeEmpty |Empty: Typen =>n: Nat
There's no way to construct a value of type Empty, because there are no such values, so we can't finish this definition. There are values of type Nat, so we can call this function, but it cannot finish because there's no way to write a return result term of type Empty.
If you try to call it using #reduce, it'll tell you that the function is defined using "sorry", which is to say that the definition is incomplete. (Yes, the error message is confusing. Sorry about that.)
nat2emptynat2empty: Nat → Empty5 -- sorry (doesn't properly reduce)5: Nat
Now let's write a function that takes an argument of type Empty and returns a result of some other type: we might as well just use Nat as an example.
def empty2nat: Empty → Nat
empty2nat : Empty: Type
Empty → Nat: Type
Nat
| e: Empty
e => nomatch e: Empty
e
There's something very odd about this function. It type basically says, "if you give me (e : Empty) I can give you a Nat." Suppose, then you do give such an e. The implementation has to give an result (of type Nat) for each possible case for e. How many cases are there? Zero! So you don't have to give an answer at all! That's the meaning of nomatch e. You don't have to specify an actual natural number result for even one case. The implementation is of the specified type nonetheless. Weird but true and it really makes sense if you think hard about it.
-- You can never call it, so it doesn't matter! defx := (x: Natempty2natempty2nat: Empty → Nat) -- can't give a value
As another example, we can even define a function defined to return a value of type Empty provided it gets on as an argment.
defempty2empty :empty2empty: Empty → EmptyEmpty →Empty: TypeEmpty |Empty: Typee => nomatche: Emptye defe: Emptyx' := (x': Emptyempty2emptyempty2empty: Empty → Empty) -- we can never call it
Indeed, there's nothing special about Nat or Empty as return types in these examples. We can write a function defined to return a value of any type, given a value of the Empty type as an argument. Again, the reason is that such a function to to return a value for each possible constructor/form of e, but there are no constructors/forms, so there are no cases to consider. We can thus define a generalize polymorphic function defined to return a value of any arbitrary type, α, if it's given an argument of the Empty type.
def empty2anytype: {α : Type} → Empty → α
empty2anytype : {α: Type
α : Type: Type 1
Type} → Empty: Type
Empty → α: Type
α
| _, e: Empty
e => nomatch e: Empty
e
end cs2120
Summary So Far
It's worth taking stock of the key ideas you've now learned in this class. We started with the notions of elementary types, such as Bool, Nat, and String, and of values of such types. Now we've seen that if we're given any two types, α and β, we can always form new types, in several ways. In particular, we can form function types, α → β; product types, α × β; and sum types, α ⊕ β.
Function types
Given any two type, α and β, we can form the function type, α → β. The → operator can be understood as taking two types and returning a new type, α → β. Here's a function showing the idea: it takes types α and β and returns a new type, namely the function type, α → β.
-- This is a function that returns a *type* deffunction_type (function_type: Type → Type → Typeαα: Typeβ :β: TypeType) :Type: Type 1Type :=Type: Type 1α →α: Typeββ: Type(function_typefunction_type: Type → Type → TypeNatNat: TypeBool)Bool: Type(function_typefunction_type: Type → Type → TypeNatNat: TypeBool)Bool: Type
A value of a function type is a function implementation that defines a procedure that, if it's given (applied to) a value of type α, then it constructs and returns a value of type β.
def negate: Bool → Bool
negate : Bool: Type
Bool → Bool: Type
Bool
| false: Bool
false => true: Bool
true
| true: Bool
true => false: Bool
false
Here's the same function with a little bit of new syntax. The syntax above is shorthand for this notation. The new element here is a match statement.
-- Learn this new syntax please (match expression) defnegate' :negate': Bool → BoolBool →Bool: TypeBool := -- type funBool: Typex :x: BoolBool => -- assume given Bool x matchBool: Typex with -- case analysis on x |x: Booltrue =>true: Boolfalse -- result in case true |false: Boolfalse =>false: Booltrue -- result in case false -- A *fun* term expresses a function *implementation*true: Bool(funx :x: BoolBool => matchBool: Typex with |x: Booltrue =>true: Boolfalse |false: Boolfalse =>false: Booltrue) -- Sometimes *fun* is written as Greek lambda *λ*true: Bool(λx :x: BoolBool => matchBool: Typex with |x: Booltrue =>true: Boolfalse |false: Boolfalse =>false: Booltrue)true: Bool
So does any of this matter to you if you're a data scientist or ML engineering programming everything in Python? Let's take a little diversion over into Python to see. Can you express anonymous function values values in Python, too? Open lecture_07.py.
Ok, so now we're back in Lean, in which every function is strongly and statically typed. Given any two types, α and β, we can construct the type, α → β; and then to construct a value of type α → β, one must produce a procedure that, if it's given any value of type α, then returns some value of type β.
This is exactly the meaning of a function type, α → β. Note that it's a conditional. It starts with a hypothesis: an assumption. A value of a function type assumes it's given a value of the specified type, and then having made that assumption, it needs to construct and return a value of the specified type. It's for exactly this reason that we can even define a function that takes an argument of a type that has no arguments, and that returns a result of a type that has no values. To wit:
def empty2empty: Empty → Empty
empty2empty : Empty: Type
Empty → Empty: Type
Empty := λ e: Empty
e => e: Empty
e
This example shows that function types are similar to logical implication statements, of the form if a then b. A value of a function type (an implementation) proves the truth of the implication, if you can give me a value of the argument type, then I can return you a value of the result type, even if you'll never be able to provide an argument in the first place.
Exercise: Which rule (case) for determining the truth of an implication in propositional (Boolean) logic is most analogous to the function type, Empty → Empty? Is such a statement true? In a sense, the existence of a function implementation shows the "truth" of such an expression! If you can define an a implementation of this type, that would prove that Empty → Empty.
Exercise: Give a function type involving the Empty type that can't be proved. What is the corresponding rule for evaluating implications in Boolean/propositional logic?
Product types
Given any types, α and β, we can form the product type, Prod α β, written as α × β in conventional mathematical notation.
{αα: Typeβ :β: TypeType} → (Type: Type 1a :a: αα) → (α: Typeb :b: ββ) →β: Typeα ×α: Typeββ: Type
Given a value a : α, and a value, b : β, we can form a value, (a, b) of type α × β, shorthand for Prod.mk a b. This constructor application term, as is, represents the ordered pair, (a, b). It's best to use this conventional mathematical notation.
("Hello","Hello": String5) -- value of type String × Nat5: Nat
To use a value of this type you apply one of the two elimination functions. One "projects" the first element of a pair, and one the second element. These functions are thus also called projection functions in ordinary mathematical discourse.
{αα: Typeβ :β: TypeType} →Type: Type 1α ×α: Typeβ →β: Typeα -- α × β is Prod α βα: Type{αα: Typeβ :β: TypeType} →Type: Type 1α ×α: Typeβ →β: Typeβ -- Sum construction and eliminationβ: Type{αα: Typeβ :β: TypeType} →Type: Type 1α →α: Typeα ⊕α: Typeβ -- α ⊕ β is Sum α ββ: Type{αα: Typeβ :β: TypeType} →Type: Type 1β →β: Typeα ⊕α: Typeββ: Type{αα: Typeββ: Typeγ :γ: TypeType} →Type: Type 1α ⊕α: Typeβ → (β: Typeα →α: Typeγ) → (γ: Typeβ →β: Typeγ) →γ: Typeγ -- Unit constructionγ: TypeUnit.unit -- There is no useful way to use a value of this type -- There is no constructor for Empty -- Empty eliminationUnit.unit: Unit{α :α: TypeType} →Type: Type 1Empty →Empty: Typeα -- Function compositionα: Type{αα: Typeββ: Typeγ :γ: TypeType} → (Type: Type 1β →β: Typeγ) → (γ: Typeα →α: Typeβ) → (β: Typeα →α: Typeγ)γ: Type