Monthly Archives: January 2020

Ad-hoc Polymorphism In Scala

Over the past few years, there seems to be a subtle trend of software engineers favoring typeclass patterns that implement polymorphism in an ad-hoc fashion, namely, Ad-hoc Polymorphism. To see the benefits of such kind of polymorphism, let’s first look at what F-bounded polymorphism, a subtype polymorphism, has to offer.

F-bounded polymorphism

// F-bounded polymorphism
trait Car[T <: Car[T]] { self: T =>
  def vin: String
  def model: String
  def price: Double
  def setPrice(newPrice: Double): T
}

Next, a couple of child classes are defined:

// F-bounded polymorphism continued
case class Sedan(vin: String, model: String, price: Double) extends Car[Sedan] {
  def setPrice(newPrice: Double) = copy(price = newPrice)
}

case class Sports(vin: String, model: String, price: Double) extends Car[Sports] {
  def setPrice(newPrice: Double) = copy(price = newPrice)
}

A F-bounded type has a peculiar signature of the self-recursive `A[T <: A[T]]` which mandates the given type `T` itself a sub-type of `A[T]`, like how type `Sedan` is defined (Sedan <: Car[Sedan]). Note that the self-type annotation used in the trait isn’t requirement for F-bounded type. Rather, it’s a common practice for safeguarding against undesirable mix-up of sub-classes like below:

// F-bounded polymorphism with self-type
case class Sports(vin: String, model: String, price: Double) extends Car[Sedan] {
  def setPrice(newPrice: Double) = Sedan(vin, model, newPrice)
}
// ERROR: illegal inheritance; self-type Sports does not conform to Car[Sedan]'s selftype Sedan ...

“Type argument” versus “Type member”

Rather than a `type argument`, a F-bounded type could also be expressed as a `type member` which needs to be defined in its child classes.:

// F-bounded polymorphism expressed as type member
trait Car {
  type T <: Car
  def vin: String
  def model: String
  def price: Double
  def setPrice(newPrice: Double): T
}

case class Sedan(...) extends Car {
  type T = Sedan
  ...
}

case class Sports(...) extends Car {
  type T = Sports
  ...
}

It should be noted that with the `type member` approach, self-type would not be applicable, hence mix-up of sub-classes mentioned above is possible.

Let’s define a sedan and test out method `setPrice`:

// F-bounded polymorphism example continued
val sedan1 = Sedan("1ABC*234", "Honda Accord", 20500)

sedan1.setPrice(19500)
// res1: Sedan = Sedan("1ABC*234", "Honda Accord", 19500.0)

Under the F-bounded type’s “contract”, a method such as the following would work as intended to return the specified sub-type:

Had the Car/Sedan hierarchy been set up as the less specific `T <: Car`, the corresponding method:

// Problem with simple subtype
def setSalePrice[T <: Car](car: T, discount: Double): T =
  car.setPrice(car.price * (1.0 - discount max 0.0))

would fail as it couldn’t guarantee the returning type is the exact type of the input.

F-bounded type collection

Next, let’s look at a collection of cars.

// Collection of F-bounded elements
val sedan2 = Sedan("2DEF*345", "BMW 330i", 38000)
val sports1 = Sports("5MNO*678", "Ford Mustang", 34000)

val cars = List(sedan1, sedan2, sports1)
// cars: List[Product with Serializable with Car[_ >: Sports with SUV with Sedan <: Product with Serializable with Car[_ >: Sports with SUV with Sedan <: Product with Serializable]]] = ...

The resulting type is a rather ugly sequence of gibberish. To help the compiler a little, give it some hints about `T <: Car[T]` as shown below:

// Existential type hints
import scala.language.existentials

val cars = List[T forSome { type T <: Car[T] }](sedan1, sedan2, sports1)
// cars: List[T forSome { type T <: Car[T] }] = List(...)

Ad-hoc polymorphism

Contrary to subtype polymorphism which orients around a supertype with a rigid subtype structure, let’s explore a different approach using typeclasses, known as Ad-hoc polymorphism.

// Ad-hoc polymorphism example
trait Car[T] {
  def vin(car: T): String
  def model(car: T): String
  def price(car: T): Double
  def setPrice(car: T, newPrice: Double): T
}

case class Sedan(vin: String, model: String, price: Double)
case class Sports(vin: String, model: String, price: Double)

Next, a couple of “ad-hoc” implicit objects are created to implement the trait methods.

