Lean for Scala programmers - Part 3

April 16, 2021

In the previous installment we discussed inductive types and patern matching.

Today we’re going to talk about Dependent Types and Type Classes.

If you want to follow along you can use online editors:

Dependent functions

A function $A \to B$ between two sets $A, B$ is defined in mathematics as a collection of pairs $f := \{(a, b)\} \subset A \times B$ with the property that elements $a$ of $A$ can appear at most once.

A dependently typed function is a generalization of this concept:

Each element $f(a)$ is allowed to be a member of a different set that depends on $a$.

This construction gives rise to a set-level function $B$ that assigns a set $B(a)$ to each element $a \in A$.

Observe that if this function $B$ is constant (i.e. it maps all elements $a$ to a single set) then we are back to the regular definition of non-dependent functions.

Example: Vector fields

At every point $p$ of a circle $S^1$, consider a vector $V(p)$ on the tangent space $T_{p}S^{1}$ at this point:

Since each $V(p)$ lives in a different tangent space $T_{p}S^{1}$ (that depends on $p$), it makes sense to regard $V$ as a dependent function.

Example: operations on tuples

A very common example of dependent types in Scala 3 is obtaining components of tuples at specific positions:

val t: (Int, String, Boolean) = (0, "", false)

assert( t.apply(0) == 0 )
assert( t.apply(1) == "" )
assert( t.apply(2) == false )

We can see how apply produces a value of a type that depends on the input index:

In fact most generic tuple operations use dependent types.

Dependent functions in Lean

In Lean the type of a regular function $f$ from $A$ to $B$ is written as

f: A -> B

here the codomain B is the same for every element a of the domain A.

In a dependent function we need a way to indicate exactly how the codomain depends on each element of the domain.

The signature of a dependent function f from A to B(a) is written in Lean like this:

which can be represented as a string diagram like so:

(Π can be entered as \Pi)

Dependent function types are also called Pi types

Detailed example: variable number of arguments

Consider a function adder that takes:

1. A number $n$ representing extra arguments
2. An initial value $acc$
3. $n$ arguments

It returns the sum of the initial value and extra arguments.

For example

example: adder 0 5        = (5 : ℕ) := rfl
example: adder 1 10 11    = (21: ℕ) := rfl
example: adder 2 18 17 12 = (47: ℕ) := rfl

i.e. the first argument is the number of extra arguments after the 2nd argument.

(This example is described in Edwin Brady’s Type-Driven Development with Idris)

We will proceed in two steps:

1. Define a type level function that calculates the return type: a function with $n$ parameters:
open nat

| 0        := ℕ
| (succ k) := ℕ -> (Adder k)

example: Adder 0 =  ℕ            := rfl
example: Adder 1 = (ℕ -> ℕ)      := rfl
example: Adder 2 = (ℕ -> ℕ -> ℕ) := rfl
1. Define a regular function, whose signature is computed by Adder based on the 1st argument:
--                  acc
--                   |
| 0        acc := acc
| (succ k) acc := λ n, adder k (n + acc)
--     |
--     n

-- adder 0 acc =                  acc
-- adder 1 acc = λ n,         n + acc
-- adder 2 acc = λ n n1, n1 + n + acc

-- with types:

-- adder 0 : ℕ -> ℕ
-- adder 1 : ℕ -> ℕ -> ℕ
-- adder 2 : ℕ -> ℕ -> ℕ -> ℕ

example: (adder 0) 5        = (5 : ℕ) := rfl
example: (adder 1) 10 11    = (21: ℕ) := rfl
example: (adder 2) 18 17 12 = (47: ℕ) := rfl

So adder n produces a function whose type depends on n:

Dependent Functions in Scala 3

Match types

Scala 3 supports a similar form of dependent typing using match types.

We follow the same steps as in Lean:

1. Define a type level function that calculates the return type:
import compiletime.ops.int.S

type Adder[N <: Int] = N match
case 0    =>  Int
case S[k] => (Int => Adder[k & Singleton])

