Audit pass: - Frame rule counts frames since the last move, so every step lasts exactly movesEvery(score) frames, also right after a speed-up - clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap - Pong Restart keeps held keys - Fact corrections (mulberry32 output coverage, Haskell IO, uniform) - New fixture cases from a mutation analysis Extensions (all three tracks, shared fixtures, course text): - Turn queue (Exercise 10.6), persisted high score (10.7), replay viewer (10.8), play() end-to-end helper (9.5) - Optional State (4.7) and optional Module 7b, stack-safe IO - Module 12, property-based testing with generators, shrinking, invariants and a model-based oracle - Module 13, Conway's Life with a Click event - JS type checking via jsconfig, JSDoc and types.d.ts - Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh, Playwright end-to-end suite (e2e/, scripts/e2e.sh) Docs: docs/contract.md is the canonical contract; specs and build log under docs/superpowers. Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
32 KiB
Module 7b — Stack-safe IO (optional)
Time: ~3 h · You'll edit: JS
js/src/safeio.js· Scalascala/src/SafeIO.scala· F#fsharp/src/SafeIO.fsTests: JScd js && node --test "test/m07b-*.test.js"· Scalacd scala && ./test.sh M07b· F#cd fsharp && ./test.sh M07bPrerequisites: all of Module 7, especially sections 8 (sequence), 10 (the monad laws) and 12 (stack safety). No later module needs this one: you can do it straight after Module 7, after the whole course, or never.
Why this module
Module 7 ended with a rule: build sequence with a right fold. Fold from the left and every Module 7 test still passes, but the 100 000-draw test of Module 9 dies with a stack overflow. Section 12 explained why in two sentences and moved on.
A rule like that is fragile. Nothing in the type says "nest me to the right", and left nesting is what you get naturally whenever you build a program step by step, adding to "the program so far":
// A perfectly reasonable way to build a program: start from nothing, add one step per item.
const drawAll = (shapes) => shapes.reduce((soFar, s) => flatMap(soFar, () => draw(s)), pure(undefined))
Here is what that costs with Module 7's IO, measured with Node on the machine this module was written on, with a while loop as the interpreter (the loop of Module 9's runIO):
| Draws, left-nested | Time | Draws, right-nested | Time |
|---|---|---|---|
| 1 000 | 11 ms | 100 000 | 38 ms |
| 5 000 | 223 ms | ||
| 10 000 | 1 082 ms | ||
| 100 000 | stack overflow |
Two different problems hide in that table. Going from 5 000 to 10 000 draws multiplies the time by almost five: the work grows with the square of the length. And at 100 000 the stack runs out, even though the interpreter is a loop. On top of that, Module 7's trace is recursive, one level per operation, so it overflows at around 10 000 operations whichever way the program is nested.
This module removes both problems by changing the representation, not the rule. You will add a third constructor, FlatMap, so that "and then" becomes a piece of data; write the one rewrite rule that makes left nesting harmless; and build an interpreter that never grows the stack, out of tiny steps. At the end, 100 000 left-nested flatMaps run in a fraction of a second, and the programs behave exactly as they did under Module 7's IO, which the tests check.
Learning objectives
By the end of this module you can:
- explain why a left-nested chain of
flatMaps is both deep (stack) and quadratic (time) with theIOof Module 7; - implement an
IOwhoseflatMaponly builds aFlatMapnode; - state the re-association rule, draw it as a picture, and justify it with the associativity law;
- implement
step, a single rewrite with a constant amount of work, and explain how an interpreter built from such steps (a trampoline) keeps the stack flat; - explain how a loop can be written without a loop in a language that does not eliminate tail calls, and why the ops must not be collected with
[...ops, op]; - describe what production effect libraries add on top of this.
Concepts
1. Where the stack goes
Take Module 7's flatMap again:
export const flatMap = (io, f) =>
io.tag === 'Pure'
? f(io.value)
: { tag: 'Effect', op: io.op, k: (result) => flatMap(io.k(result), f) }
Build a left-nested chain of three steps, flatMap(flatMap(flatMap(e, f1), f2), f3), where e is one Effect(op1, k). Work from the inside out:
flatMap(e, f1) = Effect(op1, k1) where k1(r) = flatMap(k(r), f1)
flatMap(that, f2) = Effect(op1, k2) where k2(r) = flatMap(k1(r), f2)
flatMap(that, f3) = Effect(op1, k3) where k3(r) = flatMap(k2(r), f3)
The outermost program still starts with op1, but its continuation is a tower: k3 calls k2, which calls k1, which calls k. When the interpreter answers op1 and calls k3(r), all of those calls are on the stack at the same time before anything comes back. With n steps, the tower is n calls high. A while loop in the interpreter does not help, because the depth is inside one call of a continuation, not between the loop's iterations.
With right nesting, flatMap(e, (r) => flatMap(e2, (r2) => …)), the inner flatMap sits inside a function that has not been called yet. Each continuation does one small thing and returns the next Effect. Nothing piles up.
2. Why it is also quadratic
Follow the tower one level further. When k3(r) finally returns, what does it return? k(r) gave the rest of e (say Pure(r)); flatMap(Pure(r), f1) gave f1(r), which is the program's second step, say Effect(op2, …); then flatMap(…, f2) wrapped that in a new continuation, and flatMap(…, f3) wrapped it again. So the second operation comes out with a tower of n − 1 levels, the third with n − 2, and so on. The total number of calls is about n + (n − 1) + … + 1, which is n²/2. That is the answer to the challenge question 9 of Module 7, and the reason the table above grows by a factor of four to five each time n doubles.
From everyday programming: this is the same trap as appending to the end of a singly linked list by walking from the head every time: each append is cheap on its own, but the walk gets longer with every element, and building a list of
nelements costsn²/2steps. The classic fixes are the ones this module uses: keep a better structure (a pointer to the end, or here a node that remembers "and then"), or reorganise the work so that you only ever touch the front. String building has the same shape:s = s + piecein a loop copies everything so far each time, which is why languages offer string builders andjoin.
3. The fix: make "and then" a piece of data
The trouble starts in the Effect case of flatMap, which immediately builds a new continuation around the old one. What if flatMap did nothing clever at all, and just remembered what it was asked to do?
FlatMap(io, f) "run io; then, with its result, continue with f(result)"
A program is now one of three things:
Pure(value): done, the result isvalue;Effect(op, k): performop, give its answer tok, continue with whatkreturns;FlatMap(io, f): runio; when it produces a valuex, continue withf(x).
flatMap(io, f) is now one line: build FlatMap(io, f). It costs the same for any io, and it never calls f, not even when io is a Pure. That also quietly removes the eagerness of Module 7 section 9: flatMap(pure(1), () => { throw … }) no longer throws while building.
All the cleverness moves into the interpreter, which meets FlatMap nodes and has to decide what to do with them. That is the subject of the next two sections. First, the type in the three languages.
JavaScript
// SafeIO<A> =
// { tag: 'Pure', value } value : A
// { tag: 'Effect', op, k } op : Op, k : (answer) => SafeIO<A>
// { tag: 'FlatMap', io, f } io : SafeIO<X>, f : (x: X) => SafeIO<A> for some type X
Scala
// scala/src/SafeIO.scala. Op, OpResult and Traced are the ones from IO.scala.
enum SafeIO[+A]:
case Pure(a: A)
case Effect(op: Op, k: OpResult => SafeIO[A])
case FlatMap[X, B](io: SafeIO[X], f: X => SafeIO[B]) extends SafeIO[B]
F#
// fsharp/src/SafeIO.fs, module Snake.SafeIO, compiled after IO.fs.
// Op, OpResult and Traced are the ones from Snake.IO: write them IO.Op, IO.OpResult, IO.Traced.
// (Do not `open Snake.IO`: its cases are also called Pure and Effect.)
type SafeIO<'a> =
| Pure of 'a
| Effect of IO.Op * (IO.OpResult -> SafeIO<'a>)
| FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)
Language note: the hidden type X. Look at FlatMap(io, f): the whole program produces an A, but the inner io produces something else, an X, which only f cares about. Different FlatMap nodes have different Xs, and nobody outside the node needs to know which. JS has no types to worry about. The Scala enum can say it directly: the case has its own type parameters, FlatMap[X, B], and declares that it extends SafeIO[B]; X appears in no other place, so it stays hidden inside the node (a so-called existential type). An F# union case cannot introduce a type parameter of its own, so the F# track stores the inner program as a SafeIO<obj> and the continuation as obj -> SafeIO<'a>: the type X is erased to obj. The stub gives you a small helper, erase : SafeIO<'a> -> SafeIO<obj>, which views a program as a SafeIO<obj> with constant work (it boxes a Pure's value and wraps continuations without calling them). bind is then FlatMap (erase io, fun x -> f (unbox<'a> x)). The unbox cannot fail, because only bind builds a FlatMap, and it always pairs a program of 'a with a function expecting 'a. Keep erase and unbox inside the module and the rest of the world never sees an obj.
4. The re-association rule, as a picture
An interpreter can only do something with a Pure (finished) or an Effect (perform it). So when it meets a FlatMap, it rewrites the program until the top is one of those two. Look at what can be inside a FlatMap(io, f); there are three cases, one per constructor of io:
Rule 1. FlatMap(Pure(a), f) becomes f(a). The first part is finished with a, so continue with f(a). (This is the left identity law of Module 7.)
Rule 2. FlatMap(Effect(op, k), f) becomes Effect(op, r => FlatMap(k(r), f)). The first thing to do is op; afterwards, run the rest of the first part, k(r), and then f. Note that the new continuation only builds a FlatMap node; it does not call flatMap recursively the way Module 7 did, so no tower can form.
Rule 3. FlatMap(FlatMap(m, g), f) becomes FlatMap(m, x => FlatMap(g(x), f)). This is the important one. As a picture:
FlatMap FlatMap
/ \ / \
FlatMap f ==> m x => FlatMap
/ \ / \
m g g(x) f
Read it in words: "(run m, then g), then f" is the same program as "run m, then (g, then f)". It is the associativity law of Module 7 section 10, used as an algorithm: the law says both shapes behave the same, so the interpreter is free to replace the left one by the right one. Each application moves one FlatMap from the left spine of the tree to the right side, where it waits inside a function until it is needed. A left-nested chain of n flatMaps needs n applications of rule 3 to expose its first Effect, then the continuations unwind one at a time. Every node is rewritten a constant number of times, so the total work is proportional to n, not n².
Each rule looks at most two levels into the tree, builds at most two new nodes, and calls at most one function (f in rule 1). That is what makes the next idea possible.
5. Trampolining: an interpreter made of small moves
Put the three rules into one function, step(io): given a FlatMap, apply the rule that matches and return the result; given a Pure or an Effect, return it unchanged. step is not recursive. After one step, the top of the program may still be a FlatMap (rule 3 produces one); that is fine, it will be stepped again.
An interpreter is now a loop of small moves:
- if the program is a
Pure, stop; - if it is an
Effect(op, k), performopand continue withk(answer); - if it is a
FlatMap, continue withstep(program).
In a runtime file, where a loop is allowed, that is a few lines (this is an illustration; the course's runtime keeps Module 7's IO):
// What a stack-safe runIO would look like in runtime.js, where loops and `let` are allowed.
const runSafeIO = (io, platform) => {
let current = io
while (current.tag !== 'Pure') {
current = current.tag === 'FlatMap' ? step(current) : current.k(perform(current.op, platform))
}
return current.value
}
Every move returns to the loop before the next one starts, so the stack is the same height after a million moves as after one. This technique is called a trampoline: instead of one function calling the next from inside itself (which piles frames on the stack), each move bounces back to a driver, which makes the next call. (Trampoline (computing) on Wikipedia; Rúnar Bjarnason's "Stackless Scala With Free Monads" builds exactly this structure.)
From everyday programming: you may have converted a recursive tree walk into a loop with an explicit stack, because a deep tree crashed the recursive version. That is the same move: the work that the call stack used to remember is now held in a data structure that the loop manages. Job runners do it too: instead of each job calling the next job directly, which would make an ever deeper chain of calls, each job returns "what to do next" to a dispatcher that runs it. Here the data structure is the program tree itself, and rule 3 is what keeps its left side shallow.
6. A loop without a loop: the pure trace
The tests need a pure interpreter, trace(io, seed), with the same contract as in Module 7: answer every RandomSeed with seed and every Draw with unit, and return {result, ops}. Any other op, such as Module 10's LoadBest, is an error: the given answer throws rather than inventing a reply. It must survive 100 000 operations. How it achieves that depends on the language.
Scala and F# turn a function that calls itself as its very last action (a tail call) into a loop: Scala checks it for you with @tailrec, F# does it for any let rec function that calls itself in tail position (Tail call). So trace is an inner function loop(current, performed) with one case per constructor, each ending in a call to loop: the stack stays flat and nothing is mutated. Collect the ops by putting each one at the front of an immutable list (constant work) and reverse the list once at the end.
JavaScript is the awkward one. The language standard has asked for proper tail calls since 2015, but only Safari implements them (WebKit: ECMAScript 6 proper tail calls); in Node and Chrome every call takes a stack frame, so a recursive trace overflows. A while loop would work, but this file follows the purity rules: no loops, no let. The stub therefore gives you a driver, drive(start, isDone, next), which applies a "move" function next until isDone, using recursion that is only logarithmically deep:
- doing
nmoves is doingn/2moves and thenn/2more; each half is split again, so the recursion islog2(n)levels deep (17 levels for 100 000 moves); - the driver does not know in advance how many moves the program needs, so it tries 1 move, then 2 more, then 4 more, doubling the budget each round until the program is finished (another
log2levels).
Your part is the move: traceStep(io, seed) returns [nextProgram, ops], where ops is [] for a FlatMap (rewritten with step) and [op] for an Effect (answered and continued). trace then runs drive with traceStep until the program is a Pure.
Collecting the ops. The obvious way, ops = [...ops, op] at every move, copies the whole array each time: n²/2 copies, exactly the quadratic cost this module set out to remove. Putting each op at the front of a linked list is cheap, but turning that list back into an array needs either a loop or a recursion n levels deep. drive instead keeps the outputs in a balanced tree that follows the halving above, and flattens it once at the end: every op is copied once per level of the tree, and the tree is only logarithmically deep (a few dozen levels for 100 000 ops). That is n log n work, a little more than linear and nowhere near quadratic, with no loop, no mutation and a shallow stack. You don't have to write drive, but read it: it is twenty lines.
7. What production libraries add
The three-constructor IO with a re-associating interpreter is the core of every serious effect library. Real ones add:
- A faster run loop. Instead of rewriting the tree, the interpreter keeps the pending continuations of rule 3 on a stack of its own (a mutable array, safely hidden inside the runtime): meeting
FlatMap(io, f)pushesfand continues withio; reaching aPure(a)pops the topfand continues withf(a). Same behaviour, fewer allocations. cats-effect's run loop works this way; other libraries use variations of the same idea. - Suspended side effects. A constructor such as
Delay(() => a)turns any side-effecting code into a description, so a library needs no fixed list of operations like ourOp. - Errors, cancellation and concurrency. Constructors for raising and handling errors, for running programs on lightweight threads (fibers), for waiting on asynchronous callbacks, and for cancelling a fiber safely; a scheduler that interleaves thousands of fibers on a few operating-system threads, letting a busy fiber pause regularly so that others get a turn (cats-effect: thread model).
- The same trick elsewhere. Free monads get a
FlatMappedconstructor and a stack-safe interpreter in the same way (cats: Free monads), and Scala's standard library ships a small general-purpose trampoline,scala.util.control.TailCalls.
Exercises
About test names. The test titles quoted under "Proved by" are the JavaScript ones (in
js/test/m07b-safeio.test.js). The Scala and F# suites (scala/test/M07bSafeIO.test.scala,fsharp/tests/M07bSafeIO.fs) test the same things in each track's vocabulary. Two differences:traceStepanddriveexist only in JS (section 6), and functions cannot be compared, so where the JS tests check that a node holds the very samef, the Scala and F# tests check the node's shape and then observe the program throughtrace.
All exercises are in the one file per track. Write them in order. The purity rules apply as usual (no let, no loops, no mutation; Scala/F#: no var/mutable); the given drive follows them too.
Exercise 7b.1 — pure, effect, flatMap, map, sequence
JavaScript
export const pure = (value) => /* SafeIO<A>: { tag: 'Pure', value } */
export const effect = (op) => /* SafeIO<answer>: { tag: 'Effect', op, k: pure } */
export const flatMap = (io, f) => /* SafeIO<B>: only builds { tag: 'FlatMap', io, f } */
export const map = (io, f) => /* SafeIO<B>, from flatMap and pure */
export const sequence = (ios) => /* SafeIO<undefined>, left to right; either fold is fine now */
Scala
enum SafeIO[+A]:
...
def flatMap[B](f: A => SafeIO[B]): SafeIO[B] = ???
def map[B](f: A => B): SafeIO[B] = ???
object SafeIO:
def pure[A](a: A): SafeIO[A] = ???
def effect(op: Op): SafeIO[OpResult] = ???
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = ???
F# (data-last; the helper erase is given)
let pure (a: 'a) : SafeIO<'a> = failwith "TODO m07b"
let effect (op: IO.Op) : SafeIO<IO.OpResult> = failwith "TODO m07b"
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = failwith "TODO m07b"
Proved by: pure builds a Pure, effect(op) is an Effect whose continuation is pure, flatMap only builds a FlatMap node, building flatMap(pure(1), throwingF) does not throw, building map(io, throwingF) does not throw. sequence is checked through trace in Exercise 7b.3.
Watch out for: writing flatMap the Module 7 way. If your flatMap looks at io.tag at all, it is doing the interpreter's job.
Exercise 7b.2 — step, the re-association step
JavaScript
export const step = (io) => /* SafeIO<A>: one rewrite (section 4); Pure and Effect come back unchanged */
Scala
object SafeIO:
def step[A](io: SafeIO[A]): SafeIO[A] = ???
F#
let step (io: SafeIO<'a>) : SafeIO<'a> = failwith "TODO m07b"
Proved by: step leaves a Pure unchanged, step leaves an Effect unchanged, step of FlatMap(Pure(a), f) is f(a), step of FlatMap(Effect(op, k), f) is an Effect with the same op that continues with FlatMap(k(r), f), step re-associates FlatMap(FlatMap(m, g), f) into FlatMap(m, x => FlatMap(g(x), f)), step does not call the functions it re-associates, step of a left-nested chain is one rewrite, not a whole walk, and a while loop over step is a complete interpreter for 100 000 left-nested flatMaps (the loop of section 5, written in the test file, where loops are allowed).
Watch out for: recursion. step applies one rule and returns. If it calls itself, a left-nested chain of 100 000 overflows the stack inside step, and the "one rewrite" test sees that the top is no longer a FlatMap.
Exercise 7b.3 — the pure interpreter
JavaScript (drive is given)
export const traceStep = (io, seed) => /* [nextProgram, ops]: ops is [] or [op] */
export const trace = (io, seed) => /* { result, ops }, using drive and traceStep */
Scala (a @tailrec inner function)
object SafeIO:
def trace[A](io: SafeIO[A], seed: Long): Traced[A] = ???
F# (an inner let rec that calls itself only in tail position)
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> = failwith "TODO m07b"
Answer every RandomSeed with the seed (Seed(seed) in Scala/F#) and every Draw with unit (Done), as in Module 7.
Proved by: traceStep answers an Effect and records its op, traceStep rewrites a FlatMap and records nothing, traceStep leaves a Pure unchanged, trace of pure(3) has result 3 and no ops, trace answers RandomSeed with the seed and Draw with undefined, tracing map(io, throwingF) runs f and throws, trace refuses operations of later modules, sequence of nothing has no ops, sequence runs effects left to right; the stack-safety tests 100 000 left-nested flatMaps of draws run, in order, 100 000 right-nested flatMaps of draws run, in order, 100 000 left-nested maps run without any op, sequence of 100 000 draws runs; the nine monad-law tests (left identity holds for …, right identity holds for …, associativity holds for …, for a pure value, one effect and a draw followed by reading the seed); and trace equals Module 7's trace for … on four programs, each written once and built with both IOs. (drive collects every output in order over 200 000 moves and drive of a state that is already done makes no move test the given driver; they pass from the start.)
Run (all of Module 7b):
- JS:
cd js && node --test "test/m07b-*.test.js" - Scala:
cd scala && ./test.sh M07b - F#:
cd fsharp && ./test.sh M07b
Hints
Exercise 7b.1 — nudge
pure and effect are the same as in Module 7. flatMap is shorter than in Module 7: it does not inspect anything. map and sequence can be written exactly as in Module 7, on top of the new flatMap and pure.
Exercise 7b.1 — stronger hint
flatMap(io, f) returns an object with three fields, tag, io and f, and nothing else happens. map is "and then finish with f of the value". For sequence, a left fold that starts from pure(undefined) and glues each program on with flatMap(acc, () => io) is fine now: that is the shape that broke Module 7, and the tests run 100 000 of them.
Exercise 7b.1 — near-solution (try the others first)
JavaScript
export const flatMap = (io, f) => ({ tag: ___, io, f })
export const map = (io, f) => flatMap(io, (a) => ___)
export const sequence = (ios) => ios.reduce((acc, io) => flatMap(___, () => ___), pure(undefined))
Scala
def flatMap[B](f: A => SafeIO[B]): SafeIO[B] = FlatMap(___, f)
def map[B](f: A => B): SafeIO[B] = flatMap(a => ___)
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = ios.foldLeft(pure(()))((acc, io) => ___)
F#
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = FlatMap(erase ___, fun x -> f (unbox<'a> ___))
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = io |> bind (fun a -> ___)
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = List.fold (fun acc io -> ___) (pure ()) ios
Exercise 7b.2 — nudge
First decide what to do when io is not a FlatMap (the doc comment says). Otherwise look one level down, at io.io: it is a Pure, an Effect or a FlatMap, and section 4 has one rule for each. Write each rule's right-hand side as an object literal or a flatMap call.
Exercise 7b.2 — stronger hint
Name the parts: the outer node is FlatMap(inner, f). Rule 1 needs inner.value; rule 2 needs inner.op and inner.k; rule 3 needs inner.io (the m of the picture) and inner.f (the g). Building a FlatMap node is what your flatMap does, so you can use it on the right-hand sides. Nothing in step calls step.
Exercise 7b.2 — near-solution (try the others first)
JavaScript
export const step = (io) => {
if (io.tag !== 'FlatMap') return ___
const { io: inner, f } = io
if (inner.tag === 'Pure') return ___
if (inner.tag === 'Effect') return { tag: 'Effect', op: inner.op, k: (r) => flatMap(___, f) }
return flatMap(inner.io, (x) => flatMap(___, ___))
}
Scala
def step[A](io: SafeIO[A]): SafeIO[A] = io match
case FlatMap(Pure(x), f) => ___
case FlatMap(Effect(op, k), f) => Effect(op, r => FlatMap(___, f))
case FlatMap(FlatMap(m, g), f) => FlatMap(m, x => FlatMap(___, ___))
case other => other
F#
let step (io: SafeIO<'a>) : SafeIO<'a> =
match io with
| FlatMap(Pure x, f) -> ___
| FlatMap(Effect(op, k), f) -> Effect(op, fun r -> FlatMap(___, f))
| FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(___, ___))
| other -> other
In F# the inner parts already have the erased types (m : SafeIO<obj>, g : obj -> SafeIO<obj>), so no erase or unbox is needed here.
Exercise 7b.3 — nudge
traceStep has three cases, one per constructor; section 6 lists what each returns. For trace: what is the start state, when is the driver done, and what is the move? drive returns [last, ops]; the result is in last.
Exercise 7b.3 — stronger hint
The Effect case of traceStep is the body of Module 7's trace without the recursion: answer the op, call k, and return the new program together with [op] instead of tracing it further. The driver is done when the program is a Pure. In Scala and F#, the same three cases go into the inner tail-recursive function, with the list of performed ops as an extra argument that grows at the front; the Pure case returns the result with that list reversed.
Exercise 7b.3 — near-solution (try the others first)
JavaScript
export const traceStep = (io, seed) => {
if (io.tag === 'FlatMap') return [___, []]
if (io.tag === 'Effect') return [io.k(answer(io.op, seed)), ___]
return [io, []]
}
export const trace = (io, seed) => {
const [last, ops] = drive(io, (p) => ___, (p) => traceStep(p, seed))
return { result: ___, ops }
}
Scala
def trace[A](io: SafeIO[A], seed: Long): Traced[A] =
@tailrec def loop(current: SafeIO[A], performed: List[Op]): Traced[A] = current match
case Pure(a) => Traced(a, ___)
case Effect(op, k) => loop(k(___), op :: performed)
case other => loop(___, performed)
loop(io, Nil)
F#
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> =
let rec loop current performed =
match current with
| Pure a -> { IO.Result = a; IO.Ops = ___ }
| Effect(op, k) -> loop (k ___) (op :: performed)
| FlatMap _ -> loop ___ performed
loop io []
Check your understanding
- With Module 7's
IO, why does awhileloop in the interpreter not save a left-nested chain from overflowing the stack? Where exactly is the depth? - Count, for Module 7's
IO, the calls needed to run a left-nested chain of 4 effects, then of 8. How does the count grow? Now count thesteps thatSafeIOneeds for the same chains. - Draw the tree of
flatMap(flatMap(flatMap(e, f1), f2), f3)and apply rule 3 until the top isFlatMap(e, …). How many rewrites did it take? Then write out the continuation you ended with. - Rule 3 replaces one program by another. Which law of Module 7 says that this does not change what the program does? What would go wrong if an interpreter applied rule 3 to an
IOtype that did not obey that law? stepnever calls itself. Why is that essential in JavaScript, and why would it still be a good idea in Scala or F#?- Module 7 section 9 said that
flatMap(pure(1), f)callsfwhile building. Is that still true forSafeIO? When doesfrun now? - Why doesn't
tracecollect its ops with[...ops, op]? Estimate how many element copies that would make for 100 000 ops, and compare with whatdrivedoes. - Rules 1 and 3 never look at an
Op. Suppose you add a new operation (sayPlaySound): which parts ofsafeio.jschange, and which don't? - Challenge. If you knew the exact number of moves in advance, you could write a driver with no recursion at all, using
Array.fromandreduce. How? Why doesn't that work fortrace, and how does the doubling budget ofdriveget around it?
In production
- cats-effect (Scala) and ZIO (Scala) represent
flatMapas a data constructor and interpret programs in a run loop that makes any shape of program stack-safe; they add suspended side effects, error handling, fibers, cancellation and a fair scheduler (cats-effect, ZIO). - Free monads in libraries such as cats get the same treatment: a
FlatMappedconstructor and a stack-safe interpreter (cats: Free monads). - Haskell handles left-nested binds of free monads with type-aligned sequences, which keep a chain of continuations in a queue that can be extended at either end in constant time (van der Ploeg and Kiselyov, "Reflection without Remorse").
- General-purpose trampolines appear in many standard libraries; Scala's
scala.util.control.TailCallsis one, and in JavaScript you meet the same idea whenever a long recursion is turned into a loop that calls "the next step" returned by the previous one.
Further reading
- Rúnar Bjarnason, "Stackless Scala With Free Monads" — trampolines, the
FlatMapconstructor and the re-association rule, step by step. - Atze van der Ploeg and Oleg Kiselyov, "Reflection without Remorse" — why left-nested binds are quadratic in general, and a data structure that fixes it.
- Oleg Kiselyov and Hiromi Ishii, "Freer Monads, More Extensible Effects" — the "operation plus continuation" representation of Module 7, with the continuations kept in such a queue.
- Trampoline (computing) and Tail call on Wikipedia.
- WebKit: ECMAScript 6 proper tail calls — what JavaScript was supposed to get, and why most engines did not ship it.
- Scala 3: algebraic data types with enums and F#: recursive functions — the language features the Scala and F# tracks rely on here.