Do Notation
Before we continue our exploration of monads, let's make working with them more
convenient. The Maybe
monad is great for (simple) exception handling, but it's
not particularly convenient to work with. In Java, I can write things like:
int foo(int x) {
int a = f(x);
try {
int b = g(a);
int c = h(x, a, b);
return k(b, c);
}
catch (Exception e) {
return handle_exception(x, a);
}
}
This says that we assume that f
cannot fail. Then we use g
to produce b
from a
, which may fail, as can our application of h
to x
, a
, and b
,
and our application of k
to b
and c
. If something goes wrong, it must
somehow have been caused by the values of x
and a
, so we pass x
and a
to
our exception handler.
Here's what this would look like using our Maybe
monad right now. Prepare for
some ugliness:
foo :: Int -> Maybe Int
foo x = let a = f x
in ( g a >>= \b -> h x a b
>>= \c -> k b c
) `catch` handler x a
Okay, I admit this is surprisingly compact, but it sure doesn't make it easy to
see the logic. Let's take it apart to convince ourselves that it really matches
the logic of the Java
code. First our function types:
f
cannot fail. Its type is
f :: Int -> Int
g
, h
, and k
can fails, as can the exception handler. Their types are
g :: Int -> Maybe Int
h :: Int -> Int -> Int -> Maybe Int
k :: Int -> Int -> Maybe Int
handler :: Int -> Int -> Maybe Int
Given the argument x
, we call f x
and assign the result to the local
variable a
. That's the same as int a = f(x)
. Next we build the catch-try
block. Let's add proper parentheses, which makes the code even uglier but
reveals its structure better:
foo :: Int -> Maybe Int
foo x = let a = f x
in ( g a >>= ( \b -> h x a b
>>= ( \c -> k b c
)
)
) `catch` handler x a
First we call g a
. We could try to assign its result to another local
variable b
, but this wouldn't quite do what we want. This variable would have
type Maybe Int
because that's the return type of g a
. In Java, g(a)
produces an int
; the information that g(a)
may fail is passed along behind
the scenes in the form of exceptions. We want to do the same in Haskell, using
our (>>=)
operator of the Maybe
monad to pass along the information that
things may fail, and working with plain old Int
s as long as things don't fail.
The way to do this is to build a function that expresses the computation that
follows after the call g a
. We want this function to have access to the return
value of g a
, so we bind the return value of g a
to the argument of this
function: g a >>= (\b -> ...)
. Given the logic of (>>=)
, this never calls
(\b -> ...)
if g a
fails to produce a value. Just as in Java, the
computation inside the try
block aborts, and control transfers to the
exception handler. If g a
produces some value Just t
, then (>>=)
assigns
t
(without the Just
!) to the argument b
of (\b -> ...)
and evaluates
(\b -> ...)
. Since b
is the name of the function argument of (\b -> ...)
,
it is accessible everywhere inside (\b -> ...)
, so it effectively acts like a
local variable. That's exactly the same as in Java, where the code that comes
after int b = g(a)
has access to b
, only the Haskell version is much uglier.
Let's continue. Our function (\b -> ...)
should do exactly the same as the
remainder of the try
block in Java. So the next thing we should do is call
h x a b
. This may also fail, but if it doesn't, we want to assign the result
to a variable c
. We use the same trick again: we bind the return value to the
argument of a function (\c -> ...)
that represents whatever comes after
h x a b
. This successor function calls k b c
, which is the return value of
the whole computation if k b c
succeeds.
We can think about the sequencing of steps in Java as nested scopes:
int a = f(x)
introduces the variable a
. This variable is visible everywhere
after this statement. int b = g(a)
introduces the variable b
. After this
statement, both a
and b
are visible. int c = h(x, a, b)
introduces the
variable c
. After this statement, a
, b
, and c
are visible. And, of
course, x
is visible throughout the body of foo
because it's foo
's
argument. Our nesting of anonymous functions in Haskell achieves exactly the
same effect.
If anything goes wrong in our pipeline g a >>= \b -> h x a b >>= \c -> k b c
,
then the result is Nothing
, and catch
invokes handler x a
to recover from
the exception, just as the Java
code would jump to the catch
block as soon
as an exception is raised by one of the function calls.
Now let's make our Haskell code prettier. We can in fact implement foo
like
this:
foo :: Int -> Maybe Int
foo x = do let a = f x
( do b <- g a
c <- h x a b
k b c
) `catch` (
handler x a
)
Except for sprinkling in some do
and let
keywords, this looks remarkably
like the Java code. Also note the use <-
as the assignment operator instead of
=
. This will be important shortly.
So what does do
do? It's a syntactic construct that allows us to pretend we're
imperative programmers when we're "inside" a monad—any monad, not just
Maybe
.
Intuitively, you should read the sequence of statements inside a do
-block as a
sequence of steps to be executed in order, just as you would in an imperative
program. So, here we say, let a
be the result of f x
. Then execute the code
that comes after it, which mimics our try-catch block. This code has another
do
-block inside it, but that's fine: do
-blocks can nest. The inner
do-block
starts by assigning g a
to b
, then h x a b
to c
, and finally
evaluates k b c
. Whatever k b c
returns is the return value of the entire
inner do
-block because k b c
is the last expression in this do
-block. Note
that there's no return
statement. Indeed, as we have seen, return
means
something very different in a monad than in imperative languages.
If anything goes wrong inside the inner do
-block, that is, if any of the
functions g
, h
or k
returns Nothing
, then the value of the whole
do
-block is Nothing
, so catch
runs the exception handler.
Now here's the part that makes do
-notation useful. Inside the do
-block, the
decoration of values provided by the monad is passed around completely behind
the scenes, just as exceptions in Java are completely invisible to us as long as
they don't happen. They're a hidden layer under the surface. So the assignment
b <- g a
assigns an Int
to b
, not a Maybe Int
. The assignment operator
<-
inside a do
-block peels off the decoration. The decoration is quietly
passed along to the next statement. The manner in which this happens is
determined by the (>>=)
operator of the monad. We'll discuss this in the
next section.
So what exactly are the rules for do
-blocks? Here they are: Every do
-block
consists of a sequence of statements. Each of these statements can be of one of
three forms, where we assume that the do
-block expresses a computation in some
monad m
:
-
It can be a
let
-block that defines some local variables. These local variables are accessible anywhere inside thelet
-block and after it. Note that I saidlet
-block notlet
-statement, because it is really a block. In our example above, we hadlet a = f x
, but we can buildlet
-blocks that assign more variables, as inlet a = f x b = f' a x
You can think about
let
-blocks as locally "turning off" the monad. The code inside thelet
-block is pure code, just as the variable definitions afterlet
in alet ... in ...
expression or in awhere
block. That's also why we use=
for "assignments" inside alet
-block. The<-
assignment operator used in ado
-block is reserved for peeling off the decoration from decorated values, and this works only outside oflet
-blocks. -
It can be an assignment
pat <- expr
. In this case,expr
must have the typem a
for some typea
, that is, it is an expression that evaluates to a value decorated using the monadm
.<-
peels off the decoration and assigns the value of typea
topat
.Note that I wrote
pat
, notvar
, to suggest thatpat
can be any pattern. It doesn't have to be a variable. For example, if we had an expressionbar x y z
of typem (Int, Bool, Double)
, then we can write(i, _, d) <- bar x y z
. Pattern matching then ignores the middleBool
component of the result ofbar x y z
, and it assigns theInt
andDouble
components toi
andd
.While we may use pattern matching in
pat <- expr
statements, this pattern matching must not fail.1 Ifpat
is a single variable, then pattern matching does not fail. If the function returns a tuple and we match its components to variables or wildcards, as we did in(i, _, d) <- bar x y z
, this also cannot fail. When matching against data constructors of types with multiple data constructors or against specific values, those patterns can fail and should not be used.Any variable bound as part of matching against the pattern
pat
is accessible anywhere after the statementpat <- expr
. -
It can be a single expression
expr
of some typem a
. If this is the last expression in thedo
-block, then the value of this expression is the value of the wholedo
-block. If it isn't, then this expression is evaluated only for its side effect, and its return value is ignored. For example, we could have a function in theMaybe
monad that fails if its argument is odd:failIfOdd :: Int -> Maybe () failIfOdd x = if odd x then Nothing else Just ()
We can then write something like
multiplyIfEven :: Int -> Int -> Maybe Int multiplyIfEven x y = do failIfOdd x return (x * y)
Here, we call
failIfOdd
onx
. Ifx
is odd, this fails, returnsNothing
, so the wholedo
-block fails and producesNothing.
Otherwise, we want to returnx * y
. Note that we do need to usereturn
here, and I suspect this is also the reason why this method of theMonad
class was namedreturn
. The reason is that the last expression of ado
-block must have typem a
, wherem
is our monad anda
is the return type of thedo
-block. Here, we want thedo
-block to have typeMaybe Int
. The expressionx * y
has typeInt
. To turn it into aMaybe Int
, we usereturn
, because for theMaybe
monad,return = Just
.
There's one more important rule: The last statement in a do
-block must always
be an expression. It can't be a let
-block or an assignment pat <- expr
because the do
-block must produce a value and the only statement that provides
a value is a statement of the form expr
. We're still programming functionally
after all; do
-notation only provides the illusion of imperative programming.
Another important point to make is that case
and if then else
are
expressions in Haskell, and of course nothing prevents us from calling the
function we're defining using do
-notation recursively. Thus, we have the exact
same control flow constructs available inside do
as we have when writing pure
functions. For example, here is a function that prints "Hello world!" several
times, using the IO
monad, which we will discuss shortly:
>>> :{
| greetRepeatedly :: Int -> IO ()
| greetRepeatedly reps = do
| if reps == 0 then
| return ()
| else do
| putStrLn "Hello world!"
| greetRepeatedly (reps - 1)
| :}
>>> greetRepeatedly 10
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
In this example, the whole do
-block consists of a single if then else
expression. Since greetRepeatedly
should produce a value of type IO ()
, this
is the value that both branches of the if then else
expression need to
produce. If reps == 0
, we return ()
. This doesn't really do anything. It
simply wraps the provided value ()
in the IO
monad, just as return ()
in
the Maybe
monad would produce the value Just ()
. If reps /= 0
, then the
else
-branch is itself a do
-block that first prints "Hello world!" using
putStrLn "Hello world!"
and then calls greetRepeatedly
recursively, with
argument reps - 1
, that is, this recursive call takes care of the remaining
reps - 1
times that "Hello world!" should be printed. The return value of this
inner do
-block is the value of its last statement, that is, the value produced
by greetRepeatedly (reps - 1)
. This obviously has the desired type IO ()
because that's what greetRepeatedly
returns.
I used this example only to demonstrate that we can use standard control-flow
constructs inside do
-blocks. The seasoned Haskell programmer would use
multiple equations to implement this function:
>>> :{
| greetRepeatedly :: Int -> IO ()
| greetRepeatedly 0 = return ()
| greetRepeatedly reps = do
| putStrLn "Hello world!"
| greetRepeatedly (reps - 1)
| :}
>>> greetRepeatedly 10
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Hello world!
Even if a function decorates its return value using some monad and uses
do
-notation, it is still a normal function, and all the constructs we have
discussed in the previous chapters can be used to define such a function.
-
There are some monads, such as
Maybe
, that are instances of theMonadFail
class, a subclass ofMonad
. These are monads that can cope with failure or, in the case ofMaybe
, model the very essence of failure. A failed pattern match is such a failure. Thus, in monads that are instances ofMonadFail
, we can use patterns that may fail to match. In other monads, we can't. ↩