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.