(Examples can be found on GitHub)
In the previous post, we introduced the Argonaut library to convert between values and JSON strings. The important part of this conversion is there was no superclass or interface to implement in order to get the benefit of JSON across classes. All we needed to do was define values of type CodecJson for each of the types we wanted to convert. We added the functionality to the class without changing the class itself.
Argonaut allowed us to call toJson on classes with a codec and decodeOption on Strings to produce values of classes with a codec defined. This type of polymorphism, where a function's implementation depends on its inputs is called ad-hoc polymorphism. Furthermore, when we define a type, T, which defines functionality across classes to be used in ad-hoc polymorphic functions we call T a Type Class. Type Class polymorphism is a specific flavor of ad-hoc polymorphism.
Type Class polymorphism is a powerful tool for expressing context based functionality far more powerful than subclass polymorphism. As a well-known example take the Java interfaces Comparable and Comparator. If some data is defined in a class which implements Comparable, it can be sorted one way and needs an entire second class definition to be sorted with a different method. On the other hand, using Comparator the data is defined with a single class and each sort method gets its own Comparator. Comparator is a Type Class and allows the developer to determine in which contexts which sorting method should be used.
Subclass Method
Take the following traits:
trait Adder[Type]{ def add(other: Type): Type } trait Chainer[Arg, Type[Arg]]{ def chain[Res](f: Arg => Type[Res]): Type[Res] }
Adder describes how to add two values of some Type together. Chainer describes how to chain operations over a parameterized type.
We'll use the idea of a team to illustrate. For the sake of simplicity we say a team consists of people of a certain profession. So we can have a team of engineers or a team of doctors or a team of cashiers or ...
Teams can (trivially) grow by hiring but, they can also grow by combining with other teams. Teams can be added.
Teams can have members who are themselves team leads. At times, the members of a lead's team must join the team the lead belongs to. This implies an operation which develops teams out of the members of teams. Teams can be chained.
Here is our implementation of Team given this functionality:
case class Team[Type](members: List[Type]) extends Adder[Team[Type]] with Chainer[Type, Team]{ override def add(other: Team[Type]): Team[Type] = { Team(members ++ other.members) } override def chain[Res]( f: Type => Team[Res]): Team[Res] = { val list = members.flatMap(member => f(member).members) Team(list) } }
Simple enough but this doesn't account for an organization of structured teams. For an organization who develops teams that each have one product lead and one technical lead, simple concatenation won't maintain a soft ranking of individuals within the new team. We need a new Team definition which accounts for this.
case class TeamStructured[Type](members: List[Type]) extends Adder[TeamStructured[Type]] with Chainer[Type, TeamStructured]{ override def add( other: TeamStructured[Type]): TeamStructured[Type] = { val (lead1, indi1) = members.splitAt(2) val (lead2, indi2) = other.members.splitAt(2) TeamStructured(lead1 ++ lead2 ++ indi1 ++ indi2) } override def chain[Res]( f: Type => TeamStructured[Res]): TeamStructured[Res] = { val (leaders, individuals) = members.map{member => val mems = f(member).members mems.splitAt(2) }.unzip TeamStructured( leaders.flatMap {x=>x} ++ individuals.flatMap{x=>x}) } }
Now we have two definitions for the same data that differ only by functionality. We have a triple coupling here:
- Data Definition
- Addition Description
- Chaining Description
If the data needs to change (from List to Set is a good place to start) the change needs to be made in two places. Each function which accepts a Team for the purpose of team composition and combination needs to know which style of team it needs at development time. These problems gets worse for each possibility for combining and chaining teams (maybe a round robin or reverse algorithm would fit in certain situations). Type Classes solve these issues.
Type Class Method
Our traits become:
//The underscore here implies we need a parameterized type. trait Adder[Type[_]]{ def add[Item]( left: Type[Item], right: Type[Item]): Type[Item] } trait Chainer[Type[_]]{ def chain[Item, Res]( arg: Type[Item], f: Item => Type[Res]): Type[Res] }
These have the same uses as their counterparts above. However we have a single definition of the Team type:
case class Team[Type](members: List[Type])
The data is defined in a single place. Each piece of software which requires a Team has a consistent idea about what a Team is and means. The two versions of functionality are defined by:
object unstructured{ implicit def adder: Adder[Team] = new Adder[Team]{ override def add[Item]( left: Team[Item], right: Team[Item]): Team[Item] = { Team(left.members ++ right.members) } } implicit def chainer: Chainer[Team] = new Chainer[Team]{ override def chain[Item, Res]( arg: Team[Item], f: Item => Team[Res]): Team[Res] = { val list = arg.members.flatMap( member => f(member).members) Team(list) } } } object structured{ implicit def adder: Adder[Team] = new Adder[Team]{ override def add[Item]( left: Team[Item], right: Team[Item]): Team[Item] = { val (lead1, indi1) = left.members.splitAt(2) val (lead2, indi2) = right.members.splitAt(2) Team(lead1 ++ lead2 ++ indi1 ++ indi2) } } implicit def chainer: Chainer[Team] = new Chainer[Team]{ override def chain[Item, Res]( arg: Team[Item], f: Item => Team[Res]): Team[Res] = { val (leaders, individuals) = arg.members.map{member => val mems = f(member).members mems.splitAt(2) }.unzip Team( leaders.flatMap {x=>x} ++ individuals.flatMap{x=>x}) } } }
Now, each function which accepts a team, if needed, will also accept an adder or chainer or both (wholly decoupled). The down side here is each call to such a function requires at least one extra argument from the subclass versions. Scala has a fix for this limitation.
Implicits
The implicit keyword before a definition is an important part of making Type Class polymorphism beneficial to the developer. The word implicit, according to Oxford Dictionaries, means Implied though not plainly expressed. In Scala it means we can prepend the implicit keyword to an argument list and not explicitly produce the value in code assuming a valid value is in scope. For example:
def chainTeams[Type, Result]( team: Team[Type])( func: Type => Team[Result])( implicit chain: Chainer[Team]): Team[Result] = { chain.chain(team, func) }
This has three arguments, the team to operate on, the operation to perform, and the chainer for application. However, since the final argument is implicit, if we bring a valid implicit value into scope, there is no need to pass it in directly.
import structured._ val team: Team[Person] = Team(List(???)) val func: Person => Team[Person] = {(p: Person) => ???} val newTeam: Team[Person] = chainTeams(team)(func)//valid
Since, we don't need to explicitly state the Chainer it keeps boilerplate clean. A nice effect of implicit resolution is if you have scoped two separate valid values for the implicit argument, the compiler will complain. The suggestion if you have multiple valid implicits in scope is to decouple your code functionally. No single scope should have use of more than one implicit of the same type; this is a code smell. A corollary to this is one should not explicitly provide implicit arguments; let the compiler do its work.
In the final post of this series, we will introduce another library, Cats.