Slide 1

Slide 1 text

TYPECLASSES a type system construct SCALAR, WARSAW, POLAND - 7.4.2017 ANDREA LATTUADA — @utaal PHD CANDIDATE / RA, SYSTEMS GROUP, DEPT. OF COMPUTER SCIENCE, ETH ZÜRICH 1

Slide 2

Slide 2 text

1. why typeclasses 2. as first-class citizens, encoded 3. complications arising from encoding 4. case studies, good practices 2

Slide 3

Slide 3 text

TYPECLASSES ATTACH FUNCTIONS TO EXISTING DATA-TYPES SUPPORT ad-hoc POLYMORPHISM 3

Slide 4

Slide 4 text

case class Square(side: Float) case class Rectangle(a: Float, b: Float) case class Circle(radius: Float) def area(square: Square): Float = Math.pow(square.side, 2).toFloat def area(rectangle: Rectangle): Float = rectangle.a * rectangle.b def area(circle: Circle): Float = (Math.PI * Math.pow(circle.radius, 2)).toFloat def compareArea[ A if there's a function area(A):Float, B if there's a function area(B):Float, ](a: A, b: B): Float = area(a) - area(b) compareArea(Square(10), Circle(5)) 4

Slide 5

Slide 5 text

GROUP TYPES THAT support certain operations, WITH SUBTYPING // define operations trait HasArea { def area: Float } // types that support the operations defined case class Square(side: Float) extends HasArea { def area = Math.pow(square.side, 2).toFloat } case class Rectangle(a: Float, b: Float) extends HasArea { def area = this.a * this.b } def compareArea(a: HasArea, b: HasArea): Float = a.area - b.area 5

Slide 6

Slide 6 text

GROUP TYPES FOR WHICH certain operations are supported, WITH TYPECLASSES Square, Rectangle, Circle support area def area(square: Square): Float = ... def area(rectangle: Rectangle): Float = ... def area(circle: Circle): Float = ... defined ad-hoc (specifically for certain types, and independently from their definition) 6

Slide 7

Slide 7 text

// define operations typeclass HasArea[T] { def area(t: T): Float } def compareArea[A: HasArea, B: HasArea](a: A, b: B): Float = area(a) - area(b) Type variables A and B can only be instantiated to a type whose members support the operations associated with typeclass HasArea.1 1 https://en.wikipedia.org/wiki/Type_class 7

Slide 8

Slide 8 text

1. (TYPECLASS) DEFINE SUPPORTED OPERATIONS 2. (INSTANCES) ADD TYPES TO THE "GROUP" THAT SUPPORTS THESE OPERATIONS 8

Slide 9

Slide 9 text

1. DEFINE supported operations FOR A TYPECLASS -- Haskell class HasArea a where area :: a -> Float // Scala, almost typeclass HasArea[T] { def area(t: T): Float } 9

Slide 10

Slide 10 text

2. ADD TYPE TO THE "GROUP" BY PROVIDING AN INSTANCE (implementation) -- Haskell data Square = Square { side :: Float } deriving Show data Rectangle = Rectangle { a :: Float, b :: Float } deriving Show data Circle = Circle { radius :: Float } deriving Show class HasArea a where area :: a -> Float instance HasArea Square where -- instance of HasArea for Square area x = side x ^^ 2 instance HasArea Rectangle where -- instance of HasArea for Rectangle area x = a x * b x instance HasArea Circle where -- instance of HasArea for Circle area x = pi * radius x ^^ 2 10

Slide 11

Slide 11 text

WE CAN restrict A TYPE VARIABLE TO TYPES BELONGING TO A CERTAIN TYPECLASS -- Haskell class HasArea a where area :: a -> Float ... compareArea :: HasArea a => HasArea b => a -> b -> Float compareArea x y = area x - area y 11

Slide 12

Slide 12 text

data Square = Square { side :: Float } deriving Show data Rectangle = Rectangle { a :: Float, b :: Float } deriving Show data Circle = Circle { radius :: Float } deriving Show class HasArea a where area :: a -> Float instance HasArea Square where area x = side x ^^ 2 instance HasArea Rectangle where area x = a x * b x instance HasArea Circle where area x = pi * radius x ^^ 2 compareArea :: HasArea a => HasArea b => a -> b -> Float compareArea a b = area a - area b *Main> compareArea Circle { radius = 3 } Rectangle { a = 2, b = 3 } 22.274334 12

Slide 13

Slide 13 text

TYPECLASS encoding IN SCALA 13

Slide 14

Slide 14 text

IMPLICITS class Something[A] implicit val implicitSomething: Something[Int] = new Something[Int]() // from standard library def implicitly[T](implicit e: T): T scala> implicitly[Something[Int]] res1: Something[Int] = Something@5680a178 14

Slide 15

Slide 15 text

1. (TYPECLASS) DEFINE SUPPORTED OPERATIONS 2. (INSTANCES) ADD TYPES TO THE "GROUP" THAT SUPPORTS THESE OPERATIONS 15

Slide 16

Slide 16 text

