Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ToyIO demonstration of a basic IO implementation for runtimes #1162

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions build.sbt
Expand Up @@ -183,8 +183,8 @@ lazy val core =
// periodically we use acyclic to ban cyclic dependencies and make compilation faster
,
autoCompilerPlugins := true,
addCompilerPlugin("com.lihaoyi" % "acyclic_2.13.12" % "0.3.11"),
scalacOptions += "-P:acyclic:force"
//addCompilerPlugin("com.lihaoyi" % "acyclic_2.13.12" % "0.3.11"),
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented this because it blows up the repl

//scalacOptions += "-P:acyclic:force"
).dependsOn(base)
.jsSettings(commonJsSettings)

Expand Down
95 changes: 95 additions & 0 deletions core/src/main/scala/org/bykn/bosatsu/ToyIO.scala
@@ -0,0 +1,95 @@
package org.bykn.bosatsu

sealed abstract class ToyIO[+E, +A]

object ToyIO {
case class Pure[A](get: A) extends ToyIO[Nothing, A]
case class Err[E](get: E) extends ToyIO[E, Nothing]
case class FlatMap[E, A, B](init: ToyIO[E, A], fn: A => ToyIO[E, B]) extends ToyIO[E, B]
case class RecoverWith[E, E1, A](init: ToyIO[E, A], fn: E => ToyIO[E1, A]) extends ToyIO[E1, A]
/**
* fix(f) = f(fix(f))
*/
case class ApplyFix[E, A, B](arg: A, fixed: (A => ToyIO[E, B]) => (A => ToyIO[E, B])) extends ToyIO[E, B] {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this constructor can't be written in Bosatsu because ToyIO[E, B] is in an invariant position (contravariant and covariant in the fixed function, and we can only have co-variant recursion in bosatsu. But we could define this constructor outside of bosatsu and return it via an external def.

def step: ToyIO[E, B] =
// it is temping to simplify fixed(fix(fixed)) == fix(fixed)
// but fix(af.fixed)(af.arg) == af
// so that doesn't make progress. We need to apply
// af.fixed at least once to create a new value
fixed(Fix(fixed))(arg)
}
case class Fix[E, A, B](fn: (A => ToyIO[E, B]) => (A => ToyIO[E, B])) extends Function1[A, ToyIO[E, B]] {
def apply(a: A): ToyIO[E,B] = ApplyFix(a, fn)
}

implicit class ToyIOMethods[E, A](private val io: ToyIO[E, A]) extends AnyVal {
def flatMap[E1 >: E, B](fn: A => ToyIO[E1, B]): ToyIO[E1, B] =
FlatMap(io, fn)

def map[B](fn: A => B): ToyIO[E, B] = flatMap(a => Pure(fn(a)))
def recoverWith[E1](fn: E => ToyIO[E1, A]): ToyIO[E1, A] =
RecoverWith(io, fn)

def run: Either[E, A] = ToyIO.run(io)
}

val unit: ToyIO[Nothing, Unit] = Pure(())

def pure[A](a: A): ToyIO[Nothing, A] = Pure(a)

def defer[E, A](io: => ToyIO[E, A]): ToyIO[E, A] =
unit.flatMap(_ => io)

def delay[A](a: => A): ToyIO[Nothing, A] =
defer(Pure(a))

def raiseError[E](e: E): ToyIO[E, Nothing] = Err(e)

// fix(f) = f(fix(f))
def fix[E, A, B](recur: (A => ToyIO[E, B]) => (A => ToyIO[E, B])): A => ToyIO[E, B] =
Fix(recur)

sealed trait Stack[E, A, E1, A1]

case class Done[E1, A1, E, A](ev: E1 =:= E, av: A1 =:= A) extends Stack[E1, A1, E, A]
case class FMStep[E, E1, A, B, B1](fn: A => ToyIO[E, B], stack: Stack[E, B, E1, B1]) extends Stack[E, A, E1, B1]
case class RecStep[E, E1, E2, A, B](fn: E => ToyIO[E1, A], stack: Stack[E1, A, E2, B]) extends Stack[E, A, E2, B]

def run[E, A](io: ToyIO[E, A]): Either[E, A] = {

@annotation.tailrec
def loop[E1, A1](arg: ToyIO[E1, A1], stack: Stack[E1, A1, E, A]): Either[E, A] =
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this loop is the only place with explicit recursion. This loop would be implemented in the runtime or host language using a loop to avoid blowing the stack.

arg match {
case p @ Pure(get) =>
stack match {
case Done(_, av) => Right(av(get))
case FMStep(fn, stack) =>
loop(fn(get), stack)
case RecStep(_, stack) =>
loop(p, stack)
}
case e @ Err(get) =>
stack match {
case Done(ev, _) => Left(ev(get))
case FMStep(_, stack) =>
// unwind the stack
loop(e, stack)
case RecStep(fn, stack) =>
loop(fn(get), stack)
}
case FlatMap(init, fn) =>
loop(init, FMStep(fn, stack))
case rw: RecoverWith[e, e1, a] =>
loop(rw.init, RecStep(rw.fn, stack))
case af: ApplyFix[e, a, b] =>
// fixed(fix(fixed)) = fix(fixed)
// take a step here,
// this may never terminate, because there is no
// promise that a general recursive function terminates,
// but it won't blow the stack
loop(af.step, stack)
}

loop(io, Done[E, A, E, A](implicitly, implicitly))
}
}
119 changes: 119 additions & 0 deletions core/src/test/scala/org/bykn/bosatsu/ToyIOTest.scala
@@ -0,0 +1,119 @@
package org.bykn.bosatsu

