feat(scala): m07 io and m08 stream with tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 00:12:08 +01:00
co-authored by Claude Opus 5.5
parent 56bc6b4cf7
commit bd0c9fcc0c
6 changed files with 446 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
package snake
/** An operation an IO program can ask the outside world to perform. */
enum Op:
case Draw(shape: Shape)
case RandomSeed
/** What the outside world answers: `Done` for a `Draw`, `Seed(value)` for a `RandomSeed`. */
enum OpResult:
case Done
case Seed(value: Long)
/**
* m07 io: effects as values. An IO is a plain-data description of a program; nothing runs until interpreted.
* - `Pure(a)` done, with a value
* - `Effect(op, k)` perform `op`, then continue with `k(result)`
*/
enum IO[+A]:
case Pure(a: A)
case Effect(op: Op, k: OpResult => IO[A])
def map[B](f: A => B): IO[B] = flatMap(a => IO.Pure(f(a)))
// Pure: apply f now. Effect: keep the op, and extend the continuation so f runs after k.
// Nothing is performed here, we only build a bigger description.
def flatMap[B](f: A => IO[B]): IO[B] = this match
case Pure(a) => f(a)
case Effect(op, k) => Effect(op, result => k(result).flatMap(f))
final case class Traced[A](result: A, ops: List[Op])
object IO:
def pure[A](a: A): IO[A] = Pure(a)
def effect(op: Op): IO[OpResult] = Effect(op, pure)
def sequence(ios: List[IO[Unit]]): IO[Unit] =
ios.foldRight(pure(()))((io, rest) => io.flatMap(_ => rest))
def draw(shape: Shape): IO[Unit] = effect(Op.Draw(shape)).map(_ => ())
// An interpreter that answers a RandomSeed with anything but a Seed is broken; say so loudly.
lazy val randomSeed: IO[Long] = effect(Op.RandomSeed).map {
case OpResult.Seed(value) => value
case OpResult.Done => throw new IllegalStateException("RandomSeed must be answered with a Seed")
}
def drawScene(scene: Scene): IO[Unit] = sequence(scene.map(draw))
// Answer each op with a canned result, collecting the ops as we go. Tail recursion: the compiler turns it
// into a loop, so long programs cannot overflow the stack, and still nothing is mutated.
def trace[A](io: IO[A], seed: Long): Traced[A] =
@annotation.tailrec
def go(io: IO[A], done: List[Op]): Traced[A] = io match
case Pure(a) => Traced(a, done.reverse)
case Effect(op, k) =>
val answer = op match
case Op.RandomSeed => OpResult.Seed(seed)
case Op.Draw(_) => OpResult.Done
go(k(answer), op :: done)
go(io, Nil)
+84
View File
@@ -0,0 +1,84 @@
package snake
/** Something that happens to the program: time passing (ms since start) or a key going down or up. */
enum Event:
case Time(t: Long)
case KeyDown(key: String)
case KeyUp(key: String)
/**
* m08 stream: an event stream is a pure automaton (a Mealy machine).
* There is no hidden state: whatever a stream "remembers" lives in the next stream it returns.
*/
final case class Stream[A](step: Event => (List[A], Stream[A])):
// Each combinator steps this stream and wraps the *next* stream in the same combinator.
def map[B](f: A => B): Stream[B] =
Stream { e =>
val (out, next) = step(e)
(out.map(f), next.map(f))
}
def filter(p: A => Boolean): Stream[A] =
Stream { e =>
val (out, next) = step(e)
(out.filter(p), next.filter(p))
}
def filterMap[B](f: A => Option[B]): Stream[B] =
Stream { e =>
val (out, next) = step(e)
(out.flatMap(f), next.filterMap(f))
}
def merge(other: Stream[A]): Stream[A] =
Stream { e =>
val (outA, nextA) = step(e)
val (outB, nextB) = other.step(e)
(outA ++ outB, nextA.merge(nextB))
}
// The accumulator travels in the argument of the next scan, not in a variable.
// scanLeft keeps z as its first element; drop it, we only emit new accumulators.
def scan[B](z: B)(f: (B, A) => B): Stream[B] =
Stream { e =>
val (out, next) = step(e)
val accs = out.scanLeft(z)(f).tail
(accs, next.scan(accs.lastOption.getOrElse(z))(f))
}
// Only the first step is special; afterwards we are just `next`.
def startWith(v: A): Stream[A] =
Stream { e =>
val (out, next) = step(e)
(v :: out, next)
}
def run(events: List[Event]): List[A] =
events
.foldLeft((List.empty[A], this)) { case ((outs, stream), e) =>
val (out, next) = stream.step(e)
(outs ++ out, next)
}
._1
object Stream:
// Sources may return themselves as the next stream, since they remember nothing.
lazy val keyDowns: Stream[String] = Stream {
case Event.KeyDown(key) => (List(key), keyDowns)
case _ => (Nil, keyDowns)
}
lazy val keyUps: Stream[String] = Stream {
case Event.KeyUp(key) => (List(key), keyUps)
case _ => (Nil, keyUps)
}
// `last` is the time of the previous Time event. Multiples of ms in (last, t] fire, ascending.
private def intervalFrom(ms: Long, last: Long): Stream[Long] = Stream {
case Event.Time(t) => ((last / ms + 1 to t / ms).map(_ * ms).toList, intervalFrom(ms, t))
case _ => (Nil, intervalFrom(ms, last))
}
def interval(ms: Long): Stream[Long] = intervalFrom(ms, 0)