Typeclasses — a type system construct

Typeclasses — a type system construct

Presented at Scala, Warsaw - http://scalar-conf.com

98e8177ac82d61a351b3b636e244bbd5?s=128

Andrea Lattuada

April 07, 2017
Tweet

Transcript

  1. 1.

    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
  2. 2.

    1. why typeclasses 2. as first-class citizens, encoded 3. complications

    arising from encoding 4. case studies, good practices 2
  3. 4.

    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
  4. 5.

    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
  5. 6.

    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
  6. 7.

    // 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
  7. 8.

    1. (TYPECLASS) DEFINE SUPPORTED OPERATIONS 2. (INSTANCES) ADD TYPES TO

    THE "GROUP" THAT SUPPORTS THESE OPERATIONS 8
  8. 9.

    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
  9. 10.

    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
  10. 11.

    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
  11. 12.

    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
  12. 14.

    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
  13. 15.

    1. (TYPECLASS) DEFINE SUPPORTED OPERATIONS 2. (INSTANCES) ADD TYPES TO

    THE "GROUP" THAT SUPPORTS THESE OPERATIONS 15
  14. 16.

    1. TYPECLASS definition IN SCALA -- Haskell class HasArea a

    where area :: a -> Float ‑ // Scala trait HasArea[A] { def area(x: A): Float } 16
  15. 17.

    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
  16. 18.

    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
  17. 19.

    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
  18. 20.

    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
  19. 23.

    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
  20. 24.

    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
  21. 25.

    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
  22. 26.

    SCALA - INSTANCE SCOPE based on implicit resolution ▸ most

    specific first ▸ search in implicit scope and companion objects 26
  23. 27.

    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
  24. 28.

    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
  25. 29.

    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
  26. 30.

    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
  27. 31.

    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
  28. 32.

    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
  29. 33.

    {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
  30. 34.

    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
  31. 35.

    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
  32. 36.

    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
  33. 37.

    Encoding IN SIMULACRUM @typeclass trait HasArea[A] { def area(self: A):

    Double } ‑ trait HasArea[A] { def area(self: A): Double } ꔇ 37
  34. 38.

    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] { <impl> } } ꔇ 38
  35. 39.

    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
  36. 40.

    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
  37. 41.

    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
  38. 42.

    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
  39. 43.

    WHY SIMULACRUM? ▸ robust, consistent object-oriented forwarders ("syntax") ▸ searchable,

    consistent syntax (@typeclass) ▸ A-la-carte imports for syntax (ops) via traits (for usability) 43
  40. 44.

    {SIMULACRUM} ➡ SCALA ▸ (discoverability) syntax defined in the companion

    object, and re- exported via ops and traits for usability 44
  41. 45.
  42. 46.

    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
  43. 47.

    {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
  44. 48.

    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