summon[ Adder[1] =:= (Int => Int)       ]
summon[ Adder[2] =:= (Int => Int => Int)]
1. Define a function, whose signature is computed by Adder based on the 1st argument:
inline def adder[N <: Int & Singleton](n: N)(acc: Int): Adder[N] =
inline n match
case _: 0    => acc
case _: S[k] => n => adder(constValue[k & Singleton])(n + acc)

assert( adder(2)(18)(17)(12) == 47 )

Here we can see how the fact that types are not first class in Scala introduces some complexities:

• Singleton is needed so that adder(0) is inferred as adder[0](0) and not adder[Int](0).
• After pattern matching on the type _: S[k] we obtain the type k of the predecessor n - 1.
• In order to continue the process we need to obtain the value associated with the singleton type k. For this we use constValue.
• But constValue only works for values known at compile time, so we have to use inline match and also inline the whole function.

Path-dependent types + implicit resolution

Another option is to use a type class to bundle the output type and the adder function, and construct both type and function in lockstep:

trait Bundle[N <: Int]:
def adder: Int => Adder

Now the index is kept purely on the type variable N so adder only needs to handle the accumulated value acc.

Due to a idiosyncrasy of Scala we need to create a helper type function BundleWithAdder to manually propagate type information (this is the Aux pattern common in shapeless).

type BundleWithAdder[N <: Int, A] =
Bundle[N] { type Adder = A }

// base case: Bundle[0]

new Bundle[0]:

// inductive step: Bundle[K] implies Bundle[S[K]]

given [K <: Int](using predecessor: Bundle[K]):
new Bundle[S[K]]:
def adder = acc => n => predecessor.adder(n + acc)

Sanity check:

val bundle0 = summon[Bundle[0]]
val bundle1 = summon[Bundle[1]]
val bundle2 = summon[Bundle[2]]

summon[ bundle1.Adder =:= (Int => Int)        ]
summon[ bundle2.Adder =:= (Int => Int => Int) ]

Taking the singleton type n.type of the term n will kickstart the process:

def adder(n: Int)(acc: Int)(using bundle: Bundle[n.type]) =

assert( adder(2)(18)(17)(12) == 47 )

Type Classes in Lean

Lean has support for type classes by using the keywords class / instance.

Let’s implement the adder function using implicit type class resolution this time:

class Bundle (n: ℕ) :=
(adder: ℕ -> Adder)

This declaration defines a few things:

#print Bundle

-- @[class]
-- structure Bundle : ℕ → Type 1
-- fields:
-- Bundle.Adder : Π (n : ℕ) [c : Bundle n], Type
-- Bundle.adder : Π {n : ℕ} [c : Bundle n], ℕ → Bundle.Adder n

So a class is just a structure with an annotation @[class].

Note how every member field acquired two extra parameters:

• The class argument (n: ℕ)
• An implicit instance [c : Bundle n] (indicated by the square brackets)

Something a bit surprising is that there is no way to pass a single implicit argument explicitly. This makes using functions defined as type class members awkward in some cases.

One way to overcome this is to transform the function into a variant where every argument is explicit, by prefixing the name with an @:

#check @Bundle.adder

-- Bundle.adder : Π {n : ℕ} [c : Bundle n], ℕ → Bundle.Adder n

with this in mind let’s define the adder function:

def adder (n acc: ℕ) [c: Bundle n]: Bundle.Adder n :=
@Bundle.adder n c acc

The only thing we did was to rearrange the parameters of Bundler.adder: both n and acc are now explicit, and the implicit Bundle n was moved to the end of the list.

(This makes adder much easier to call than Bundle.adder as can be seen below)

We need two Bundle instances again, corresponding to the base case and the inductive step:

instance bundleBase: Bundle 0 := {
}

instance bundleStep (k: ℕ) [predecessor: Bundle k] : Bundle (succ k) := {
}

Notes:

• id is the identity function
• adder k (n + acc) is using the implicit instance in scope (predecessor), so we don’t need to pass it manually.

Sanity check:

example: (adder 0) 5        = (5 : ℕ) := rfl
example: (adder 1) 10 11    = (21: ℕ) := rfl
example: (adder 2) 18 17 12 = (47: ℕ) := rfl

In summary, we need to handle 4 cases:

n Type (Adder) Term (adder)
0 ℕ identity function
k + 1 ℕ -> predecessor.Adder λ acc n, adder k (n + acc)

