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.
