Lean for Scala programmers - Part 2

March 14, 2021

The previous installment we gave an initial look at some basic Lean features:

  • Propositions
  • Proofs
  • Tactics

Today we’re going to focus on two topics:

  • creating data structures via inductive types
  • using pattern matching to transform or consume inductive types.

Latest revision: Sep 4, 2022

Inductive types

Inductive types are the type theory version of what in functional programming is known as Algebraic Data Types.

1. Enumerations

The simplest kind: not parameterized, nor recursive.

Lean 4

Scala 3

inductive Weekday
  | sunday
  | monday
  | tuesday
  | wednesday
  | thursday
  | friday
  | saturday  
enum Weekday:
  case Sunday 
  case Monday 
  case Tuesday 
  case Wednesday 
  case Thursday 
  case Friday 
  case Saturday 

This definition creates 1) a type 2) a namespace 3) exactly 7 ways to construct values of type Weekday.

Elements can be accessed like so

Weekday.sunday

alternatively the namespace can be opened:

Lean 4

Scala 3

open Weekday 
import Weekday.*

An important difference is that in Scala each constructor has its own type, whereas in Lean constructors are just functions (without arguments in this case).

This has a consequence: if we want to define operations that only work on some constructors we need to come up with a way to distinguish them in the type system, perhaps using another type as index .

1.1 Pattern matching

There are several ways to pattern match on inductive types.

A common approach is to use a function defined by cases:

Lean 4

Scala 3

 def next: Weekday -> Weekday
  | sunday    => monday 
  | monday    => tuesday
  | tuesday   => wednesday
  | wednesday => thursday
  | thursday  => friday
  | friday    => saturday
  | saturday  => sunday
val next: Weekday => Weekday =
  case Sunday    => Monday
  case Monday    => Tuesday
  case Tuesday   => Wednesday
  case Wednesday => Thursday
  case Thursday  => Friday
  case Friday    => Saturday
  case Saturday  => Sunday

which is syntactic sugar for match expressions:

Lean 4

Scala 3

def next' (d: Weekday) : Weekday := 
  match d with
  | sunday    => monday 
  | monday    => tuesday
  | tuesday   => wednesday
  | wednesday => thursday
  | thursday  => friday
  | friday    => saturday
  | saturday  => sunday
def next(d: Weekday): Weekday = 
  d match
    case Sunday    => Monday
    case Monday    => Tuesday
    case Tuesday   => Wednesday
    case Wednesday => Thursday
    case Thursday  => Friday
    case Friday    => Saturday
    case Saturday  => Sunday

Let’s prove that applying next 7 times is the identity function.

First we need a way to iterate a function a number of times:

def iter (f: A -> A): Nat -> (A -> A) 
  | 0     => id
  | n + 1 => f ∘ (iter f n)


example: iter next 0 = id                 := rfl
example: iter next 1 = next               := rfl
example: iter next 2 = next ∘ next        := rfl
example: iter next 3 = next ∘ next ∘ next := rfl

id is the identity function fun x => x. The composition operator can be entered as \circ.

Observe how we’re using n + 1 as a pattern, allowing us to extract n and pass it to the next iter call. This is similar to deconstructing a list as h :: t.

In both cases Lean can verify that the recursive invocation is given a smaller data structure, and that the recursion will eventually terminate.

Now we can state and prove the proposition:

for all weekdays, the function next has period 7

example:(w: Weekday), (iter next 7) w = w := by
  intro w
  cases w
  repeat { rfl }

1.2 Examples

Some important enumerations are defined in the std library:

-- a type without inhabitants

inductive Empty: Type

-- a proposition without inhabitants; that means that it's imposible to prove.
-- This is taken as the definition of False.

inductive False : Prop

-- a proposition with only one way to prove it: True.intro

inductive True : Prop where
  | intro : True
  

2. Structures

Structures in Lean are inductive definitions with only one case, and one or more fields.

Lean 4

Scala 3

structure Color where
  red  : Nat
  green: Nat
  blue : Nat
  
case class Color(
  red  : Int,
  green: Int,
  blue : Int
)

The keyword structure defines several things at once:

  1. A type (Color)
  2. A constructor (Color.mk)
  3. A getter function Color -> Nat for each field (Color.red, Color.green, Color.blue)

Lean provides a limited form of dot notation:

def yellow := Color.mk 255 255 0

example: yellow.red   = 255 := rfl
example: yellow.green = 255 := rfl
example: yellow.blue  = 0   := rfl

Structures can have parameters; for example here’s how the product of two types A and B is defined in the std library:

Lean 4

Scala 3

structure Prod (A B) where
  fst: A 
  snd: B
  
def t := Prod.mk 1 "one"
case class Prod[A, B](
  fst: A, 
  snd: B
)
val t = Prod(1, "one")

3. Algebraic Data Types

Here’s a simplified version of Option and Sum as defined in the std library. They are good examples of simple ADTs.

Lean 4

Scala 3

inductive Option (A)
  | none
  | some (a: A)
  
inductive Sum (A B)
  | inl (a: A)
  | inr (b: B)  
enum Option[+A]:
  case None
  case Some(a: A)

enum Sum[A, B]:
  case Inl(a: A)
  case Inr(b: B)

3.1 Recursive definitions

List is different from Option and Product in that a list can contain other lists of the same type.