// Ad-hoc polymorphism example continued
import scala.language.implicitConversions

implicit object SedanCar extends Car[Sedan] {
  def vin(car: Sedan) = car.vin
  def model(car: Sedan) = car.model
  def price(car: Sedan) = car.price
  def setPrice(car: Sedan, newPrice: Double): Sedan = car.copy(price = newPrice)
}

implicit object SportsCar extends Car[Sports] {
  def vin(car: Sports) = car.vin
  def model(car: Sports) = car.model
  def price(car: Sports) = car.price
  def setPrice(car: Sports, newPrice: Double): Sports = car.copy(price = newPrice)
}

Note that alternatively, the implicit objects could be set up as ordinary companion objects of the case classes with implicit anonymous classes:

// Alternative - defining implicits in companion objects
case class Sedan(vin: String, model: String, price: Double)
object Sedan {
  implicit val SedanCar = new Car[Sedan] {
    def vin(car: Sedan) = car.vin
    def model(car: Sedan) = car.model
    def price(car: Sedan) = car.price
    def setPrice(car: Sedan, newPrice: Double): Sedan = car.copy(price = newPrice)
  }
}

case class Sports(vin: String, model: String, price: Double)
object Sports {
  implicit val SportsCar = new Car[Sports] {
    def vin(car: Sports) = car.vin
    def model(car: Sports) = car.model
    def price(car: Sports) = car.price
    def setPrice(car: Sports, newPrice: Double): Sports = car.copy(price = newPrice)
  }
}

Unifying implemented methods

Finally, an implicit conversion for cars of type `T` is provided by means of an implicit class to create a “unified” method that takes the corresponding method implementations from the provided implicit `Car[T]` parameter.

// Ad-hoc polymorphism example continued
implicit class CarOps[T](car: T)(implicit ev: Car[T]) {
  def setPrice(newPrice: Double): T = ev.setPrice(car: T, newPrice: Double)
}

Testing it out:

// Ad-hoc polymorphism example continued
val sedan1 = Sedan("1ABC*234", "Honda Accord", 20500)

sedan1.setPrice(19500)
// res1: Sedan = Sedan("1ABC*234", "Honda Accord", 19500.0)

New methods, like `setSalePrice`, can be added as needed in the implicit objects:

// Ad-hoc polymorphism example continued
implicit object SedanCar extends Car[Sedan] {
  ...
  def setSalePrice(car: Sedan, discount: Double): Sedan =
    car.setPrice(car.price * (1.0 - discount max 0.0))
}

implicit object SportsCar extends Car[Sports] {
    ...
  def setSalePrice(car: Sports, discount: Double): Sports =
    car.setPrice(car.price * (1.0 - discount max 0.0))
}

Ad-hoc type collection

Next, a collection of cars:

// Collection of elements of ad-hoc type
val sedan2 = Sedan("2DEF*345", "BMW 330i", 38000)
val sports1 = Sports("5MNO*678", "Ford Mustang", 34000)

val cars = List(sedan1, sedan2, sports1)
// cars: List[Product with java.io.Serializable] = List(
//   Sedan("1ABC*234", "Honda Accord", 20500.0),
//   Sedan("2DEF*345", "BMW 330i", 38000.0),
//   Sports("5MNO*678", "Ford Mustang", 34000.0)
// )

Similar to the F-bounded collection, the inferred resulting type isn’t very helpful. Unlike in the F-bounded case, we do not have a `T <: Car[T]` contract. Using an approach illustrated in this blog post, we could assemble the collection as a list of `(car, type)` tuples:

// Existential type hints
import scala.language.existentials

val cars = List[(T, Car[T]) forSome { type T }](
  (sedan1, implicitly[Car[Sedan]]),
  (sedan2, implicitly[Car[Sedan]]), 
  (sports1, implicitly[Car[Sports]])
)
// cars: List[(T, Car[T] forSome { type T })] = List(...)

By means of a simple example, we’ve now got a sense of how Ad-hoc polymorphism works. The F-bounded example serves as a contrasting reference of how the polymorphism bound by a more “strict” contract plays out in comparison. Given the flexibility of not having to bind the base classes into a stringent subtype relationship upfront, the rising popularity of Ad-hoc polymorphism certainly has its merits.

That said, lots of class models in real-world applications still fits perfectly well into a subtype relationship. In suitable use cases, F-bounded polymorphism generally imposes less boilerplate code. In addition, Ad-hoc polymorphism typically involves using of implicits that may impact code maintainability.