Module 07.2: Monads — Composing Computations¶
In Module 07.1, we discovered that fmap generalizes the idea of map: given a function a -> b, we can transform values inside a list, tree, Maybe, or another functor.
Now consider functions that return a Maybe themselves. How do we compose those functions without repeatedly writing code to check for Nothing?
That small problem will lead us to a surprisingly general abstraction: monads.
By the end of this module, you should be able to:
- explain why ordinary composition fails for
Maybe-producing functions, - derive and use
(>>=)(pronounced bind), - understand
pure, - read a small
Monadinstance, - use
donotation, and - describe how the same interface supports different kinds of computations.
How to use this module
When you see a box labeled Gradescope question, answer that question in the Module 7.2 Completion assignment on Gradescope.
Ordinary Function Composition¶
Recall function composition (.) from Module 02.2. Take two ordinary functions:
double :: Double -> Double
double x = 2 * x
increment :: Double -> Double
increment x = x + 1
We can compose them:
(increment . double) 5 -- 11.0
Composition works because the first function returns the kind of value the second function expects:
f :: a -> b
g :: b -> c
-- g . f :: a -> c
But some useful functions don't return an ordinary value directly.
A Square Root That Might Fail¶
Recall that Maybe distinguishes a present value from a missing one. Let's make a function that declines to compute real square roots of negative numbers:
safeSqrt :: Double -> Maybe Double
safeSqrt x =
if x < 0
then Nothing
else Just (sqrt x)
Examples:
safeSqrt 9 -- Just 3.0
safeSqrt (-4) -- Nothing
Now suppose we want a safe fourth root. Mathematically, we can take the square root twice. It is tempting to write:
safeFourthRoot = safeSqrt . safeSqrt -- TYPE ERROR
The types don't fit:
safeSqrt :: Double -> Maybe Double
first result: Maybe Double
second input needs: Double
The second safeSqrt expects a Double, not a Maybe Double.
Gradescope question: Why doesn't composition work?
Submit this response on Gradescope.
Explain why safeSqrt . safeSqrt is ill-typed. Be specific about the output type of the first call and the input type of the next call.
A Working Solution, with Repeated Bookkeeping¶
We already know how to solve the problem with pattern matching:
safeFourthRoot :: Double -> Maybe Double
safeFourthRoot x =
case safeSqrt x of
Nothing -> Nothing
Just y -> safeSqrt y
This handles both possibilities. If the first square root fails, the whole calculation fails. Otherwise, we feed the value to the next square root.
What about an eighth root?
safeEighthRoot :: Double -> Maybe Double
safeEighthRoot x =
case safeFourthRoot x of
Nothing -> Nothing
Just y -> safeSqrt y
We're repeating a pattern:
case someResult of
Nothing -> Nothing
Just x -> nextStep x
Most of this code has nothing to do with square roots. It is the same failure-handling plumbing every time. That suggests a familiar course theme: factor out what repeats.
Extract a Reusable Operation¶
Let's give that pattern a name:
andThen :: Maybe a -> (a -> Maybe b) -> Maybe b
andThen Nothing _ = Nothing
andThen (Just x) f = f x
Read the type slowly:
Maybe a -> (a -> Maybe b) -> Maybe b
previous result next computation final result
If the previous result is Nothing, we stop. If it is Just x, we give x to the next function. Notice that this next function can also fail.
Now the earlier definitions become:
safeFourthRoot x = safeSqrt x `andThen` safeSqrt
safeEighthRoot x =
safeSqrt x
`andThen` safeSqrt
`andThen` safeSqrt
The checks are gone from the main program: they live in andThen.
This operation is so useful that Haskell has a standard name for it:
(>>=) :: Monad m => m a -> (a -> m b) -> m b
It is called bind. Maybe already implements it, with the behavior we just defined as andThen.
safeFourthRoot x = safeSqrt x >>= safeSqrt
The key idea is not the strange-looking operator. It is the repeated structure that the operator eliminates.
Gradescope question: Follow bind
Submit this response on Gradescope.
Predict the results and briefly explain how (>>=) behaves in each case:
Just 16 >>= safeSqrt
Nothing >>= safeSqrt
safeSqrt 9 >>= safeSqrt
Composing Different Possibly Failing Operations¶
This is not just a special trick for repeated square roots. Consider another operation that might fail:
safeReciprocal :: Double -> Maybe Double
safeReciprocal 0 = Nothing
safeReciprocal x = Just (1 / x)
We can combine it with square root:
safeSqrt 4 >>= safeReciprocal
-- Just 0.5
safeSqrt (-4) >>= safeReciprocal
-- Nothing
The two functions have different jobs, but the same result structure. Bind takes care of propagating failure consistently.
Notice the distinction from Module 07.1:
fmap (+1) (Just 5)
-- Just 6
Just 5 >>= safeReciprocal
-- Just 0.2
fmap expects a function of type a -> b, returning an ordinary value.
>>= expects a function of type a -> m b, returning another value wrapped in a context.
Here context just means the wrapper around a value. For Maybe, the context is whether a value is present at all (Just) or missing (Nothing). The rest of this module uses the word in that sense.
If we mistakenly tried:
fmap safeSqrt (Just 16)
we would get:
Just (Just 4.0)
A nested Maybe! Bind knows how to sequence the computation without accumulating an extra layer of Maybe.
Gradescope question: fmap versus bind
Submit this response on Gradescope.
Compare the types of fmap and (>>=). Why does fmap safeSqrt (Just 16) produce a different shape of result from Just 16 >>= safeSqrt?
Putting an Ordinary Value into a Context: pure¶
We also need a simple way to produce an ordinary successful result inside a context:
pure 5 :: Maybe Int
-- Just 5
The general type is:
pure :: Applicative m => a -> m a
(Applicative is another typeclass; the next section says just enough about it.)
For Maybe, pure behaves like Just. It does not check that a calculation is safe. It only places an already-computed value into the chosen context.
So the two play different roles: a safe operation such as safeReciprocal can introduce a failure; pure never does. It always succeeds.
Older Haskell materials commonly use return for this operation. Despite the name, Haskell's return is not the return statement from Python or Java: it doesn't exit a function, it just wraps a value, exactly like pure. In modern Haskell, we will use pure.
The Absolute Minimum About Applicatives¶
We have seen Functor, which provides fmap. Modern Haskell places another typeclass between Functor and Monad:
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
The idea of <*> is to apply a function inside a context to an argument inside a context:
Just (+1) <*> Just 5 -- Just 6
Just (+1) <*> Nothing -- Nothing
For our purposes, the takeaway is small: Applicative is where pure is defined, and (as the next section shows) every Monad is also an Applicative. We won't go further into applicatives in this course.
Gradescope question: What does pure do?
Submit this response on Gradescope.
What does pure 5 :: Maybe Int produce? Why should we not use pure (1 `div` 0) :: Maybe Int as a replacement for a safe division function?
The Monad Typeclass and a Concrete Instance¶
Here's the relevant portion of the modern class definition:
class Applicative m => Monad m where
(>>=) :: m a -> (a -> m b) -> m b
The relationship is:
Functor Applicative Monad
fmap ---> pure, <*> ---> >>=
The Applicative m => in the class header is a superclass constraint, like Eq a => Ord a in Module 07.1. Every Monad must also be an Applicative, and (from Functor f => Applicative f in the previous section) every Applicative must also be a Functor.
To see that these are just ordinary Haskell functions, look at illustrative definitions for Maybe. They already exist in the standard library; don't paste these instance declarations into your code.
instance Functor Maybe where
fmap _ Nothing = Nothing
fmap f (Just x) = Just (f x)
instance Applicative Maybe where
pure = Just
Nothing <*> _ = Nothing
Just f <*> x = fmap f x
instance Monad Maybe where
Nothing >>= _ = Nothing
Just x >>= f = f x
The Monad part is exactly our earlier andThen pattern. Nothing supernatural happened: we found a useful repetitive operation and gave it a shared interface.
do Notation: Easier to Read, Same Meaning¶
Long chains of binds can become hard to read:
example :: Double -> Maybe Double
example x =
safeSqrt x >>= \a ->
safeReciprocal a >>= \b ->
pure (a + b)
Haskell's do notation is a more readable way to write the same sequence:
example :: Double -> Maybe Double
example x = do
a <- safeSqrt x
b <- safeReciprocal a
pure (a + b)
The translation to remember is:
do
a <- first
b <- second a
finish a b
means (roughly):
first >>= \a ->
second a >>= \b ->
finish a b
For Maybe, the rest of the block is skipped if any preceding step produces Nothing. The do block itself does not perform the check; the Maybe instance of bind does.
Four practical rules¶
1. Use <- for computations in the context. If safeSqrt x :: Maybe Double, then:
a <- safeSqrt x
binds a :: Double within the remainder of the do block when the computation succeeds. This is not mutable assignment.
2. Use let for ordinary values. For example:
let doubled = 2 * a
There is no in in this do-block let statement. This would not be appropriate:
doubled <- 2 * a -- WRONG: right side is not a Maybe computation
3. The final line determines the result of the do block. For instance:
pure doubled
produces a Maybe Double. A final line that is just doubled would have type Double, not Maybe Double.
4. A computation on a line by itself runs, but its result is discarded. A line with no <- and no let still takes part in the sequence, so in Maybe it can still make the whole block fail. You write a line like this when you care about what the step does (here, possibly failing) and not about the value it produces.
One complete example¶
Here is a single do block that uses all four rules. First, one more small helper. It either succeeds with no interesting value or fails:
atMost :: Double -> Double -> Maybe ()
atMost limit y = if y <= limit then Just () else Nothing
Its result type is Maybe (). Just () means "the check passed," and Nothing means "the check failed." The () inside carries no information.
Now the example:
example2 :: Double -> Maybe Double
example2 x = do
a <- safeSqrt x -- (1) bind: a :: Double; this step could fail
let b = a - 3 -- (2) let: an ordinary value; this step cannot fail
atMost 100 b -- (3) bare: may fail; its () result is thrown away
c <- safeReciprocal b -- (4) bind again, using b
pure (a + c) -- (5) final line: the result of the whole block
Look at each line:
- Line 1 uses
<-becausesafeSqrt xis aMaybe Double, and we want theDoubleinside. - Line 2 uses
letbecausea - 3is a plainDouble. There is nothing to unwrap and nothing that can fail. - Line 3 is a bare computation of type
Maybe (). We could write_ <- atMost 100 b, but naming a()is pointless. We can't leave the line out, though: if the check fails, the whole block isNothing. The value gets discarded, but the success or failure does not. - Line 4 uses
<-again, and it can usebfrom line 2. - Line 5 is
pure (a + c), so the block has typeMaybe Double, which matches the signature.
Tracing a few inputs shows that each Maybe line can be the one that stops the block:
x |
a (line 1) |
b (line 2) |
atMost 100 b (line 3) |
c (line 4) |
result |
|---|---|---|---|---|---|
16 |
4.0 |
1.0 |
Just () |
1.0 |
Just 5.0 |
-4 |
— | — | — | — | Nothing, stopped at line 1 |
9 |
3.0 |
0.0 |
Just () |
— | Nothing, stopped at line 4 |
1000000 |
1000.0 |
997.0 |
Nothing |
— | Nothing, stopped at line 3 |
Here is the same function written out with >>=. Haskell turns the do block into exactly this:
example2 x =
safeSqrt x >>= \a ->
let b = a - 3 in
atMost 100 b >>= \_ ->
safeReciprocal b >>= \c ->
pure (a + c)
Each kind of line has a direct translation:
In a do block |
Means |
|---|---|
x <- m |
m >>= \x -> ...rest... |
let x = e |
let x = e in ...rest... |
m (bare) |
m >>= \_ -> ...rest... (Haskell also spells this m >> ...rest...) |
| final line | stays as-is, and must have the monadic type (here, Maybe Double) |
The bare line becomes a bind whose lambda ignores its argument (\_ ->). That is why its result is discarded but its failure still counts: Nothing >>= \_ -> ... is Nothing, like any other bind.
You will see bare lines most often with IO, in the next section. A line like putStrLn "Hello" in a do block is a bare computation for the same reason: we want it to happen, and its () result is useless.
Try it in ghci - nothing to submit
Put safeSqrt, safeReciprocal, atMost, and example2 in a file, load it in ghci, and run example2 on the four inputs from the table. Then delete line 3 and reload. Which input's result changes, and why?
Gradescope question: Read and rewrite do
Submit this response on Gradescope.
Rewrite this definition using do notation:
f x = safeSqrt x >>= \a ->
safeReciprocal a >>= \b ->
pure (a + b)
If safeSqrt x produces Nothing, does safeReciprocal run? Explain which operation is responsible for the behavior.
Other Meanings of Sequencing¶
Maybe is a monad whose sequencing means continue on success, stop on failure. But the same interface works elsewhere.
Lists: A computation can have multiple results. Bind applies the function to each value and concatenates the resulting lists:
[1, 2] >>= \x -> [x, x * 10]
-- [1,10,2,20]
IO: A computation can perform input/output operations, which we can sequence with do:
main :: IO ()
main = do
name <- getLine
putStrLn ("Hello, " ++ name)
Here getLine :: IO String is a computation that reads a line of input and produces a String, and putStrLn :: String -> IO () prints a line. (() is Haskell's "no interesting result" type.) This is how a standalone Haskell program does input and output. So far in this course you've only run code through ghci and test suites, so you haven't needed it.
Parsers: The course's Parser type from Module 06.2 is also a monad (see the ParserBase Reference). But be careful with a look-alike operator: the >>=: you used in 06.2 is not bind. Specialized to parsers, it behaves like this:
(>>=:) :: Parser a -> (a -> b) -> Parser b
p >>=: f = fmap f p
It transforms a parser's result with an ordinary function a -> b, so it's really fmap from Module 07.1 with its arguments flipped. Real bind, >>=, takes a function a -> Parser b, which would let the next parser depend on what was just parsed. We didn't need that in 06.2.
Different monads give different meanings to sequencing. do notation does not itself skip steps, produce multiple results, or perform I/O; the type and its >>= determine the behavior.
Three Short Laws¶
Just as functors obey laws, monads obey laws describing sensible sequencing. Here m means an existing monadic computation, and f and g are functions that produce monadic computations.
Left identity:
pure x >>= f = f x
Wrapping an ordinary value before immediately using it changes nothing.
Right identity:
m >>= pure = m
Taking a result and wrapping it straight back up changes nothing.
Associativity:
(m >>= f) >>= g = m >>= (\x -> f x >>= g)
Grouping a sequence differently doesn't change its meaning. Importantly, this does not mean that we can reorder the steps.
These are laws of behavioral equivalence, not necessarily executable Haskell == tests; not every monadic type supports Eq.
Where Homework 7 Goes Next¶
We've learned to remove repetitive failure checks from programs that use Maybe. But failure isn't the only kind of bookkeeping.
Suppose you have a tiny arithmetic-language evaluator:
data Term = Num Int | Div Term Term
eval :: Term -> Int
What if you later want it to report errors, count evaluation steps, or produce a trace? A naive solution can bury the actual evaluation algorithm under error checks, a step counter passed through every recursive call, or string concatenation for the trace.
In Homework 7, you'll build these variations and discover how the same sequencing abstraction can keep the core evaluator understandable.
The recurring idea of this course is once again abstraction through factoring out repetition. Monads provide a standard interface for abstracting the plumbing around a sequence of computations.
Finish Module 07.2¶
Make sure you have answered the five Gradescope questions embedded above:
- Why is
safeSqrt . safeSqrtill-typed? - How does
(>>=)behave onJustandNothing? - Why do
fmap safeSqrtand>>= safeSqrtproduce different shapes of result? - What does
puredo, and why isn't it a safety check? - How do you rewrite a chain of binds using
donotation?
Then answer the final two questions.
Gradescope question: Time spent
Submit this response on Gradescope.
Approximately how much time did you spend on this module?
- less than 30 minutes
- 30 - 45 minutes
- 45 - 60 minutes
- 60 - 75 minutes
- 75 - 90 minutes
- more than 90 minutes
Gradescope question: Lingering questions
Submit this response on Gradescope.
What lingering questions or thoughts do you have about this module?