Semigroupal and Applicative
Functors and Monads lets us sequence operations using map and flatMap, which is very useful. But there are some limitations in what computation flow they can do.
For example, form validation. We want all errors, just not the first one. Either is great for validation, but it only gets us the first one.
Another example, concurrent evaluation of Futures. map and flatMap can combine Futures, but each one is dependent on the previous one completing.
There are two type classes that enables these use cases:
Semigroupalcomposes pairs of context. By usingSemigroupalandFunctorwe can sequence functions with multiple arguments.ApplicativeextendsSemigroupalandFunctor. It provides a way to apply functions to parameters within a context. It also provides thepurefunction we've used before.
Semigroupal
Semigroupal is a type class that allows us to combine contexts. Semigroupal[F] allows us to combine F[A] and F[B] into F[(A,B)].
trait Semigroupal[F[_]] {
def product[A,B](fa: F[A], fb: F[B]): F[(A,B)]
}
The order of the two contexts doesn't matter, they are independent. This makes this more flexible than flatMap which has a definite order.
import cats.Semigroupal
import cats.instances.option._ // for Semigroupal
Semigroupal[Option].product(Some(123), Some("abc"))
// res0: Option[(Int, String)] = Some((123,abc))
Semigroupal[Option].product(None, Some("abc"))
// res1: Option[(Nothing, String)] = None
Semigroupal[Option].product(Some(123), None)
// res2: Option[(Int, Nothing)] = None
Semigroupal With Example Types
Future
We can use Semigroupal to have concurrent Futures zipped together:
import cats.syntax.apply._ // for mapN
case class Cat(
name: String,
yearOfBirth: Int,
favoriteFoods: List[String]
)
val futureCat = (
Future("Garfield"),
Future(1978),
Future(List("Lasagne"))
).mapN(Cat.apply)
Await.result(futureCat, 1.second)
// res4: Cat = Cat(Garfield,1978,List(Lasagne))
List
However, Lists doesn't get zipped, but rather it performs a cartesian product:
import cats.Semigroupal
import cats.instances.list._ // for Semigroupal
Semigroupal[List].product(List(1, 2), List(3, 4))
// res5: List[(Int, Int)] = List((1,3), (1,4), (2,3), (2,4))
Either
We had the example of form validation to collect all errors. So, naturally we expect combining Eithers would give us all the errors. We'd be wrong. It does the same fail fast behavior as flatMap:
import cats.instances.either._ // for Semigroupal
type ErrorOr[A] = Either[Vector[String], A]
Semigroupal[ErrorOr].product(
Left(Vector("Error 1")),
Left(Vector("Error 2"))
)
// res7: ErrorOr[(Nothing, Nothing)] = Left(Vector(Error 1))
Semigroupal with Monads
The reason why Either and List is weird is because they are both monads. Monad extends Semigroupal and has a consistent, standard definition of product in terms of map and flatMap. The consistency is needed for higher level abstractions.
Future only works because we create the futures before we use product.
So why semigroupal? We can create data types that have instances of Semigroupal (and Applicative) but not Monad. This enables us to have useful product implementations.
Exercise: Implement Monad's Product
Implement product in terms of flatMap:
import cats.syntax.flatMap._ // for flatMap
import cats.syntax.functor._ // for map
import cats.Monad
def product[M[_]: Monad, A, B](x: M[A], y: M[B]): M[(A, B)] =
x.flatMap(a => y.map(b => (a, b)))
which is the same as:
def product[M[_]: Monad, A, B](x: M[A], y: M[B]): M[(A, B)] =
for {
a <- x
b <- y
} yield (a, b)
This explains why List does not zip and Either only takes the first error.
Validated
Cats provides a data type called Validated that has an instance of Semigroupal but no instance of Monad (which Either does). The product can then aggregate errors.
import cats.Semigroupal
import cats.data.Validated
import cats.instances.list._ // for Monoid
type AllErrorsOr[A] = Validated[List[String], A]
Semigroupal[AllErrorsOr].product(
Validated.invalid(List("Error 1")),
Validated.invalid(List("Error 2"))
)
// res1: AllErrorsOr[(Nothing, Nothing)] = Invalid(List(Error 1, Error 2))
We can use Validated to accumulate errors and Either to fail fast.
Validated isn't a monad, so it doesn't have flatMap but it does have andThen for something similar. It isn't called flatMap because it doesn't follow all the laws of monads.
Exercise - Form Validation
TODO
Apply and Applicative
Semigroupals are not referenced frequently - they provide a subset of the functionality of another type class, applicative functor or "applicative" for short.
Cats provide two type classes to model applicatives:
Applywhich extendsSemigroupalandFunctorand addsapmethod, which applies a parameter to a function with a context.ApplicativeextendsApplyand addspuremethod.
trait Apply[F[_]] extends Semigroupal[F] with Functor[F] {
def ap[A, B](ff: F[A => B])(fa: F[A]): F[B]
def product[A, B](fa: F[A], fb: F[B]): F[(A, B)] =
ap(map(fa)(a => (b: B) => (a, b)))(fb)
}
trait Applicative[F[_]] extends Apply[F] {
def pure[A](a: A): F[A]
}
- The
apmethod applies a parameterfato a functionffwithin a contextF[_]. - The
productis defined in terms ofapandmap.
ap, map, and product are tightly bound - each one can be expressed in terms of the other.
Applicative introduces pure method to create an instance from an unwrapped value.
Applicative is to Apply as Monoid is to Semigroup.
Excercise - Define ap, map, and product in Monad only in flatMap and pure
From previous Monad seciton, we already defined map
trait Monad[F[_]] extends Applicative[F] with FlatMap[F] {
def pure[A](a: A): F[A]
def flatMap[A, B](value: F[A])(func: A => F[B]): F[B]
def map[A, B](value: F[A])(func: A => B): F[B] =
flatMap(value)(a => pure(func(a)))
}
How do we define def ap[A, B](ff: F[A => B])(fa: F[A]): F[B]?
def ap[A, B](ff: F[A => B])(fa: F[A]): F[B] =
for {
f <- ff
a <- fa
} yield f(a)
or
def ap[A, B](ff: F[A => B])(fa: F[A]): F[B] =
ff.flatMap(f => fa.map(f))
Putting it together (and pulling product definition in Apply)
trait Monad[F[_]] extends Applicative[F] with FlatMap[F] {
def pure[A](a: A): F[A]
def flatMap[A, B](value: F[A])(func: A => F[B]): F[B]
def map[A, B](value: F[A])(func: A => B): F[B] =
flatMap(value)(a => pure(func(a)))
def ap[A, B](ff: F[A => B])(fa: F[A]): F[B] =
flatMap(ff)(f => map(fa)(f))
def product[A, B](fa: F[A], fb: F[B]): F[(A, B)] =
ap(map(fa)(a => (b: B) => (a, b)))(fb)
}
Replacing map and ap (need to double check my code here):
trait Monad[F[_]] extends Applicative[F] with FlatMap[F] {
def pure[A](a: A): F[A]
def flatMap[A, B](value: F[A])(func: A => F[B]): F[B]
def map[A, B](value: F[A])(func: A => B): F[B] =
flatMap(value)(a => pure(func(a)))
def ap[A, B](ff: F[A => B])(fa: F[A]): F[B] =
flatMap(ff)(f => flatMap(fa)(a => pure(f(a))))
//Yikes
def product[A, B](fa: F[A], fb: F[B]): F[(A, B)] =
flatMap(flatMap(fa)(a => pure((b: B) => (a, b))))(f => flatMap(fb)(b => pure(f(b))))
}