But I also believe there’s satisfaction and benefit to be had in drawing connexions between related ideas and in learning the terminology used by other people.
The central idea of the essay is this:
In some sense, this essay simply shows a few program fragments being translated into different systems of notation, but the hope is actually to show how two ideas (existential types and Rust traits) are similar and how they are different. Please note that this essay is a
We’ll present each program fragment in four systems.
Systems 1, 2, and 3 use
λ2
or
λω
languages endowed with existential types, but system 4 has a separate metalanguage and language.
Each program fragment is presented in a box with four tabs, like so:
pair: Type = N × N neighbors: N → Pair = λ n . ⟨ n + 7 mod 8 , n + 1 mod 8 ⟩
Pair :Type = ( u64 ,u64 ) neighbors :u64 -> Pair = lambda ( n ) { ( n + 7 mod 8 ,n + 1 mod 8 ) }
type Pair = ( u64 ,u64 ) ;fn neighbors ( n :u64 ) -> Pair { return ( ( n + 7 ) %8 ,( n + 1 ) %8 ) ;}
let arg_types = vec! [ RustType :: U64 ] ;let ret_type = RustType :: Tuple ( vec! [ RustType :: U64 ,RustType :: U64 ] ) ;fn body ( arguments :& [ RustObject ] ) -> RustObject { let [ RustObject :: Integer ( n ) ] = arguments else { panic! ( ) ;} ;let prev = RustObject :: Integer ( ( n + 7 ) %8 ) ;let next = RustObject :: Integer ( ( n + 1 ) %8 ) ;return RustObject :: Tuple ( vec! [ prev ,next ] ) ;} context .insert ( "Pair" ,RustItem :: Type { typ :ret_type .clone ( ) } ) ;context .insert ( "neighbors" ,RustItem :: Value { typ :RustType :: Function ( arg_types ,Box :: new ( ret_type ) ) ,value :RustObject :: Function ( body ) } ) ;
Since the sort of notation used in type theory or programming language theory may be unfamilar, and because we’ll depart from convention in a few places, we’ll go over the notation of system 1 before getting to existential types and traits.
If you’re familiar with the notation, however, you can jump ahead to those sections.
The notation
We’ll generally use the name
Type
rather than
𝒰
The way we often talk about types can encourage us to think of them as sets, but types are not collections. We can indeed consider the collection of all objects of a type
There are a few short posts on the subject that might be of interest:
A product type is a pair of types, and an instance of a product type is a pair of values.
⟨x , y ⟩ : X × Y x : X y : Y
For convenience, we’ll actually extend this; instead of defining × to be a binary operator so that
X × Y × Z= (X × Y) × Z ⟨⟨ x , y ⟩ , z ⟩ : X × Y × Z
or
X × Y × Z= X × (Y × Z) ⟨x , ⟨ y , z ⟩ ⟩ : X × Y × Z
depending on associativity, we’ll define it to be a variadic operator, so that a product type is a tuple of types.
⟨x , y , z ⟩ : X × Y × Z
The elements of product types are usually accessed using projection functions that are defined alongside product types
t: X × Y = ⟨ x , y ⟩ π1(t) : X = x π2(t) : Y = y
or by matching
assert(let ⟨ a , b ⟩ = t in a) = x
but we’ll also use dots for element access.
t= ⟨ x , y ⟩ t . 1 = x t . 2 = y
A record type is a product type with unordered, named fields.
⟨a: x , b: y , c: z ⟩ : a X × b Y × c Z
Here, a, b, and c are metavariables standing for distinct labels.
Like product types, we’ll use matching or dots for fields access.
r: a X × · · · = ⟨ a: x , . . . ⟩ r. a : X = x
Note that this use of
Record types with different field names are distinct, even if the constituent types are the same.
aX × c Y ≠ a X × d Y
These ought to be familiar.
λ x. e : S → T x : S e : T (λ x. e) (y) = e [ y / x ]
where
e
Sometimes we might label the left-hand type with the name of the parameter.
λ x. e : x S → T
Note that functions are values, as they are instances of function types.
The unit type is a type with a single inhabitant.
unit: Unit
In Rust, both the type and the value are spelled “()”, which is a
0-tuple
(an empty product). In type theory, the type is sometimes named
The unit type is useful when a placeholder is required by the type system; for example, the return type of functions that don’t return anything explicitly is the unit type. It’s also useful in sum types, which we’ll examine next.
Sum types are also known as “disjoint unions”, “discriminated unions”, or “tagged unions”.
ι1(x) : X + Y x : X ι 2(y) : X + Y y : Y
The injections “
Values of a sum type can only be used by matching:
matchu with ι 1(v1) in e1 or ι 2(v2) in e2 : T u : X + Y (v1 : X e1 : T) (v2 : Y e2 : T) (matchι 1(x) with ι 1(v) in e1 or ι 2(v) in e2) = e1 [ x / v ] (matchι 2(y) with ι 1(v) in e1 or ι 2(v) in e2) = e2 [ y / v ]
Like product types, we’ll make sum types variadic.
X + Y + Z≠ X + (Y + Z) ≠ (X + Y) + Z
ι1(x) : X + Y + Z x : X ι 2(y) : X + Y + Z y : Y ι 3(z) : X + Y + Z z : Z
After we described product types, we described record types, which are product types distinguished by the labels of their fields even when they have the same component types. We’ll now do the same with sum types, and in fact,
these
are the types that feature in programming languages.
Essentially, we’re replacing the injections
“
extension(X + Y) = { p | p : X + Y } ≅ { (0 , x) | x : X } ∪ { (1 , y) | y : Y }
extension(a X + b Y) ≅ { (a , x) | x : X } ∪ { (b , y) | y : Y }
a(x) : a X + b Y x : X b(y) : a X + b Y y : Y
If we need to declare the constructors a and b, we might simply write
aX + b Y : Type
but if we need to indicate syntactically that
⁅a: x ⁆ : a X + b Y ⁅b: y ⁆ : a X + b Y
mirroring the syntax for record types.
⟨a: x , b: y ⟩ : a X × b Y
In Rust, sum types are called “enumerated types” (and declared using “enum”), but more commonly, “enumerated type” refers to a sum type where the type of every variant is
Unit.
Digression: many languages use a syntax like
S: Type = C0 | C1
Here the type of each variant is
Unit,
and this is fine.
But when the types of the variants are not
Unit,
many languages use
S: Type = C1 X | C2 Y
and this is horrendous and terribly confusing because it then appears that C1 should be applied to a type, when in fact it should be applied to a value.
Slightly better is something like
S: Type = C1 (_ : X) | C2 (_ : Y)
Now Consider
S: Type = C1 (_ : X) | C2 (_ : Y) | C3 (_ : Z) S : Type = X + Y + Z
which implies, if
C1
(C1 (_: X) | C2 (_ : Y) ) | C3 (_ : Z) = (X + Y) + Z
Then the
aX : Type X : Type aX ≠ X aX ≠ b X a ≠ b (a:x) : a X x : X
Here is Rocq:
Inductive S : Type := | inl : A -> S | inr : B -> S.
Here is Lean:
inductive S : Type where | inl : A -> S | inr : B -> S
(Here
A
and
B
would be concrete types previously defined.)
Also notice that the syntax used by Rocq and Lean is up front about constructors behaving like functions.
Suppose we’re using this sort of syntax.
S= C1 (_ : X) | C2 (_ : Y) s = C1(x)
Then of course
s: S
But is it also the case that...?
s: X + Y
data Pair = Pair Int Int
so that it’s impossible to tell whether
Pair
refers to the type or to the constructor, and the fact that people actually do this is mildly infuriating.
In Rust parlance, these are generic functions.
Λ α. e : Π α . T α : Type T : Type e : T (Λ α. e) [ X ] = e [ X / α ] X : Type
Note that an instance of a universal type yields a value, not a type, when applied to an argument!
Also note that α is a metavariable standing in for a type variable rather than standing for a type.
Since universal types are more unusual than everything we’ve seen so far, an example may be expedient.
There are a few different ways to write universal types.
∀ α. T αType ⇒ T Π(α : 𝒰) T Π α. T
For example, the type of
Λ A. λ x . ⟨ x , x ⟩
might written any of the following ways:
∀ A. A → A × A AType ⇒ (A → A × A) ΠA : 𝒰 A → A × A Π A. A → A × A
A language with universal types is called a λ2 language. The language that has only function types and universal types is called System F.
System F features impredicative quantification, meaning that terms may be applied to types that themselves contain quantifiers. An instance of a type can be applied to that same type.
Id= Π α . α → α id: Id = Λ α . λ (x : α) . x id[ Id ] : (Π α . α → α) → (Π α . α → α) = λ (x : Π α . α → α) . x assertid [ Id ] (id) = id
Notably, despite the fact that recursion is not possible (System F is strongly normalizing), type inference for System F is undecidable (—
Impredicative quantification is rare in programming languages, presumably for that reason and because impredicativity makes monomorphization difficult.
As one might expect,
predicative
quantification means that terms may only be applied to types that do
numbers: N × N × N = ⟨ 5 , 17 , 257 ⟩ colors : C × C × C = ⟨ Amaranth , Violet , Periwinkle ⟩ select-pair: (Π α . α × α × α → α) → ⟨ N , C ⟩ = λ p . ⟨ p [ N ] (numbers) , p [ C ] (colors) ⟩ fst= Λ α . λ t . t .1 snd = Λ α . λ t . t .2 thd = Λ α . λ t . t .3 assertselect-pair (fst) = ⟨5, Amaranth⟩ assert select-pair (snd) = ⟨17, Violet⟩ assert select-pair (thd) = ⟨257, Periwinkle⟩
Here, static analysis of the body of
select-pair
alone allows us to rewrite its signature as
select-pair: p1 (N × N × N → N) × p2 (C × C × C → C) → ⟨ N , C ⟩ = λ p1 , p2 . ⟨ p1 (numbers) , p2 (colors) ⟩
and now we can compile the function in isolation.
Later, we can mechanically fix up each call site accordingly.
assertselect-pair (fst[ N ] , fst[ C ] ) = ⟨5, Amaranth⟩ assert select-pair (snd[ N ] , snd[ C ] ) = ⟨17, Violet⟩ assert select-pair (thd[ N ] , thd[ C ] ) = ⟨257, Periwinkle⟩
We might, however, restrict ourselves further to
prenex polymorphism, where quantifiers are required to appear at the beginning (at the outermost level) of a type.
Type: Kind Type ⇒ Type: Kind Λ α. T : α Type ⇒ Type α : Type T : Type
or alternatively
Π α. Type : Kind Λ α. T : Π α . Type α : Type T : Type
and then
(Λ α. T) [ X ] = T [ X / α ] X : Type
Note that, in constrast to an instance of a universal type, an instance of
A language with type operators is called a λω language. The langauge that has only function types and type operators is called System Fω.
A language with both universal types and type operators is called a λω language. The language that has only function types, universal types, and type operators is called System Fω.
Lean and Rocq are a nice demonstration. Here is Lean:
inductive S (α : Type u) (β : Type v) : Type (max u v) where | inl : α -> S α β | inr : β -> S α β
Here is Rocq:
Inductive s (A : Type) (B : Type) : Type := | inl : A -> s A B | inr : B -> s A B.
If
s: Π α , β . Type
and we might also write the following type annotations.
inl: Π α , β . α → s [ α , β ] inr : Π α , β . β → s [ α , β ]
Here s is a type operator and inl and inr are universally quantified.
Ordinary function types, universal types, and type operators can be seen as specializations of a more general function type.
Suppose that
| X | → | Y | function types |
| Type | → | Y | universal types |
| Type | → | Type | type operators |
| X | → | Type | dependent types |
In a type system with all four, the type hierarchy collapses, erasing the distinction between types and terms that are not types (which we’ve been calling values). The language that allows all four of these is called the calculus of constructions.
Below is a summary of the typographic conventions we’ve been using.
| Examples | Class of Entity |
|---|---|
| x |
metavariable for a value parameter or value variable |
| e |
metavariable for a value constant or value expression |
| α |
metavariable for a type parameter or type variable |
| S |
metavariable for a type constant or type expression |
| x |
value parameter |
| x |
value constant or value variable that is not a parameter |
| α |
type parameter |
| Ss |
type constant or type variable that is not a parameter |
| a |
metavariable for a label |
| a |
metavariable for a label |
| as |
label |
| As |
label |
| a |
metavariable for a value constructor |
| a |
metavariable for a type constructor |
| Cs |
value constructor |
| Fs |
type constructor |
Now for our first example.
IterTrait: Type = Π Output . Σ Self . state Self × next (& Self → Option Output) ListIterator: Type = Π Item . list & List Item × index Integer listNext: Π Item . & ListIterator Item → Option Item = Λ Item . λ state . if state. index < state. list . length then procedure let x = state. list [ state. index ] increment & state. index yield Some (x) else None listToIterator: Π Item . & List Item → IterTrait Item = Λ Item . λ xs . ⟨ Self: ListIterator , state: ⟨ list: xs , index: 0 ⟩ , next: listNext Item ⟩
IterTrait :Type = Π Output .Σ Self .Self × ( & mut Self -> Option Output ) ListIterator :Type = Π α .& List α × Usize listNext :Π α .& mut ListIterator α -> Option α = Λ α .λ s .if s .index < s .list .length // “s” for iterator state then proc | let x = s .list [ s .index ] | s .index += 1 | yield Some ( x ) else None listToIterator :Π α .& List α → IterTrait α = Λ α .λ xs .⟨ state :⟨ list :xs ,index :0 ⟩ ,next :listNext α ⟩
With the iterator object captured in a closure, the existential type is no longer required.
Iterator: Type = Π Output . Unit → Option Output listToIterator: Π Item . & List Item → Iterator Item = Λ Item . λ xs . let index = Box 0 in λ _ . if ⭑ index < xs. length then procedure let x = xs [ ⭑ index ] increment index yield Some (x) else None
Iterator :Type = Π Output .Unit -> Option Output listToIterator :Π α .& List α → Iterator α = Λ α .λ ( xs :List α ) .let mut index = Box 0 in λ .if index < xs .length then proc | let x = xs [ index ] | * index += 1 | yield Some ( x ) else None