39 KiB
Module 7 — The IO type
Time: ~5 h (take a break halfway; this is the deepest module of the course) · You'll edit: JS
js/src/io.js· Scalascala/src/IO.scala· F#fsharp/src/IO.fsTests: JScd js && node --test "test/m07-*.test.js"· Scalacd scala && ./test.sh M07· F#cd fsharp && ./test.sh M07Prerequisites: Modules 0–6, especially Module 4 (the state-passing pattern,pure/map/flatMaponRandom) and Module 6 (Scene)
Why this module
Here is a question that sounds like a contradiction: what does it mean for a program that draws on the screen to be pure?
A pure function, as you saw in Module 1, gives the same result for the same arguments and does nothing else. Drawing is "something else". If drawSquare() paints a square, then calling it twice paints twice; you can't replace the call with its result (undefined) without changing what the program does. Asking the browser for a random seed is worse: the same call gives a different answer each time. So it seems a game must be impure somewhere in the middle, wherever it draws or asks the outside world for something.
Module 6 already showed the way out for one half of the problem. view doesn't draw; it returns a Scene, a description of a drawing. This module takes that idea to its conclusion: every effect becomes a value that describes it. "Draw this rectangle" becomes a value. "Ask for a random seed, and then, with the answer, do this" becomes a value. A whole program becomes a value. Building those values is pure. Something, at the very edge, still has to perform the description — but that will be one small function (Module 9), and everything you write before it stays pure and testable.
The type of such descriptions is called IO. You will build it from scratch, understand why it has exactly the shape it has, write a pure interpreter for it so you can test programs without a screen, and check that it obeys the three laws that make it a monad.
Learning objectives
By the end of this module you can:
- explain the difference between performing an effect and describing it, and why descriptions keep a program referentially transparent;
- explain why an
IOneeds a continuationkand cannot be a plain list of operations; - implement
pure,effect,map,flatMap,sequence,draw,randomSeed,drawSceneand a pure interpretertrace; - state the three monad laws and explain how the tests check them using an interpreter;
- predict when a function given to
map/flatMapruns: when the program is built, or when it is interpreted; - compare our
IOwith Haskell'sIO, cats-effect, ZIO and free monads.
Concepts
1. Performing versus describing
Recall referential transparency from Module 1: an expression is referentially transparent when you can replace it by its value without changing the program. Try it on these two snippets:
const x = Math.random()
const pair1 = [x, x]
const pair2 = [Math.random(), Math.random()]
If Math.random() were a value you could substitute, pair1 and pair2 would be the same. They are not: pair1 has two equal numbers, pair2 almost surely two different ones. Math.random() is not an expression that has a value; it is an action that produces one when performed.
Now compare a different design. Suppose randomSeed is not an action but a value that describes the action "ask the outside world for a seed":
const randomSeed = { tag: 'Effect', op: { tag: 'RandomSeed' }, /* ... we'll see what goes here ... */ }
const program1 = [randomSeed, randomSeed]
This randomSeed is a plain object. Writing it twice or naming it once makes no difference at all — you can substitute freely. It does nothing until someone interprets it. That is the trick of the whole module, and it is also, essentially, how Haskell has been pure since the 1990s (A Gentle Introduction to Haskell: Input/Output).
You saw the same move twice already:
| Module | The "doing" version | The "describing" version |
|---|---|---|
| 4 | Math.random() mutates a hidden seed |
Random<A> = seed → [A, seed], a function you can pass around |
| 6 | draw(ctx, state) paints |
view(state) returns a Scene |
| 7 | ctx.fillRect(...), crypto.getRandomValues(...) |
IO<A>, a value describing a program that produces an A |
2. First attempt: a program is a list of operations
Let us design the description step by step. The simplest idea: a program is a list of operations (we'll call them Ops), and an interpreter performs them one after another. We need two operations for the whole course:
JavaScript
// An Op is plain data:
// { tag: 'Draw', shape } draw a Rect or Text from Module 6 (answer: nothing, undefined)
// { tag: 'RandomSeed' } ask the outside world for a seed (answer: a uint32 number)
Scala
// Already in scala/src/IO.scala
enum Op:
case Draw(shape: Shape)
case RandomSeed
enum OpResult: // what the outside world answers
case Done // ... to a Draw
case Seed(value: Long) // ... to a RandomSeed
F#
// Already in fsharp/src/IO.fs
type Op =
| Draw of Shape
| RandomSeed
type OpResult =
| Done
| Seed of uint32
From Scala: notice that Scala and F# need an explicit
OpResulttype, because an interpreter must hand back some typed answer for anyOp. JS is untyped, so the answer toDrawis justundefinedand the answer toRandomSeedis just a number.
A list-of-ops program and its interpreter would look like this (illustration only, not part of the exercises):
// A program as a list of ops, and an interpreter that performs them.
const program = [{ tag: 'Draw', shape: background }, { tag: 'Draw', shape: score }]
const runAll = (ops, platform) => ops.forEach((op) => perform(op, platform))
For drawing a scene, this is enough: drawScene would be scene.map(shape => ({tag:'Draw', shape})). But now try to write this program:
Ask for a random seed. Then start a new game with that seed, and draw it.
The shapes to draw depend on the seed, and the seed only exists after the first operation has been performed. A list must be complete before anybody performs it, so there is nowhere to put "and then, with the answer, decide what to do next". The list-of-ops design can describe programs whose steps are fixed in advance, and nothing else.
3. Second attempt: an operation and a continuation
The fix is to pair each operation with a function that receives the answer and returns the rest of the program:
Effect( op = RandomSeed,
k = seed => <the rest of the program, which may use seed> )
That function is called the continuation, traditionally named k. "Continuation" just means "what to do next"; you have used them every time you passed a callback (button.onclick = () => ...), and the idea has a long history under the name continuation-passing style.
When the program is finished there is no operation left, only a final value. So a program is one of two things:
Pure(value)— done; the program's result isvalue;Effect(op, k)— performop; give its answer tok;kreturns the rest of the program (anotherIO).
That's the whole type. Here it is in the three tracks (this definition is given to you in the stub files):
JavaScript
// IO<A> =
// { tag: 'Pure', value } value : A
// { tag: 'Effect', op, k } op : Op, k : (answer) => IO<A>
Scala
enum IO[+A]:
case Pure(a: A)
case Effect(op: Op, k: OpResult => IO[A])
F#
type IO<'a> =
| Pure of 'a
| Effect of Op * (OpResult -> IO<'a>)
From Scala: this is a recursive algebraic data type, like
List(Nil | Cons(head, tail)). The difference is that the "tail" is hidden behind a function: you can only get the rest of the program by supplying an answer. A list is a chain of values; anIOis a chain of questions.
Let us write the program from the previous section by hand, with no helpers at all. (JS shown; it is the same tree in every language.)
// "Ask for a seed; then draw a square whose x depends on the seed; then finish with the seed."
const program = {
tag: 'Effect',
op: { tag: 'RandomSeed' },
k: (seed) => ({
tag: 'Effect',
op: { tag: 'Draw', shape: { tag: 'Rect', x: seed % 20, y: 0, w: 1, h: 1, color: '#ffffff' } },
k: (_) => ({ tag: 'Pure', value: seed }),
}),
}
Drawn as a picture, the program is a chain where each link waits for an answer:
Effect(RandomSeed) ──answer: seed──▶ Effect(Draw(Rect at x = seed % 20)) ──answer: (none)──▶ Pure(seed)
Stop and check two things:
- Building
programperformed nothing. No seed was asked for; no square was drawn.programis an object, and the functions inside it have not been called. - The second operation depends on the first answer. That dependency is written as an ordinary JS function. This is the expressive power the list did not have.
4. Interpreting a program by hand
An interpreter is a function that walks this structure: if it sees Pure, it's done; if it sees Effect(op, k), it performs op somehow, calls k with the answer, and continues with whatever k returns.
The interesting phrase is "performs op somehow". Who decides what "performing" means? The interpreter does. That gives us the freedom to write two very different interpreters for the same programs:
trace(io, seed)(this module): a pure interpreter for tests. It doesn't draw anything. It answers everyRandomSeedwith the fixedseedyou give it, answers everyDrawwith "done", and records the list of operations it saw. It returns{ result, ops }.runIO(io, platform)(Module 9): the real interpreter, which calls the canvas and the browser's random generator.
Let us run trace(program, 7) by hand. Fill in the table yourself before reading the last row:
| Step | Current program | Operation performed | Answer given | ops so far |
|---|---|---|---|---|
| 1 | Effect(RandomSeed, k1) |
RandomSeed |
7 |
[RandomSeed] |
| 2 | k1(7) = Effect(Draw(Rect x=7), k2) |
Draw(Rect x=7) |
undefined (Scala/F#: Done) |
[RandomSeed, Draw(Rect x=7)] |
| 3 | k2(undefined) = Pure(7) |
— | — | finished: { result: 7, ops: [RandomSeed, Draw(Rect x=7)] } |
Now ask: is trace a pure function? Same io, same seed → same {result, ops}, every time; nothing outside is touched. Yes. So programs that "use randomness and draw on the screen" can be tested with deepStrictEqual, the same way you have tested everything so far. This is the single most important consequence of the module.
From Scala: an interpreter is a fold over a data structure, like
foldRightover aList, except that to get from one node to the next you must supply an answer. You will writetraceby recursion (no loops, no mutation): the ops ofEffect(op, k)areopfollowed by the ops of whateverk(answer)traces to.
5. Writing programs comfortably: pure and effect
Nobody wants to write nested objects like program above. We need small building blocks and ways to glue them. The two simplest:
pure(a)— the program that does nothing and producesa. It is justPure(a).effect(op)— the program that performs exactly oneopand produces its answer. What should its continuation be? It must take the answer and return "the rest of the program", and the rest of the program is "nothing more; the result is the answer". Which of our building blocks is exactly "take a value, return a finished program with that value"? (The docs in the stub say it outright:Effect(op, pure).)
With effect you can build the two operations the game needs:
draw(shape)— oneDrawoperation. In JS the answer is alreadyundefined, sodraw(shape)can beeffect(...)directly. In Scala/F#effectgives anIO[OpResult], butdrawpromisesIO[Unit]: you'll need to turn theDoneinto().randomSeed— oneRandomSeedoperation, producing the seed. In Scala/F# you must turnSeed(value)intovalue. What aboutDone? An interpreter that answers aRandomSeedwithDoneis broken; the honest thing to do is fail loudly (throw an exception with a clear message). That case cannot happen with a correct interpreter.
Notice that randomSeed is a value, not a function. In JS you write randomSeed, not randomSeed(). Why does that make sense? Because the description "ask for a seed" is always the same description. Using it twice in a program asks twice when interpreted, exactly as you would expect — but the value itself never changes. (In the JS stub it is a strange-looking object with a getter, only so that the stub fails loudly; replace it with a plain value.)
6. map: changing the result without running anything
Suppose you have randomSeed : IO<uint32> and you want IO<GameState>: "ask for a seed, and the result is newGame(seed)". That's map(randomSeed, newGame).
What must map(io, f) do? Think case by case, and think about when f may run:
- If
ioisPure(a), the value is right there. The result is a finished program whose value isf(a). - If
ioisEffect(op, k), there is no value yet. It will only exist when an interpreter performsopand callsk. Somapcan't callfnow. The new program must perform the sameop, and its continuation must take the answer, compute the rest of the original program withk, and then applyfto whatever that rest eventually produces. That last part is itself amap— on a smaller program.
That second case is the heart of the matter: map doesn't transform a value; it builds a new description that will transform the value once it exists. The tests check this directly:
test('m07 building map(io, throwingF) does not throw', ...) // building must not call f
test('m07 tracing map(io, throwingF) runs f and throws', ...) // interpreting must call f
From Scala: you know
Option.mapandList.map: they applyfto the value(s) inside.Future.mapis the closer analogue: it can't applyfuntil the value arrives, so it registersfto run later. Ourmapdoes the same thing with plain data instead of threads.
Language note. JS functions are data-first, map(io, f). Scala uses a method, io.map(f). F# uses a data-last module function, IO.map f io, so it works with the pipe: io |> IO.map f.
7. flatMap: "and then"
map lets the result depend on an earlier answer. But we need more: the next operations must depend on an earlier answer ("ask for a seed, then draw the game made from it"). That's flatMap(io, f), where f returns not a value but another program:
flatMap(io: IO<A>, f: A → IO<B>) : IO<B>
Read it as "run io; with its result a, continue with the program f(a)". It's the semicolon of our little language. Again, case by case:
Pure(a): the first program is already finished witha, so the combined program isf(a).Effect(op, k): perform the sameop; the new continuation takes the answer, gets the rest of the original program fromk, and then continues that rest withf. Again, the "and then continue withf" is itself aflatMapon a smaller program.
A picture helps. flatMap takes the chain of io and grafts f onto its end — at the place where io would have said Pure(a):
io: Effect(op1) ─▶ Effect(op2) ─▶ Pure(a)
flatMap(io, f): Effect(op1) ─▶ Effect(op2) ─▶ f(a) = Effect(op3) ─▶ Pure(b)
Two observations to make you comfortable:
- Once you have
flatMapandpure, you getmapfor free: "transform the result withf" is the same as "and then finish withf(a)". Can you writemapin one line usingflatMapandpure? (This is a classic exercise; it is also a nice way to check yourflatMap.) - You have seen this exact pair before. In Module 4,
Random<A>hadpure,mapandflatMapwith the same shapes and the same meaning ("then, with the value, continue"). This is not a coincidence; it's a pattern with a name, which you will meet properly in section 10.
From Scala:
flatMapis the method thatfor-comprehensions desugar to, and you've used it onList,Option,EitherandFuture. OnListit means "for each element, a list; concatenate"; onOption"if there is a value, continue; otherwise stop"; onIOit means "when the answer arrives, continue with this program".
Language note. In F# this function is called bind (IO.bind f io), which is the traditional name in ML and Haskell (>>=). Same function, different name.
8. sequence and drawScene
To draw a whole scene we need to run many IO<unit> programs one after another. sequence(ios) builds that combined program, left to right; sequence([]) is a program that does nothing and produces unit. Then
drawScene(scene) = sequence(scene.map(draw))
and every Scene from Module 6 becomes a program that draws it. trace(drawScene(view(s)), 0).ops is then exactly view(s) wrapped in Draw ops — which is what Module 9's render test checks.
How should you build sequence? It's a fold: combine programs pairwise with flatMap ("run this one, then the rest"). There are two ways to fold, and they produce differently shaped programs:
right-nested: flatMap(io1, _ => flatMap(io2, _ => flatMap(io3, _ => pure())))
left-nested: flatMap(flatMap(flatMap(pure(), _ => io1), _ => io2), _ => io3)
Both mean the same thing (the associativity law below guarantees it), and both pass this module's tests. But they don't cost the same. Keep the question "which one should I build?" in mind; section 12 answers it, and Module 9 has a test that cares.
9. When does f run? An honest note about eagerness
You should now be able to predict exactly when the function you pass to flatMap runs. Look at the Pure case again: flatMap(Pure(a), f) is f(a) — and our implementation computes f(a) right away, while building the program, not later during interpretation.
Is that a problem? In a pure world, no. If f is pure, calling it now or later gives the same result and has no other effect; nobody can tell the difference. Building flatMap(pure(3), n => draw(...)) doesn't draw anything either way, because f only returns a description of drawing.
There are exactly two ways to notice the difference, and you should know them:
- Exceptions.
flatMap(pure(1), _ => { throw new Error('boom') })throws while building, whereasflatMap(randomSeed, _ => { throw ... })only throws when interpreted. - Non-termination or very expensive work. If
floops forever or takes a minute, building the program hangs or takes a minute.
Both are, in a sense, impurities in f, and our fs won't have them. Production libraries do care: cats-effect, ZIO and Haskell's IO add a case like Suspend(() => io) / Delay so that even the first step can be postponed until interpretation. Our tiny IO doesn't need it, and it is good to understand why it doesn't.
10. The monad laws
A type with pure and flatMap that satisfies three equations is called a monad. The equations are not bureaucracy; each one is a refactoring you want to be allowed to do without changing what your program does.
Write ≡ for "behaves the same". For any value a, any program m, and any functions f, g that return programs:
- Left identity:
flatMap(pure(a), f) ≡ f(a)"Wrapping a value and immediately continuing with it is the same as continuing with it directly." - Right identity:
flatMap(m, pure) ≡ m"Continuing with 'return it unchanged' adds nothing." - Associativity:
flatMap(flatMap(m, f), g) ≡ flatMap(m, x => flatMap(f(x), g))"It doesn't matter how you group a chain of 'and then's." This is what lets you extract any part of a sequence into a helper function.
In Scala and F# syntax:
Scala
IO.pure(a).flatMap(f) ≡ f(a)
m.flatMap(IO.pure) ≡ m
m.flatMap(f).flatMap(g) ≡ m.flatMap(x => f(x).flatMap(g))
F#
IO.pure a |> IO.bind f ≡ f a
m |> IO.bind IO.pure ≡ m
m |> IO.bind f |> IO.bind g ≡ m |> IO.bind (fun x -> f x |> IO.bind g)
How can a test check "behaves the same"? You can't compare two IO values with ==: they contain functions, and functions can't be compared. But you can compare what they do under an interpreter! Two programs that produce the same result and perform the same operations, under the same answers, are indistinguishable to anyone using them. So the tests check:
assert.deepStrictEqual(trace(flatMap(pure(a), f), 5), trace(f(a), 5))
for a few programs (pure(4), randomSeed, and a draw followed by randomSeed). This is called observational equality: equal as far as any observer (here: the interpreter) can tell. It is also a lesson in why the pure interpreter is valuable: without it, you could not even state these tests.
From Scala:
Option,List,EitherandFutureall havepure-like constructors (Some,List(_),Right,Future.successful) andflatMap, and they all obey these laws. Theforsyntax relies on them: the compiler rewritesforinto nestedflatMaps in one particular grouping, and associativity is what makes that grouping irrelevant.
11. Syntax that hides the plumbing
Nested flatMaps become hard to read quickly. Scala and F# offer syntax for exactly this pattern; JS doesn't (but see the note).
Scala — because IO has map and flatMap, it works in a for-comprehension with no extra work:
val program: IO[Long] =
for
seed <- IO.randomSeed
_ <- IO.drawScene(View.view(Spawn.newGame(seed)))
yield seed
// desugars to: IO.randomSeed.flatMap(seed => IO.drawScene(...).map(_ => seed))
F# — F# has computation expressions. You enable them by writing a small builder object that tells the compiler which functions mean "bind" and "return". This is optional and not tested; try it after the exercises:
type IOBuilder() =
member _.Bind(io, f) = IO.bind f io
member _.Return(a) = IO.pure a
member _.ReturnFrom(io) = io
let io = IOBuilder()
let program : IO<uint32> =
io {
let! seed = IO.randomSeed
do! IO.drawScene (View.view (Spawn.newGame seed))
return seed
}
JavaScript has no such syntax. (Generators can imitate it — libraries such as redux-saga do this — but that's beyond our scope.) We'll keep our JS programs small enough that plain flatMap reads well.
Read the two snippets above and say what they would do if interpreted by trace(…, 42): what is the result, and what is the first element of ops?
12. Stack safety (why the shape of the tree matters)
Our IO is made of nested functions, and calling nested functions uses the call stack. Two places can blow the stack if a program is very long (tens of thousands of operations):
- The interpreter.
traceis written by recursion, one level per operation. That's fine for tests (a few hundred ops). The real interpreter in Module 9,runIO, must handle a full game for hours and is written as a loop instead (or, in Scala, a@tailrecfunction, which the compiler turns into a loop). - The continuations themselves. Look again at what
flatMapdoes to anEffect: its new continuation calls the oldkand then callsflatMapagain on the result. If you nestflatMapon the left —flatMap(flatMap(flatMap(…)))— then the first continuation, when called, must unwind every one of those levels at once before it can return the next step. A left-nested chain of 100 000flatMaps overflows the stack even if the interpreter is a loop.
That is the answer to the question from section 8: build sequence right-nested (fold from the right), so each continuation does a constant amount of work before handing back the next Effect. Module 9 has a test that runs sequence of 100 000 draws through runIO; a left-nested sequence will fail it with a stack overflow.
Production libraries solve this once and for all with a technique called trampolining: they represent flatMap itself as a data constructor (FlatMap(io, f)) and the interpreter re-associates left-nested chains on the fly, in a loop, so no shape of program can overflow the stack. Rúnar Bjarnason's paper "Stackless Scala With Free Monads" explains it beautifully; see also Trampoline (computing). We keep the simpler representation and the rule "fold right".
13. What we have built, and its family name
Step back and look at the type: a program is either done, or one instruction plus a function from the instruction's answer to the rest of the program. The instructions (Op) are a separate, ordinary data type; the IO structure doesn't care what they are. This shape is known as a free monad (more precisely, a close cousin called the "operational" or "freer" monad): it gives you pure/flatMap and the monad laws for free on top of any set of instructions, and leaves the meaning of the instructions entirely to the interpreter. Gabriel Gonzalez's "You Could Have Invented Free Monads" tells the same story from Haskell.
Consequences you will use in the rest of the course:
- Adding an effect is adding an
Op. Want to play a sound, or save a high score? Add{tag:'PlaySound', name}or{tag:'Save', key, value}and teach the interpreters what to do with it (you'll think through exactly this in Module 10). - Many interpreters.
tracefor tests,runIOfor the browser, and you could write a third that logs every op, or one that answersRandomSeedfrom a recorded file to replay a bug.
Exercises
All exercises are in the one file per track. Write them in the order below; each one uses the previous ones. Remember the purity rules: no let, no loops with mutation, no push in JS; no var/mutable. (trace is recursive.)
Exercise 7.1 — pure and effect
JavaScript
export const pure = (a) => /* IO<A> */
export const effect = (op) => /* IO<answer>: Effect(op, pure) */
Scala
object IO:
def pure[A](a: A): IO[A] = ???
def effect(op: Op): IO[OpResult] = ???
F#
module IO =
let pure (a: 'a) : IO<'a> = failwith "TODO m07"
let effect (op: Op) : IO<OpResult> = failwith "TODO m07"
Proved by: effect(op) is an Effect whose continuation is pure (it calls the continuation with an answer and compares with pure(answer)).
Exercise 7.2 — map and flatMap
JavaScript
export const map = (io, f) => /* IO<B> */
export const flatMap = (io, f) => /* IO<B> */
Scala (methods on the IO enum)
enum IO[+A]:
...
def map[B](f: A => B): IO[B] = ???
def flatMap[B](f: A => IO[B]): IO[B] = ???
F# (data-last; bind is F#'s name for flatMap)
let map (f: 'a -> 'b) (io: IO<'a>) : IO<'b> = failwith "TODO m07"
let bind (f: 'a -> IO<'b>) (io: IO<'a>) : IO<'b> = failwith "TODO m07"
Proved by: map transforms the result without adding ops, flatMap passes the result of the first IO to the second, building map(io, throwingF) does not throw, tracing map(io, throwingF) runs f and throws, and the nine monad-law tests (left identity holds for …, right identity holds for …, associativity holds for …). The law tests need trace (7.4) to run, so expect them to stay red until then.
Watch out for: calling f in the Effect case while building. If your map ever calls f before an interpreter supplies an answer, the "does not throw" test catches it. In Scala, flatMap on the enum is a method, so you will this match { ... }.
Exercise 7.3 — sequence, draw, randomSeed, drawScene
JavaScript
export const sequence = (ios) => /* IO<undefined>, left to right */
export const draw = (shape) => /* IO<undefined> */
export const randomSeed = /* IO<number>: a value, not a function */
export const drawScene = (scene) => /* sequence(scene.map(draw)) */
Scala
def sequence(ios: List[IO[Unit]]): IO[Unit] = ???
def draw(shape: Shape): IO[Unit] = ???
lazy val randomSeed: IO[Long] = ???
def drawScene(scene: Scene): IO[Unit] = ???
F#
let sequence (ios: IO<unit> list) : IO<unit> = failwith "TODO m07"
let draw (shape: Shape) : IO<unit> = failwith "TODO m07"
let randomSeed : IO<uint32> = (* a value *)
let drawScene (scene: Scene) : IO<unit> = failwith "TODO m07"
Proved by: randomSeed is answered with the seed given to trace, randomSeed records a RandomSeed op, draw records a Draw op and produces undefined (Scala/F#: ()), drawScene draws the shapes in order, drawScene of an empty scene has no ops, sequence of nothing has no ops, sequence runs effects left to right.
Watch out for: building sequence right-nested (section 12). The m07 tests won't notice a left-nested one, but Module 9's 100 000-draw test will.
Exercise 7.4 — trace, the pure interpreter
JavaScript
export const trace = (io, seed) => /* { result, ops } */
Scala
final case class Traced[A](result: A, ops: List[Op])
def trace[A](io: IO[A], seed: Long): Traced[A] = ???
F#
type Traced<'a> = { Result: 'a; Ops: Op list }
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = failwith "TODO m07"
Answer every RandomSeed with the seed (Seed(seed) in Scala/F#), every Draw with unit (Done), and collect the ops in the order they were performed. Use recursion, not mutation.
Proved by: trace of pure(3) has result 3 and no ops, and every other test in the module (they all observe programs through trace).
Run (all of Module 7):
- JS:
cd js && node --test "test/m07-*.test.js" - Scala:
cd scala && ./test.sh M07 - F#:
cd fsharp && ./test.sh M07
Hints
Exercise 7.1 — nudge
pure builds the "finished" case. effect builds the other case: which op? And which function, among those you've already written, takes an answer and returns "a finished program whose result is that answer"?
Exercise 7.1 — near-solution (try the others first)
export const pure = (a) => ({ tag: 'Pure', value: /* ? */ })
export const effect = (op) => ({ tag: 'Effect', op, k: /* a function you just wrote */ })
In Scala, IO.Pure(a) and IO.Effect(op, /* ? */); in F#, Pure a and Effect (op, (* ? *)).
Exercise 7.2 — nudge
Do flatMap first; map is easier afterwards. Split on the two cases of io. In the Pure case you have the value: what do you do with f? In the Effect case you have op and k but no value: what must the new Effect contain? Its op is the same. Its continuation receives an answer r … what can you compute from r with k, and what must happen to that afterwards?
Exercise 7.2 — stronger hint
For Effect(op, k), k(r) is "the rest of the original program". You want "the rest of the original program, and then f". You're writing the function that means "and then f" right now — call it recursively on k(r). The recursion happens inside the new continuation, so it only runs when an interpreter calls it.
For map, use flatMap and pure: "and then finish with f of the value".
Exercise 7.2 — near-solution (try the others first)
JavaScript
export const flatMap = (io, f) =>
io.tag === 'Pure'
? /* ? */
: { tag: 'Effect', op: io.op, k: (r) => /* ? uses io.k, r, flatMap and f */ }
export const map = (io, f) => flatMap(io, (a) => /* ? */)
Scala
def flatMap[B](f: A => IO[B]): IO[B] = this match
case Pure(a) => /* ? */
case Effect(op, k) => Effect(op, r => /* ? */)
def map[B](f: A => B): IO[B] = flatMap(a => /* ? */)
F#
let rec bind (f: 'a -> IO<'b>) (io: IO<'a>) : IO<'b> =
match io with
| Pure a -> (* ? *)
| Effect (op, k) -> Effect (op, fun r -> (* ? *))
let map (f: 'a -> 'b) (io: IO<'a>) : IO<'b> = io |> bind (fun a -> (* ? *))
Exercise 7.3 — nudge
draw and randomSeed are one effect each (plus, in Scala/F#, a map to turn the OpResult into the right type). sequence is a fold over the list that starts from "the program that does nothing" and glues programs with flatMap, ignoring the unit results. Which fold keeps the tree right-nested (section 12)?
Exercise 7.3 — near-solution (try the others first)
JavaScript
export const sequence = (ios) => ios.reduceRight((rest, io) => flatMap(/* ? */, () => /* ? */), /* ? */)
export const draw = (shape) => effect({ tag: 'Draw', shape })
export const randomSeed = /* ? one effect */
Scala
def sequence(ios: List[IO[Unit]]): IO[Unit] = ios.foldRight(/* ? */)((io, rest) => /* ? */)
lazy val randomSeed: IO[Long] = effect(Op.RandomSeed).map {
case OpResult.Seed(v) => /* ? */
case OpResult.Done => throw new IllegalStateException("RandomSeed must be answered with a Seed")
}
F#
let sequence (ios: IO<unit> list) : IO<unit> = List.foldBack (fun io rest -> (* ? *)) ios (* ? *)
Exercise 7.4 — nudge
trace of a Pure is easy: a result and no ops. For an Effect(op, k), which answer do you give k? Once you have k(answer), you have a smaller program; trace it (recursion!) and combine its result with op.
Exercise 7.4 — near-solution (try the others first)
JavaScript
const answer = (op, seed) => /* seed for RandomSeed, undefined for Draw */
export const trace = (io, seed) => {
if (io.tag === 'Pure') return { result: io.value, ops: [] }
const rest = trace(/* ? */, seed)
return { result: rest.result, ops: [/* ? */, ...rest.ops] }
}
Scala (recursion that is not in tail position is fine here; @tailrec with an accumulator is a nice extra)
def trace[A](io: IO[A], seed: Long): Traced[A] = io match
case IO.Pure(a) => Traced(a, Nil)
case IO.Effect(op, k) =>
val rest = trace(k(/* ? answer for op */), seed)
Traced(/* ? */, /* ? */)
F#
let rec trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
match io with
| Pure a -> { Result = a; Ops = [] }
| Effect (op, k) ->
let rest = trace seed (k ((* ? answer for op *)))
{ rest with Ops = (* ? *) }
Check your understanding
- In one sentence each: what is the difference between
Math.random()andrandomSeed? Betweenctx.fillRect(...)anddraw(rect)? - Why can't an
IObe a plain list ofOps? Give a concrete program from the Snake game that a list could not express. - Predict
trace(flatMap(randomSeed, (s) => map(draw(r), () => s * 2)), 21): what areresultandops? - Predict: does
const p = map(randomSeed, (s) => { throw new Error('x') })throw? Doestrace(p, 0)? DoesflatMap(pure(1), () => { throw new Error('x') })? Explain each answer using the structure offlatMap. - Why do the tests compare
trace(...)results instead of comparingIOvalues directly? What is "observational equality", and why is it the right notion of "the same program" here? - State the associativity law in words. Which everyday refactoring (think "extract function") would be unsafe if it didn't hold?
- You build
sequencewith a left fold and every m07 test passes. What breaks later, and why doesn't the loop in the interpreter save you? - To add sound to the game you'd add
{tag:'PlaySound', name}. List every place in the code that has to change. Which place does not have to change at all, and why is that the point of the design?
In production
- Haskell's
IOis the original:main :: IO ()is a value that the runtime performs; every effectful function returns anIOdescription. Haskell'sdo-notation is the same sugar as Scala'sforand F#'s computation expressions (Haskell wiki: IO inside). - cats-effect (Scala) gives you
IO[A]withpure,map,flatMap,IO.delay/IO.blockingto suspend side-effecting code, and a runtime that interprets it on a fiber scheduler with stack safety, cancellation and concurrency (cats-effect). The book Functional Programming in Scala, chapter 13, builds anIOvery much like ours and then makes it stack-safe. - ZIO (Scala) uses
ZIO[R, E, A]: a description of a program that needs an environmentR, may fail withE, or succeeds withA(ZIO). TheRis a typed version of ourPlatform. - Free monads are used directly to separate a program's instructions from their interpretation, e.g. to run the same business logic against a real database and an in-memory test double (Gabriel Gonzalez, "You Could Have Invented Free Monads").
- Elm commands (
Cmd msg) are effect descriptions returned byupdate; the Elm runtime performs them and feeds results back as messages (Elm guide: Commands and Subscriptions). In F#, Elmish has the sameCmdtype (Elmish).
Further reading
- Gabriel Gonzalez, "You Could Have Invented Free Monads" — the same derivation as this module, in Haskell; read it to see the idea in its native habitat.
- Rúnar Bjarnason, "Stackless Scala With Free Monads" — how to make this structure stack-safe for arbitrarily long programs.
- Haskell wiki: Monad laws — the three laws with examples and the intuition behind each.
- Mostly Adequate Guide to Functional Programming, chapters 8–9 — functors,
IOand monads, in JavaScript. - Scala: for-comprehensions and F#: Computation expressions — the syntax for writing
flatMapchains in each language. - Referential transparency (Wikipedia) — the property this whole module protects.
- Continuation-passing style (Wikipedia) — where the
kcomes from.