Monads as a Design Pattern
Lately I've found monads to be more and more useful in several programming projects. For example, Harlan's type inferencer uses a monad to keep track of what variables have been unified with each other, among other things. It took me a while to really grok monads. One reason is that many of the tutorials I've seen start out with category theory and the monad laws. These things don't strike me as all that useful when I'm trying to make my code better in some way.
What I have found useful is to think of monads as a style of programming (like continuation passing style), or even a design pattern. I've found monads are really handy when you need to thread some object through a sequence of function calls. To see how this works, we're going to start with a store-passing interpreter for a small language and show how to use monads to hide the store in the cases where we don't need it.
A Store Passing Interpreter🔗
Let's start by looking at an interpreter. This is basically the first interpreter from this post, but we've added mutable references.
(define value-of/store
(lambda (e env store)
(match e
(,x (guard (symbol? x))
(cons (lookup x env) store))
(,n (guard (number? n))
(cons n store))
((λ (,x) ,e)
(cons (lambda (v store)
(value-of/store e (extend-env x v env) store))
store))
((ref ,e)
(match (value-of/store e env store)
((,v . ,store)
(let ((label (gensym)))
(cons `(ref ,label)
(cons (cons label v) store))))))
((deref ,e)
(match (value-of/store e env store)
(((ref ,label) . ,store)
(cons (cdr (assq label store))
store))))
((update ,e-ref ,e-val)
(match (value-of/store e-ref env store)
(((ref ,label) . ,store)
(match (value-of/store e-val env store)
((,val . ,store)
(cons `(ref ,label)
(cons (cons label val) store)))))))
((,rator ,rand)
(match (value-of/store rator env store)
((,rator . ,store)
(match (value-of/store rand env store)
((,rand . ,store)
(rator rand store)))))))))
There are a couple of high level changes from the standard environment
passing interpreter. First, we've added an additional store
parameter. This represents the state of the memory, as opposed to the
environment, which tracks variables on the stack. Second, since
operations such as ref
and update
can modify the store, in
addition to returning the value of an expression, we also have to
return a potentially updated store. We could use Scheme's support for
multiple return values, but I went with cons
instead for
simplicity's sake.
The first few cases are pretty straightforward. Starting on line 4, we have the variable lookup line. This reads a value from the environment and returns it. The store is left unchanged, so we just return that as is. Likewise with the number case in line 6.
For the lambda case on line 8, we use Scheme's lambda to represent
functions in our language. There's a slight subtly here. Instead of
having the procedure only take a value argument (v
), it also must
take a store. The reason is that otherwise this procedure would modify
the store that was in effect when it the procedure was created, rather
than the store in effect at the call site.
Next we have ref
, deref
and update
. This is where the store
starts to be interesting. We've represented the store as an
association list between labels and values. We use ref
to create a
new reference (think of it like new
in C++ or Java), which we
implement by using gensym
to create a new label, and then adding a
new label:value pair to the store. The value of a ref
expression is
a value tagged as a reference, containing an address into the
store. Think of these as pointers.
For deref
, we first evaluate the argument and use match
to unpack
the label from the returned reference value. Once we have the label,
we use assq
to look it up in the store then we return its value.
The update
expression requires us to evaluate two arguments: the
destination location and the new value. We do this with two recursive
calls to value-of/store
. In the interpreter from previous posts, we
could have used match
's catamorphism facility to make this quite a
bit shorter, but here we need to be explicit about ordering because of
a potentially changing store. When we get around to actually updating
the store, we just prepend a new label:value pair. Because assq
returns the first match it finds, this results in whatever value was
previously there being ignored.
Finally, the application case (line 30) is similar to before. The main changes are that we need to be explicit about the order of evaluation and we pass the current store into the function we are applying.
What we have now is an interpreter for a small language with mutable
references. You'll notice we didn't use any side effects in creating
the interpreter, which is kind of cool. Still, a few things are less
than ideal. To go from a language without mutable references to one
with mutable references, we had to change basically every line in the
interpreter. Every recursive call to value-of/store
needs an
additional store parameter, and every return from the interpreter has
to return a store as well. This is the case even though only ref
and
update
can modify the store. What we'd like is to only have to be
aware of the store for cases that interact with the store. Monads can
help us do that.
Cleaning up the Interpreter🔗
To deal with some of the shortcomings in the previous paragraph, we are going to derive a monad through a series of correctness-preserving transformations. Some of the intermediate steps will look rather ugly, but the end should look pretty nice.
The first thing we're going to do is Curry (Schönfinkel?) the store argument.
(define value-of/store
(lambda (e env)
(lambda (store)
(match e
(,x (guard (symbol? x))
(cons (lookup x env) store))
(,n (guard (number? n))
(cons n store))
((λ (,x) ,e)
(cons (lambda (v)
(lambda (store)
((value-of/store e (extend-env x v env)) store)))
store))
((ref ,e)
(match ((value-of/store e env) store)
((,v . ,store)
(let ((label (gensym)))
(cons `(ref ,label)
(cons (cons label v) store))))))
((deref ,e)
(match ((value-of/store e env) store)
(((ref ,label) . ,store)
(cons (cdr (assq label store))
store))))
((update ,e-ref ,e-val)
(match ((value-of/store e-ref env) store)
(((ref ,label) . ,store)
(match ((value-of/store e-val env) store)
((,val . ,store)
(cons `(ref ,label)
(cons (cons label val) store)))))))
((,rator ,rand)
(match ((value-of/store rator env) store)
((,rator . ,store)
(match ((value-of/store rand env) store)
((,rand . ,store)
((rator rand) store))))))))))
As I said, we're going to make things worse for a while. Curried
functions in Scheme programs are always kind of messy. The nice thing
is that now we can push the (lambda (store) ...)
part inside each of
the match branches. It will become clear why we might want to do this
in a second.
(define value-of/store
(lambda (e env)
(match e
(,x (guard (symbol? x))
(lambda (store)
(cons (lookup x env) store)))
(,n (guard (number? n))
(lambda (store)
(cons n store)))
((λ (,x) ,e)
(lambda (store)
(cons (lambda (v)
(lambda (store)
((value-of/store e (extend-env x v env)) store)))
store)))
((ref ,e)
(lambda (store)
(match ((value-of/store e env) store)
((,v . ,store)
(let ((label (gensym)))
(cons `(ref ,label)
(cons (cons label v) store)))))))
((deref ,e)
(lambda (store)
(match ((value-of/store e env) store)
(((ref ,label) . ,store)
(cons (cdr (assq label store))
store)))))
((update ,e-ref ,e-val)
(lambda (store)
(match ((value-of/store e-ref env) store)
(((ref ,label) . ,store)
(match ((value-of/store e-val env) store)
((,val . ,store)
(cons `(ref ,label)
(cons (cons label val) store))))))))
((,rator ,rand)
(lambda (store)
(match ((value-of/store rator env) store)
((,rator . ,store)
(match ((value-of/store rand env) store)
((,rand . ,store)
((rator rand) store))))))))))
Notice how we have a couple of cases, such as the variable and number
case, where all we do is cons
some value onto an unchanged
store. Since we repeat this pattern a few times, let's pull it into a
separate function that we'll call... return
.
(define return
(lambda (v)
(lambda (store)
(cons v store))))
We can rewrite our previous interpreter using return:
(define value-of/store
(lambda (e env)
(match e
(,x (guard (symbol? x))
(return (lookup x env)))
(,n (guard (number? n))
(return n))
((λ (,x) ,e)
(return (lambda (v)
(value-of/store e (extend-env x v env)))))
((ref ,e)
(lambda (store)
(match ((value-of/store e env) store)
((,v . ,store)
(let ((label (gensym)))
(cons `(ref ,label)
(cons (cons label v) store)))))))
((deref ,e)
(lambda (store)
(match ((value-of/store e env) store)
(((ref ,label) . ,store)
(cons (cdr (assq label store))
store)))))
((update ,e-ref ,e-val)
(lambda (store)
(match ((value-of/store e-ref env) store)
(((ref ,label) . ,store)
(match ((value-of/store e-val env) store)
((,val . ,store)
(cons `(ref ,label)
(cons (cons label val) store))))))))
((,rator ,rand)
(lambda (store)
(match ((value-of/store rator env) store)
((,rator . ,store)
(match ((value-of/store rand env) store)
((,rand . ,store)
((rator rand) store))))))))))
For the first time, we've introduced a transformation that makes our program shorter! While I was at it, I also did a transformation called an eta reduction in the lambda line at lines 9-10. The result is that the first three cases in the interpreter make no mention of the store. This is real tangible progress towards our goal.
Let's look at the last case, the application case, for a bit. Here we
take a store, pass it into a call to evaluate the operator, and then
thread the resulting store into a call to evaluate operand, and then
finally we thread that store into the actual application of the
operand. In each case, we don't really care about the store; we only
care about the value of the expression. Let's see if we can write a
function that does the store-threading for us. We'll call
it... bind
.
(define bind
(lambda (m f)
(lambda (store)
(match (m store)
((,v . ,store)
((f v) store))))))
Here, m
is for monad, meaning it's a function that does something
when it receives a store. The f
parameter is a function describing
what to do next. It expects a value and returns a new monad. We get
this value by applying a store to the m
argument. We expect f
to
return a function expected a new store, and we apply this function to
the updated store returned by m
. It's a bit complicated, but it
simplifies our application line somewhat:
(value-of/store
(lambda (e env)
(match e
(,x (guard (symbol? x))
(return (lookup x env)))
(,n (guard (number? n))
(return n))
((λ (,x) ,e)
(return (lambda (v)
(value-of/store e (extend-env x v env)))))
((ref ,e)
(lambda (store)
(match ((value-of/store e env) store)
((,v . ,store)
(let ((label (gensym)))
(cons `(ref ,label)
(cons (cons label v) store)))))))
((deref ,e)
(lambda (store)
(match ((value-of/store e env) store)
(((ref ,label) . ,store)
(cons (cdr (assq label store))
store)))))
((update ,e-ref ,e-val)
(lambda (store)
(match ((value-of/store e-ref env) store)
(((ref ,label) . ,store)
(match ((value-of/store e-val env) store)
((,val . ,store)
(cons `(ref ,label)
(cons (cons label val) store))))))))
((,rator ,rand)
(bind (value-of/store rator env)
(lambda (rator)
(bind (value-of/store rand env)
(lambda (rand)
(rator rand)))))))))
Our interpreter just shrunk by another line. The only cases where we
see the store actually appears are ref
, deref
, and update
, which
are exactly the cases that need to read or write memory. The rest of
the interpreter is completely oblivious to the existence of memory.
We can clean up the application line a little further. If you squint a
little, you'll noticed that the calls to bind
look a little like the
traditional expansion of (let ((x e)) b)
to ((lambda (x) b) e)
. Let's build some extra syntax to make it easier to chain lots of
calls to bind
together. We'll call it... do*
.
(define-syntax do*
(syntax-rules ()
((_ ((x e) rest ...) body)
(bind e (lambda (x) (do* (rest ...) body))))
((_ () body)
body)))
This macro works a little like let*
, where it performs a sequence of
computations in order and names each of their results. Using this
macro, our interpreter becomes:
(define value-of/store
(lambda (e env)
(match e
(,x (guard (symbol? x))
(return (lookup x env)))
(,n (guard (number? n))
(return n))
((λ (,x) ,e)
(return (lambda (v)
(value-of/store e (extend-env x v env)))))
((ref ,e)
(lambda (store)
(match ((value-of/store e env) store)
((,v . ,store)
(let ((label (gensym)))
(cons `(ref ,label)
(cons (cons label v) store)))))))
((deref ,e)
(lambda (store)
(match ((value-of/store e env) store)
(((ref ,label) . ,store)
(cons (cdr (assq label store))
store)))))
((update ,e-ref ,e-val)
(lambda (store)
(match ((value-of/store e-ref env) store)
(((ref ,label) . ,store)
(match ((value-of/store e-val env) store)
((,val . ,store)
(cons `(ref ,label)
(cons (cons label val) store))))))))
((,rator ,rand)
(do* ((rator (value-of/store rator env))
(rand (value-of/store rand env)))
(rator rand))))))
Not bad.
We're going to cheat a little for the last few cases. We're doing to
define three new functions, create-ref
, get-ref
and set-ref
,
which do the store manipulation for us. Here they are:
(define (create-ref val)
(lambda (store)
(let ((label (gensym)))
(cons `(ref ,label)
(cons (cons label val) store)))))
(define (get-ref ref)
(lambda (store)
(match ref
((ref ,label)
(cons (cdr (assq label store)) store)))))
(define (set-ref ref val)
(lambda (store)
(match ref
((ref ,label)
(cons ref (cons (cons label val) store))))))
These feel like cheating to me because we got these functions
basically by copying and pasting the body of the ref
, deref
and
update
cases. But, we can think of it as defining a clean interface
to the store, which is just good software engineering. Plus, our
interpreter was actually doing two things in each case. First it had
to evaluate the arguments and then it had to access the store. Using
these helper functions makes that separation clearer.
Using these functions in the interpreter completes our transition over to monads.
(define value-of/store
(lambda (e env)
(match e
(,x (guard (symbol? x))
(return (lookup x env)))
(,n (guard (number? n))
(return n))
((λ (,x) ,e)
(return (lambda (v)
(value-of/store e (extend-env x v env)))))
((ref ,e)
(do* ((val (value-of/store e env)))
(create-ref val)))
((deref ,e)
(do* ((ref (value-of/store e env)))
(get-ref ref)))
((update ,e-ref ,e-val)
(do* ((ref (value-of/store e-ref env))
(val (value-of/store e-val env)))
(set-ref ref val)))
((,rator ,rand)
(do* ((rator (value-of/store rator env))
(rand (value-of/store rand env)))
(rator rand))))))
Our interpreter is now a whole 11 lines shorter than where we started! Granted, we've moved some of the functionality into helper functions, so the total length of the program may be longer. Still, this program seems much better factored to me. The interpreter itself shows how to evaluate different language forms, and things like order of evaluation are very clear. The details of how to manipulate the store-related data structures are all hidden in the monad.
Conclusion🔗
I've found that thinking of deriving monads through a series of program transformations has helped them make a lot more sense to me. In functional programming it's common to simulate stateful things by threading extra arguments around. This quickly gets tedious. Monads help you keep this hidden when you don't care about it and cleanly expose it in cases where you do.
Another benefit of this style of programming that I've found is that
once your program is written in terms of return
, bind
and do*
,
you can modify the monad without having to change the rest of your
program. In rewriting the Harlan type inferencer, there were a few
times I realized I had some more information to thread through the
inferencer. In the old style, I would have had to change many lines of
code to do this. Since the type inferencer was written monadically,
however, I could modify a handful of functions while leaving the bulk
of the code unchanged.