These types are ugly and cumbersome; they are not at all human readable. How do we get data out of such a type and format it in a way that is useful to the operator? Let's again start with a naive example.
def stringify1[A, B, C]( fa: A => String, fb: B => String, fc: C => String, in: List[(A, (B, C))]): String = { in.map{case (a, (b, c)) => fa(a) + ", " + fb(b) + ", " + fc(c) }.mkString("(", "; ", ")") }
Here we take a List of nested pairs and return a string that is hopefully more human readable than the toString method would provide.
Abstract the F
Like before, we will abstract the F from our function that it may be used with any type constructor of arity 1 rather than hard-coded to List.
import cats.Functor val functorList = new Functor[List]{ override def map[A, B](fa: List[A])(f: A => B): F[B] = fa.map(f) } def stringify2[F[_]: Functor, A, B, C]( fa: A => String, fb: B => String, fc: C => String, in: F[(A, (B, C))]): String = { val F = implicitly[Functor[F]] F.map(in){case (a, (b, c)) => fa(a) + ", " + fb(b) + ", " + fc(c) } ??? }
The cats library provides a nifty Functor typeclass for us so, we can abstract the map call pretty easily. What of the mkString? As it turns out, cats provides a typeclass for this as well! It is called Show; let's see how it works.
import cats.Show def stringify3[F[_]: Functor, A, B, C]( fa: A => String, fb: B => String, fc: C => String, in: F[(A, (B, C))])(implicit FS: Show[F[String]]): String = { val F = implicitly[Functor[F]] val result = F.map(in){case (a, (b, c)) => fa(a) + ", " + fb(b) + ", " + fc(c) } FS.show(result) }
And extending this idea of Show to the Function1 instances we have
def stringify4[F[_]: Functor, A: Show, B: Show, C: Show]( in: F[(A, (B, C))])(implicit FS: Show[F[String]]): String = { val F = implicitly[Functor[F]] val fa = implicitly[Show[A]].show _ val fb = implicitly[Show[B]].show _ val fc = implicitly[Show[C]].show _ val result = F.map(in){case (a, (b, c)) => fa(a) + ", " + fb(b) + ", " + fc(c) } FS.show(result) }
Abstracting over Arity
We'll attempt to build a recursive version of this function using the same principled we've used in previous posts.
def stringify5[F[_]: Functor, A: Show, B: Show]( in: F[(A, B)])(implicit FS: Show[F[String]]): String = { val F = implicitly[Functor[F]] val fa = implicitly[Show[A]].show _ val fb = implicitly[Show[B]].show _ val result = F.map(in){case (a, b) => fa(a) + ", " + fb(b) } FS.show(result) }
This is not what we want! We need to recurse on the Show instance inside the Functor. Let's make a recursive function for Show. This will follow the code we read from Shapeless very closely.
implicit def makeShow[A: Show, B: Show]: Show[(A, B)] = { val fa = implicitly[Show[A]].show _ val fb = implicitly[Show[B]].show _ new Show[(A, B)]{ override def show(t: (A, B)): String = { val (a, b) = t "(" + fa(a) + ", " + fb(b) + ")“ } } }
This says, Given any two Show instances, a Show instances for their pair can be produced. Alternatively, it can be said that given a proof for Show[A] and a proof for Show[B] a proof for Show[(A, B)] follows.
So, now we have the following stringify function:
def stringify[F[_]: Functor, A: Show]( in: F[A])(implicit FS: Show[F[String]]): String = { val F = implicitly[Functor[F]] val fa = implicitly[Show[A]].show _ val result = F.map(in)(fa) FS.show(result) }
Notice how simple our code has become. All of the specific type information has been absorbed into the recursive implicit functions. This is simply the Show instance for Functor itself. Furthermore, our makeShow function will produce Show instances for any nesting of Tuple2 instances; it generates Show instances for binary trees. The makeShow function is 10 lines of code (even including the Scala boilerplate) and gave us a giant boost in usefulness for our library code.
What's the Point?
Let's see an example of how this can be used. Given our individual Show instances:
implicit val ShowListString = new Show[List[String]]{ def show(in: List[String]): String = in.mkString("(", "; ", ")") } implicit val showInt = new Show[Int]{ override def show(in: Int): String = in.toString} implicit val showLong = new Show[Long]{ override def show(in: Long): String = in.toString} implicit val showString = new Show[String]{ override def show(in: String): String = in} implicit val showDouble = new Show[Double]{ override def show(in: Double): String = f"$in%.2f"} implicit val showFloat = new Show[Float]{ override def show(in: Float): String = f"$in%.2f"} implicit val showArrayByte = new Show[Array[Byte]]{ override def show(in: Array[Byte]): String = new String(in)}
Our writer and reader code becomes:
//Write the thing with our writer val result = implicitly[Result1] //Read the thing back with our reader println(stringify(result))
For anyone to create an application which reads and writes data with our library they need only define the specific business logic and types for their application. If they miss writing an instance, the compiler will tell them. If they rearrange the order of the types in IO, the compiler will figure it out for them. Many common pain points of writing data readers and writers have been taken care of by the library implementor giving the rest of the team the ability to focus on business logic.
The goal here is not to have an entire company of developers who write this kind of code. Just like the goal of any business is to have employees who each bring something new to the team, the goal here is to have a few developers who write these libraries that other developers can use to develop business applications. The benefit of this is the libraries help limit the kinds of errors that can make it to production; the entire production cycle gets a confidence boost.