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.