(Examples can be found on GitHub)
Last time, we discovered Type Classes and how they give us more power to build extensible software than sub class polymorphic trees can give us. Here we introduce the Cats library to use a production quality library instead of the Adder and Chainer classes from the previous post.
We'll need the following imports from the cats library:
import cats.Monoid import cats.Monad import cats.implicits._
and to recall our Team definition:
case class Team[Type](members: List[Type])
The Monoid Type Class
Recall the Adder trait described the process of adding two containers of the same type together to create a new container of that type. This is the basic idea for a structure in category theory called a Monoid. A full treatment of a Monoid is beyond the scope of this post; the important thing here is they describe semantics for adding members of other types together.
In the cats library, a Monoid for our unstructured Team data is defined thus:
implicit def adder[Arg]: Monoid[Team[Arg]] = new Monoid[Team[Arg]]{ override def empty: Team[Arg] = Team(Nil) override def combine( left: Team[Arg], right: Team[Arg]): Team[Arg] = { val newMembers = left.members ++ right.members Team(newMembers) } }
A Monoid has two operations, empty and combine, where empty is the identity element under the combine operation. In other words empty is a value, e, that when combined with any other value, v, returns v. Monoid replaces our Adder trait from the previous post. Our structured Team would have Monoid:
implicit def adder[Arg]: Monoid[Team[Arg]] = new Monoid[Team[Arg]]{ override def empty: Team[Arg] = Team(Nil) override def combine( left: Team[Arg], right: Team[Arg]): Team[Arg] = { val (lead1, indi1) = left.members.splitAt(2) val (lead2, indi2) = right.members.splitAt(2) val newMembers = lead1 ++ lead2 ++ indi1 ++ indi2 Team(newMembers) } }
Note, the empty value is the same for both cases. This is often the case for multiple Monoids over the same data structure; no matter how you combine elements, the identity is trivially applied.
The Monad Type Class
Now, we shift our focus to the Chainer trait from the previous post. This trait described how to sort of flatten a nesting of containers for instance, a Team[Team[_]] into a Team[_]. This is the basic operation behind the Monad. Again, we're not interested in figuring out Monads in detail; we're just trying to use a library in our work.
With Cats we'll have:
implicit def chainer: Monad[Team] = new Monad[Team]{ override def flatMap[Arg, Ret]( team: Team[Arg])(f: Arg => Team[Ret]): Team[Ret] = { val newMembers = team.members.flatMap(f(_).members) Team(newMembers) } }
for our unstructred Monad and our structured would look like:
implicit def chainer: Monad[Team] = new Monad[Team]{ override def flatMap[Arg, Ret]( team: Team[Arg])(f: Arg => Team[Ret]): Team[Ret] = { val (leaders, individuals) = team.members.map{member => val mems = f(member).members mems.splitAt(2) }.unzip Team( leaders.flatMap {x=>x} ++ individuals.flatMap{x=>x}) } }
Monads add the flatMap (also called bind or >>= in some circles) operation to a data type. flatMap describes how to take a value of Team and a function which maps a member to a Team to produce another Team. It is a flattening operation. These are important as they describe data flow and functional composition through a system. To get the individual contributors from their Directors on could:
case class Director(name: String) case class Manager(name: String) case class Individual(name: String) val directors: Team[Director] = ??? def managers(director: Director): Team[Managers] = ??? def individualContributors(manager: Manager): Team[Individual] = ??? val individuals = directors >>= (managers) >>= (individualContributors)
Then swapping out different functionality is simply recombining your function calls around your Monadic chain.
Monoids and Monads are simple to use. They describe operations to combine and process data as simple, type safe functional chains.