(The two rows come from the fact that natural numbers are an inductive type with two cases: zero and succ)

There’s a certain symmetry between the two approaches we’ve described:

• Regular dependent functions allows us to define the Type column at once and the Term column at once.
• Chained type classes allows us to define the n = 0 row at once and the n = k + 1 row at once.

Propositions

Note: Lean defines two synonyms for Π: forall and ∀. By convention the forall versions are used when the dependent function produces propositions.

Properties of natural numbers

Consider the statement:

For all natural numbers, $n + 0 = n$

The expression $n + 0 = n$ is a proposition that depends on $n$, and so the statement can be modeled as a dependent function $f$ from natural numbers to a family of propositions:

lemma f (n : ℕ) : n + 0 = n :=
rfl

Algebraic structures

Here’s how one could define a monoid in Lean

class Monoid (M: Type*) :=
(mul : M -> M -> M)
(one : M)
(mul_assoc : ∀ (a b c : M), mul (mul a b) c = mul a (mul b c))
(one_mul   : ∀ (a : M), mul one a = a)
(mul_one   : ∀ (a : M), mul a one = a)

In this kind of situations we rely heavily on dependent types to encode all the laws associated with the structure at hand. In this example each law is a dependent function from M to propositions.

Here’s a monoid instance for addition of natural numbers:

instance monoidNat: Monoid ℕ := {
mul := (+),
one := 0,
}

Luckily in this case the basic properties have already been proved in the std library, so we can just delegate to them.

A note on syntax

The reason for the name “Pi types” (and the symbol Π) is the following :

In math, it is common to use the notation $B^A$ to indicate the set of all functions from $A$ to $B$.

For simplicity let’s assume that $A$ is finite (and has cardinality $|A| = n$)

Then we can define:

$B^A := \Pi_{a: A}\ B = \underbrace{B \times B \times \cdots \times B}_{|A|\ \mathrm{ times}}$

and each element $f \in \Pi_{a: A}\ B$ has the form $(f_{a_1}, f_{a_2}, \dots, f_{a_n})$.

In other words, for each $a_i \in A$ we can find an element $f_{a_i}\in B$ at the position $i$.

Given such a tuple we can create a function $f: A \to B$ by the rule

$f(a_i) := f_{a_i}$

conversely, given a function $f: A \to B$ we can create the tuple $(f(a_1), f(a_2), \dots) \in \Pi_{a: A}\ B$

In a dependent function we let $B$ depend on $a \in A$ like so:

$\Pi_{a: A}\ B_a = B_{a_1} \times B_{a_2} \times \cdots \times B_{a_n}$

This is the motivation for the syntax:

Π (a: A), B a

When B does not depend on a: A this becomes

Π (_: A), B

and we can use the syntactic sugar

A -> B

Dependent products

There is another important example of dependent types called Sigma types or dependent product.

Consider the regular cartesian product of two sets $A$, $B$:

This represents all possible combinations of elements from both sets:

$(a, x), (a, y), (a, w), (b, x), (b, y), (b, w)$

The dependent product type $\Sigma (x: A), B(x)$ denotes the type of pairs $(a, b)$ where $a \in A$ and $b \in B(a)$

The diagram depicts a situation where $x$ and $y$ are elements of $B(a)$ and $w \in B(b)$. In this case the inhabitants of the dependent product $\Sigma (x: A), B(x)$ are the pairs:

$(a, x), (a, y), (b, w)$

Dependent products in Lean

In Lean dependent products are defined as the inductive type:

universes u v

structure sigma {A: Type u} (B: A -> Type v) :=
(fst: A) (snd: B fst)

The symbol Σ can be used as well (typed as \Si):

Σ (x : A), B x

Dependent products can be constructed like so:

sigma.mk a b

Dependent products in Scala

Dependent products can be encoded directly with path-dependent types.

trait Fn0:
type B

class Sigma[A <: Fn0](fst: A, snd: fst.B)

In general, every function that uses path-dependent types in its parameters list is an example of Sigma types in action:

def f(a: A, b: a.B) = ...

Next time we’ll talk about proofs and tactics. Stayed tunned!