Simulating Subtyping and Object Oriented Polymorphism in Lean
September 13, 2022
A recent thread (public archive) in Lean's Zulip server with some insights by Mario Carneiro and other folks motivated this post.
The question posed by Chris Lovett was: how can we encode Object Oriented Polymorphism in Lean 4?
The Challenge
As a running example we'll use the following class hierarchy:
Scala | Java |
|
|
Before jumping in let's recall a few key features of Object Oriented Programming:
- Inheritance
- Subtyping
- Bundling data + functions (a.k.a. methods)
- Methods have access to private state, frequently mutably.
- Dynamic Polymorphism
(Flame) wars have been fought over the exact characterization, but this list seems good enough for our purposes.
Approach 1: Structure Inheritance
First we create a structure corresponding to our interface/trait
structure Shape where
perimeter: Float
area: FloatThis will introduce 5 things into the scope (plus some auxiliary definitions, like Shape.rec, that we won't need):
- A new type
Shape - A namespace
Shape - A constructor
Shape.mk : Float → Float → Shape Shape.perimeter : Shape → FloatShape.area : Shape → Float
Next we use inheritance to reuse the fields in Shape:
abbrev Radius := Float
abbrev Side := Float
def Float.pi : Float := 3.1415926535897932385
structure Circle extends Shape where
r: Radius
perimeter := 2 * Float.pi * r
area := Float.pi * r * r
structure Rectangle extends Shape where
(x y: Side)
perimeter := 2 * (x + y)
area := x * yWe're providing default implementations for perimeter and area, and notice how we can refer to r in the body of other fields, mimicking the implicit this reference common in OO languages.
At this point we can create some values like so:
def c: Circle := {r := 2.4}
def r: Rectangle := {x := 3.1, y := 4.4}Keep in mind that these defaults are values, not methods: Lean computes them once, when it creates the structure. If we update r later, the area stays the same:
#eval ({ c with r := 10 } : Circle).area
-- 18.095574If we wanted we could define constructors to simulate Circle.apply and Rectangle.apply in Scala:
def Circle.new (r: Radius): Circle :=
{r := r}
def Rectangle.new (x y: Side): Rectangle :=
{x := x, y := y}Subtyping
In Java and Scala a consequence of subtyping is that we get a coercion function from subtype to supertype for free (it is just the identity):
val circleToShape: Circle => Shape = <:<.refl
val rectangleToShape: Rectangle => Shape = <:<.refl
// manual coercion
val s1: Shape = circleToShape(Circle(2.4))
val s2: Shape = rectangleToShape(Rectangle(3.1, 4.4))
// automatic coercion
val s3: Shape = Circle(2.4)
val s4: Shape = Rectangle(3.1, 4.4)Automatic coercion enables the creation of a list of Shapes from a collection of objects of different types, for example:
val shapes: List[Shape] =
List(Circle(2.4), Rectangle(3.1, 4.4))
val areas = shapes.map(_.area)
// List(18.09557368467721, 13.640000000000002)In this example, we're providing the type List[Shape] explicitly, so each element is accepted as a Shape (no conversion happens at run time).
(If we had not provided the type, then type inference would have found Shape as a common supertype of Circle and Rectangle. The least upper bound is the union type Circle | Rectangle, but Scala 3 widens it to Shape)
Inheritance
In Lean, the keyword extends automatically generates a conversion function to the parent structure:
Circle.toShape: Circle -> Shape
Rectangle.toShape: Rectangle -> ShapeRecalling the definition of Circle:
structure Circle extends Shape where
r: Radius
perimeter := 2 * Float.pi * r
area := Float.pi * r * rwe're introducing the following elements in the scope:
Circle : TypeCircle.mk : Shape → Radius → CircleCircle.r : Circle → RadiusCircle.toShape : Circle → Shape- A namespace
Circle
Notice that only the new field r got its own function. The other fields are nested inside the field toShape
{ toShape := { perimeter := ..., area := ... }, r := ... }(Newer versions of Lean print this value flat, as { perimeter := ..., area := ..., r := ... }. Use set_option pp.structureInstances.flatten false to see the nesting.)
We can use this field to convert a Circle to a Shape. In fact Lean does it automatically when using dot notation:
def c: Circle := {r := 2.4}
def r: Rectangle := {x := 3.1, y := 4.4}
#eval c.area
-- 18.095574
#eval r.area
-- 13.640000What is really happening here is:
c.toShape.area
r.toShape.areaOn the other hand this doesn't work:
def c': Shape := {r := 2.4 : Circle}
-- Type mismatch
-- { perimeter := 2 * Float.pi * 2.4, area := Float.pi * 2.4 * 2.4, r := 2.4 }
-- has type
-- Circle
-- but is expected to have type
-- ShapeCoercions
At this point we can use the automatic coercion mechanism provided by Lean. It is based on this class (simplified; Lean already defines it, so we don't write it ourselves):
class Coe (A) (B) where
coe : A -> BAn instance of this class carries a function coe that converts values of type A into B. It is applied automatically whenever a type B is expected but A was provided:
instance: Coe Circle Shape := ⟨Circle.toShape⟩
instance: Coe Rectangle Shape := ⟨Rectangle.toShape⟩
Now we can use Circle and Rectangle values whenever a Shape is required (but a List Circle is still not a List Shape: there's no covariance).
def c': Shape := {r := 2.4 : Circle}
def r': Shape := {x := 3.1, y := 4.4 : Rectangle}
def shapes': List Shape := [c', r']
#eval shapes'.map (·.area)
-- [18.095574, 13.640000]A word of caution, though. In the thread, Mario Carneiro said: "I would generally want to stay away from implicit conversions (Coe)". The reason is that they can cause type inference problems when there is also other polymorphism.
Upper Bounds
Coercions can be used as a kind of upper bound:
Scala | Lean |
|
|
One difference: <: is reflexive and transitive, but a Coe instance is not. ShapeContainer Shape doesn't compile (there's no Coe Shape Shape), and a chain of coercions doesn't count either. The class CoeTC (the reflexive, transitive closure of Coe) is closer to <:.
Approach 2: Existential types
(This is a fresh start: use a new file or a new namespace. We still need Radius, Side and Float.pi from above.)
The idea is to separate data from functionality: data will go into individual structures Circle, Rectangle, etc., and functionality will go into a new type class:
class ShapeOps (A) where
perimeter : A -> Float
area: A -> FloatNow the data structures
structure Circle where
r: Radius
structure Rectangle where
(x y: Side)With instances:
instance : ShapeOps Circle where
perimeter | {r} => 2 * Float.pi * r
area | {r} => Float.pi * r * r
instance : ShapeOps Rectangle where
perimeter | {x, y} => 2 * (x + y)
area | {x, y} => x * ySo far so good.
At this point we can use ShapeOps as a common interface:
def circle: Circle := ⟨2.4⟩
def rectangle: Rectangle := ⟨3.1, 4.4⟩
open ShapeOps
#eval area circle
-- 18.095574
#eval area rectangle
-- 13.640000But there's a couple of things we can't do:
- Create a list with circles and rectangles
- Provide a
Circlewhen aShapeOpsis required (this doesn't even make much sense, as these are two different things)
A strange looking type
In order to simulate the idea of a common super type we need to create a type that hides individual subtypes and a way to "upcast":
structure Shape where
{A: Type}
[ops: ShapeOps A]
shape: A
attribute [instance] Shape.ops -- to make Lean use the instance when needed This idea comes from Mario Carneiro in the Zulip thread: his DynVector structure has the same fields (a type, an instance and a value), and he explained why we need the attribute [instance] line.
This is a peculiar structure at first, so let's spell it out. To create a Shape we need:
- A type
A(inferred) - an (implicit) instance
opsof the classShapeOps A - a value of type
A
In Lean terms, Shape is a dependent pair, like (A : Type) × ShapeOps A × A. (We can't use Lean's ∃ here: it lives in Prop, so we couldn't get A back out.)
#check @Shape.mk
-- @Shape.mk : {A : Type} → [ops : ShapeOps A] → A → ShapeBecause Shape holds a type, it lives one universe up: Shape : Type 1. That's why ShapeOps takes (A) without a type: with (A : Type), the instance ShapeOps Shape that we define below doesn't compile.
The reason we need to carry around the instance ShapeOps is that otherwise we can create lists and pass shapes to functions, but we don't know which type we're dealing with, so we can't do anything with the values!
By having the instance ops alongside the value shape we can use it to call all functions defined in ShapeOps.
def shape1: Shape := Shape.mk (Circle.mk 2.4)
def shape2: Shape := Shape.mk (Rectangle.mk 3.1 4.4)
#eval area shape1.shape
-- 18.095574
#eval area shape2.shape
-- 13.640000
def shapes1: List Shape := [shape1, shape2]
#eval shapes1.map (fun s => area s.shape)
-- [18.095574, 13.640000]Still we're manually:
- Creating instances of
Shape - unwrapping
shape1.shapeeach time so we can callShapeOps.area
More coercions
(1) Can be addressed with Coe as before
instance : Coe Circle Shape where coe r := Shape.mk r
instance : Coe Rectangle Shape where coe r := Shape.mk r
def c: Shape := Circle.mk 2.4
def r: Shape := Rectangle.mk 3.1 4.4
def shapes: List Shape := [c, r]Dispatching on the type
As for (2) we can create an instance ShapeOps Shape that dispatches on each specific "subtype" by using the operations stored in ops
instance : ShapeOps Shape where
perimeter | {shape, ops, ..} => ops.perimeter shape
area | {shape, ops, ..} => ops.area shapeallowing us to use ShapeOps functions on Shape values without unwrapping the underlying concrete element
#eval area c
-- 18.095574
#eval area r
-- 13.640000