We have a reasonably abstract pipeline in
trait Pipeline[F[_], A, B]{ final def apply(uri: URI): F[Unit] = write(computation(read(uri))) def read(uri: URI): F[A] def computation(in: F[A]): F[B] def write(in: F[B]): F[Unit] }
Recognizing Higher-Kinded Duplication
Taking a close look at the trait, we see the computation and write functions are the same aside from their type variables. In fact, if we rename them to have the same name, the compiler complains
scala> :paste // Entering paste mode (ctrl-D to finish) trait Pipeline[F[_], A, B]{ def perform(in: F[A]): F[B] def perform(in: F[B]): F[Unit] } // Exiting paste mode, now interpreting. <console>:9: error: double definition: method perform:(in: F[B])F[Unit] and method perform:(in: F[A])F[B] at line 8 have same type after erasure: (in: Object)Object def perform(in: F[B]): F[Unit]
Since these are the same, we can build an abstraction to simplify our API even further.
trait Pipeline[F[_], A, B]{ final def apply(uri: URI): F[Unit] = { val in = read(uri) val computed = convert(in)(computation) convert(computed)(write) } def read(uri: URI): F[A] def computation(in: A): B def write(in: B): Unit def convert[U, V](in: F[U], f: U => V): F[V] }
We've removed the need for the developer to understand the effect type in order to reason about a computation or write step. Now, let's focus on this new function
def convert[U, V](in: F[U], f: U => V): F[V]
This is super abstract. Like so abstract it is meaningless without context. I am reminded of this video in which Rob Norris explains how he continued to abstract his database code until some mathematical principles sort of arose from the work. In this talk, he points out that anytime he writes something sufficiently abstract he checks a library for it, as he probably has not himself discovered some new basic principle of mathematics. We do the same here.
Inside the cats library we find the following def within the Functor class
def map[A, B](fa: F[A])(f: A => B): F[B]
This is the same as if we wrote our convert function as curried rather than multiple argument. We replace our custom function with one from a library; the chance is greater that a developer is well-informed on cats than our internal library. (post on implicits and type classes)
trait Pipeline[F[_], A, B]{ final def apply(uri: URI)(implicit F: Functor[F]): F[Unit] = { val in = read(uri) val computed = F.map(in)(computation) F.map(computed)(write) } def read(uri: URI): F[A] def computation(in: A): B def write(in: B): Unit }
Here we were able to replace an internal (thus constrained) implementation detail with an external (thus liberated) argument. In other words, we have lifted an implementation detail outside our class giving the client code freedom to use the same instantiated Pipeline in a multitude of contexts.