Files
functional-programming-snake/course/07b-stack-safe-io.md
T
Lucas WintherandClaude Fable 5.2 edd57f0148 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>
2026-11-11 12:05:29 +01:00

32 KiB
Raw Blame History

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":

// 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 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:

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

// 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), 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):

// 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 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).
  • The same trick elsewhere. Free monads get a FlatMapped constructor 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: 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

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

  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 steps 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, ZIO).
  • Free monads in libraries such as cats get the same treatment: a FlatMapped constructor 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.TailCalls 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