Continuing with scary names, Applicative is next.
Applicative
Cats define the Applicative typeclass with 250 lines of code. Those, for learning purposes, can be shrunk to the following 4.
trait Applicative[F[_]] extends cats.Functor[F] {
def pure[A](x: A): F[A]
def ap[A, B](ff: F[A => B])(fa: F[A]): F[B]
}
The first piece of information is about the type. Applicatives are higher-kinded types. They wrap around another type likeOption[_]
, or List[_]
.
Next, Applicatives extend Functors. This gives them the map
function, but forces them to obey the identity, and composition laws.
Finally, Applicatives have two abstract functions. The first, pure
, is the easiest of the two. Similar to a constructor, it wraps a value of type A
in a new F[A]
. The second, ap
, executes code within the context of F[_]
. This allows to build functions like the following.
def tuple2[A, B](fa: F[A], fb: F[B]): F[(A, B)] =
ap(map(fa)(a => (b: B) => (a, b)))(fb)
tuple2
, also called product
, combines the content of two F[_]
into a tuple
. tuple3
, tuple4
, up to tuple22
, combines more.
Laws
On top of the Functor laws, Applicatives have two they must obey.
Homomorphism
The result of a function should always be the same whether it is obtained inside, or outside of the F[_]
context.
val f = (a: Int) => a + 1
assert(Option(f).ap(Option(1)) == Option(f(1)))
assert(List(f).ap(List(1)) == List(f(1)))
Interchange
Like for multiplication, the order of the arguments shouldn’t affect the results.
val f = (a: Int) => a + 1
assert(
Option(f).ap(Option(1)) ==
Option((f: Int => Int) => f(1)).ap(Option(f)))
assert(
List(f).ap(List(1)) ==
List((f: Int => Int) => f(1)).ap(List(f)))
Example
Leaving the theoretical behind, the following example should highlight the benefits of Applicatives.
Invoices are documents given with purchases of goods, and services. They contain user details, a billing address, and information on the purchased items.
import cats.data.NonEmptyList
import java.util.UUID
type Quantity = Int
type Price = BigDecimal
case class User(id: UUID)
case class Address(id: UUID)
case class Item(id: UUID)
case class Invoice(
id: UUID,
user: User,
billing: Address,
items: NonEmptyList[(Item, Quantity, Price)])
To limit duplication, a factory creates invoices out of identifiers.
object InvoiceFactory {
def fromIdentifiers(
id: UUID,
userId: UUID,
billingId: UUID,
itemIds: NonEmptyList[(UUID, Quantity, Price)]
): Invoice = ???
}
Without dependency injection, this constructor becomes very rigid. A better approach is to give its readers.
trait UsersReader {
def fromIdentifier(id: UUID): User
}
trait AddressesReader {
def fromIdentifier(id: UUID): Address
}
trait ItemsReader {
def fromIdentifier(id: UUID): Item
}
class InvoiceFactory(
usersReader: UsersReader,
billingsReader: BillingsReader,
itemsReader: ItemsReader) {
def fromIdentifiers(
id: UUID,
userId: UUID,
billingId: UUID,
itemIds: NonEmptyList[(UUID, Quantity, Price)]
): Invoice = ???
}
Those readers can access the data from a variety of sources. Some would be pure, while others could throw errors. To represent this, results are wrapped in a type.
Enforcing a particular effect, like Option
, Either
, or Future
, would be limiting. A higher-kinded type is the perfect solution.
trait UsersReader[F[_]] {
def fromIdentifier(id: UUID): F[User]
}
trait AddressesReader[F[_]] {
def fromIdentifier(id: UUID): F[Address]
}
trait ItemsReader[F[_]] {
def fromIdentifier(id: UUID): F[Item]
}
class InvoiceFactory[F[_]](
usersReader: UsersReader[F],
billingsReader: BillingsReader[F],
itemsReader: ItemsReader[F]) {
def fromIdentifiers(
id: UUID,
userId: UUID,
billingId: UUID,
itemIds: NonEmptyList[(UUID, Quantity, Price)]
): F[Invoice] = ???
}
Without specifying more information about F[_]
, it is impossible to define fromIdentifiers
. An F[Invoice]
can’t be constructed with F[User]
, F[Address]
, and List[F[Item]]
.
This is where Applicatives come into play.
import cats.Applicative
import cats.implicits._
class InvoiceFactory[F[_]: Applicative](
usersReader: UsersReader[F],
addressesReader: AddressesReader[F],
itemsReader: ItemsReader[F]) {
def fromIdentifiers(
id: UUID,
userId: UUID,
billingId: UUID,
itemIds: NonEmptyList[(UUID, Quantity, Price)]
): F[Invoice] = {
val fUser = usersReader.fromIdentifier(userId)
val fBilling = addressesReader.fromIdentifier(billingId)
val fItems = itemIds.traverse { case (id, q, p) =>
itemsReader.fromIdentifier(id).map((_, q, p))
}
(fUser, fBilling, fItems).mapN(Invoice(id, _, _, _))
}
}
Applicative solved our problem with .traverse
, and .mapN
.
.traverse
executes, for elements in a Traversable
, a function that returns an Applicative. Instead of returning Traversable[F[_]]
, like .map
, it returns F[Traversable[_]]
.
scala> import cats.implicits._
import cats.implicits._
scala> List(1, 2, 3).traverse(i => i.some)
res0: Option[List[Int]] = Some(List(1, 2, 3))
Beware, .traverse
stops at the first “error”.
scala> List(1, 2, 3).traverse { i => println(i); none[Int] }
1
res1: Option[List[Int]] = None
.mapN
is a helper for tupled
, and then map
. It composes Applicatives into a tuple and maps over it.
scala> (1.some, 2.some, 3.some).mapN {
| case (a, b, c) => a + b + c
| }
res2: Option[Int] = Some(6)
Again, .mapN
will return the first “error” it encounters.
scala> (1.some, 2.some, none[Int]).mapN {
| case (a, b, c) => a + b + c
| }
res3: Option[Int] = None
With the generic InvoiceFactory
defined the readers would be next. For them to be compatible, they would have to return an Applicative. This would allow test instances to return Id
, and production ones Future
, or similar.
Applicatives are great abstractions. They permitted me to define a flexible factory that returns only successful results. Nothing to be scared about.