import cats.Eval
import cats.data.EitherT
import org.scalacheck.{Gen, Cogen, Prop}
import org.bykn.bosatsu.ToyIO.Pure
import org.bykn.bosatsu.ToyIO.Err
import org.bykn.bosatsu.ToyIO.FlatMap
import org.bykn.bosatsu.ToyIO.ApplyFix
import org.bykn.bosatsu.ToyIO.RecoverWith

class ToyIOTest extends munit.ScalaCheckSuite {

def toEvalT[E, A](toyio: ToyIO[E, A]): EitherT[Eval, E, A] =
toyio match {
case Pure(get) => EitherT[Eval, E, A](Eval.now(Right(get)))
case Err(get) => EitherT[Eval, E, A](Eval.now(Left(get)))
case fm: FlatMap[e, a1, a2] =>
val first = Eval.defer[Either[e, a1]](toEvalT(fm.init).value)
EitherT(first.flatMap {
case Right(a1) =>
val fn1 = fm.fn.andThen { toyio => Eval.defer(toEvalT[e, a2](toyio).value) }
fn1(a1)
case Left(err) =>
Eval.now(Left(err))
})
case rw: RecoverWith[e, e1, a] =>
val first = Eval.defer[Either[e, a]](toEvalT(rw.init).value)
EitherT(first.flatMap {
case Right(a1) => Eval.now(Right(a1))
case Left(err) =>
val fn1 = rw.fn.andThen { toyio => Eval.defer(toEvalT[e1, a](toyio).value) }
fn1(err)
})
case af: ApplyFix[e, a, b] =>
lazy val fix: a => ToyIO[e, b] =
{ (a: a) => af.fixed(fix)(a) }

EitherT(Eval.defer(toEvalT(fix(af.arg)).value))
}

trait Move[A] {
def notLessThan(a: A): A
def notMoreThan(a: A): A
}
object Move {
implicit val moveBoolean: Move[Boolean] =
new Move[Boolean] {
def notLessThan(a: Boolean): Boolean = true
def notMoreThan(a: Boolean): Boolean = false
}

implicit val moveByte: Move[Byte] =
new Move[Byte] {
def notLessThan(a: Byte): Byte =
if (a == Byte.MaxValue) Byte.MaxValue
else (a + 1).toByte
def notMoreThan(a: Byte): Byte =
if (a == Byte.MinValue) Byte.MinValue
else (a - 1).toByte
}

def apply[A](implicit m: Move[A]): Move[A] = m
}

def genToy[E: Cogen, A: Cogen: Ordering: Move](genE: Gen[E], genA: Gen[A]): Gen[ToyIO[E, A]] = {
lazy val recur = Gen.lzy(genToy(genE, genA))
val cogenFn: Cogen[A => ToyIO[E, A]] =
Cogen(_.hashCode.toLong)

lazy val genFix: Gen[(A => ToyIO[E, A]) => (A => ToyIO[E, A])] =
Gen.zip(genA, Gen.oneOf(true, false), genA, genE).map { case (cut, lt, result, err) =>

val ord = implicitly[Ordering[A]]
val cmpFn =
if (lt) { (a: A) => ord.lt(a, cut) }
else { (a: A) => ord.gt(a, cut) }
val step =
if (lt) { (a: A) => Move[A].notLessThan(a) }
else { (a: A) => Move[A].notMoreThan(a) }

{ recur =>
(a: A) => {
if (ord.equiv(a, cut)) ToyIO.pure(result)
else if (cmpFn(a)) recur(step(a))
else ToyIO.raiseError(err)
}
}
}
Gen.function1(Gen.function1(recur)(Cogen[A]))(cogenFn)

Gen.frequency(
2 -> genA.map(ToyIO.pure(_)),
2 -> genE.map(ToyIO.raiseError(_)),
1 -> Gen.zip(recur, Gen.function1[A, ToyIO[E, A]](recur)).map { case (io, fn) =>
io.flatMap(fn)
},
1 -> Gen.zip(recur, Gen.function1[E, ToyIO[E, A]](recur)).map { case (io, fn) =>
io.recoverWith(fn)
},
1 -> Gen.zip(genA, genFix).map { case (a, fn) => ToyIO.fix(fn)(a) }
)
}

val bytes = Gen.choose(Byte.MinValue, Byte.MaxValue)
val bools = Gen.oneOf(true, false)

property("evaluation of ToyIO via Eval matches E=Byte, A=Byte") {
Prop.forAll(genToy(bytes, bytes)) { toyio =>
assertEquals(toyio.run, toEvalT(toyio).value.value)
}
}

property("evaluation of ToyIO via Eval matches E=Bool, A=Bool") {
Prop.forAll(genToy(bools, bools)) { toyio =>
assertEquals(toyio.run, toEvalT(toyio).value.value)
}
}
}