1. TYPECLASS definition IN SCALA -- Haskell class HasArea a where area :: a -> Float ‑ // Scala trait HasArea[A] { def area(x: A): Float } 16

Slide 17

Slide 17 text

2. TYPECLASS instance IN SCALA data Square = Square { side :: Float } deriving Show instance HasArea Square where area x = side x ^^ 2 ‑ case class Square(side: Float) implicit val squareHasArea: HasArea[Square] = new HasArea[Square] { def area(x: Square): Float = Math.pow(x.side, 2).toFloat } 17

Slide 18

Slide 18 text

TYPE constraint IN SCALA compareArea :: HasArea a => HasArea b => a -> b -> Float compareArea a b = area a - area b ‑ def compareArea[A, B](a: A, b: B) (implicit aHasArea: HasArea[A], bHasArea: HasArea[B]): Float = aHasArea.area(a) - bHasArea.area(b) // or, equivalently (desugars to equivalent form) def compareArea[A: HasArea, B: HasArea](a: A, b: B): Float = implicitly[HasArea[A]].area(a) - implicitly[HasArea[B]].area(b) 18

Slide 19

Slide 19 text

TYPECLASS syntax IN SCALA area $ Square { side = 3.5 } ‑ implicit class areaOps[A: HasArea](a: A) { def area: Float = implicitly[HasArea[A]].area(a) } scala> Square(side = 3.5).area 19

Slide 20

Slide 20 text

trait HasArea[A] { def area(x: A): Float } implicit val squareHasArea: HasArea[Square] = new HasArea[Square] { def area(x: Square): Float = Math.pow(x.side, 2).toFloat } // more implementations implicit class areaOps[A: HasArea](a: A) { def area: Float = implicitly[HasArea[A]].area(a) } scala> Square(side = 3.5).area def compareArea[A: HasArea, B: HasArea](a: A, b: B): Float = a.area - b.area 20

Slide 21

Slide 21 text

{HASKELL, RUST} ➡ SCALA 21

Slide 22

Slide 22 text

TYPECLASS instance scope (WHERE THE COMPILER SEARCHES FOR INSTANCES) 22

Slide 23

Slide 23 text

ORPHAN INSTANCES An orphan instance is a type class instance for class C and type T which is neither defined in the module where C is defined nor in the module where T is defined.2 2 https://wiki.haskell.org/Orphan_instance 23

Slide 24

Slide 24 text

HASKELL - INSTANCE SCOPE ▸ open world assumption ▸ instances imported when importing a module that defines them or imports them (no hiding possible) — even when the typeclass is not in scope 24

Slide 25

Slide 25 text

RUST - INSTANCE SCOPE ▸ instances can only be defined in the same file as the datatype or the typeclass (no orphan instances) ▸ instances available when the trait is imported 25

Slide 26

Slide 26 text

SCALA - INSTANCE SCOPE based on implicit resolution ▸ most specific first ▸ search in implicit scope and companion objects 26

Slide 27

Slide 27 text

SCALA: MOST SPECIFIC FIRST trait SomeTypeclass[T] { val where: String } // wildcard instance implicit def genericSomeTypeclass[T]: SomeTypeclass[T] = new SomeTypeclass[T] { val where = "generic" } implicit val someTypeclassString: SomeTypeclass[String] = new SomeTypeclass[String] { val where = "specific" } > implicitly[SomeTypeclass[String]].where res1: String = "specific" 27

Slide 28

Slide 28 text

SCALA: DIFFERENT ORIGIN 1. (implicit scope) local scope, imported or inherited values/defs; if one is available in both, ambiguous implicit ▸ among imported or inherited values/defs, the one defined in the most specific type 2. companion object of the datatype or typeclass 28

Slide 29

Slide 29 text

SCALA: ISSUES ▸ (readability) where is this instance coming from? ▸ (discoverability) where do I find available instances for a certain typeclass? ▸ (coherence, maintainability) what instance am I using when multiple are available? 29

Slide 30

Slide 30 text