3.1.1 Lists

Lean 4

Scala 3

inductive List (T)
  | nil
  | cons (head: T) (tail: List T)
    
infixr:67 " :: " => List.cons
notation "[" "]" => List
.nil

def lst := 1 :: 2 :: []  
enum List[+T]:
  case Nil
  case Cons(head: T, tail: List[T])

extension [T] (h: T) 
  def :: (t: List[T]) = List.Cons(h,t)


val lst = 1 :: 2 :: List.Nil

notation is not really an extension method, but rather something closer to a macro.

3.1.2 Natural numbers

The natural numbers (Nat) are defined inductively in Lean.

(number literals are provided via type classes, which we’ll explore in the future).

inductive Nat
  | zero
  | succ (n: nat)

Unfortunately Nat is not available in Scala’s std library but we could create one like so:

enum Nat:
  case Zero()
  case Succ[N <: Nat](n: N)

(This version carries both type and value-level information).

3.1.3 Addition

Addition of two natural numbers as just created can be defined via two transformation rules:

  1. add l zero => l
  2. add l (succ r) => succ (add l r)

Lean 4

def add (l: Nat): Nat -> Nat
  | zero   => l
  | succ r => succ (add l r)

Numbers are normally represented using literals like 0, 1, etc, so we can write:

def add (l: Nat): Nat -> Nat
  | 0     => l
  | r + 1 => (add l r) + 1
example: add 1 2 = 3 := rfl

-- by definition of add:

theorem add_zero (l: Nat) : add l 0 = l := by rfl

theorem add_succ (l r: Nat): add l (r + 1) = (add l r) + 1:= by rfl  

Proving add 0 + m = m on the other hand requires a proof by induction, which we’ll discuss in the future.

4. Generalized Algebraic Data Types

GADTs are called inductive families in Lean.

Here are two examples:

4.1 A simple language

A tiny typed language of arithmetic expressions, that delegates its typing to the host language.

Lean 4

inductive Expr : Type -> Type
  | num (n: Nat)          : Expr Nat
  | bool (b: Bool)        : Expr Bool
  | add (e1 e2: Expr Nat) : Expr Nat
  | mul (e1 e2: Expr Nat) : Expr Nat

Scala 3

enum Expr[A]:
  case Num(n: Int) extends Expr[Int]
  case Bool(b: Boolean) extends Expr[Boolean]
  case Add(e1: Expr[Int], e2: Expr[Int]) extends Expr[Int]
  case Mul(e1: Expr[Int], e2: Expr[Int]) extends Expr[Int]

Here’s an interpreter for this language:

def Expr.eval: Expr A -> A
  | num n => n
  | bool b => b
  | add e1 e2 => e1.eval + e2.eval
  | mul e1 e2 => e1.eval * e2.eval

Scala 3

import Expr.*

def eval[A]: Expr[A] => A =
  case Num(n) => n
  case Bool(b) => b
  case Add(e1, e2) => eval(e1) + eval(e2)
  case Mul(e1, e2) => eval(e1) * eval(e2)

4.2 Vectors

Each Vec is parameterized by a type A and a natural number (a value).

Lean 4

inductive Vec (A: Type) : Nat -> Type
  | vnil : Vec A 0
  | cons {n: Nat} (a: A) (v: Vec A n) : Vec A (n + 1)

infixr:67 " :: " => Vec.cons
notation "[" "]" => Vec.nil

def Vec.head: Vec A (n + 1) -> A
  | h :: _ => h

def Vec.add: Vec Nat n -> Vec Nat n -> Vec Nat n 
  | [],      []        => vnil
  | h1 :: t1, h2 :: t2 => (h1 + h2) :: (add t1 t2)

head takes a vector of length n + 1. Lean can infer that there’s no need to handle the vnil case.

def v: Vec Nat 2 := 1 :: 1 :: []

example: Vec.head v = 1 := rfl
example: Vec.add v v = (2 :: 2 :: [] : Vec Nat _) := rfl

-- doesn't compile!
#check_failure Vec.head Vec.vnil

Scala 3

import compiletime.ops.int._
import compiletime.S

enum Vec[+A, n <: Int]:
  case VNil extends Vec[Nothing, 0]
  case Cons(a: A, v: Vec[A, n]) extends Vec[A, n + 1] 

import Vec.*

extension [A, n <: Int] (a: A) 
  def :: (v: Vec[A, n]): Vec[A, n + 1] = Cons(a, v)

def head[A, n <: Int]: Vec[A, S[n]] => A =
  case VNil                   => ??? // impossible
  case Cons(a, _): Cons[A, _] => a

def add[n <: Int]: (Vec[Int, n], Vec[Int, n]) => Vec[Int, n] =
  case (VNil, VNil)                 => VNil
  case (Cons(h1, t1), Cons(h2, t2)) => (h1 + h2) :: add(t1, t2)

Scala will correctly reject head(VNil), but will still complain if we don’t add the useless case VNil => ???.

val v = 1 :: 1 :: VNil

assert( head(v) == 1 )
assert( add(v, v) == 2 :: 2 :: VNil )

// Doesn't even compile:
// head(VNil)

In the next episode of this series we’ll discuss dependent types at length, getting us closer to our goal of being able to prove some properties of our code.

Resources


Profile picture

Personal blog of Juan Pablo Romero Méndez.