Functional Design Patterns in Scala: 3. Monads
Learning (and subsequently trying to explain) monads has become something of a rite of passage in functional programming. Burrito analogies aside, the most helpful description I have come across is given by Noel Welsh and Dave Gurnell in Scala with Cats:
“A monad is a mechanism for sequencing computations.”
Monads provide a pattern for abstracting over the effects of these computations, allowing them to be composed into larger programs. In this post, we will discuss three uses of this pattern: the Writer, Reader, and State monads.
The Writer Monad
This pattern provides a means to return a log along with the result of a computation. This is
especially useful in multithreaded contexts to avoid the log messages of concurrent computations
becoming interleaved. An implementation of the Writer monad is provided in cats.data.Writer
, which
we can use as follows:
import cats.data.Writer
import cats.instances.vector._
val x = Writer(Vector("some intermediary computation"), 3)
val y = Writer(Vector("another intermediary computation"), 4)
val z = for {
a <- x
b <- y
} yield a + b
// WriterT(Vector(some intermediary computation, another intermediary computation),7)
We can access the result and the log separately:
println(z.value)
// 7
println(z.written)
// Vector(some intermediary computation, another intermediary computation)
A computation wrapped in a Writer
monad can be executed with the run
method:
val (log, result) = z.run
The cats.syntax.writer
package provides additional syntax for working with Writers, with the
pure
and tell
methods:
import cats.syntax.applicative._
import cats.syntax.writer._
type Logged[A] = Writer[Vector[String], A]
val writer1 = for {
a <- 10.pure[Logged] // no log
_ <- Vector("a", "b", "c").tell // no value, but log still gets appended
b <- 32.writer(Vector("x", "y", "z")) // log and value
} yield a + b // map transforms the result
println(writer1)
// WriterT((Vector(a, b, c, x, y, z),42))
The Reader Monad
The Reader monad provides a functional mechanism for implementing dependency injection. It is particularly useful for passing in a known set of parameters into a program composed of pure functions. Another advantage of using Readers is that each step of a program can be easily tested in isolation.
Suppose we have some configuration, whose structure is given by the following case class:
case class Config(name: String, age: Int)
We can then say that programs that depend on this configuration are effectively a function from
Config
to some type A
. We can wrap these functions in the Reader
monad, so that they can be
composed in the usual way using map
and flatMap
. In our example, two such ‘programs’
allow us to read a name given in the config object, and perform validation on the configured age:
import cats.data.Reader
type ConfigReader[A] = Reader[Config, A]
def greet(salutation: String): ConfigReader[String] = Reader(cfg => s"$salutation ${cfg.name}")
def validAge: ConfigReader[Int] = Reader(cfg => math.abs(cfg.age))
Because these programs are both expressed using the Reader monad, we can use them as the building blocks of larger programs (which are themselves Readers). We do this in the below example to construct a greeting from the given config:
import cats.syntax.applicative._ // allows us to use `pure`
def greeting: ConfigReader[String] = for {
g <- greet("Hi")
a <- validAge
p <- (if (a < 18) "a child" else "an adult").pure[ConfigReader]
} yield s"$g; you are $p."
The program can finally be run by supplying a concrete Config
instance to the Reader
‘s
run
method.
val myCfg = Config("Holmes", -37)
println(greeting.run(myCfg))
// Hi, Mr Holmes; you are an adult.
The State Monad
Programs that carry state along with a computation can be expressed in terms of the State monad. For
some state S
and result type A
, the type State[S, A]
represents a function that takes an
initial state and returns a result together with some new state. In the example below, we will show
how a sequence of arithmetic operations can be chained together, passing the result as the state
between each step:
import cats.data.State
def addOne = State[Int, String] { state =>
val a = state + 1
(a, s"Result of addOne is $a")
}
def double = State[Int, String] { state =>
val a = state * 2
(a, s"Result of double is $a")
}
def modTen = State[Int, String] { state =>
val a = state % 10
(a, s"Result of modTen is $a")
}
Because each of these individual steps is wrapped in a monad, we can chain them together using a for-comprehension:
def genNumber = for {
a <- addOne // threads the new state to the next computation
b <- double // threads the new state to the next computation
c <- modTen
} yield c
The resulting program can then be executed using the run
method, passing in an initial state:
val (state, result) = genNumber.run(3).value
println(state)
println(result)
We can use the runA
and runS
methods to return only the result or state respectively:
val resultOnly = genNumber.runA(3)
val stateOnly = genNumber.runS(3)
Summary
We have seen from the examples in this post that the general concept of a Monad allows us to build
different types of sequential programs. The implementations of flatMap
and pure
encapsulated in
the implementations of Writer, Reader and State handle the mechanics of this composition, allowing
us to focus on the business logic within each step of the program. In this sense, we can see Monads
as an extremely general pattern for building the more specialised tools that we have examined here.