SCALA: Coherence AND MAINTANABILITY object HasArea { implicit val rectangleHasArea = new HasArea[Rectangle] { def area(r: Rectangle): Double = r.a * r.b } } ꔇ class Module { implicit sphericalRectangleHasArea = new HasArea[Rectangle] { def area(r: Rectangle): Double = // ... } compareArea(Rectangle(10, 12), Rectangle(13, 14)) } 30

Slide 31

Slide 31 text

Coherence: BLANKET IMPLEMENTATIONS trait RootJsonCodec[T] { def encodeToJson(t: T): JsonValue } trait DbCodec[T] { def encodeToDbValue(t: T): DbValue } implicit def dbCodecRootJsonCodec[A: RootJsonCodec]: DbCodec[T] 31

Slide 32

Slide 32 text

Coherence: overlapping BLANKET IMPLEMENTATIONS trait RootJsonCodec[T] { ... } trait BinaryCodec[T] { ... } trait DbCodec[T] implicit def dbCodecRootJsonCodec[A: RootJsonCodec]: DbCodec[T] implicit def dbCodecBinaryCodec[A: BinaryCodec]: DbCodec[T] case class Square(side: Double) implicit val squareRootJsonCodec = new RootJsonCodec[Square] {...} implicit val squareBinaryCodec = new BinaryCodec[Square] {...} 32

Slide 33

Slide 33 text

{HASKELL, RUST} ➡ SCALA ▸ define instances in companion objects (whenever possible) ▸ define blanket instances in companion objects of target typeclass ▸ be conservative and follow a convention for orphan and blanket instances (more on this later) 33

Slide 34

Slide 34 text

OPERATIONS (SYNTAX) in Haskell and Rust, functions defined/imported along with the typeclass definition -- Haskell area $ Circle { radius = 5 } // Scala implicitly[HasArea[Square]].area(Square(side = 5)) 34

Slide 35

Slide 35 text

IN SCALA, OPERATIONS encoded AS AN IMPLICIT CONVERSION implicit class hasAreaOps[A: HasArea](a: A) { def area: Float = implicitly[HasArea[A]].area(a) } scala> Square(side = 5).area 35

Slide 36

Slide 36 text

SIMULACRUM3 addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) ꔇ import simulacrum._ @typeclass trait HasArea[A] { def area(self: A) } 3 https://github.com/typelevel/cats 36

Slide 37

Slide 37 text

Encoding IN SIMULACRUM @typeclass trait HasArea[A] { def area(self: A): Double } ‑ trait HasArea[A] { def area(self: A): Double } ꔇ 37

Slide 38

Slide 38 text

Syntax IN SIMULACRUM @typeclass trait HasArea[A] { ... } ‑ ꔇ object HasArea { ꔇ trait Ops[A] { def area: Double } trait ToHasAreaOps { implicit def toHasAreaOps[A: HasArea](target: A): Ops[A] = new Ops[A] { } } ꔇ 38

Slide 39

Slide 39 text

Syntax IN SIMULACRUM @typeclass trait HasArea[A] { def area(self: A): Double } ‑ object HasArea { ꔇ trait AllOps[A] extends Ops[A] { // inherits implementation of def area: Double } object ops { implicit def toAllHasAreaOps[A: HasArea](target: A): AllOps[A] = new AllOps[A] { def area: Double = // ... } } } 39

Slide 40

Slide 40 text

Syntax WITHOUT SIMULACRUM trait HasArea[A] { def area(self: A): Double } object HasArea { object ops { implicit class toHasAreaOps[A: HasArea](target: A) { def area: Double = implicitly[HasArea[A]].area(target) } } } 40

Slide 41

Slide 41 text

SYNTAX - usage import HasArea.ops._ scala> Square(side = 5).area or, only with Simulacrum object GeometryOps extends ToHasAreaOps // and more scala> import GeometryOps._ scala> Square(side = 5).area 41

Slide 42

Slide 42 text

SIMULACRUM does not overwrite THE TYPECLASS' COMPANION OBJECT import simulacrum._ @typeclass trait HasArea[A] { def area(self: A): Double } case class Rectangle(a: Double, b: Double) object HasArea { implicit val rectangleHasArea = new HasArea[Rectangle] { def area(self: Rectangle): Double = self.a * self.b } } 42

Slide 43

Slide 43 text

WHY SIMULACRUM? ▸ robust, consistent object-oriented forwarders ("syntax") ▸ searchable, consistent syntax (@typeclass) ▸ A-la-carte imports for syntax (ops) via traits (for usability) 43

Slide 44

Slide 44 text

{SIMULACRUM} ➡ SCALA ▸ (discoverability) syntax defined in the companion object, and re- exported via ops and traits for usability 44

Slide 45

Slide 45 text

CATS3 Lightweight, modular, and extensible library for functional programming. Uses simulacrum. 3 https://github.com/typelevel/cats 45

Slide 46

Slide 46 text

CATS: SCOPE AND COHERENCE for cats datatypes, instances defined only in companion objects for scala datatypes, orphan instances defined only in package cats.instances selective import via traits 46

Slide 47

Slide 47 text

{RUST, CATS} ➡ SCALA ▸ prefer companion objects for typeclasses/datatypes you control ▸ group all orphan instances in a package, allow selective import ▸ avoid overlapping instances, unless there's a specific usability need 47

Slide 48

Slide 48 text

NEWTYPE: SAFER ALTERNATIVE TO CONFLICTING INSTANCES (coherence) object HasArea { implicit val rectangleHasArea = new HasArea[Rectangle] { ... } } ꔇ class Module { case class Spherical[A](shape: A) implicit sphericalRectangleHasArea = new HasArea[Spherical[Rectangle]] { def area(r: Spherical[Rectangle]): Double = // ... } compareArea(Spherical(Rectangle(10, 12)), Spherical(Rectangle(13, 14))) } 48

Slide 49

Slide 49 text

➡ SCALA ▸ (maintainability/readability) use newtype for conflicting instances 49

Slide 50

Slide 50 text

▸ discoverability ▸ readability ▸ coherence/maintainability 50