Posts Diving into Scala's Typeclasses
Post
Cancel

Diving into Scala's Typeclasses

Typeclasses are one of the most beautiful ways of extending existing classes and building new features and functionality over them. It is always considered a good practise to evolve programs/systems by extending and not altering them. There are also situations where you can’t change the classes. Typeclasses come to our rescue in such cases.

Typeclasses are a feature in both Haskell and Scala. Haskell provides it as a language feature and its usecase is to achieve polymorphism(ad-hoc polymorphism). Many claim that typeclasses in scala are way more powerful because being an OO language with a very strong polymorphism through subtyping, it can combine typeclasses to add specialized functionality to this inheritance chain. Nope, you can’t do that with typeclasses. In other words, typclasses are not like interfaces. And we will discuss why I believe so in this blog post.

Inheritance is one of the key features of Object oriented programming to add new functionality. However, if we want to add new features to classes that we can’t extend, we generally follow one of the following methods.
Let us assume we need to a write a logger that prints a given variable along with its type.

1. Pattern match against type

object TypeLogger {
  def convert[T](a: T) = a match {
    case _: Int => "Int " + a.toString
    case _: Double => "Double " + a.toString
  }
  def log[T](a: T) = println(convert(a))
}

import TypeLogger._
log(1)
log(1.01)

2. Writing wrapper classes

object TypLogger1 {
  trait Loggable {
    def convert: String
    def log = println(convert)
  }
  case class IntLogger(a: Int) extends Loggable {
    override def convert: String = "Int "+ a.toString
  }
  case class DoubleLogger(a: Double) extends Loggable {
    override def convert: String = "Double "+a.toString
  }
}

import TypLogger1._
IntLogger(1).log
DoubleLogger(1.01).log

But even here, you might have to pattern match to find the Logger for that type.

3. Implicits
Scala provides an easier way of doing this by hiding such conversions behind the scenes using implicit conversions.

object TypeLogger2 {
  trait Loggable {
    def convert: String
    def log = println(convert)
  }
  implicit class Int2String(a: Int) extends Loggable {
    def convert = "Int " + a.toString
  }
  implicit class Double2String(a: Double) extends Loggable {
    def convert = "Double "+a.toString
  }
}

import TypeLogger2._
1.log
1.01.log

You could have seen something similar in scala.concurrent.duration

import scala.concurrent.duration._
10 seconds
10.seconds

Yes, that is also achieved using implicit conversions.

Let’s solve the same using typeclasses,

object TypeLogger3 {
  trait Loggable[T] {
    def convert(a: T): String
    def log(a: T) = println(convert(a))
  }
  implicit object Int2String extends Loggable[Int] {
    def convert(a: Int) = "Int " + a.toString
  }
  implicit object Double2String extends Loggable[Double] {
    def convert(a: Double) = "Double " + a.toString
  }
  object Loggable{
    def apply[T: Loggable]: Loggable[T] = implicitly
  }
  implicit class LoggableOps[T: Loggable](a: T) {
    def log = Loggable[T].log(a)
  }
}

import TypeLogger3._
1.log
1.01.log

You can clearly see that you could achieve the same with implicits. What special is achieved by using typeclass then?

Another wrong assumption is that typeclasses can be used to add specialized functionality to inheritance chain which is partly true but not exactly the way one would think regular subtyping in inheritance works. Let us take an example of the AnimalKingdom,

object AnimalKingdom {
  trait LivingBeing
  trait Animal extends LivingBeing
  case class Dog(name: String) extends Animal
  case class Cat(name: String) extends Animal
}

object SoundImplicits {
  trait CanMakeSound[T]{
    def makeNoise(t: T): String
  }
  implicit object CatCanMakeSound extends CanMakeSound[Cat] {
    override def makeNoise(t: Cat): String = "meow"
  }
  implicit object DogCanMakeSound extends CanMakeSound[Dog] {
    override def makeNoise(t: Dog): String = "woof"
  }
  object CanMakeSound {
    def apply[A: CanMakeSound]: CanMakeSound[A] = implicitly
  }
}

object God {
  import AnimalKingdom.Animal
  def assignCharacteristicSound(animals: List[Animal]) = {
    import SoundImplicits._
    //animals.map(animal => CanMakeSound[]) 
    //Can't do. It looks for an implementation CanMakeSound[Animal]
  }

  def main(args: Array[String]): Unit = {
    val dog = Dog("Snowie")
    val cat = Cat("Garfield")
    assignCharacteristicSound(List(dog, cat))
  }
}

Implicits are resolved at compile-time. However, subtyping relies on dynamic-dispatching. As a result, the new God here cannot give the animals their characteristic sound. It should have been well planned during design time(here, I mean during the creation of the animals in the animalkingdom).

In some examples I see something similar to this being provided.

def main(args: Array[String]): Unit = {
  val dog = Dog("Snowie")
  val cat = Cat("Garfield")

  import SoundImplicits._
  println(dog.makeNoise)
  println(cat.makeNoise)
}

This seems to work, cause we know the dog is of type Dog, cat is of type Cat, and the compiler can pick the CanMakeSound[Dog] and CanMakeSound[Cat]. However, we are actually explicitly providing the subclass Type, which defeats the entire purpose of inheritance of object-oriented programming.

From the above, I don’t meant that typeclassess aren’t useful in scala. Typeclasses are definitely useful but not in ways we generally think of polymorphism in OOPS. Taking the same AnimalKingdom example, we can add few powers to the new God.

object SleepImplicits {
  import AnimalKingdom.LivingBeing
  trait CanSleep[T]{
    def sleep(t:T): String
  }
  implicit object LivingBeingsCanSleep extends CanSleep[LivingBeing] {
    override def sleep(t: LivingBeing): String = "zzz"
  }
  object CanSleep {
    def apply[A: CanSleep]: CanSleep[A] = implicitly
  }
}

object God {
  import AnimalKingdom.Animal
  def putToSleep(animals: List[Animal]) = {
    import SleepImplicits._
    animals.map(animal: Animal => CanSleep[LivingBeing].sleep(animal)) //Can do, since Animals are LivingBeings
  }

  def main(args: Array[String]): Unit = {
    val dog = Dog("Snowie")
    val cat = Cat("Garfield")
    putToSleep(List(dog, cat)).foreach(println)
  }
}

This works, cause you have an sleep implementation available for all livingbeings. So, the new God can put all the animals to sleep.
Another classic example from scala library is

def sorted[B >: A](implicit ord: Ordering[B]): Seq[A]

Ordering is used to resolve the relative ordering between two items of type A.

Ordering[B] is a typeclass here. Different implementations of compare function are available as implicits in the scope of the sorted method call. B >: A means B is a supertype of A. Thus if you provide an Ordering of Numbers and Int extends Numbers, meant you could sort a List of Int(s).

Updated Mar 25, 2020 2020-03-25T09:51:27+00:00
This post is licensed under CC BY 4.0