diff --git a/scala/solutions/IO.scala b/scala/solutions/IO.scala new file mode 100644 index 0000000..3f1dd04 --- /dev/null +++ b/scala/solutions/IO.scala @@ -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) diff --git a/scala/solutions/Stream.scala b/scala/solutions/Stream.scala new file mode 100644 index 0000000..369b4e9 --- /dev/null +++ b/scala/solutions/Stream.scala @@ -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) diff --git a/scala/src/IO.scala b/scala/src/IO.scala new file mode 100644 index 0000000..6e81219 --- /dev/null +++ b/scala/src/IO.scala @@ -0,0 +1,60 @@ +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)` (an unsigned 32-bit number) for a `RandomSeed`. */ +enum OpResult: + case Done + case Seed(value: Long) + +/** + * m07 io: effects as values. An `IO[A]` is a description of a program that, when interpreted, produces an `A`: + * - `Pure(a)` finished, nothing left to do + * - `Effect(op, k)` perform `op`, then continue with `k(result)` (which returns another IO) + * Building an IO never performs anything; only an interpreter (`IO.trace` here, `Runtime.runIO` in m09) does. + */ +enum IO[+A]: + case Pure(a: A) + case Effect(op: Op, k: OpResult => IO[A]) + + /** Transform the result with `f`. Must not call `f` until the IO is interpreted. */ + def map[B](f: A => B): IO[B] = ??? + + /** + * Run this IO, then feed its result to `f` to get the next IO. + * `Pure(a)`: `f(a)`. `Effect(op, k)`: the same op, with the continuation extended by `f`. + */ + def flatMap[B](f: A => IO[B]): IO[B] = ??? + +/** The result of `IO.trace`: the final value and the operations performed, in execution order. */ +final case class Traced[A](result: A, ops: List[Op]) + +object IO: + + /** An IO that does nothing and produces `a`. */ + def pure[A](a: A): IO[A] = ??? + + /** An IO that performs the single operation `op` and produces its result: `Effect(op, pure)`. */ + def effect(op: Op): IO[OpResult] = ??? + + /** Run the IOs one after another, left to right, producing `()`. `sequence(Nil)` performs nothing. */ + def sequence(ios: List[IO[Unit]]): IO[Unit] = ??? + + /** An IO that draws one shape (a `Rect` or `Text` from m06) and produces `()`. */ + def draw(shape: Shape): IO[Unit] = ??? + + /** An IO that asks the outside world for a random seed and produces it (the `value` of the `Seed` answer). */ + lazy val randomSeed: IO[Long] = ??? + + /** Draw every shape of a scene in order: `sequence(scene.map(draw))`. */ + def drawScene(scene: Scene): IO[Unit] = ??? + + /** + * A pure interpreter for tests. Answers every `RandomSeed` with `Seed(seed)` and every `Draw` with `Done`. + * Returns the final value and the operations in the order they were executed. + * Implemented by recursion, with no mutation. + */ + def trace[A](io: IO[A], seed: Long): Traced[A] = ??? diff --git a/scala/src/Stream.scala b/scala/src/Stream.scala new file mode 100644 index 0000000..e2f8534 --- /dev/null +++ b/scala/src/Stream.scala @@ -0,0 +1,53 @@ +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 as a pure automaton (a Mealy machine). + * `step` feeds one event and returns the (possibly empty) list of outputs it produced and the stream to use for + * the next event. Nothing is mutated, so a stream value can be run any number of times with the same results. + */ +final case class Stream[A](step: Event => (List[A], Stream[A])): + + /** Apply `f` to every output. */ + def map[B](f: A => B): Stream[B] = ??? + + /** Keep only the outputs for which `p` is true. */ + def filter(p: A => Boolean): Stream[A] = ??? + + /** Apply `f` to every output; `Some(b)` emits `b`, `None` is dropped. */ + def filterMap[B](f: A => Option[B]): Stream[B] = ??? + + /** Both streams step on every event; the outputs of `this` come before the outputs of `other`. */ + def merge(other: Stream[A]): Stream[A] = ??? + + /** Fold the outputs into an accumulator, starting from `z`; emits each new accumulator (not `z` itself). */ + def scan[B](z: B)(f: (B, A) => B): Stream[B] = ??? + + /** + * Emit `v` once, before the outputs of the first step; later steps are unaffected. + * Note: streams only act when an event arrives, so if no event ever arrives, `v` is never emitted. + */ + def startWith(v: A): Stream[A] = ??? + + /** Feed all events through the stream and collect every output, in order. A pure fold. */ + def run(events: List[Event]): List[A] = ??? + +object Stream: + + /** Emits the `key` of every KeyDown event; ignores everything else. */ + lazy val keyDowns: Stream[String] = ??? + + /** Emits the `key` of every KeyUp event; ignores everything else. */ + lazy val keyUps: Stream[String] = ??? + + /** + * On `Time(t)`, emits every multiple `k*ms` with `last < k*ms <= t`, ascending, where `last` starts at 0 + * and becomes `t` afterwards. A big time jump can emit several values. Ignores key events. + * Hint: write a private helper that carries `last`. + */ + def interval(ms: Long): Stream[Long] = ??? diff --git a/scala/test/M07IO.test.scala b/scala/test/M07IO.test.scala new file mode 100644 index 0000000..ded077f --- /dev/null +++ b/scala/test/M07IO.test.scala @@ -0,0 +1,99 @@ +package snake + +import IO.* + +class M07IOSuite extends munit.FunSuite: + private def rectAt(x: Long): Shape = Shape.Rect(x.toInt, 0, 1, 1, "#000000") + private val r1 = rectAt(0) + private val t1 = Shape.Text(1, 2, "hi", "#ffffff", 16, "left") + private def drawOp(shape: Shape): Op = Op.Draw(shape) + private val seedOp: Op = Op.RandomSeed + + test("trace of pure(3) has result 3 and no ops") { + assertEquals(trace(pure(3), 0), Traced(3, Nil)) + } + + test("randomSeed is answered with the seed given to trace") { + assertEquals(trace(randomSeed, 9).result, 9L) + } + + test("randomSeed records a RandomSeed op") { + assertEquals(trace(randomSeed, 9).ops, List(seedOp)) + } + + test("effect(op) is an Effect whose continuation is pure") { + effect(seedOp) match + case IO.Effect(op, k) => + assertEquals(op, seedOp) + assertEquals(k(OpResult.Seed(5)), IO.Pure(OpResult.Seed(5))) + case other => fail(s"expected an Effect, got $other") + } + + test("draw records a Draw op and produces ()") { + assertEquals(trace(draw(r1), 0), Traced((), List(drawOp(r1)))) + } + + test("drawScene draws the shapes in order") { + assertEquals(trace(drawScene(List(r1, t1)), 0).ops, List(drawOp(r1), drawOp(t1))) + } + + test("drawScene of an empty scene has no ops") { + assertEquals(trace(drawScene(Nil), 0).ops, Nil) + } + + test("sequence of nothing has no ops") { + assertEquals(trace(sequence(Nil), 0), Traced((), Nil)) + } + + test("sequence runs effects left to right") { + assertEquals( + trace(sequence(List(randomSeed.map(_ => ()), draw(t1), draw(r1))), 0).ops, + List(seedOp, drawOp(t1), drawOp(r1)) + ) + } + + test("map transforms the result without adding ops") { + assertEquals(trace(randomSeed.map(_ + 1), 41), Traced(42L, List(seedOp))) + } + + test("flatMap passes the result of the first IO to the second") { + assertEquals( + trace(randomSeed.flatMap(s => draw(rectAt(s))), 7).ops, + List(seedOp, drawOp(rectAt(7))) + ) + } + + test("building io.map(throwingF) does not throw") { + val boom: Long => Long = _ => throw new RuntimeException("boom") + randomSeed.map(boom) // only builds a description + } + + test("tracing io.map(throwingF) runs f and throws") { + val boom: Long => Long = _ => throw new RuntimeException("boom") + interceptMessage[RuntimeException]("boom")(trace(randomSeed.map(boom), 0)) + } + + // Monad laws, checked by interpreting both sides and comparing Traced(result, ops). + private val programs: List[(String, () => IO[Long])] = List( + "a pure value" -> (() => pure(4L)), + "randomSeed" -> (() => randomSeed), + "a draw followed by reading the seed" -> (() => draw(r1).flatMap(_ => randomSeed)) + ) + private val f: Long => IO[Long] = n => draw(rectAt(n)).flatMap(_ => pure(n * 2)) + private val g: Long => IO[Long] = n => randomSeed.map(_ + n) + + programs.foreach { (name, program) => + test(s"left identity holds for $name") { + val a = trace(program(), 5).result + assertEquals(trace(pure(a).flatMap(f), 5), trace(f(a), 5)) + } + test(s"right identity holds for $name") { + assertEquals(trace(program().flatMap(pure), 5), trace(program(), 5)) + } + test(s"associativity holds for $name") { + assertEquals( + trace(program().flatMap(f).flatMap(g), 5), + trace(program().flatMap(x => f(x).flatMap(g)), 5) + ) + } + } diff --git a/scala/test/M08Stream.test.scala b/scala/test/M08Stream.test.scala new file mode 100644 index 0000000..e0dad0c --- /dev/null +++ b/scala/test/M08Stream.test.scala @@ -0,0 +1,88 @@ +package snake + +import Stream.{keyDowns, keyUps, interval} + +class M08StreamSuite extends munit.FunSuite: + private def time(t: Long): Event = Event.Time(t) + private def down(key: String): Event = Event.KeyDown(key) + private def up(key: String): Event = Event.KeyUp(key) + + test("keyDowns emits keys of KeyDown events only") { + assertEquals(keyDowns.run(List(down("a"), time(5), up("a"), down("b"))), List("a", "b")) + } + + test("keyUps emits keys of KeyUp events only") { + assertEquals(keyUps.run(List(down("a"), time(5), up("a"), down("b"), up("b"))), List("a", "b")) + } + + test("run of no events is empty") { + assertEquals(keyDowns.run(Nil), Nil) + } + + test("interval emits each multiple once as time passes") { + assertEquals(interval(10).run(List(time(5), time(10), time(35))), List(10L, 20L, 30L)) + } + + test("interval emits several multiples after one big time jump") { + assertEquals(interval(10).run(List(time(35))), List(10L, 20L, 30L)) + } + + test("interval ignores key events") { + assertEquals(interval(10).run(List(down("a"), up("a"))), Nil) + } + + test("map transforms every output") { + assertEquals(keyDowns.map(_ + "!").run(List(down("a"), down("b"))), List("a!", "b!")) + } + + test("filter keeps outputs satisfying the predicate") { + assertEquals(keyDowns.filter(_ != "a").run(List(down("a"), down("b"))), List("b")) + } + + test("filterMap transforms outputs and drops Nones") { + assertEquals( + keyDowns.filterMap(k => if k == "a" then None else Some(k.toUpperCase)).run(List(down("a"), down("b"))), + List("B") + ) + } + + test("merge puts outputs of the first stream before the second at the same event") { + assertEquals( + keyDowns.map("first " + _).merge(keyDowns.map("second " + _)).run(List(down("x"))), + List("first x", "second x") + ) + } + + test("merge interleaves sources by event order") { + assertEquals( + interval(10).merge(keyDowns.map(_ => -1L)).run(List(time(10), down("a"), time(20))), + List(10L, -1L, 20L) + ) + } + + test("scan emits each new accumulator, not the initial value") { + assertEquals(keyDowns.scan(0)((n, _) => n + 1).run(List(down("a"), up("a"), down("b"), down("c"))), List(1, 2, 3)) + } + + test("scan passes the output to the folding function") { + assertEquals(keyDowns.scan("")(_ + _).run(List(down("a"), down("b"))), List("a", "ab")) + } + + test("startWith emits the value once, before the outputs of the first step") { + assertEquals(keyDowns.startWith("init").run(List(down("a"), down("b"))), List("init", "a", "b")) + } + + test("startWith emits at the first event even if that step has no outputs") { + assertEquals(keyDowns.startWith("init").run(List(time(1), down("a"))), List("init", "a")) + } + + test("startWith emits nothing when no event ever arrives") { + assertEquals(keyDowns.startWith("init").run(Nil), Nil) + } + + test("running the same stream value twice gives the same outputs") { + val s = interval(10).merge(keyDowns.map(_ => 100L)).scan(0L)(_ + _) + val events = List(time(25), down("a"), time(40)) + assertEquals(s.run(events), s.run(events)) + assertEquals(s.run(events), List(10L, 30L, 130L, 160L, 200L)) + }