feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
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>
This commit is contained in:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -0,0 +1,472 @@
|
||||
# Module 7b — Stack-safe IO (optional)
|
||||
|
||||
> **Time:** ~3 h · **You'll edit:** JS `js/src/safeio.js` · Scala `scala/src/SafeIO.scala` · F# `fsharp/src/SafeIO.fs`
|
||||
> **Tests:** JS `cd js && node --test "test/m07b-*.test.js"` · Scala `cd scala && ./test.sh M07b` · F# `cd fsharp && ./test.sh M07b`
|
||||
> **Prerequisites:** 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":
|
||||
|
||||
```js
|
||||
// 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 `flatMap`s 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 `flatMap`s is both deep (stack) and quadratic (time) with the `IO` of Module 7;
|
||||
- **implement** an `IO` whose `flatMap` only builds a `FlatMap` node;
|
||||
- **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:
|
||||
|
||||
```js
|
||||
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 `n` elements costs `n²/2` steps. 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 + piece` in a loop copies everything so far each time, which is why languages offer string builders and `join`.
|
||||
|
||||
### 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 is `value`;
|
||||
- **`Effect(op, k)`**: perform `op`, give its answer to `k`, continue with what `k` returns;
|
||||
- **`FlatMap(io, f)`**: run `io`; when it produces a value `x`, continue with `f(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**
|
||||
|
||||
```js
|
||||
// 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
|
||||
// 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
|
||||
// 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 `X`s, 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` `flatMap`s 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)`, perform `op` and continue with `k(answer)`;
|
||||
- if it is a `FlatMap`, continue with `step(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`):
|
||||
|
||||
```js
|
||||
// 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)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29) on Wikipedia; Rúnar Bjarnason's ["Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) 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](https://en.wikipedia.org/wiki/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](https://webkit.org/blog/6240/ecmascript-6-proper-tail-calls-in-webkit/)); 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 `n` moves is doing `n/2` moves and then `n/2` more; each half is split again, so the recursion is `log2(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 `log2` levels).
|
||||
|
||||
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)` pushes `f` and continues with `io`; reaching a `Pure(a)` pops the top `f` and continues with `f(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 our `Op`.
|
||||
- **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](https://typelevel.org/cats-effect/docs/thread-model)).
|
||||
- **The same trick elsewhere.** Free monads get a `FlatMapped` constructor and a stack-safe interpreter in the same way ([cats: Free monads](https://typelevel.org/cats/datatypes/freemonad.html)), and Scala's standard library ships a small general-purpose trampoline, [`scala.util.control.TailCalls`](https://www.scala-lang.org/api/current/scala/util/control/TailCalls$.html).
|
||||
|
||||
## 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: `traceStep` and `drive` exist only in JS (section 6), and functions cannot be compared, so where the JS tests check that a node holds the very same `f`, the Scala and F# tests check the node's shape and then observe the program through `trace`.
|
||||
|
||||
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**
|
||||
|
||||
```js
|
||||
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**
|
||||
|
||||
```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)
|
||||
|
||||
```fsharp
|
||||
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**
|
||||
|
||||
```js
|
||||
export const step = (io) => /* SafeIO<A>: one rewrite (section 4); Pure and Effect come back unchanged */
|
||||
```
|
||||
|
||||
**Scala**
|
||||
|
||||
```scala
|
||||
object SafeIO:
|
||||
def step[A](io: SafeIO[A]): SafeIO[A] = ???
|
||||
```
|
||||
|
||||
**F#**
|
||||
|
||||
```fsharp
|
||||
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)
|
||||
|
||||
```js
|
||||
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)
|
||||
|
||||
```scala
|
||||
object SafeIO:
|
||||
def trace[A](io: SafeIO[A], seed: Long): Traced[A] = ???
|
||||
```
|
||||
|
||||
**F#** (an inner `let rec` that calls itself only in tail position)
|
||||
|
||||
```fsharp
|
||||
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 `IO`s. (`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
|
||||
|
||||
<details><summary>Exercise 7b.1 — nudge</summary>
|
||||
|
||||
`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`.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 7b.1 — stronger hint</summary>
|
||||
|
||||
`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.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 7b.1 — near-solution (try the others first)</summary>
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```js
|
||||
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**
|
||||
|
||||
```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#**
|
||||
|
||||
```fsharp
|
||||
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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 7b.2 — nudge</summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 7b.2 — stronger hint</summary>
|
||||
|
||||
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`.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 7b.2 — near-solution (try the others first)</summary>
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```js
|
||||
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**
|
||||
|
||||
```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#**
|
||||
|
||||
```fsharp
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 7b.3 — nudge</summary>
|
||||
|
||||
`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`.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 7b.3 — stronger hint</summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 7b.3 — near-solution (try the others first)</summary>
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```js
|
||||
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**
|
||||
|
||||
```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#**
|
||||
|
||||
```fsharp
|
||||
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 []
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Check your understanding
|
||||
|
||||
1. With Module 7's `IO`, why does a `while` loop in the interpreter not save a left-nested chain from overflowing the stack? Where exactly is the depth?
|
||||
2. 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 the `step`s that `SafeIO` needs for the same chains.
|
||||
3. Draw the tree of `flatMap(flatMap(flatMap(e, f1), f2), f3)` and apply rule 3 until the top is `FlatMap(e, …)`. How many rewrites did it take? Then write out the continuation you ended with.
|
||||
4. 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 `IO` type that did not obey that law?
|
||||
5. `step` never calls itself. Why is that essential in JavaScript, and why would it still be a good idea in Scala or F#?
|
||||
6. Module 7 section 9 said that `flatMap(pure(1), f)` calls `f` while building. Is that still true for `SafeIO`? When does `f` run now?
|
||||
7. Why doesn't `trace` collect its ops with `[...ops, op]`? Estimate how many element copies that would make for 100 000 ops, and compare with what `drive` does.
|
||||
8. Rules 1 and 3 never look at an `Op`. Suppose you add a new operation (say `PlaySound`): which parts of `safeio.js` change, and which don't?
|
||||
9. **Challenge.** If you knew the exact number of moves in advance, you could write a driver with no recursion at all, using `Array.from` and `reduce`. How? Why doesn't that work for `trace`, and how does the doubling budget of `drive` get around it?
|
||||
|
||||
## In production
|
||||
|
||||
- **cats-effect** (Scala) and **ZIO** (Scala) represent `flatMap` as 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](https://typelevel.org/cats-effect/), [ZIO](https://zio.dev/)).
|
||||
- **Free monads** in libraries such as cats get the same treatment: a `FlatMapped` constructor and a stack-safe interpreter ([cats: Free monads](https://typelevel.org/cats/datatypes/freemonad.html)).
|
||||
- **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"](https://okmij.org/ftp/Haskell/zseq.pdf)).
|
||||
- **General-purpose trampolines** appear in many standard libraries; Scala's [`scala.util.control.TailCalls`](https://www.scala-lang.org/api/current/scala/util/control/TailCalls$.html) is 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"](http://blog.higher-order.com/assets/trampolines.pdf) — trampolines, the `FlatMap` constructor and the re-association rule, step by step.
|
||||
- [Atze van der Ploeg and Oleg Kiselyov, "Reflection without Remorse"](https://okmij.org/ftp/Haskell/zseq.pdf) — 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"](https://okmij.org/ftp/Haskell/extensible/more.pdf) — the "operation plus continuation" representation of Module 7, with the continuations kept in such a queue.
|
||||
- [Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29) and [Tail call](https://en.wikipedia.org/wiki/Tail_call) on Wikipedia.
|
||||
- [WebKit: ECMAScript 6 proper tail calls](https://webkit.org/blog/6240/ecmascript-6-proper-tail-calls-in-webkit/) — what JavaScript was supposed to get, and why most engines did not ship it.
|
||||
- [Scala 3: algebraic data types with enums](https://docs.scala-lang.org/scala3/reference/enums/adts.html) and [F#: recursive functions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/functions/recursive-functions-the-rec-keyword) — the language features the Scala and F# tracks rely on here.
|
||||
Reference in new issue
Block a user