Type Classes
In this blog, we will talk about Type Class
in Scala and try to find out why we need this pattern.
Question
Say we already have a number of models in our code
case class Age(value: Int)
case class Person(name: String, age: Age)
case class Point(x: Int, y: Int)
One day, we get a new requirement which need to add a def add(delta: Int)
method to all the models
What should we do?
Solution
In Java, if we want to add a add
function to a class, we may just modify the class like this
case class Age(var value: Int){
def add(delta: Int):Unit =
value += delta
}
This is not ideal in FP, we change the Age
class to be mutable and modify the existing code which will increase the likelihood of errors.
Could we both keep the existing models unchanged and add the add
function?
In Scala, we can use implicit class
to do this
object Age {
implicit class AgeOps(v: Age){
def add(delta: Int): Age = Age(v.value + delta)
}
}
object Person {
implicit class PersonOps(v: Person){
def add(delta: Int): Person = Person(v.name, v.age.add(delta)) // Age alreay has add function
}
}
object Point {
implicit class PointOps(v: Point){
def add(delta: Int): Point = Point(v.x + delta, v.y + delta)
}
}
$ val age = Age(18)
$ age.add(1) // Age(19)
$ val person = Person("Job", Age(18))
$ person.add(1) // Person("Job", Age(19))
$ val point = Point(1, 1)
$ point.add(1) // Point(2, 2)
Now we add the add
function to the existing models, what if there is another function which want to invoke the add
function?
After all we have the add
function with same parameter on all the existing models, it make sense to have a function like this
def processAdd[A](a: A, delta: Int): A = {
a match {
case x: Age => x.add(delta).asInstanceOf[A]
case x: Person => x.add(delta).asInstanceOf[A]
case x: Point => x.add(delta).asInstanceOf[A]
case _ => throw new Exception(s"Can not process ${a}")
}
}
$ processAdd(Age(18), 1) // Age(19)
$ processAdd(Person("Job", Age(18)), 1) // Person("Job", Age(19))
$ processAdd(Point(1, 1), 1) //Point(2, 2)
Refactor
Let’s see which part can be improved in the above code
Age
,Person
andPoint
have a function calledadd
, but there is no hard rule to restrict them to use the same function name. For example,Age
can changeadd
toaddInt
without any error.processAdd
is ugly, it throw exception, useasInstanceOf
and has duplicated code
How should we restrict the signature(including name) of add
function on different models?
The root cause of this problem is each model implement their own implicit class
, maybe we can try to use type parameter on one implicit class
like this
object AddSyntax {
implicit class AddOps[A](v: A){
def add(delta: Int): A = ???
}
}
But how should we implement the function body?
Different model has different implementation and we can’t predict if there are new model using this function.
Seems what we can do now is to leave a hook here to allow the consumer of this function to decide the implementation
object AddSyntax {
implicit class AddOps[A](v: A)(implicit f: (A, Int) => A){
def add(delta: Int): A = f(v, delta)
}
}
Then for each model, they just need to implement an implicit function (A, Int) => A
like this
implicit def ageAddFunction(age: Age, delta: Int) = Age(age.value + delta)
implicit def personAddFunction(person: Person, delta: Int): Person = Person(person.name, person.age.add(delta)) // Age alreay has add function
implicit def pointAddFunction(point: Point, delta: Int): Point = Point(point.x + delta, point.y + delta)
$ val age = Age(18)
$ age.add(1) // Age(19)
$ val person = Person("Job", Age(18))
$ person.add(1) // Person("Job", Age(19))
$ val point = Point(1, 1)
$ point.add(1) // Point(2, 2)
Looks good for now, we have an unified signature for add
function and we don’t need to care about how each model implement the function (A, Int) => Int
in the implicit class
.
But what if we want to add another new function sub
?
$ val age = Age(18)
$ age.sub(1) // Age(17)
$ val person = Person("Job", Age(18))
$ person.sub(1) // Person("Job", Age(17))
$ val point = Point(1, 1)
$ point.sub(1) // Point(0, 0)
Based on the above discussion, we need to implement it like this
object SubSyntax {
implicit class SubOps[A](v: A)(implicit f: (A, Int) => A){
def sub(delta: Int): A = f(v, delta)
}
}
implicit def ageSubFunction(age: Age, delta: Int) = Age(age.value - delta)
implicit def personSubFunction(person: Person, delta: Int): Person = Person(person.name, person.age.sub(delta)) // Age alreay has sub function
implicit def pointSubFunction(point: Point, delta: Int): Point = Point(point.x - delta, point.y - delta)
But in terms of the implicit scope rule, we can’t use add
and sub
together,
because we need to import sub
implicit function and add
implicit function in one scope.
Unfortunately they have the same signature and compiler will raise error.
For example
implicit def ageSubFunction(age: Age, delta: Int) = Age(age.value - delta)
implicit def ageAddFunction(age: Age, delta: Int) = Age(age.value + delta)
ageSubFunction
and ageAddFunction
have different name but same signature, the compiler will raise error when finding them in one scope.
How should we avoid the signature conflict?
We can’t use object to wrap them here, because we need to import them together anyway. Then we only have two options: class or trait.
Let’s try class first
class AddInterface[A] {
def add(value: A, delta: Int): A
}
class SubInterface[A] {
def sub(value: A, delta: Int): A
}
object AddSyntax {
implicit class AddOps[A](v: A)(implicit addInstance: AddInterface[A]){
def add(delta: Int): A = addInstance.add(v, delta)
}
}
object SubSyntax {
implicit class SubOps[A](v: A)(implicit subInstance: SubInterface[A]){
def sub(delta: Int): A = subInstance.sub(v, delta)
}
}
implicit val ageAddInstance = new AddInterface[Age] {
override def add(age: Age, delta: Int): Age = Age(age.value + delta)
}
implicit val personAddInstance = new AddInterface[Person] {
override def add(person: Person, delta: Int): Person = Person(person.name, person.age.add(delta)) // Age alreay has add function
}
implicit val pointAddInstance = new SubInterface[Point] {
override def add(point: Point, delta: Int): Point = Point(point.x + delta, point.y + delta)
}
implicit val ageSubInstance = new SubInterface[Age] {
override def sub(age: Age, delta: Int): Age = Age(age.value - delta)
}
implicit val personSubInstance = new SubInterface[Person] {
override def sub(person: Person, delta: Int): Person = Person(person.name, person.age.sub(delta)) // Age alreay has sub function
}
implicit val pointSubInstance = new SubInterface[Point] {
override def sub(point: Point, delta: Int): Point = Point(point.x - delta, point.y - delta)
}
$ val age = Age(18)
$ age.sub(1).add(1) // Age(18)
$ val person = Person("Job", Age(18))
$ person.sub(1).add(1) // Person("Job", Age(18))
$ val point = Point(1, 1)
$ point.sub(1).add(1) // Point(1, 1)
It works, we can use add
and sub
together now!
But wait, seems class here doesn’t give us more value than trait, let’s use trait replace class
trait AddInterface[A] {
def add(value: A, delta: Int): A
}
trait SubInterface[A] {
def sub(value: A, delta: Int): A
}
Now we refactor the first part, let’s see if we can get a better processAdd
based the above refactor
def processAdd[A: AddInterface](a: A, delta: Int): A = a.add(delta)
Pretty simple! We use context bound here which only allow the type with add
function to use processAdd
.
In this way, we don’t need to throw exception and check the type of parameter one by one.
Summary
-
To unify the signature of
add
function, we involveimplicit class
with type parameter and hook functionobject AddSyntax { implicit class AddOps[A](v: A)(implicit f: (A, Int) => A){ def add(delta: Int): A = f(v, delta) } }
-
To avoid the signature conflict of hook functions, we involve trait to wrap them
trait AddInterface[A] { def add(value: A, delta: Int): A }
-
To generate the instance of
AddOps
for given type, we need to create a implicit instance of hook traitimplicit val ageAddInstance = new AddInterface[Age] { override def add(age: Age, delta: Int): Age = Age(age.value + delta) }
This is Type Class
, it consist of three parts:
-
Interface
It unify group of operations which can be applied to existing models without changing them.
trait DoSomething[A] { def doSomething(a:A) }
-
Instances
It implements the real logic of
DoSomething
for given typeimplicit val someTypeDoSomething = new DoSomething[SomeType] { def doSomething(a: SomeType) = ??? }
-
Consumer
It is the code which want to use the
DoSomething
operations of given type, it can be animplicit class
or a functionimplicit class DoSomethingOps[A: DoSomething](v: A) { def doSomething(a: A) = implicitly[DoSomething[A]].doSomething(a) }
def processDoSomething[A: DoSomething](a: A) = ???
Type Class
is heavily used by the Scala FP library. Usually the library will supply the Interface
, Consumer
and some of the Instances
of popular data models.
When we want to use the library, we need to implement the Instances
of our custom data models or define our own Consumer
.
For example, Show
is a Type Class
of Cats
.
Cats
already defined the Show Interface
and Show Syntax(Consumer)
.
If we want our class Age
to support Show
, we need to do it like this.
object Age {
implicit val ageShow = new Show[Age] {
override def show(age: Age): String = age.value.toString
}
}
$ val age = Age(18)
$ age.show // "18"
Comments