One topic we have been dancing around, but haven’t gotten into is the notion of the monad transformer. Luckily there’s another good Haskell book that I’ve read that’s also available online.
Real World Haskell says:
It would be ideal if we could somehow take the standard
State
monad and add failure handling to it, without resorting to the wholesale construction of custom monads by hand. The standard monads in themtl
library don’t allow us to combine them. Instead, the library provides a set of monad transformers to achieve the same result.A monad transformer is similar to a regular monad, but it’s not a standalone entity: instead, it modifies the behaviour of an underlying monad.
Let’s look into the idea of using Reader
datatype (Function1
)
for dependency injection, which we saw on day 6.
case class User(id: Long, parentId: Long, name: String, email: String)
trait UserRepo {
def get(id: Long): User
def find(name: String): User
}
Jason Arhart’s Scrap Your Cake Pattern Boilerplate: Dependency Injection Using the Reader Monad generalizes the notion of Reader
datatype for supporting multiple services by creating a Config
object:
import java.net.URI
trait HttpService {
def get(uri: URI): String
}
trait Config {
def userRepo: UserRepo
def httpService: HttpService
}
To use this, we would construct mini-programs of type Config => A
, and compose them.
Suppose we want to also encode the notion of failure using Option
.
We can use the Kleisli
datatype we saw yesterday as ReaderT
, a monad transformer version of the Reader
datatype, and stack it on top of Option
like this:
import cats._, cats.data._, cats.syntax.all._
type ReaderTOption[A, B] = Kleisli[Option, A, B]
object ReaderTOption {
def ro[A, B](f: A => Option[B]): ReaderTOption[A, B] = Kleisli(f)
}
We can modify the Config
to make httpService
optional:
import java.net.URI
case class User(id: Long, parentId: Long, name: String, email: String)
trait UserRepo {
def get(id: Long): Option[User]
def find(name: String): Option[User]
}
trait HttpService {
def get(uri: URI): String
}
trait Config {
def userRepo: UserRepo
def httpService: Option[HttpService]
}
Next we can rewrite the “primitive” readers to return ReaderTOption[Config, A]
:
trait Users {
def getUser(id: Long): ReaderTOption[Config, User] =
ReaderTOption.ro {
case config => config.userRepo.get(id)
}
def findUser(name: String): ReaderTOption[Config, User] =
ReaderTOption.ro {
case config => config.userRepo.find(name)
}
}
trait Https {
def getHttp(uri: URI): ReaderTOption[Config, String] =
ReaderTOption.ro {
case config => config.httpService map {_.get(uri)}
}
}
We can compose these mini-programs into compound programs:
trait Program extends Users with Https {
def userSearch(id: Long): ReaderTOption[Config, String] =
for {
u <- getUser(id)
r <- getHttp(new URI("http://www.google.com/?q=" + u.name))
} yield r
}
object Main extends Program {
def run(config: Config): Option[String] =
userSearch(2).run(config)
}
val dummyConfig: Config = new Config {
val testUsers = List(User(0, 0, "Vito", "vito@example.com"),
User(1, 0, "Michael", "michael@example.com"),
User(2, 0, "Fredo", "fredo@example.com"))
def userRepo: UserRepo = new UserRepo {
def get(id: Long): Option[User] =
testUsers find { _.id === id }
def find(name: String): Option[User] =
testUsers find { _.name === name }
}
def httpService: Option[HttpService] = None
}
// dummyConfig: Config = repl.MdocSession1@659c61d9
The above ReaderTOption
datatype combines Reader
’s ability to read from some configuration once, and the Option
’s ability to express failure.
RWH:
When we stack a monad transformer on a normal monad, the result is another monad. This suggests the possibility that we can again stack a monad transformer on top of our combined monad, to give a new monad, and in fact this is a common thing to do.
We can stack StateT
on top of ReaderTOption
to represent state transfer.
type StateTReaderTOption[C, S, A] = StateT[({type l[X] = ReaderTOption[C, X]})#l, S, A]
object StateTReaderTOption {
def state[C, S, A](f: S => (S, A)): StateTReaderTOption[C, S, A] =
StateT[({type l[X] = ReaderTOption[C, X]})#l, S, A] {
s: S => Monad[({type l[X] = ReaderTOption[C, X]})#l].pure(f(s))
}
def get[C, S]: StateTReaderTOption[C, S, S] =
state { s => (s, s) }
def put[C, S](s: S): StateTReaderTOption[C, S, Unit] =
state { _ => (s, ()) }
def ro[C, S, A](f: C => Option[A]): StateTReaderTOption[C, S, A] =
StateT[({type l[X] = ReaderTOption[C, X]})#l, S, A] {
s: S =>
ReaderTOption.ro[C, (S, A)]{
c: C => f(c) map {(s, _)}
}
}
}
This is a bit confusing, so let’s break it down. Ultimately the point of State
datatype is to wrap S => (S, A)
, so I kept those parameter names for state
. Next, I needed to modify the kind of ReaderTOption
to * -> *
(a type constructor that takes exactly one type as its parameter).
Similarly, we need a way of using this datatype as a ReaderTOption
, which is expressed as C => Option[A]
in ro
.
We also can implement a Stack
again. This time let’s use String
instead.
type Stack = List[String]
{
val pop = StateTReaderTOption.state[Config, Stack, String] {
case x :: xs => (xs, x)
case _ => ???
}
}
Here’s a version of pop
and push
using get
and put
primitive:
import StateTReaderTOption.{get, put}
val pop: StateTReaderTOption[Config, Stack, String] =
for {
s <- get[Config, Stack]
(x :: xs) = s
_ <- put(xs)
} yield x
// pop: StateTReaderTOption[Config, Stack, String] = cats.data.IndexedStateT@136ad671
def push(x: String): StateTReaderTOption[Config, Stack, Unit] =
for {
xs <- get[Config, Stack]
r <- put(x :: xs)
} yield r
We can also port stackManip
:
def stackManip: StateTReaderTOption[Config, Stack, String] =
for {
_ <- push("Fredo")
a <- pop
b <- pop
} yield(b)
Here’s how we can use this:
stackManip.run(List("Hyman Roth")).run(dummyConfig)
// res3: Option[(Stack, String)] = Some(value = (List(), "Hyman Roth"))
So far we have the same feature as the State
version.
We can modify Users
to use StateTReaderTOption.ro
:
trait Users {
def getUser[S](id: Long): StateTReaderTOption[Config, S, User] =
StateTReaderTOption.ro[Config, S, User] {
case config => config.userRepo.get(id)
}
def findUser[S](name: String): StateTReaderTOption[Config, S, User] =
StateTReaderTOption.ro[Config, S, User] {
case config => config.userRepo.find(name)
}
}
Using this we can now manipulate the stack using the read-only configuration:
trait Program extends Users {
def stackManip: StateTReaderTOption[Config, Stack, Unit] =
for {
u <- getUser(2)
a <- push(u.name)
} yield(a)
}
object Main extends Program {
def run(s: Stack, config: Config): Option[(Stack, Unit)] =
stackManip.run(s).run(config)
}
We can run this program like this:
Main.run(List("Hyman Roth"), dummyConfig)
// res4: Option[(Stack, Unit)] = Some(
// value = (List("Fredo", "Hyman Roth"), ())
// )
Now we have StateT
, ReaderT
and Option
working all at the same time.
Maybe I am not doing it right, but setting up the monad constructor functions like state
and ro
to set up StateTReaderTOption
is a rather mind-bending excercise.
Once the primitive monadic values are constructed, the usage code (like stackManip
) looks relatively clean.
It sure does avoid the cake pattern, but the stacked monad type StateTReaderTOption
is sprinkled all over the code base.
If all we wanted was being able to use getUser(id: Long)
and push
etc.,
an alternative is to construct a DSL with those commands using Free monads we saw on day 8.