step 02: Cats
Abstractions
It turns out that most of the considerations from the previous step are not specific to our row parser domain - they occur in various contexts, and they can be implemented as reusable abstractions.
We have reinvented three such abstractions:
- The idea of sinking a pure function into a black box to transform it internally is known as “Functor” - our
#transform()
is usually calledmap
. - Combining black box that can conjure up a function with another black box of the same type that knows how to produce a value for the input type of the function is called “Applicative”.
#combine()
is commonly known asap
(or alternetively as the operator<*>
that we already use),#const()
is usually calledpure
(or sometimes, confusingly,return
). - Having a function produce a pair of some output along with another item that can be fed as input into the same (or a similar) function again is known as “State”.
Cats
The Cats library provides implementations for all these abstractions:
Functor and Applicative are implemented as type classes. State is a concrete data type that in turn is integrated with type classes such as Functor and Applicative.
We’ll look into State in a later post. For now, we’ll convert our #const()
and #combine()
to a Cats Applicative. We’ll find that this gives us a richer API than our RYO version (and a Functor for free).
Applicative and Functor
Transforming our custom #const()
and #combine()
to Applicative
is straightforward:
given Applicative[RowParser] with
def pure[A](x: A): RowParser[A] = (x, _)
def ap[A, B](ff: RowParser[A => B])(fa: RowParser[A]): RowParser[B] =
row => {
val (f, fr) = ff.parse(row)
val (a, ar) = fa.parse(fr)
f(a) -> ar
}
We could convert #transform()
to Functor
as well, but we don’t have to. Given an Applicative, we can always build a Functor: Just wrap the function with pure
and then use it with ap
in order to get map
. Cats provides this as a default implementation, so we can use it directly.
val int: RowParser[Int] = string.map(_.toInt)
val date: RowParser[LocalDate] = string.map(LocalDate.parse)
Applicative Syntax
We can assemble our user parser with syntax similar to what we came up with before.
val userParser: RowParser[User] =
(User.apply.curried).pure[RowParser] <*> int <*> string <*> date
Cats provides an alternative variant.
val userParser: RowParser[User] =
(int, string, date).mapN(User.apply)
Sanity check:
sbt:nanocsv> runMain de.sangamon.nanocsv.step02.main data/users.csv
1,Torsten Test,1970-01-01
2,Andrea Anders,2000-02-20
User(1,Torsten Test,1970-01-01)
User(2,Andrea Anders,2000-02-20)
There are more convenience functions built on Applicative
, for example the operators *>
and <*
that ignore the result of the left or right hand side. (We’ll probably see example applications of this in a later post.) There are also ties between Applicative
and other abstractions that provide an even richer API.
The full code for this post can be found in package de.sangamon.nanocsv.step02
. Now that we’ve become proper citizens of Cats, let’s take one further step to ease parser assembly for our users.