Retrospective Polymorphism and Type Classes
Polymorphism is a programming paradigm, being able to vary runtime behaviour of an abstraction based on some kind of context. The context can be the runtime time type of a target of an invocation, a type tagged against an invocation or the presence of an evidence that is available at runtime.
Scala supports three types of polymorphism,
- Parametric polymorphism: In the Scala world this is implemented as generics, where classes and methods are abstracted over types
- Sub type polymorphism: This is the most recognisable polymorphism for folks coming from an object oriented background. This primarily employs method overriding in an inheritance hierarchy.
- Retrospective Polymorphism: Retrospective polymorphism, also called ad hoc polymorphism, is the most dynamic and powerful one, where polymorphic behaviour can be achieved without relying on definition site language level constructs like type inheritance. Languages like Haskell provides support for this as language level constructs, where in Scala it is realised as a pattern and some syntax sugar
Let us look at this with a simple library of shapes. The base abstraction within the library is the Shape trait shown below,
trait Shape { def draw() } defined trait Shape
We have two concrete implementation of the trait representing a circle and a square,
case class Circle(r: Double) extends Shape { override def draw() { println(s"Circle radius: $r") } } case class Square(a: Double) extends Shape { override def draw() { println(s"Square side: $a") } }
Now let us define a helper method that accepts a shape and draws it, as shown below.
def draw(s: Shape) = s.draw draw(Circle(1.2)) draw(Square(1.2))
This is a classic case of sub-type polymorphism, where the runtime behaviour is decided by the runtime type of the target instance on which invocation is made. Now let us say we get a new shape representing a rectangle from a third party. We don’t have access to its source code and obviously it doesn’t extend our Shape trait, and let us say its public API as a render method, instead of the draw method. Anyone, who has held the thick blue hardbound GoF book will run for the adaptor pattern and define an adaptor for the new Rectangle class as below.
case class Rectangle(l: Double, b: Double) { def render() { println(s"Rectangle length: $l breadth: $b") } } case class RectangleAdaptor(r: Rectangle) extends Shape { override def draw() { r.render } } draw(RectangleAdaptor(Rectangle(1.2, 2.4))
As and when you import more shape types that don’t comply with your interface, you write more adaptors and you wrap instances of the third party classes in your adaptor instances, before you pass them down the draw method. One of the main reasons of this anti-pattern is that the abstraction defined by the draw method couples the shape types and the need to draw them on the screen. If we decouple both, our draw method become a lot more extensible in terms of dynamically adapting it to types, whose source code we have no control on.
Type class pattern is an exact fit to achieve this. To do this, the first thing we do is define a trait to draw shapes.
trait Drawable[S] { def draw(s: S) }
Now we change the draw method’s API, to reflect the decoupling of the abstractions,
def draw[S](s: S)(implicit d: Drawable[S]) { d.draw(s) }
The method draw now takes an additional implicit parameter, which is responsible for drawing the matching shape. The type tag on the draw method ensures the Shape type and Drawable type are compatible. You will also provide implementations of the Shape types you have in your library as follows. It is good to implement them as implicit objects within the companion object of the Drawable trait, so that the implicit parameter is automatically resolved.
object Drawable { implicit object SquareDrawable extends Drawable[Square] { def draw(s: Square) { s.draw } } implicit object CircleDrawable extends Drawable[Circle] { def draw(c: Circle) { c.draw } } }
Now you can print instances of Circle and Square,
draw(Circle(1.2)) draw(Square(1.2))
Though, if you try to draw a rectangle, you will get a compiler error.
draw(Rectangle(1.2, 2.4)) error: could not find implicit value for parameter d: Drawable[Rectangle] draw(Rectangle(1.2, 2.4))
This is because there is no implicit available in the scope for drawing rectangles. You can amend the compiler error message by annotating the Drawable trait with the @implicitNotFound annotation. All you will have to do is to implement a Drawable type than can handle Rectangle type and bring an instance of that into the implicit scope as below,
implicit object RectangleDrawable extends Drawable[Rectangle] { def draw(r: Rectangle) { r.render } }
In Scala, you can also use context bounds instead of an implicit argument and use the implicitly construct as shown below,
def draw[S: Drawable](s: S) { implicitly[Drawable[S]].draw(s) }
The context bound requires an implicit to be available in the scope, instead of needing to declare an implict parameter.
I never thought I would find such an everyday topic so ennltahlirg!