docs(course): replace Scala bridges with general programming in 06–11, method, reference
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b46a3611af
commit
9891bb596d
@@ -91,7 +91,7 @@ type Scene = Shape list
|
||||
let scene : Scene = [ Rect (0, 0, 400, 400, "#111111"); Text (6, 18, "hello", "#ffffff", 16, "left") ]
|
||||
```
|
||||
|
||||
> **From Scala:** a `Scene` is a `List[Shape]` where `Shape` is a sum type with two cases. You would never think of a `List[Shape]` as "doing" anything; it just sits there. That is the whole point. In JS you get the same thing with an array of tagged objects; nothing stops you from mutating it, which is why the tests freeze the inputs and why you keep using the habits from Module 1.
|
||||
> **From everyday programming:** think of a shopping list, or the list of rows a SQL query returns. You would never think of such a list as "doing" anything; it just sits there until someone reads it. A `Scene` is the same kind of thing: a list of shapes, each of which is one of exactly two kinds (a rectangle or a text). That is the whole point. In the Scala and F# tracks the compiler keeps the list and its shapes immutable; in JS you build it from arrays of tagged objects, and nothing stops you from mutating them, which is why the tests freeze the inputs and why you keep using the habits from Module 1.
|
||||
|
||||
Notice what is *not* in a shape: no canvas, no context, no "current fill colour". In the stateful version, `ctx.fillStyle = color` sets a hidden variable that the next `fillRect` reads. In a shape, the colour travels *with* the rectangle. Every shape is complete on its own. That is the same move you made with `GameState` in Module 2: instead of state that lives somewhere and changes, a value that contains everything.
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ type OpResult =
|
||||
| Seed of uint32
|
||||
```
|
||||
|
||||
> **From Scala:** notice that Scala and F# need an explicit `OpResult` type, because an interpreter must hand back *some* typed answer for any `Op`. JS is untyped, so the answer to `Draw` is just `undefined` and the answer to `RandomSeed` is just a number.
|
||||
**Language note.** The Scala and F# tracks need an explicit `OpResult` type, because an interpreter must hand back *some* typed answer for any `Op`. JS is untyped, so the answer to `Draw` is just `undefined` and the answer to `RandomSeed` is just a number.
|
||||
|
||||
A list-of-ops program and its interpreter would look like this (illustration only, not part of the exercises):
|
||||
|
||||
@@ -106,6 +106,8 @@ const program = [{ tag: 'Draw', shape: background }, { tag: 'Draw', shape: score
|
||||
const runAll = (ops, platform) => ops.forEach((op) => perform(op, platform))
|
||||
```
|
||||
|
||||
> **From everyday programming:** you have met this idea under other names. In the [command pattern](https://refactoring.guru/design-patterns/command), a request such as "save the document" becomes an object that can be stored, queued, logged or undone, and some other object executes it later. A job queue holds descriptions of work ("resize image 42") that a worker performs when it gets to them. A recorded macro in an editor is a list of keystrokes that does nothing until you replay it. In each case, *describing* the work and *doing* it are separated, and something else — the executor, the worker, the replay button — decides when and how the work happens. An `Op` is a command object; an interpreter is the executor.
|
||||
|
||||
For drawing a scene, this is enough: `drawScene` would be `scene.map(shape => ({tag:'Draw', shape}))`. But now try to write this program:
|
||||
|
||||
> Ask for a random seed. Then start a new game with that seed, and draw it.
|
||||
@@ -154,7 +156,7 @@ type IO<'a> =
|
||||
| Effect of Op * (OpResult -> IO<'a>)
|
||||
```
|
||||
|
||||
> **From Scala:** this is a recursive algebraic data type, like `List` (`Nil | Cons(head, tail)`). The difference is that the "tail" is hidden behind a function: you can only get the rest of the program by supplying an answer. A list is a chain of values; an `IO` is a chain of *questions*.
|
||||
> **From everyday programming:** compare a linked list. Each node holds a value and a `next` pointer to the rest of the list, and the last node has no `next`. An `IO` has the same recursive shape — `Effect` nodes chained together, ending in a `Pure` — with one difference: the `next` pointer is replaced by a function, so you can only get the rest of the program by supplying an answer. A linked list is a chain of values; an `IO` is a chain of *questions*. Think of a phone menu: "press 1 for sales, 2 for support" — which question comes next depends on what you answered.
|
||||
|
||||
Let us write the program from the previous section *by hand*, with no helpers at all. (JS shown; it is the same tree in every language.)
|
||||
|
||||
@@ -201,7 +203,9 @@ Let us run `trace(program, 7)` by hand. Fill in the table yourself before readin
|
||||
|
||||
Now ask: *is `trace` a pure function?* Same `io`, same `seed` → same `{result, ops}`, every time; nothing outside is touched. Yes. So programs that "use randomness and draw on the screen" can be tested with `deepStrictEqual`, the same way you have tested everything so far. This is the single most important consequence of the module.
|
||||
|
||||
> **From Scala:** an interpreter is a fold over a data structure, like `foldRight` over a `List`, except that to get from one node to the next you must supply an answer. You will write `trace` by **recursion** (no loops, no mutation): the ops of `Effect(op, k)` are `op` followed by the ops of whatever `k(answer)` traces to.
|
||||
> **From everyday programming:** `trace` plays the role a mock or fake plays in a unit test. When code talks to a database through an interface, you can inject an in-memory fake in the tests and the real database in production — that is dependency injection. Here the program doesn't even hold a reference to the thing it talks to: it is pure data, and the interpreter you hand it to *is* the injected dependency. `trace` is the fake that records every call; `runIO` is the real implementation.
|
||||
|
||||
You will write `trace` by **recursion** (no loops, no mutation): the ops of `Effect(op, k)` are `op` followed by the ops of whatever `k(answer)` traces to. Walking a structure like this, node by node, is the same kind of work as summing a linked list recursively, except that to get from one node to the next you must supply an answer.
|
||||
|
||||
### 5. Writing programs comfortably: `pure` and `effect`
|
||||
|
||||
@@ -233,7 +237,7 @@ test('m07 building map(io, throwingF) does not throw', ...) // building must n
|
||||
test('m07 tracing map(io, throwingF) runs f and throws', ...) // interpreting must call f
|
||||
```
|
||||
|
||||
> **From Scala:** you know `Option.map` and `List.map`: they apply `f` to the value(s) inside. `Future.map` is the closer analogue: it can't apply `f` until the value arrives, so it registers `f` to run later. Our `map` does the same thing with plain data instead of threads.
|
||||
> **From everyday programming:** on an array, `[1, 2, 3].map(f)` applies `f` to every element and gives you a new array; the values are there, so `f` runs at once. A closer analogue is a JS promise (or a C# `Task`, or a Java `CompletableFuture`): in `fetch(url).then((response) => response.status)`, the callback can't run until the response arrives, so `.then` registers it to run later and immediately hands you a new promise for the status. Our `map` does the same thing, with one difference: a promise represents work that has *already started*, whereas an `IO` is a description that nothing has started yet. `map` on an `IO` registers `f` inside plain data, with no threads and no network involved.
|
||||
|
||||
**Language note.** JS functions are data-first, `map(io, f)`. Scala uses a method, `io.map(f)`. F# uses a data-last module function, `IO.map f io`, so it works with the pipe: `io |> IO.map f`.
|
||||
|
||||
@@ -262,7 +266,9 @@ Two observations to make you comfortable:
|
||||
1. Once you have `flatMap` and `pure`, you get `map` for free: "transform the result with `f`" is the same as "and then finish with `f(a)`". Can you write `map` in one line using `flatMap` and `pure`? (This is a classic exercise; it is also a nice way to check your `flatMap`.)
|
||||
2. You have seen this exact pair before. In Module 4, `Random<A>` had `pure`, `map` and `flatMap` with the same shapes and the same meaning ("then, with the value, continue"). This is not a coincidence; it's a pattern with a name, which you will meet properly in section 10.
|
||||
|
||||
> **From Scala:** `flatMap` is the method that `for`-comprehensions desugar to, and you've used it on `List`, `Option`, `Either` and `Future`. On `List` it means "for each element, a list; concatenate"; on `Option` "if there is a value, continue; otherwise stop"; on `IO` it means "when the answer arrives, continue with this program".
|
||||
> **From everyday programming:** the name `flatMap` appears on many types, always with the shape "apply a function that returns another container, then flatten". On a JS array, `[1, 2].flatMap((n) => [n, n * 10])` is `[1, 10, 2, 20]`: for each element, an array; concatenate them. On promises the same idea is spelled `.then` with a callback that returns another promise: if `loadUser(id)` and `loadOrders(user)` both return promises, `loadUser(id).then((user) => loadOrders(user))` means "when the user arrives, start the next request with it, and give me a promise of the orders" — a promise of orders, not a promise of a promise. On `IO` it means "when the answer arrives, continue with this program".
|
||||
>
|
||||
> **Scala-track syntax note:** in the Scala track, `flatMap` is what the `for`-comprehension syntax translates to; section 11 shows it.
|
||||
|
||||
**Language note.** In F# this function is called **`bind`** (`IO.bind f io`), which is the traditional name in ML and Haskell (`>>=`). Same function, different name.
|
||||
|
||||
@@ -337,13 +343,15 @@ assert.deepStrictEqual(trace(flatMap(pure(a), f), 5), trace(f(a), 5))
|
||||
|
||||
for a few programs (`pure(4)`, `randomSeed`, and a draw followed by `randomSeed`). This is called **observational equality**: equal as far as any observer (here: the interpreter) can tell. It is also a lesson in why the pure interpreter is valuable: without it, you could not even *state* these tests.
|
||||
|
||||
> **From Scala:** `Option`, `List`, `Either` and `Future` all have `pure`-like constructors (`Some`, `List(_)`, `Right`, `Future.successful`) and `flatMap`, and they all obey these laws. The `for` syntax relies on them: the compiler rewrites `for` into nested `flatMap`s in one particular grouping, and associativity is what makes that grouping irrelevant.
|
||||
> **From everyday programming:** you rely on laws like these whenever you refactor. Associativity is the reason you can take three consecutive steps of a function, move the last two into a helper, and call the helper — the grouping changed, the behaviour didn't. It is the same property that lets you write `a + b + c` without parentheses. JS arrays obey all three laws too: with `pure(a) = [a]`, `[a].flatMap(f)` equals `f(a)`, and `xs.flatMap((x) => [x])` equals `xs`. Try the associativity law on a small array in the console.
|
||||
>
|
||||
> **Scala-track syntax note:** the Scala compiler rewrites a `for`-comprehension into nested `flatMap`s in one particular grouping; associativity is what makes that grouping irrelevant. F#'s computation expressions rely on the same law.
|
||||
|
||||
### 11. Syntax that hides the plumbing
|
||||
|
||||
Nested `flatMap`s become hard to read quickly. Scala and F# offer syntax for exactly this pattern; JS doesn't (but see the note).
|
||||
|
||||
**Scala** — because `IO` has `map` and `flatMap`, it works in a `for`-comprehension with no extra work:
|
||||
**Scala** — the Scala track has a `for`-comprehension for this. Each line `x <- io` means "run `io`, call its result `x`, and continue with the lines below"; `yield` gives the final result. The compiler turns the block into nested `flatMap`s with a `map` at the end, so because `IO` has `map` and `flatMap`, it works in a `for`-comprehension with no extra work:
|
||||
|
||||
```scala
|
||||
val program: IO[Long] =
|
||||
@@ -394,7 +402,7 @@ Step back and look at the type: a program is either *done*, or *one instruction
|
||||
Consequences you will use in the rest of the course:
|
||||
|
||||
- **Adding an effect is adding an `Op`.** Want to play a sound, or save a high score? Add `{tag:'PlaySound', name}` or `{tag:'Save', key, value}` and teach the interpreters what to do with it (you'll think through exactly this in Module 10).
|
||||
- **Many interpreters.** `trace` for tests, `runIO` for the browser, and you could write a third that logs every op, or one that answers `RandomSeed` from a recorded file to replay a bug.
|
||||
- **Many interpreters.** `trace` for tests, `runIO` for the browser, and you could write a third that logs every op, or one that answers `RandomSeed` from a recorded file to replay a bug — the way you would swap in a different implementation of an interface with dependency injection, except that here nothing needs to be injected into the program at all.
|
||||
|
||||
## Exercises
|
||||
|
||||
@@ -681,7 +689,7 @@ let rec trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
|
||||
|
||||
## In production
|
||||
|
||||
- **Haskell's `IO`** is the original: `main :: IO ()` is a value that the runtime performs; every effectful function returns an `IO` description. Haskell's `do`-notation is the same sugar as Scala's `for` and F#'s computation expressions ([Haskell wiki: IO inside](https://wiki.haskell.org/IO_inside)).
|
||||
- **Haskell's `IO`** is the original: `main :: IO ()` is a value that the runtime performs; every effectful function returns an `IO` description. Haskell's `do`-notation is the same sugar as the Scala track's `for` and F#'s computation expressions (section 11) ([Haskell wiki: IO inside](https://wiki.haskell.org/IO_inside)).
|
||||
- **cats-effect** (Scala) gives you `IO[A]` with `pure`, `map`, `flatMap`, `IO.delay`/`IO.blocking` to suspend side-effecting code, and a runtime that interprets it on a fiber scheduler with stack safety, cancellation and concurrency ([cats-effect](https://typelevel.org/cats-effect/)). The book [*Functional Programming in Scala*](https://www.manning.com/books/functional-programming-in-scala-second-edition), chapter 13, builds an `IO` very much like ours and then makes it stack-safe.
|
||||
- **ZIO** (Scala) uses `ZIO[R, E, A]`: a description of a program that needs an environment `R`, may fail with `E`, or succeeds with `A` ([ZIO](https://zio.dev/)). The `R` is a typed version of our `Platform`.
|
||||
- **Free monads** are used directly to separate a program's instructions from their interpretation, e.g. to run the same business logic against a real database and an in-memory test double ([Gabriel Gonzalez, "You Could Have Invented Free Monads"](https://www.haskellforall.com/2012/06/you-could-have-invented-free-monads.html)).
|
||||
|
||||
@@ -114,7 +114,7 @@ type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)
|
||||
|
||||
This is a well-known object in computer science: a **Mealy machine**, a state machine whose output depends on its current state and its input ([Mealy machine](https://en.wikipedia.org/wiki/Mealy_machine)). The usual textbook definition has a set of states and a transition table; ours is the same idea where "the current state" is hidden inside the closure of `step`, and "the next state" is the next stream itself. In Haskell this is also called an *automaton arrow* or a "Mealy" type ([Justin Le, "Intro to Machines & Arrows"](https://blog.jle.im/entry/intro-to-machines-arrows-part-1-stream-and.html) is a gentle introduction).
|
||||
|
||||
> **From Scala:** this is a recursive case class whose only field is a function. Think of it as the dual of a lazy list: a `LazyList` *gives* you the next element when you ask; a `Stream` *takes* an event when you push one, and gives you some outputs plus the rest of itself.
|
||||
> **From everyday programming:** compare an event listener. In the [observer pattern](https://refactoring.guru/design-patterns/observer) — `addEventListener('keydown', handler)` is an instance of it — a handler is called once per event and typically reacts by changing some variable that lives outside it. A stream's `step` is also called once per event, but instead of changing anything it *returns* what happened (the outputs) and the handler to use for the next event. You may also have written a state machine as a `switch` over a `currentState` variable that each event reassigns. A stream is that state machine with the variable removed: each state is a stream, and the transition is "return the next one". Unlike an iterator, which *gives* you the next element when you ask, a stream *takes* an event when you push one, and gives you some outputs plus the rest of itself.
|
||||
|
||||
Let's write one by hand to get a feel for it — not one of the exercises, but a cousin: a stream that counts key presses and emits the running count.
|
||||
|
||||
@@ -193,7 +193,7 @@ interval(10) = from(10, last=0) ──Time 5──▶ from(10, 5) ──Time 10
|
||||
|
||||
The stub docs tell you how: "write a private helper that carries `last`". `interval(ms)` itself is just that helper started at `last = 0`. Before you code it, answer: what are the outputs of `run(interval(10), [Time 35])`? (Answer: `[10, 20, 30]`; one big jump catches up on everything.)
|
||||
|
||||
> **From Scala:** the helper is the familiar "loop with an accumulator" from recursive functions, e.g. `def go(acc: Int, xs: List[Int])`. Here the accumulator is carried from step to step *between events*, by building the next stream with the new value.
|
||||
> **From everyday programming:** in a loop you would keep `last` in a variable declared outside the loop body, `let last = 0; for (const ev of events) { …; last = ev.t }`, so that each iteration sees what the previous one left behind. The helper does the same job without the variable: it takes `last` as a parameter, and each step builds the next stream by calling the helper again with the new value. The accumulator is carried from step to step *between events*, inside the next stream.
|
||||
|
||||
### 5. Combinators: building streams from streams
|
||||
|
||||
@@ -231,7 +231,9 @@ scan(keyDowns, (acc, k) => acc + k, '') events: KeyDown 'a', KeyDown 'b'
|
||||
run → ['a', 'ab']
|
||||
```
|
||||
|
||||
> **From Scala:** these are the methods you know from `List` (`map`, `filter`, `collect`/`flatMap` with `Option`, `scanLeft`), lifted to "lists that haven't happened yet". `scan` is `scanLeft` without the initial element. In Scala they are **methods**: `s.map(f)`, `s.merge(other)`, and `s.scan(z)(f)` with the initial value in its own parameter list, like `foldLeft`. In F# they are data-last module functions: `Stream.map f s`, `Stream.scan f z s`, `Stream.startWith v s`, `Stream.run events s`.
|
||||
> **From everyday programming:** `map` and `filter` are the array methods of the same names — or, in SQL terms, the expression list of a `SELECT` and the `WHERE` clause — applied to a list that hasn't happened yet: the outputs are transformed or dropped as each event arrives, instead of all at once. `scan` is a `reduce` that reports every intermediate result instead of only the last one, like the running-balance column of a bank statement, or a spreadsheet column where each cell is "the cell above plus the amount on this row". `[3, 4, 5].reduce((a, x) => a + x, 0)` gives `12`; scanning the same list gives `[3, 7, 12]`.
|
||||
|
||||
**Language note.** In the Scala track these are **methods**: `s.map(f)`, `s.merge(other)`, and `s.scan(z)(f)` with the initial value in its own parameter list, like `foldLeft`. In F# they are data-last module functions: `Stream.map f s`, `Stream.scan f z s`, `Stream.startWith v s`, `Stream.run events s`.
|
||||
|
||||
### 6. `startWith` and the "nothing happens until an event arrives" rule
|
||||
|
||||
@@ -435,7 +437,7 @@ All combinators have the same shape: step the inner stream(s), compute the outpu
|
||||
|
||||
<details><summary>Exercise 8.4 — stronger hint (scan and startWith)</summary>
|
||||
|
||||
For `scan`: from the inner outputs `[o1, o2, o3]` and accumulator `z`, you need `[f(z,o1), f(f(z,o1),o2), …]` — that's Scala's `scanLeft` without its first element, which you can also write as a `reduce` that builds the list of accumulators. The next stream is `scan(next, f, lastAcc)` where `lastAcc` is the last emitted accumulator, or `z` if nothing was emitted.
|
||||
For `scan`: from the inner outputs `[o1, o2, o3]` and accumulator `z`, you need `[f(z,o1), f(f(z,o1),o2), …]` — that's a running total, which you can write as a `reduce` whose accumulator is the list of accumulators so far (or, in the Scala track, as `scanLeft` without its first element). The next stream is `scan(next, f, lastAcc)` where `lastAcc` is the last emitted accumulator, or `z` if nothing was emitted.
|
||||
|
||||
For `startWith`: on the first step, step the inner stream, put `v` in front of its outputs, and return the inner **next** stream unchanged, without wrapping it again.
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ There's one more thing: `scan` emits only *new* accumulators, never the initial
|
||||
states(seed, actions) = startWith(scan(actions, update, newGame(seed)), newGame(seed))
|
||||
```
|
||||
|
||||
> **From Scala:** `scan` is `scanLeft` on a list that hasn't happened yet. `List(a1, a2).scanLeft(s0)(update)` gives `List(s0, s1, s2)` *including* `s0`; our `startWith(scan(…), s0)` is how we get the same "include the start" behaviour.
|
||||
> **From everyday programming:** in the classic game loop, `while (running) { input(); update(); render(); }`, the game state is one variable that each iteration overwrites, and the sequence of states exists only as the history of that variable. `startWith(scan(…), s0)` gives you that history as a value: `s0, s1, s2, …`, one state per action, the way a bank statement lists the opening balance followed by the balance after each transaction. `scan` alone is the list of balances *after* each transaction; `startWith` adds the opening balance in front.
|
||||
|
||||
You've seen this exact shape elsewhere: **Redux** reducers are `(state, action) → state` folded over a stream of dispatched actions, and **Elm**'s `update : Msg -> Model -> Model` is the same fold run by the Elm runtime. (The argument order differs per library; the idea doesn't.)
|
||||
You may have seen this exact shape elsewhere: **Redux** reducers are `(state, action) → state` folded over a stream of dispatched actions, and **Elm**'s `update : Msg -> Model -> Model` is the same fold run by the Elm runtime. (The argument order differs per library; the idea doesn't.)
|
||||
|
||||
### 4. From states to drawings, and `main`
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ Run all the tests of your track after this module. They should all be green, inc
|
||||
|
||||
This is the "open for extension" property you get from data + a fold: a new feature is a new case, and old cases don't move. When something does break, it breaks in a fixture that names the exact situation (`restart-from-paused`, `frame-tick-can-end-the-game`), not in a browser session.
|
||||
|
||||
> **From Scala:** in Scala, write `update` as a `match` with one case per `Action` and **no `case _`**. Then if you add a case to `Action` and forget it in `update`, the compiler warns you ("match may not be exhaustive"). The stub's doc comment asks for this. F# gives the same warning for incomplete matches (`FS0025`). JS has no such check; the fixtures are your safety net.
|
||||
**Language note.** In the Scala track, write `update` as a `match` with one case per `Action` and **no `case _`**. Then if you add a case to `Action` and forget it in `update`, the compiler warns you ("match may not be exhaustive"). The stub's doc comment asks for this. F# gives the same warning for incomplete matches (`FS0025`). JS has no such check; the fixtures are your safety net.
|
||||
|
||||
### 6. A thought exercise: a high score that survives a reload
|
||||
|
||||
|
||||
Reference in New Issue
Block a user