Invariant Monoidal
API Documentation: InvariantMonoidal
InvariantMonoidal
combines Invariant
and Semigroupal
with the addition of a unit
methods, defined in isolation the InvariantMonoidal
type class could be defined as follows:
trait InvariantMonoidal[F[_]] {
def unit: F[Unit]
def imap[A, B](fa: F[A])(f: A => B)(g: B => A): F[B]
def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
}
Practical uses of InvariantMonoidal
appear in the context of codecs, that is interfaces to capture both serialization and deserialization for a given format. Another notable examples is Semigroup
.
This tutorial first shows how Semigroup
is InvariantMonoidal
, and how this can be used create Semigroup
instances by combining other Semigroup
instances. Secondly, we present a complete example of Codec
for the CSV format, and show how it is InvariantMonoidal
. Lastly, we present an alternative definition of InvariantMonoidal
as a generalization of Invariant
, and show that both definitions are equivalent.
Semigroup
is InvariantMonoidal
As explained in the Invariant
tutorial, Semigroup
forms an invariant functor. Indeed, given a Semigroup[A]
and two functions A => B
and B => A
, one can construct a Semigroup[B]
by transforming two values from type B
to type A
, combining these using the Semigroup[A]
, and transforming the result back to type B
. Thus to define an InvariantMonoidal[Semigroup]
we need implementations for unit
and product
.
To construct a Semigroup
from a single value, we can define a trivial Semigroup
with a combine that always outputs the given value. A Semigroup[(A, B)]
can be obtained from two Semigroup
s for type A
and B
by deconstructing two pairs into elements of type A
and B
, combining these element using their respective Semigroup
s, and reconstructing a pair from the results:
import cats.Semigroup
def unit: Semigroup[Unit] = (_, _) => ()
def product[A, B](fa: Semigroup[A], fb: Semigroup[B]): Semigroup[(A, B)] = {
case ((xa, xb), (ya, yb)) => fa.combine(xa, ya) -> fb.combine(xb, yb)
}
Given an instance of InvariantMonoidal
for Semigroup
, we are able to combine existing Semigroup
instances to form a new Semigroup
by using the Semigroupal
syntax:
import cats.syntax.all._
// Let's build a Semigroup for this case class
case class Foo(a: String, c: List[Double])
implicit val fooSemigroup: Semigroup[Foo] = (
(implicitly[Semigroup[String]], implicitly[Semigroup[List[Double]]])
.imapN(Foo.apply)(Function.unlift(Foo.unapply))
)
Our new Semigroup in action:
Foo("Hello", List(0.0)) |+| Foo("World", Nil) |+| Foo("!", List(1.1, 2.2))
// res1: Foo = Foo(a = "HelloWorld!", c = List(0.0, 1.1, 2.2))
CsvCodec
is InvariantMonoidal
We define CsvCodec
, a type class for serialization and deserialization of CSV rows:
type CSV = List[String]
trait CsvCodec[A] {
def read(s: CSV): (Option[A], CSV)
def write(a: A): CSV
}
The read
method consumes columns from a CSV row and returns an optional value and the remaining CSV. The write
method produces the CSV representation of a given value.
Beside the composition capabilities illustrated later in this tutorial, grouping both serialization and deserialization in a single type class has the advantage to allows the definition of a law to capture the fact that both operations play nicely together:
forAll { (c: CsvCodec[A], a: A) => c.read(c.write(a)) == ((Some(a), List()))
Let's now see how we could define an InvariantMonoidal
instance for CsvCodec
. Lifting a single value into a CsvCodec
can be done "the trivial way" by consuming nothing from CSV and producing that value, and writing this value as the empty CSV:
trait CCUnit {
def unit: CsvCodec[Unit] = new CsvCodec[Unit] {
def read(s: CSV): (Option[Unit], CSV) = (Some(()), s)
def write(u: Unit): CSV = List.empty
}
}
Combining two CsvCodec
s could be done by reading and writing each value of a pair sequentially, where reading succeeds if both read operations succeed:
trait CCProduct {
def product[A, B](fa: CsvCodec[A], fb: CsvCodec[B]): CsvCodec[(A, B)] =
new CsvCodec[(A, B)] {
def read(s: CSV): (Option[(A, B)], CSV) = {
val (a1, s1) = fa.read(s)
val (a2, s2) = fb.read(s1)
((a1, a2).mapN(_ -> _), s2)
}
def write(a: (A, B)): CSV =
fa.write(a._1) ++ fb.write(a._2)
}
}
Changing a CsvCodec[A]
to CsvCodec[B]
requires two functions of type A => B
and B => A
to transform a value from A
to B
after deserialized, and from B
to A
before serialization:
trait CCImap {
def imap[A, B](fa: CsvCodec[A])(f: A => B)(g: B => A): CsvCodec[B] =
new CsvCodec[B] {
def read(s: CSV): (Option[B], CSV) = {
val (a1, s1) = fa.read(s)
(a1.map(f), s1)
}
def write(a: B): CSV =
fa.write(g(a))
}
}
Putting it all together:
import cats.InvariantMonoidal
implicit val csvCodecIsInvariantMonoidal: InvariantMonoidal[CsvCodec] =
new InvariantMonoidal[CsvCodec] with CCUnit with CCProduct with CCImap
We can now define a few CsvCodec
instances and use the methods provided by InvariantMonoidal
to define CsvCodec
from existing CsvCodec
s:
val stringCodec: CsvCodec[String] =
new CsvCodec[String] {
def read(s: CSV): (Option[String], CSV) = (s.headOption, s.drop(1))
def write(a: String): CSV = List(a)
}
def numericSystemCodec(base: Int): CsvCodec[Int] =
new CsvCodec[Int] {
def read(s: CSV): (Option[Int], CSV) =
(s.headOption.flatMap(head => scala.util.Try(Integer.parseInt(head, base)).toOption), s.drop(1))
def write(a: Int): CSV =
List(Integer.toString(a, base))
}
case class BinDec(binary: Int, decimal: Int)
val binDecCodec: CsvCodec[BinDec] = (
(numericSystemCodec(2), numericSystemCodec(10))
.imapN(BinDec.apply)(Function.unlift(BinDec.unapply))
)
case class Foo(name: String, bd1: BinDec, bd2: BinDec)
val fooCodec: CsvCodec[Foo] = (
(stringCodec, binDecCodec, binDecCodec)
.imapN(Foo.apply)(Function.unlift(Foo.unapply))
)
Finally let's verify out CsvCodec law with an example:
val foo = Foo("foo", BinDec(10, 10), BinDec(20, 20))
// foo: Foo = Foo(
// name = "foo",
// bd1 = BinDec(binary = 10, decimal = 10),
// bd2 = BinDec(binary = 20, decimal = 20)
// )
val fooCsv = fooCodec.write(foo)
// fooCsv: CSV = List("foo", "1010", "10", "10100", "20")
fooCodec.read(fooCsv)
// res2: (Option[Foo], CSV) = (
// Some(
// value = Foo(
// name = "foo",
// bd1 = BinDec(binary = 10, decimal = 10),
// bd2 = BinDec(binary = 20, decimal = 20)
// )
// ),
// List()
// )
fooCodec.read(fooCodec.write(foo)) == ((Some(foo), List()))
// res3: Boolean = true
InvariantMonoidal
as a generalization of Invariant
To better understand the motivations behind the InvariantMonoidal
type class, we show how one could naturally arrive to its definition by generalizing the concept of Invariant
functor. This reflection is analogous to the one presented in Free Applicative Functors by Paolo Capriotti to show how Applicative
are a generalization of Functor
.
Given an Invariant[F]
instance for a certain context F[_]
, its imap
method gives a way to lift two unary pure functions A => B
and B => A
into contextualized functions F[A] => F[B]
. But what about functions of other arity?
For instance, a value a
of type A
can be seen as a pair of nullary functions, one than given no input returns a
, and the other than give a
return no output, which we might want to lift them into a contextualized F[A]
. Similarly, given two functions of type (A, B) => C
and C => (A, B)
, we might want to contextualize them as functions of type (F[A], F[B]) => F[C]
.
The Invariant
instance alone does not provide either of these lifting, and it is therefore natural to define define a type class for generalizing Invariant
s for functions of arbitrary arity:
trait MultiInvariant[F[_]] {
def imap0[A](a: A): F[A]
def imap1[A, B](f: A => B)(g: B => A)(fa: F[A]): F[B]
def imap2[A, B, C](f: ((A, B)) => C)(g: C => (A, B))(fa: F[A], fb: F[B]): F[C]
}
Higher-arity imapN
can be defined in terms of imap2
, for example for N = 3
:
trait MultiInvariantImap3[F[_]] extends MultiInvariant[F] {
def imap3[A, B, C, D](
f: ((A, B, C)) => D,
g: D => (A, B, C),
fa: F[A],
fb: F[B],
fc: F[C]
): F[D] = (
imap2[A, (B, C), D]
(f compose { case (a, (b, c)) => (a, b, c) })
(g andThen { case (a, b, c) => (a, (b, c)) })
(fa, imap2[B, C, (B, C)](identity)(identity)(fb, fc))
)
}
We can observe that MultiInvariant
is none other than an alternative formulation for InvariantMonoidal
. Indeed, imap1
and imap
only differ by the order of their argument, and imap2
can easily be defined in terms of imap
and product
:
trait Imap2FromImapProduct[F[_]] extends cats.InvariantMonoidal[F] {
def imap2[A, B, C](f: ((A, B)) => C)(g: C => (A, B))(fa: F[A], fb: F[B]): F[C] =
imap(product(fa, fb))(f)(g)
}