Reader: Computing with Context
The three monads we have discussed so far—Maybe
, Either e
, and lists—modify
what it means to execute the steps in a do
-block in sequence. The next three
monads—Reader
, Writer
, and State
—stick with a sequential execution model
that we are used to from imperative programming languages. These monads are
provided by either the transformers
or mtl
package. We'll use the latter
because it makes working with monad transformers slightly easier. Neither of
these two packages is technically part of the standard library, but they might
as well be; many Haskell projects use them.
-
State
is the most flexible of the three. It models functions that depend on some states
and, in addition to returning some value, may modify the current state. In particular, any such function can both read and writes
. -
Reader
is likeState
but cannot modify the state. We can think about functions that use theReader
monad as functions that rely on some read-only context, such as configuration settings read from a config file. -
Writer
is likeState
but it cannot read the state. It assumes that the state type is some monoid. Functions using theWriter
monad can add to the state using the monoid's multiplication operation. A commonly used monoid is the list monoid. In this case, the state is like a log, and functions can add to this log without ever reading it.
Before looking at the simplest of the three, the Reader
monad, let's ask
ourselves why it is useful to have three monads that do fairly similar things.
Clearly, any computation that uses the Reader
monad could also use the State
monad. Our functions read the state and simply don't use their ability to also
modify it. They effectively use State
as a Reader
. Similarly, a function in
the State
monad does not have to read the current state, and we can implement
these functions so they append to the state instead of overwriting it, just what
the Writer
monad would do.
The reason why it's useful to have three monads instead of only the State
monad is communication of intent to the reader of our code. If we see a
computation that uses the State
monad, we know that it cannot perform I/O, but
we must assume that some of the functions in this computation both read and
write the state. To understand the code, we thus need to understand how each
function updates the state. On the other hand, if we have a computation that
uses the Reader
monad, we know that none of the functions can modify the
state. Thus, we can focus on the return values that flow from function to
function and on the shared, read-only context of the entire computation. The
logic of such code is simpler, so it takes less effort to wrap our heads around
it. Similarly, a computation that uses Writer
does not really pass any state
from function to function, not even read-only state. For the most part, these
functions behave like pure functions. All we need to understand is how the
functions add to the log.1 In the end, having to use monads to do
things we can "just do" in imperative languages takes some getting used to, but
once we are comfortable with monads, the specific monad used by some piece of
code tells us exactly what this code can and cannot do, and this simplifies the
mental model we need to use to understand this piece of code.
Now let's look at the Reader
monad. Here is the Reader
data type again:
newtype Reader r a = Reader { runReader :: r -> a }
When discussing that
Reader r
is a functor, that is, a
well-behaved container, I mentioned that we can think of Reader r a
as a
container that stores values of type a
indexed using values of type r
. Here,
it is more helpful to think of a value of type Reader r a
as a value of type
a
that depends on some read-only context of type r
. This is the decoration
provided by the Reader r
monad. To make Reader r
a monad, we once again need
to figure out how to implement return
and (>>=)
. First return
.
return x
should take a pure value x :: a
and decorate it to produce a value
of type m a
or, here, a value of type Reader r a
. Since Reader r a
is
essentially simply a function of type r -> a
, we somehow need to turn x
into
a function that maps a context of type r
to a value of type a
. Since we
don't know anything about the types r
and a
, there isn't all that much we
can do. Pretty much the only thing we can do is to ignore the context and simply
return x
. So this gives
return x = Reader (const x)
or in point-free form,
return = Reader . const
return x
is a computation that produces x
no matter the context. It ignores
the context. But that's fine. Nobody says that every computation in the Reader
monad absolutely must read the context. Taking the view of Reader r
as a
container indexed by the type r
, return x
is the container that stores x
at every possible index.
Now what should x >>= f
look like? If x :: Reader r a
and
f :: a -> Reader r b
, then x >>= f :: Reader r b
. So x >>= f
should wrap a
function of type r -> b
:
x >>= f = Reader $ \r -> ...
How can we produce a value of type b
from a value of type r
having only x
and f
at our disposal? We have runReader x :: r -> a
. For some value
y :: a
, we also have runReader (f y) :: r -> b
. The only thing we can do to
produce a value of type b
from a value of type r
is to apply
runReader (f y)
to r
:
x >>= f = Reader $ \r -> let y = ...
in runReader (f y) r
To do this, we first need a value y
of type a
, to which we can apply f
.
The only tool we have to produce such a value of type a
is to apply
runReader x
to our context r
. Thus, the only possible implementation
of x >>= f
is
x >>= f = Reader $ \r -> let y = runReader x r
in runReader (f y) r
Remember that, for any monad m
, x >>= f
should somehow capture the idea of
applying f
to x
, only x
is not a pure value—it's a value decorated by the
monad m
. f
's return value is also decorated. The decoration provided by
Reader r
is the ability to depend on the context r
. If you squint hard
enough at the definition of x >>= f
, then you can see that the function
\r -> let y = runReader x r in runReader (f y) r
implements a whole family of
function applications, one for each possible context r
: Given a particular
context r
, we extract the value y
that x
has in this context, by using
runReader x r
. We then apply f
to this value, but even the value of this
application of f
to y
depends on the context. By passing the same value r
to runReader x
and to runReader (f y)
, we evaluate x
and f y
in the same
context. Our implementation of x >>= f
ensures that we thread the same
read-only context through the entire computation.
The final Monad
instance for Reader r
is
instance Monad (Reader r) where
return = Reader . const
x >>= f = Reader $ \r -> let y = runReader x r
in runReader (f y) r
Accessing the Context
Inside a do
-block, if f :: m a
, then the statement x <- f
assigns to x
the value of type a
returned by f
. In other words, statements inside a
do
-block don't see the decoration provided by the monad m
! Whatever
additional information the monad carries is threaded through the computation
behind the scenes via the monad's (>>=)
operator. That was the whole point of
do
-notation: not having to deal with the decoration provided by the monad and
pretending that we're working with pure undecorated values.
The point of the Reader r
monad is to implement computations that depend on
some context of type r
. It would be silly if we had to abandon do
-notation
and use the (>>=)
operator directly whenever we need to access this context.
To avoid this, the Control.Monad.Reader
module provides us with functions to
access the context from within do
-blocks.
The first one is
ask :: Reader r r
ask = Reader id
Given that the function wrapped by Reader
is the indentity function r
, the
value returned by ask
is simply the context of the monad. Thus, we can write
ctxt <- ask
inside a do
-block to assign the context to the local variable
ctxt
, and then the rest of the computation inside the do
-block can freely
access ctxt
to compute values that depend on this context.
It is also useful to be able to retrieve only part of the context. Given a
context of type r
, we can think of a function of type r -> p
as extracting
some part of the context of type p
. Control.Monad.Reader
gives us the
function
asks :: (r -> p) -> Reader r p
asks f = Reader f
With this, we can write partOfCtxt <- asks f
, and partOfCtxt
becomes the
part of the context accessed by f
. For example, if our context is a collection
of configuration settings read from a configuration file, we might represent our
context as
data Config = Cfg
{ verbosity :: Int
, usePager :: Bool
, writeToFile :: Maybe FileName
}
Few functions in our program depend on the entire context. If a function cares only about whether we should use a pager when printing our program's output on screen, for example, we could write
do usePgr <- asks usePager
if usePgr then
-- Do whatever we need to do to use a pager
else
-- Do whatever we need to do to proceed without using a pager
Pure Functions with Context
None of the monads we have discussed so far expresses anything we cannot model
using pure functions—that's evident from the fact that we were able to implement
the monad instances for Maybe
, Either e
, lists, and Reader r
in plain
Haskell. The only monad that we will discuss later that captures true side
effects that we cannot implement using pure functions is the IO
monad.
There is a difference between the Maybe
, Either e
, and list monads and the
Reader r
monad though. If a function f
uses the Maybe
or Either e
monad,
its type is
f :: a -> Maybe b
or
f :: a -> Either e b
We may be using the fact that Maybe
and Either e
are monads to implement
such a function more elegantly, but from the outside, we can also simply view
f
as a pure function that returns a value of type Maybe b
or Either e b
,
and this is presumably exactly the result type we want because we need to know
whether f
succeeded or not.
Similarly, when using the list monad to implement a function of type
f :: a -> [b]
then we may use the list monad to implement such a function, but we can also
view this function as a pure function that maps a value of type a
to a list of
b
s, and we are usually interested in all of those b
s, so the return type is
exactly the type we want.
Reader r
is different. When we apply a function of type
f :: a -> Reader r b
to some argument of type a
, we don't obtain any value of type b
as a result.
What we have, wrapped in Reader
, is a function of type r -> b
. To obtain a
value of type b
, we need to apply this function to some context of type r
.
What we really want is to be able to convert values of type Reader r b
back
into pure functions. For example, instead of the function
f :: a -> Reader r b
, we would really like to have a plain old function
runFWithContext :: a -> r -> b
So, while functions that use Maybe
, Either e
or lists can be viewed as pure
functions, we need the ability to "run" any computation in the Reader
monad
somehow to obtain an actual value. That's exactly what the runReader
accessor
function of the Reader
monad does—the name is no coincidence. Using this
accessor function, we gain access to the function wrapped by
Reader
. Thus, we can implement runFWithContext
simply as
runFWithContext a r = runReader (f a) r
f a
has type Reader r b
. Thus, it wraps a function of type r -> b
.
runReader (f a)
accesses this function. And finally the whole expression
runReader (f a) r
applies this function to the context r
.
The pattern is that we want to implement a computation composed of functions
that all depend on some value of type r
. Instead of passing this value around
explicitly, as an argument to each function we call, we do so implicitly, by
using the Reader
monad. However, the Reader
monad is merely a convience. In
the end, we still have a pure function, and runReader
allows us to convert our
computation that uses the Reader
monad back into a plain old function whose
type doesn't even reveal that the implementation uses the Reader
monad under
the hood.
We will see the same pattern again when we look at the Writer
and State
monads next, which are also merely conveniences to implement computations that
could have been implemented using plain old functions. The State s
monad
provides us with a computation that depends on and potentially modifies some
state of type s
. We will have a function
runState :: State s a -> s -> (a, s)
If f :: State s a
is a computation that operates on some state s
and returns
a value of type a
, then runState f init
runs f
with the state initialized
to init
, and the result is a pair consisting of the return value of f
and
the state left behind once f
is finished. The function runState f
is a plain
function of type s -> (a, s)
. Nothing in this type signature reveals that a
monad is involved; the State
monad is used merely to implement this function
more easily.
For the Writer
monad, we will have the function
runWriter :: Writer w a -> (a, w)
that will make sense once we discuss the Writer
monad. Again, if
f :: Writer w a
, then runWriter f
is a value of type (a, w)
. Nothing in
this type reveals that a monad is involved in producing it.
Back to the Reader
monad. Here's an example that is a bit less abstract:
main :: IO ()
main = do
cfg <- readConfigFile
let result = runReader programLogic cfg
writeResultToFile result
readConfigFile :: IO Config
readConfigFile = ...
programLogic :: Reader Config String
programLogic = ...
writeResultToFile :: String -> IO ()
writeResultToFile = ...
This example uses both the Reader
monad and the IO
monad, which we discuss
towards the end of this chapter. It provides the ability to perform I/O, such as
reading and writing files or printing things on screen. main
is the entry
point of every Haskell program, just as the main
function in C, C++ or Rust or
the main
method in a Java program. Its type is IO ()
, that is, it uses I/O
to read its input and write its output, and it does not return anything: any
result it produces needs to be communicated to the user by writing this result
to the screen or to a file, or maybe by sending the result over a network
connection, before the program exits.
In this example, I assume that we have some function readConfigFile
that reads
our program's configuration file and returns the result in the form of a
Config
object, where Config
is a type we use to represent configuration
settings of our program, just as the Config
type above does. The main program
logic is implemented by a function programLogic
. This function uses the
Reader Config
monad to pass the configuration between function calls. The
result of this computation is some string. To run this computation and collect
the result, our main
function calls runReader programLogic cfg
, passing the
configuration read by readConfigFile
to our computation. The final step of our
main
function is to write the result to some file, using a function
writeResultToFile
whose implementation I also didn't provide here.
-
The compiler also has more opportunities to optimize code in the
Reader
andWriter
monads. Nowadays, GHC is really good at scheduling the evaluation of pure functions that don't depend on each other's return values so they get evaluated in parallel, in separate threads, on modern CPUs. This is one of the advantages of functional programming that proponents often cite: we get parallelism for free. We don't have to worry about expressing what can run in parallel ourselves. The compiler does this for us. Given that functions can only depend on each other via their arguments and return values in purely functional code, it is much easier for the compiler of a functional language to determine which parts of a program can be executed in parallel. Thus, automatically parallelizing functional code is much easier than automatically parallelizing imperative code.The
State
monad is the essence of sequentially threading state through a computation. Each function potentially depends on updates to the state made by a previous function. The compiler's opportunities to parallelize such code are extremely limited. For theReader
monad, there is no state to be modified. Just as pure functions, functions that use theReader
monad depend on each other only if the result of one gets passed to the other as an argument. The compiler can optimize such code just like ordinary pure functions. Functions in theWriter
monad are essentially pure functions, except they also add to a log. However, the additions to the log are combined using monoid multiplication, which is associative. Thus, we can once again evaluate functions in theWriter
monad in parallel and use the associativity of the monoid multiplication to combine the log messages efficiently. ↩