Parametric Polymorphism with Type Classes
This walkthrough starts with an inheritance-based design for ordering and then refactors it into a type-class style design. The progression illustrates a core separation-of-concerns idea: data types should not need to inherit behavior just to participate in generic algorithms.
1) Mixing concerns: data inherits ordering
In this first version, values implement ordering themselves by inheriting from OrdI[T].
trait OrdI[T]:
def compare(other: OrdI[T]) : Int
end OrdI
case class MyInt(v: Int) extends OrdI[MyInt]:
def compare(other: OrdI[MyInt]): Int =
v - other.asInstanceOf[MyInt].v
end MyInt
def maxI[T](x: OrdI[T], y: OrdI[T]) : OrdI[T] =
if x.compare(y) > 0 then x else y
end maxI
maxI(new MyInt(3), new MyInt(4))This works, but ordering logic is tightly coupled to the data representation (MyInt). Next we separate data from behavior.
2) Separate concerns with an explicit order object
We define Ord[T] as a standalone abstraction and pass an instance explicitly.
trait Ord[T]:
def compare(x: T, y: T) : Int
end Ord
def maxExplicit[T](x: T, y: T)(ord: Ord[T]) : T =
if ord.compare(x, y) > 0 then x else y
end maxExplicit
val intOrder = new Ord[Int] {
def compare(x: Int, y: Int) : Int = x - y
}
maxExplicit(4, 5)(intOrder)Now maxExplicit is fully generic, but call sites can become verbose. Scala 3 context parameters remove that boilerplate.
3) Use using parameters for contextual orderings
The algorithm still depends on Ord[T], but callers can provide it contextually.
def max[T](x: T, y: T)(using ord: Ord[T]) : T =
if ord.compare(x, y) > 0 then x else y
end max
max(4, 5)(using intOrder)To make most calls fully implicit, we provide default given instances.
4) Install default instances with given
These instances are discovered automatically when a method requires Ord[T].
given Ord[Int]:
def compare(x: Int, y: Int) : Int = x - y
given Ord[String]:
def compare(x: String, y: String) : Int =
x.compareTo(y)
max(4, 5)
max("A", "B")With this setup, generic collection operations become concise.
5) Lift max to lists: maximum
The following versions show equivalent ways to propagate contextual evidence.
def maximum1[T](xs: List[T])(using ord: Ord[T]) : T =
xs.reduceLeft(max(_, _)(using ord))
def maximum2[T](xs: List[T])(using ord: Ord[T]) : T =
xs.reduceLeft(max)
def maximum[T : Ord](xs: List[T]) : T =
xs.reduceLeft(max)The context-bound form T : Ord is the most compact and idiomatic here. Next we reuse this infrastructure to derive new behavior.
6) Derive inverse order and implement minimum
Instead of writing a second aggregation algorithm, we define inverse ordering once and reuse maximum.
def inverseOrd[T](using ord: Ord[T]) = new Ord[T]:
def compare(x: T, y: T) : Int = ord.compare(y, x)
def minimum[T : Ord](xs: List[T]) : T =
maximum(xs)(using inverseOrd)
maximum(List(1, 2, 3, 4, 5))
minimum(List(1, 2, 3, 4, 5))This final step highlights the main design payoff: we can extend ordering behavior compositionally without changing data types or duplicating algorithms.