HW 7: Discovering Monads by Building Interpreters¶
In this assignment you write four versions of one tiny evaluator. The first one just evaluates. The other three also report errors, count divisions, or keep a log, and each of those jobs adds its own bookkeeping to the evaluator. Then you factor that bookkeeping out into >>= and rewrite the evaluators in monadic style. The language has only integers and division, so nothing distracts from how the evaluator is built.
It's the same move as followedBy in Lab 7, applied to an interpreter.
How you'll get materials and submit this assignment
Getting the starter code and turning in your work follows the same process as every homework this semester. See The Homework Workflow for the full walkthrough. In short: from the hw7 template repo, make your own private copy, clone it to the course server, and submit your test output and code on Gradescope, along with a few short written answers. The rest of this page covers what the assignment actually asks of you.
There isn't much code to write. Most TODOs are one to five lines, and the whole assignment is around 80 lines of Haskell. The real work is understanding what each version has to carry along, and noticing what stays the same.
What You'll Do¶
- Tell apart the essential work of an evaluator and the extra plumbing needed for error handling, counting, or logging.
- Represent each kind of computation with a Haskell datatype of its own.
- Trace how values, errors, a counter, or log text move through a recursive evaluation.
- Derive an operation like
>>=by factoring out repeated code. - Write
Functor,Applicative, andMonadinstances for your own types. - Rewrite the same evaluator using
pure,>>=, anddonotation. - Check the monad laws on concrete examples, and explain what they guarantee.
Checklist¶
- Get the assignment materials (see The Homework Workflow)
- Part 1:
evalPlain - Part 2:
safeDivandevalSafe(handle errors by hand) - Part 3:
evalCount(pass the counter along by hand) - Part 4:
evalTrace(build the log by hand) - Part 5:
safePure,safeBind, andevalSafeWithBind - Part 6:
SafeResultinstances,evalSafeM, andevalPlainM - Part 7:
countBind,Countedinstances, andevalCountM - Part 8:
Tracedinstances andevalTraceM - Part 9: write
evalWith, compare all four, and check a monad law - Turn in the assignment (see Turning It In)
Collaboration¶
You can work with a partner on this assignment. If you do, you must cooperate on every part, follow the pair-programming rules from the syllabus at all times (even while writing prose), and share roles equally.
Background: Wadler's Evaluators¶
This assignment walks through the evaluator examples in Philip Wadler's paper Monads for Functional Programming (1995), especially Section 2. There, Wadler takes one tiny evaluator, changes it three ways (exceptions, state, and output), and then shows how a monad lets each change happen in one place.
You don't need to read the paper to do the assignment, because everything you need is on this page. It's worth reading afterward, though, to see how closely your own work matches his. One thing to know if you do: Wadler calls every version eval. Here each version gets its own name, so you can keep all of them in one file and compare them.
The File¶
Everything happens in HW07.hs. It's split into sections that match the Parts below, with type signatures and undefined placeholders. Work through the Parts in order.
undefined marks a step you haven't done yet. The file still loads with these placeholders in it, but calling an unfinished function gives an error.
Keep every evaluator. Don't delete the by-hand versions after you write the monadic ones. Comparing them is the whole point.
How to Work: GHCi and Tests¶
GHCi¶
ghci HW07.hs
After each change, run :reload. Each Part ends with a ✅ Check your work box of expressions to try. At the bottom of HW07.hs there's also a list called checks, made up of named True/False tests. When you've finished everything, this should print True:
all snd checks -- True
Tests¶
From the top of the repo, this runs the whole test suite:
runhaskell -itest test/Spec.hs
To run only some tests, add --match and a piece of text. Every test whose name contains that text runs. The tests are grouped by the Part numbers on this page, so:
runhaskell -itest test/Spec.hs --match "Part 3" # just Part 3
runhaskell -itest test/Spec.hs --match "evalSafe" # evalSafe, evalSafeWithBind, and evalSafeM
Until you finish a TODO, its tests fail with Prelude.undefined and the line number in HW07.hs. That's expected, and the rest of the tests still run. (A few tests check provided code, so they pass from the start.)
What the tests can't check
The tests check what your functions return. They can't tell whether your monadic versions really let the Monad instance do the plumbing, or whether your by-hand versions still do it by hand. That part is up to you, and it's what the written questions at the end are about.
Checkpoints
Each Part ends with a Checkpoint question. Checkpoints 3, 5, and 8 are graded on Gradescope. The others aren't submitted, but they feed into the Part 9 comparison table, which is. Jot a sentence or two in a scratch file as you go (see Turning It In).
Part 0: The Smallest Possible Language¶
Every evaluator in this homework works on the same abstract syntax tree (AST):
data Term
= Num Int
| Div Term Term
deriving (Show, Eq)
Num n is an integer literal. Div t u means: evaluate t and u, then divide the first result by the second using integer division (Haskell's div).
HW07.hs includes some sample terms:
tOk :: Term
tOk = Div (Div (Num 1999) (Num 2)) (Num 23)
tBad :: Term
tBad = Div (Num 1) (Num 0)
tNested :: Term
tNested = Div (Div (Num 8) (Num 2)) (Num 2)
In ordinary arithmetic, tOk is:
(1999 `div` 2) `div` 23
= 999 `div` 23
= 43
You'll use these terms throughout. tBad is the interesting one: it separates evaluators that crash on division by zero from evaluators that represent failure as data. For every evaluator you write, try it on all three: tOk, tBad, and tNested.
Part 1: The Plain Evaluator, evalPlain¶
Start with the simplest possible interpretation:
evalPlain :: Term -> Int
There are exactly two cases:
- A number evaluates to that number.
- A division evaluates each child recursively, then divides the results.
Implement both equations. This should take only a few lines.
✅ Check your work (Part 1)
evalPlain (Num 7) -- 7
evalPlain tOk -- 43
evalPlain tBad -- *** Exception: divide by zero
evalPlain tNested -- 2
Tests: runhaskell -itest test/Spec.hs --match "Part 1"
evalPlain tBad raises Haskell's divide-by-zero exception. For now that's expected.
Checkpoint 1
Suppose you wanted evalPlain tBad to report the problem instead of crashing, without changing the type Term -> Int. Could you? What would you have to change?
Part 2: Error Reporting, evalSafe¶
Now let's make the evaluator handle division by zero without crashing. It should return an ordinary value that describes either success or failure:
data SafeResult a
= Exception String
| Value a
deriving (Show, Eq)
SafeResult Int is either an integer or an explanatory error message: Value 43, or Exception "division by zero".
Step 2.1: The Division Check¶
Implement:
safeDiv :: Int -> Int -> SafeResult Int
It returns Exception "division by zero" for a zero denominator, and otherwise Value holding the integer quotient.
Step 2.2: An Evaluator That Handles Errors Explicitly¶
Implement:
evalSafe :: Term -> SafeResult Int
For Num n, return Value n. For Div t u:
- Call
evalSafe t. - If it fails, return the same
Exceptionimmediately. - If it succeeds, call
evalSafe u. - If that fails, return its
Exceptionimmediately. - If both succeeded, call
safeDivon the two integers.
Use nested case expressions. Don't use >>=, do, or any other monadic shortcut yet. The point is to see the repetition before you hide it. Here is the shape (HW07.hs has it too):
evalSafe (Div t u) =
case evalSafe t of
Exception msg -> ...
Value a ->
case evalSafe u of
Exception msg -> ...
Value b -> ...
✅ Check your work (Part 2)
safeDiv 10 2 -- Value 5
safeDiv 10 0 -- Exception "division by zero"
evalSafe tOk -- Value 43
evalSafe tBad -- Exception "division by zero"
evalSafe tNested -- Value 2
Tests: runhaskell -itest test/Spec.hs --match "Part 2"
Checkpoint 2
Compare evalSafe with evalPlain. Which lines are actually about evaluating division? Which are only about checking and passing along failures? How much longer is the recursive case?
Part 3: Counting Divisions, evalCount¶
This version ignores errors. Instead, it keeps track of how many successful divisions it performs.
A program could use a mutable global counter, but we can also pass an immutable counter explicitly from one computation to the next. A counted computation:
- receives the current counter,
- returns its ordinary result,
- and also returns the updated counter.
newtype Counted a = Counted (Int -> (a, Int))
Read Counted a as "a computation that produces an a while carrying an integer counter." A newtype is like a data declaration with exactly one constructor holding exactly one thing, here a function (see the ParserBase reference, where Parser is built the same way). It gives Counted a type of its own, so later we can make it an instance of Monad. A plain type synonym couldn't do this: type Counted a = Int -> (a, Int) would just be another name for a function type, not a new type, so it couldn't have its own Monad instance.
HW07.hs provides a function that takes the function back out of a Counted and runs it on a starting counter:
runCounted :: Counted a -> Int -> (a, Int)
runCounted (Counted f) = f
A computation that produces 7 without changing the counter is Counted (\count -> (7, count)). One that produces "hi" and adds 1 to the counter is Counted (\count -> ("hi", count + 1)). A Counted value is a function waiting for a starting counter. Run one with runCounted by supplying that counter:
runCounted (Counted (\count -> (7, count))) 0 -- (7,0)
runCounted (Counted (\count -> ("hi", count + 1))) 0 -- ("hi",1)
runCounted (Counted (\count -> ("hi", count + 1))) 5 -- ("hi",6)
The same computation gives a different final count depending on where it starts.
HW07.hs also provides runFromZero computation = runCounted computation 0.
Step 3.1: Thread the Counter by Hand¶
Implement:
evalCount :: Term -> Counted Int
Each case builds a Counted computation, a function waiting for the current count. For Num n, that function should produce the pair of n and the count it was given, leaving the counter unchanged. For Div t u, when the computation runs starting at count0, it must:
The right child gets the counter produced by the left child, not the original one. HW07.hs provides the let skeleton for the recursive case. Fill it in using runCounted and Counted.
✅ Check your work (Part 3)
runFromZero (evalCount (Num 7)) -- (7,0)
runFromZero (evalCount tOk) -- (43,2)
runFromZero (evalCount tBad) -- (*** Exception: divide by zero
runFromZero (evalCount tNested) -- (2,2)
tOk has two Div nodes, so the counter ends at 2. This version is only about counting, so it doesn't handle division by zero.
Tests: runhaskell -itest test/Spec.hs --match "Part 3"
Checkpoint 3
Trace the values of count0, count1, and count2 in evalCount (Div t u). Why can't we just evaluate the left and right subtrees independently, both starting from the original count? Give a concrete term where that would produce the wrong count.
Part 4: Recording a Trace, evalTrace¶
This version ignores errors and counting, and keeps a written record of its work instead. It doesn't print as it goes. It returns the trace as a string, which keeps the evaluator pure:
data Traced a = Traced String a
deriving (Show, Eq)
A traced result holds (the accumulated text, the computed value). HW07.hs provides a helper:
traceLine :: Term -> Int -> String
traceLine term n =
"eval(" ++ show term ++ ") = " ++ show n ++ "\n"
traceLine only formats a line of text. It doesn't evaluate anything, so you pass in the term and the value it should report:
traceLine (Num 7) 7 -- "eval(Num 7) = 7\n"
traceLine (Div (Num 8) (Num 2)) 4 -- "eval(Div (Num 8) (Num 2)) = 4\n"
putStr (traceLine (Div (Num 8) (Num 2)) 4)
-- eval(Div (Num 8) (Num 2)) = 4
GHCi shows a String with quotes and \n. putStr prints it as a line of text.
Step 4.1: Concatenate the Log by Hand¶
Implement:
evalTrace :: Term -> Traced Int
Make every log line with traceLine, so the format matches the tests. For Num n, return n and the line traceLine (Num n) n. For Div t u:
- Evaluate the left child, giving a value and some log text.
- Evaluate the right child, giving another value and more log text.
- Compute the quotient.
- Concatenate the left trace, the right trace, and a line for this division, in that order.
- Return that trace and the quotient together.
HW07.hs gives you a let skeleton for this case too.
✅ Check your work (Part 4)
evalTrace (Num 7)
-- Traced "eval(Num 7) = 7\n" 7
traceSummary (evalTrace tOk) -- (43,5)
traceSummary (evalTrace tBad) -- (*** Exception: divide by zero
traceSummary (evalTrace tNested) -- (2,5)
traceSummary (provided) gives the value and the number of trace lines. The five lines are three number leaves and two divisions. To see the whole trace, try let Traced text _ = evalTrace tOk in putStr text. Using putStr shows each \n as a real line break. Like evalCount, this version doesn't handle division by zero.
Tests: runhaskell -itest test/Spec.hs --match "Part 4"
Checkpoint 4
Put the Div cases of evalPlain, evalSafe, evalCount, and evalTrace side by side. What do all four have in common? For each of the last three, which lines are there only to carry the extra information along?
Puzzle (optional): a crash that isn't one
evalCount and evalTrace don't handle division by zero, so you'd expect tBad to crash them right away. Try snd (runFromZero (evalCount tBad)) and let Traced text _ = evalTrace tBad in putStr text. You get a count, and most of a trace, before anything goes wrong. Why? (Hint: Haskell is lazy. When does a `div` b actually get computed?)
Part 5: Discovering the Abstraction with SafeResult¶
Start with the easiest kind of bookkeeping: failure. Look again at the shape that kept repeating in Part 2:
case earlierResult of
Exception msg -> Exception msg
Value x -> nextStep x
None of the error-handling logic depends on which computation comes next. So it can go in a helper that takes the earlier result and a function for the next step.
Before you read on, write down what you think that helper's type should be. Then compare your guess with Step 5.2.
Step 5.1: Put an Ordinary Value into a Result¶
Implement safePure :: a -> SafeResult a, which represents an ordinary successful result: safePure 5 is Value 5.
Step 5.2: Connect One Result to the Next¶
Implement:
safeBind :: SafeResult a
-> (a -> SafeResult b)
-> SafeResult b
Here's how the pieces of the type line up:
SafeResult a -> (a -> SafeResult b) -> SafeResult b
^ ^ ^
earlier work function describing combined result
what to do next
The next function expects a plain a, not a SafeResult a. safeBind resolves that mismatch:
- On
Exception msg, pass the error along and don't call the next function. - On
Value x, call the next function withx.
safeBind (Value 7) (\x -> Value (x + 1)) -- Value 8
safeBind (Exception "oops") (\x -> Value (x + 1)) -- Exception "oops"
In the second example, the lambda is never called.
Step 5.3: Refactor the Evaluator, Before Any Typeclass¶
Implement:
evalSafeWithBind :: Term -> SafeResult Int
Use safePure for Num, and two calls to safeBind for Div. No nested case expressions. Use safeDiv for the final step, the one that might fail. The recipe:
evaluate left
THEN take its successful value a
evaluate right
THEN take its successful value b
safely divide a by b
Hint: two lambdas, one inside the other
Div has two children to evaluate, and each result goes through its own safeBind. Each safeBind needs a function that says what to do next, so you'll write two lambdas: one that receives a and one that receives b. The second lambda goes inside the body of the first. That's the only way a is still in scope when you finally divide a by b. Each THEN in the recipe above is a safeBind, and the indentation shows the nesting.
Compare the result to evalSafe.
✅ Check your work (Part 5)
safePure 5 -- Value 5
safeBind (Value 7) (\x -> Value (x + 1)) -- Value 8
safeBind (Exception "oops") (\x -> Value (x + 1)) -- Exception "oops"
evalSafeWithBind tOk -- Value 43
evalSafeWithBind tBad -- Exception "division by zero"
evalSafeWithBind tNested -- Value 2
Tests: runhaskell -itest test/Spec.hs --match "Part 5"
Checkpoint 5
Explain why safePure (a `div` b) is not a substitute for safeDiv a b. Where does the actual division-by-zero check belong?
Part 6: Making SafeResult a Real Monad¶
You've now invented two operations with very general-looking types:
safePure :: a -> SafeResult a
safeBind :: SafeResult a -> (a -> SafeResult b) -> SafeResult b
Haskell's standard names for these are:
pure :: Applicative m => a -> m a
(>>=) :: Monad m => m a -> (a -> m b) -> m b
>>= is pronounced "bind". (Wadler calls pure "unit", and older code often uses return. This assignment always uses pure.)
Why is pure in Applicative and not Monad? Haskell organizes these interfaces in a hierarchy:
Functor fmap
|
Applicative pure, (<*>)
|
Monad (>>=)
(<*>) applies a wrapped function to a wrapped value. For example, Value (+1) <*> Value 2 is Value 3. In this assignment you mostly need it because Haskell won't accept a Monad instance unless there's an Applicative instance too. You met Functor in Module 7.1.
Step 6.1: Three Instances for SafeResult¶
Complete the Functor, Applicative, and Monad instances in HW07.hs:
Functor: the same idea asfmaponMaybe. Transform the value inValue x, and leave anExceptionunchanged.Applicative:purebehaves likesafePure.Exception msg <*> anythingkeeps that error.Value f <*> resultappliesfto the value insideresult, if there is one. You can usefmapfor this.Monad: reuse exactly the two cases you discovered insafeBind. It should look almost identical.
These instances are for your own type
You can define instances for SafeResult because you created the type. Don't try to redefine Haskell's existing Monad Maybe instance.
Once your instances compile, try these in GHCi:
fmap (+1) (Value 2) -- Value 3
fmap (+1) (Exception "oops") -- Exception "oops"
pure 5 :: SafeResult Int -- Value 5
Value (+1) <*> Value 2 -- Value 3
Value (+1) <*> Exception "oops" -- Exception "oops"
Value 7 >>= \x -> Value (x + 1) -- Value 8
Exception "oops" >>= \x -> Value (x + 1) -- Exception "oops"
Value 7 >>= \x -> safeDiv 84 x -- Value 12
Value 0 >>= \x -> safeDiv 84 x -- Exception "division by zero"
Step 6.2: Rewrite the Evaluator a Third Time¶
Implement:
evalSafeM :: Term -> SafeResult Int
This time use pure and >>=, or the equivalent do notation, and finish with safeDiv. The evaluator should never inspect Exception or Value directly.
Once it's done, try:
evalSafeM tOk -- Value 43
evalSafeM tBad -- Exception "division by zero"
evalSafeM tNested -- Value 2
Step 6.3: Check a Monad Law¶
The right identity law says:
m >>= pure == m
Try it for both constructors:
(Value 7 >>= pure) == Value 7 -- True
(Exception "oops" >>= pure) == (Exception "oops" :: SafeResult Int) -- True
Step 6.4: A Tiny Bridge Back to the Plain Evaluator¶
Wadler also treats the simplest computation, one that carries no extra information, as a monad. HW07.hs provides it, instances included:
newtype Plain a = Plain a
deriving (Show, Eq)
unPlain :: Plain a -> a
unPlain (Plain x) = x
-- its bind just passes the value along:
-- Plain x >>= next = next x
These work already:
fmap (+1) (Plain 2) -- Plain 3
pure 5 :: Plain Int -- Plain 5
Plain (+1) <*> Plain 2 -- Plain 3
Plain 7 >>= \x -> Plain (x * 2) -- Plain 14
Your job is just to write:
evalPlainM :: Term -> Plain Int
using pure and do notation. It should mirror evalSafeM almost exactly, except that its last step can use ordinary div, because it has no error reporting. This version adds almost no behavior. It's there so the comparison of all four monadic evaluators is complete.
✅ Check your work (Part 6)
evalSafeM tOk -- Value 43
evalSafeM tBad -- Exception "division by zero"
evalSafeM tNested -- Value 2
unPlain (evalPlainM tOk) -- 43
unPlain (evalPlainM tBad) -- *** Exception: divide by zero
unPlain (evalPlainM tNested) -- 2
Tests: runhaskell -itest test/Spec.hs --match "Part 6", and --match "right identity" for the law.
Checkpoint 6
Explain by considering both constructors why right identity holds for your SafeResult. Which branch of your bind handles each case?
Part 7: The Same Idea for the Counter¶
Now return to the counter threading from Part 3. A Counted a is, underneath, an Int -> (a, Int). The first counter goes to the computation that runs first, and the next computation must receive its updated counter.
Step 7.1: One Useful Counted Operation¶
HW07.hs provides:
tick :: Counted ()
tick = Counted (\count -> ((), count + 1))
tick increments the counter without producing an interesting value. The value () means "nothing useful to report." Its type is also ().
Like any Counted computation, tick does nothing until you run it with a starting counter:
runCounted tick 0 -- ((),1)
runCounted tick 1 -- ((),2)
runCounted tick 2 -- ((),3)
runCounted tick 3 -- ((),4)
Step 7.2: Factor Out the Counter Threading¶
Do for Counted what Part 5 did for SafeResult. Implement:
countBind :: Counted a -> (a -> Counted b) -> Counted b
countBind first next is a counted computation. Given a starting counter, it runs first, passes the resulting value to next, and runs the computation next returns on the updated counter. Look at the let you wrote in evalCount. If this feels familiar, it's because followedBy in Lab 7 did the same thing with a stack.
countBind builds a computation. It doesn't run one. Calling countBind first next runs nothing. It returns a Counted b, which is a function waiting for a starting counter. The running happens later, when runCounted hands that function a count. So your answer will wrap a function of the starting counter in Counted, the same shape as the Div case of your evalCount.
Inside that function, the pieces line up like this:
| Name | Type | Where it comes from |
|---|---|---|
count0 |
Int |
the starting counter your function receives |
first |
Counted a |
an argument. It's wrapped, so you can't apply it to a number directly. runCounted unwraps it. |
x, count1 |
a, Int |
what running first from count0 gives back |
next x |
Counted b |
the next computation, chosen using x (not run yet) |
| the answer | (b, Int) |
what running next x from count1 gives back |
Here's a worked run with two small computations. five produces 5 and counts one step. double x produces twice x and counts one step:
let five = Counted (\count -> (5, count + 1))
let double x = Counted (\count -> (x * 2, count + 1))
runCounted (countBind five double) 3 should go:
count0 = 3
run five from 3 -> x = 5, count1 = 4
next x = double 5 -> a computation, not run yet
run double 5 from 4 -> (10,5)
Try these once countBind is written:
runCounted (countBind five double) 3 -- (10,5)
runCounted (countBind five double) 0 -- (10,2)
runCounted (countBind tick (\_ -> five)) 0 -- (5,2)
runCounted (countBind tick (\_ -> tick)) 0 -- ((),2)
If the first one gives (10,4), you ran double 5 from count0 instead of count1, so the step five counted was lost.
Step 7.3: Finish the Instances for Counted¶
The provided code includes fmap and <*>. You implement pure (in the Applicative instance) and >>= (in the Monad instance). >>= should do exactly what countBind does, so first >>= next = countBind first next is a fine answer. pure x produces x without changing the counter (compare the 7 example in Part 3).
Once your instances compile, try these in GHCi (five is from Step 7.2):
runCounted (pure 5) 3 -- (5,3)
runCounted (fmap (* 2) five) 0 -- (10,1)
runCounted (pure (+1) <*> five) 0 -- (6,1)
runCounted (five >>= \x -> pure (x * 2)) 0 -- (10,1)
runCounted (tick >>= \_ -> five) 10 -- (5,12)
fmap and pure change only the value. The count goes up only when a computation like five or tick runs.
Step 7.4: Rewrite the Evaluator¶
Implement:
evalCountM :: Term -> Counted Int
using do notation, pure for the ordinary result, and tick to count the division. Start from your evalSafeM. The only new ingredient is tick.
✅ Check your work (Part 7)
runCounted (countBind tick (\_ -> tick)) 0 -- ((),2)
runFromZero (evalCount tOk) -- (43,2)
runFromZero (evalCountM tOk) -- (43,2)
runFromZero (evalCountM tBad) -- (*** Exception: divide by zero
runFromZero (evalCountM tNested) -- (2,2)
runCounted (evalCountM tOk) 10 -- (43,12)
Tests: runhaskell -itest test/Spec.hs --match "Part 7"
Checkpoint 7
Compare evalCountM and evalSafeM. What is structurally the same in the recursive case? What differs? evalCountM never mentions a counter variable. Where did count0, count1, and count2 go?
Part 8: The Same Idea for Traces¶
The third variation produced both a value and some log text, Traced "some log text" 42 :: Traced Int.
Step 8.1: Finish the Instances for Traced¶
The provided code includes fmap and <*>. You implement pure and >>=:
pure produces the value with an empty log. Your bind should:
- Start with the first traced result, which has text
text1and valuex. - Call the next function with
x. - Get back text
text2and the next value. - Return the combined trace
text1 ++ text2with the next value.
It's the same type pattern as error handling and counting, but the bookkeeping is different.
Once your instances compile, try these in GHCi:
pure 5 :: Traced Int -- Traced "" 5
fmap (+1) (Traced "a\n" 2) -- Traced "a\n" 3
Traced "f\n" (+1) <*> Traced "x\n" 2 -- Traced "f\nx\n" 3
Traced "one\n" 7 >>= \x -> Traced "two\n" (x * 2) -- Traced "one\ntwo\n" 14
record "hi\n" >>= \_ -> pure 5 -- Traced "hi\n" 5
Step 8.2: One Useful Traced Operation¶
HW07.hs provides:
record :: String -> Traced ()
record message = Traced message ()
record is a small computation whose only job is to add a message to the trace.
record on its own gives a Traced with your message and the useless value (). It becomes useful when you chain it with >>= to whatever comes next. Bind keeps the message and throws away the ():
record "hi\n" -- Traced "hi\n" ()
record "one\n" >>= \_ -> Traced "two\n" 42 -- Traced "one\ntwo\n" 42
record "one\n" >>= \_ -> pure 42 -- Traced "one\n" 42
record "a\n" >>= \_ -> record "b\n" >>= \_ -> pure 7 -- Traced "a\nb\n" 7
In do notation, a line with no <- means exactly >>= \_ ->: run it, keep its text, ignore its value. So the third example is the same as:
do record "one\n"
pure 42
Step 8.3: Rewrite the Evaluator¶
Implement:
evalTraceM :: Term -> Traced Int
Use do notation in both cases:
Num n: a two-linedoblock. Userecordto add the line for the number (made withtraceLine, as inevalTrace), thenpure n. This is the shape of the last example in Step 8.2.Div t u: adoblock that evaluates both children, usesrecordfor the division's line, then returns the quotient. It should look a lot likeevalCountM, withrecorddoing the jobtickdid there. Record after both children, so the trace comes out in the same order asevalTrace's.
You'll need the quotient twice, once in the log line and once as the result. To name it, use let inside do. It has no in:
do a <- ...
b <- ...
let q = a `div` b
...
✅ Check your work (Part 8)
evalTraceM (Num 7) -- Traced "eval(Num 7) = 7\n" 7
traceSummary (evalTraceM tOk) -- (43,5)
traceSummary (evalTraceM tBad) -- (*** Exception: divide by zero
traceSummary (evalTraceM tNested) -- (2,5)
evalTraceM tOk == evalTrace tOk -- True
Tests: runhaskell -itest test/Spec.hs --match "Part 8"
Checkpoint 8
Change your bind to concatenate text2 ++ text1 instead, reload, and look at the trace for tOk (let Traced text _ = evalTraceM tOk in putStr text). What changed, and what didn't? Explain why, using your bind. Then change it back.
Part 9: Seeing the Common Computation Again¶
Put your four monadic division cases side by side: evalPlainM, evalSafeM, evalCountM, and evalTraceM. Each should have this recognizable shape:
do
a <- eval... t
b <- eval... u
-- possibly one piece of variation-specific work
-- produce a result
Step 9.1: One Evaluator for All Four¶
The four monadic evaluators differ only in two places: what happens after a node is evaluated, and how to divide. So you can write the evaluator once, for any monad, and pass those two pieces in:
evalWith :: Monad m
=> (Term -> Int -> m ()) -- afterNode
-> (Int -> Int -> m Int) -- divide
-> Term -> m Int
The first argument, afterNode, is a function that evalWith calls after evaluating each node, numbers included. It gets the node and the value that node evaluated to: afterNode (Num n) n for a number, and afterNode (Div t u) v for a division that came out to v. It returns m (), a step that produces no useful value but can do this monad's extra work. Most evaluators don't need it and pass \_ _ -> pure (). The trace evaluator uses it to record a line.
The second argument, divide, gets the two evaluated children and produces the quotient however this monad needs. For example, the safe evaluator would pass safeDiv, which reports division by zero.
For Num n, call afterNode and return n. For Div t u, evaluate left, evaluate right, divide, call afterNode on the result, and return it.
Tip: start from an evaluator you already wrote
evalWith is one of your …M evaluators with its specific parts swapped out for the hooks. Copy your evalTraceM into evalWith and change it in three places. The record lines become calls to afterNode. The division becomes a call to divide. The recursive calls become calls to evalWith.
Tip: three things that go wrong in evalWith
- The recursive calls need the hooks too.
evalWithtakes the two hooks before the term, so the children are evaluated with the sameafterNodeanddivideyou were given. divideis a step, not a plain value. It returnsm Int, so get its result with<-, notlet. (InevalTraceMyou usedlet, becausedivis an ordinary function.)afterNoderuns on numbers too. If you forget it in theNumcase, the trace loses its leaf lines.
Then implement evalPlainW, evalSafeW, evalCountW, and evalTraceW, each with a single call to evalWith. Each should agree with its …M version. Think about which hook does the extra work in each case. For some evaluators, one of the hooks does nothing (\_ _ -> pure ()).
Tip: fill in this table first
For each evaluator, answer two questions. What extra work happens after every node? What extra work happens when dividing?
| Evaluator | afterNode (every node) |
divide (each division) |
|---|---|---|
evalSafeW |
nothing | safeDiv, which may fail |
evalPlainW |
? | ? |
evalCountW |
? | ? |
evalTraceW |
? | ? |
A hook that does nothing still has to be a step in the monad. That's why "nothing" is written with pure.
Tip: name a hook, test it, then plug it in
A hook is just a function. You can define it at the top level of HW07.hs with its own type signature, and try it in GHCi before passing it to evalWith. For example, if you write a counting divide countDivide :: Int -> Int -> Counted Int, then runCounted (countDivide 7 2) 0 should give (3,1). A trace hook traceAfter :: Term -> Int -> Traced () should give traceAfter (Num 7) 7 ⟹ Traced "eval(Num 7) = 7\n" (). Once each hook works on its own, each …W definition is one line.
✅ Check your work (Step 9.1)
unPlain (evalPlainW tOk) -- 43
evalSafeW tBad -- Exception "division by zero"
runFromZero (evalCountW tOk) -- (43,2)
evalTraceW tOk == evalTraceM tOk -- True
Each ...W evaluator should give exactly what its Part 6–8 version gives on tOk, tBad, and tNested.
Tests: runhaskell -itest test/Spec.hs --match "evalWith"
Now adding a new kind of evaluator means writing an instance and two small hooks. The recursion never changes.
Step 9.2: The Comparison Table¶
You'll fill in this table on Gradescope (see Turning It In):
| Evaluator | What extra information does it carry? | Where is the bookkeeping in the manual version? | Where does it live in the monadic version? | Which evalWith argument does the extra work? |
|---|---|---|---|---|
evalPlain / evalPlainM |
... | ... | ... | ... |
evalSafe / evalSafeM |
... | ... | ... | ... |
evalCount / evalCountM |
... | ... | ... | ... |
evalTrace / evalTraceM |
... | ... | ... | ... |
Step 9.3: Three Laws, Intuitively¶
You checked right identity for SafeResult. A monad must satisfy three laws:
Left identity. Wrapping an ordinary value with pure and then passing it to f is the same as just calling f:
pure x >>= f = f x
Right identity. Taking the ordinary result and re-wrapping it changes nothing:
m >>= pure = m
Associativity. Changing the grouping of a chain doesn't change its behavior. It does not mean you can reorder the computations:
(m >>= f) >>= g = m >>= (\x -> f x >>= g)
The tests check examples of all three laws (--match "Monad law"), but on Gradescope you'll show one check you did yourself.
Checkpoint 9: a law check
Choose one law other than right identity. Test it with concrete values for SafeResult or Traced, and explain why both sides agree. For Counted, run both sides from the same starting counter and compare: Counted wraps a function, so it has no Eq instance.
Step 9.4: What This All Shows¶
All four evaluators use the same two operations, pure and >>=, but each type's instance decides what "and then" means. Changing what an evaluator carries is a change to one instance, not to every recursive case. That's how Wadler makes changes to an evaluator modular.
Turning It In¶
Submit on Gradescope, under HW 07: Discovering Monads by Building Interpreters:
- From the top of the repo, run the full test suite one last time:
runhaskell -itest test/Spec.hs > testing.log. Paste the entire contents oftesting.loginto the test-output text box, even if some tests still fail. - Upload
HW07.hs, the only file you edit.
Gradescope also asks a few short questions. You'll fill in the Part 9 comparison table, answer Checkpoints 3, 5, and 8, and show one monad-law check you ran in GHCi (Checkpoint 9). Your Checkpoint notes will come in handy here. The other Checkpoints aren't submitted.
The Homework Workflow covers the submission step in context. Make sure your final code is committed and pushed to your repo as well.
Final Self-Check¶
-
HW07.hsloads in GHCi, and nothing you were asked to write is stillundefined. - All the by-hand evaluators (
evalPlain,evalSafe,evalCount,evalTrace) are still there and still work. -
evalSafeWithBindusessafeBind, not nestedcases. -
evalSafeM,evalCountM, andevalTraceMlet>>=(ordo) do the plumbing: no matching onException/Value, no counter variables, no++. -
evalPlainW,evalSafeW,evalCountW, andevalTraceWare each one call toevalWith. -
all snd checksprintsTrue.
Optional Bonuses¶
These are for fun and practice. They're not part of the required assignment: the tests don't check them, and you don't submit them on Gradescope.
Bonus 1: Errors and Counting Together¶
Every result type so far added one extra behavior. Real programs often want several. Try combining safe division and counting in a single type. One starting point:
newtype SafeCount a = SafeCount (Int -> (SafeResult a, Int))
Define a combined pure and bind (as named helpers or as full instances), and write evalSafeCount :: Term -> SafeCount Int.
We recommend counting successful divisions. When a division fails, keep the error, stop evaluating later steps, and keep the count of the divisions that succeeded before it. HW07.hs defines:
bonusExample :: Term
bonusExample = Div (Div (Num 12) (Num 3)) (Num 0)
The inner division succeeds and the outer one fails, so running from counter 0 should give something equivalent to (Exception "division by zero", 1).
Questions to think about:
- Which pieces of your earlier code can you reuse?
- What should bind do if the first computation returned an
Exception? - Should a failed division count as an attempted division? In
evalCountM, it didn't matter whethertickcame before or after the children. Here, where you put thetickrelative to the division decides which one you're counting. Why the difference? -
You don't have to write
fmapand<*>by hand. Once you have>>=,fmap = liftMand(<*>) = apwork for any monad. They come fromControl.Monad, so add this line right undermodule HW07 where:- Does your combined bind satisfy right identity and associativity?import Control.Monad (ap, liftM)
This bonus points toward the broader problem of combining computational behaviors.
Bonus 2: Variables, Environments, and Simplifiers¶
The language so far has only numbers and division. If you want to keep going, here are some directions to try. Do as much or as little as you like, in a new file (say Bonus2.hs) so HW07.hs stays as you submitted it.
Grow the language. Make a new expression type with addition, subtraction, and multiplication alongside division. Then add variables, and a let form that gives a name to a value inside a smaller expression. To evaluate variables you'll need an environment, a record of which value each name has, and something to do when a name isn't in it. Your SafeResult monad is a natural fit for that. A list of pairs and the Prelude's lookup are enough to start. (If you import HW07, write import HW07 (SafeResult (..)) so your new constructors don't clash with Term's.)
Notice that almost every case of the new evaluator just passes the environment along unchanged. That's plumbing again. Could a monad hide it, the way Counted hid the counter?
Write simplifiers. A simplifier is a function that rewrites an expression into a simpler one, like turning x + 0 into x, x * 1 into x, x * 0 into 0, or 2 + 3 into 5. Try writing each rule as its own small function of type Expr -> Expr. Because they're just functions, you can combine them with .. Some things worth playing with:
- Does the order you compose them in matter?
- A rule that only looks at the top of an expression misses anything nested. How would you apply a rule everywhere in an expression?
- When should you stop simplifying? Is one pass ever not enough?
Ask whether it's correct. A simplifier should never change what a program means. Evaluate a few expressions before and after simplifying and compare. Then think about 0 * (1 / 0), or 0 * y when y isn't defined. Is your simplifier still correct? What would you do about it?
Where to Go From Here¶
Now's a good time to read Wadler's Monads for Functional Programming. Section 2 will feel familiar. Section 3 states the monad laws properly. Section 5 builds a parser as a monad, which is the same idea behind the parser combinators you used in HW 6: a parser is a computation that carries "the rest of the input" and might fail. If Bonus 1 made you wonder how to combine several behaviors without writing a new monad by hand every time, look up monad transformers.