From ac6d04b66f8a5c983aff88b11f8d60526380a803 Mon Sep 17 00:00:00 2001 From: Flavio Brasil Date: Wed, 13 Sep 2023 23:46:15 -0700 Subject: [PATCH] wip --- README.md | 367 +++++++++++++++++- .../shared/src/main/scala/kyo/KyoApp.scala | 2 +- kyo-core/shared/src/main/scala/kyo/ios.scala | 10 +- .../scala/kyoTest/concurrent/fibersTest.scala | 2 +- .../src/test/scala/kyoTest/iosTest.scala | 8 +- .../test/scala/kyoTest/resourcesTest.scala | 4 +- kyo-zio/src/main/scala/kyo/KyoZioApp.scala | 2 +- 7 files changed, 380 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2692100a2..c9a087a65 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,372 @@ ![Chat](https://img.shields.io/discord/1087005439859904574) ![Version](https://img.shields.io/maven-central/v/io.getkyo/kyo-core_3) -Sorry, documentation coming soon + +Kyo is a complete toolkit for Scala application development, spanning from browser-based apps in ScalaJS to high-performance backends on the JVM. It introduces a novel approach based on algebraic effects to deliver straightforward APIs that adhere to the pure Functional Programming paradigm. Unlike similar solutions, Kyo achieves this without inundating developers with esoteric concepts from Functional Programming and Category Theory -- making for a development experience that's as intuitive as it is robust. + +Drawing inspiration from ZIO's effect rotation, Kyo takes a more generalized approach. While ZIO restricts effects to two channels -- dependency injection and short-circuiting -- Kyo allows for an arbitrary number of effectful channels. This enhancement affords developers greater flexibility in effect management, while also simplifying Kyo's internal codebase through more principled design patterns. + +## The `>` type + +In Kyo, computations use the infix type `>`, which takes two parameters: + +1. The first parameter specifies the type of the expected output. +2. The second parameter lists the pending effects that must be addressed, represented as an **unordered** type-level set via a type interssection. + +```scala +// Expect an Int after handling the 'Options' effect +Int > Options + +// Expect a String after handling both 'Options' and 'IOs' effects +String > (Options with IOs) +``` + +> Note: effect types follow a naming convention, which is the plural form of the functionalities they manage. + +Kyo is designed so that any type `T` is automatically a `T > Any`, where `Any` signifies an empty set of pending effects. This design makes it straightforward to handle computations as if they are pure types, when appropriate. + +```scala +// An 'Int' is also an 'Int > Any' +val a: Int > Any = 1 +``` + +It's possible to directly extract the pure value from a computation marked as `T > Any`. The given example essentially signifies a computation that yields an `Int` without any pending effects. Therefore, it's possible to safely extract the value: + +```scala +// Since there are no pending effects, the computation can produce a pure value +val b: Int = a.toPure +``` + +This property eliminates the need to distinguish between `map` and `flatMap`, as values are automatically lifted to a Kyo computation with no pending effects. + +```scala +// Kyo still supports both `map` and `flatMap` +def example1(a: Int > Options, b: Int > Tries): Int > (Options with Tries) = + a.flatMap(v => b.map(_ + v)) + +// But `map` alone suffices due to Kyo's design +def example1(a: Int > Options, b: Int > Tries): Int > (Options with Tries) = + a.map(v => b.map(_ + v)) +``` + +The `map` method in Kyo has the ability to automatically update the set of pending effects. When you apply `map` to computations that have different sets of pending effects, Kyo reconciles these into a new computation type that combines all the unique pending effects from both operands. + +## Effect widening + +Kyo's set of pending effects is a contravariant type parameter. This encoding permits computations to be widened to encompass a larger set of effects. The concept of "widening" refers to the capability to expand the set of pending effects that a computation may have. + +```scala +// An 'Int' with an empty effect set (`Any`) +val a: Int > Any = 1 + +// Widening the effect set from empty (`Any`) to include `Options` +val b: Int > Options = a + +// Further widening the effect set to include both `Options` and `Tries` +val c: Int > (Options with Tries) = b + +// Directly widening a pure value to have `Options` and `Tries` +val d: Int > (Options with Tries) = 42 +``` + +This contravariant encoding enables a fluent API for effectful code, allowing methods to accept parameters with a specific set of pending effects while also permitting those with fewer or no effects: + +```scala +// The function expects a parameter with both 'Options' and 'Tries' effects pending +def example1(v: Int > (Options with Tries)) = + v.map(_ + 1) + +// A value with only the 'Tries' effect can be automatically widened to include 'Options' +def example2(v: Int > Tries) = example1(v) + +// A pure value can also be automatically widened +def example3 = example1(42) +``` + +Here, `example1` is designed to accept an `Int > (Options with Tries)`. However, thanks to the contravariant encoding of the type-level set of effects, `example2` and `example3` demonstrate that you can also pass in computations with a smaller set of effects—or even a pure value—and they will be automatically widened to fit the expected type. + +## Using effects + +Kyo offers a modular approach to effect management, accommodating both built-in and custom effects through an organized system of object modules. This organization ensures a consistent API and allows developers to focus on building complex applications without worrying about effect management intricacies. + +Importing the corresponding object module into scope brings in the effect and any additional implicits it may need. The naming convention uses lowercase object modules for each effect type. + +```scala +// for 'Options' effect +import kyo.options._ +// for 'Tries' effect +import kyo.tries._ +``` + +For effects that support it, a `get` method is provided, which permits the extraction of the underlying value from a computation. + +```scala +// Retrieves an 'Int' tagged with 'Options' +val a: Int > Options = Options.get(Some(1)) +``` + +Effect handling is done using the `run` method. Though it's named `run`, the method doesn't necessarily execute the computation immediately, as it can also be suspended. + +```scala +// Handles 'Options' effect +val b: Option[Int] > Any = Options.run(a) + +// Retrieves pure value as there are no more pending effects +val c: Option[Int] = b.pure +``` + +The order in which you handle effects in Kyo can significantly influence both the type and value of the result. In Kyo, since effects are unordered at the type level, the runtime behavior depends on the sequence in which effects are processed. + +```scala +def optionsFirst(a: Int > (Options with Tries)): Try[Option[Int]] = { + val b: Option[Int] > Tries = Options.run(a) + val c: Try[Option[Int]] > Any = Tries.run(b) + c.pure +} +def triesFirst(a: Int > (Options with Tries)): Option[Try[Int]] = { + val b: Try[Int] > Options = Tries.run(a) + val c: Option[Try[Int]] > Any = Options.run(b) + c.pure +} +``` + +In this example, the order in which effects are handled significantly influences the outcome, particularly when the effects have the ability to short-circuit the computation: + +```scala +val ex = new Exception + +// if the effects don't short-circuit, only the order of nested effects in the result changes +assert(optionsFirst(Options.get(Some(1))) == Success(Some(1))) +assert(optionsFirst(Tries.get(Success(1))) == Success(Some(1))) + +// note how the result type changes from 'Try[Option[T]]' to 'Option[Try[T]]' +assert(triesFirst(Options.get(Some(1))) == Some(Success(1))) +assert(triesFirst(Tries.get(Success(1))) == Some(Success(1))) + +// if there's short-circuiting, the resulting value can be different +assert(optionsFirst(Options.get(None)) == Success(None)) +assert(optionsFirst(Tries.get(Failure(ex))) == Failure(ex)) + +assert(triesFirst(Options.get(None)) == None) +assert(triesFirst(Tries.get(Failure(ex))) == Some(Failure(ex))) +``` + +## Core Effects + +### Options: Optional Values + +```scala +// 'get' to "extract" the value of an 'Option' +val a: Int > Options = Options.get(Some(1)) + +// 'apply' is the effectful version of 'Option.apply' +val b: Int > Options = Options(1) + +// if 'apply' receives a 'null', it's equivalent to 'Options.get(None)' +assert(Options.run(Options(null)) == Options.run(Options.get(None))) + +// effectful version of `Option.getOrElse` +val c: Int > Options = Options.getOrElse(None, 42) + +// effectful verion of 'Option.orElse' +val d: Int > Options = Options.getOrElse(b, c) +``` + +### Tries: Exception Handling + +```scala +// 'get' to "extract" the value of an 'Try' +val a: Int > Tries = Tries.get(Try(1)) + +// 'fail' to short-circuit the computation +val b: Int > Tries = Tries.fail(new Exception) + +// 'fail' has an overload that takes an error message +val c: Int > Tries = Tries.fail("failed") + +// 'apply' is the effectful version of 'Try.apply' +val c: Int > Tries = Tries(1) + +// 'apply' automatically catches exceptions. Equivalent to 'Tries.fail(new Exception)' +val d: Int > Tries = Tries(throw new Exception) +``` + +### Aborts: Short Circuiting + +Both the `Tries` and `Options` effects are internally implemented in terms of `Aborts`, which is the generic implementation for short-circuiting effects. It's equivalent to ZIO's failure channel. + +```scala +// 'get' allows to "extract" the value from an 'Either' +val a: Int > Aborts[String] = Aborts[String].get(Right(1)) + +// short-circuiting via 'Left' +val b: Int > Aborts[String] = Aborts[String].get(Left("failed!")) + +// short-circuiting via 'Fail' +val c: Int > Aborts[String] = Aborts[String].fail("failed!") + +// 'catching' automatically catches exceptions similarly to 'Tries.apply' +val d: Int > Aborts[Exception] = Aborts[Exception].catching(throw new Exception) +``` + +> Note how the `Aborts` effect has a type parameter and its methods can only be accessed if the type parameter is provided. + +### Consoles: interaction with the console + +```scala +// reads a line from the console +val a: Int > Consoles = Consoles.readln + +// prints to the stdout +val b: Int > Consoles = Consoles.print("ok") + +// prints to the stdout with a new line +val c: Unit > Consoles = Consoles.println("ok") + +// prints to the stderr +val d: Unit > Consoles = Consoles.printErr("fail") + +// prints to the stderr with a new line +val e: Unit > Consoles = Consoles.printlnErr("fail") + +// runs with the default implicit 'Console' implementation +val f: Int > IOs = Consoles.run(e) + +// explictily setting the 'Console' implementation +val f: Int > IOs = Consoles.run(Console.default)(e) +``` + +> Note how `Consoles.run` returns a computation with the `IOs` effect pending, which ensures the implementation of `Consoles` is pure. + +### Clocks: Time Management + +```scala +// obtain the current time +val a: Instant > Clocks = Clocks.now + +// run with default 'Clock' +val b: Instant > IOs = Clocks.run(a) + +// run with an explicit 'Clock' +val c: Instant > IOs = Clocks.run(Clock.default)(a) +``` + +### Envs: Dependency Injection + +The `Envs` effect is similar to ZIO's environment mechanism but with a more flexible scoping since values can be provided individually. `Envs` doesn't provide a solution like ZIO's layers, though. The user is responsible from initializing environment values like services in parallel for example. + +Both `Consoles` and `Clocks` are implemented in terms of `Envs` internally. + +```scala +// Given an interface +trait Database { + def count: Int > IOs +} + +// The 'Envs' effect can be used to summon an instance. +// Note how the computation produces a 'Database' but at the +// same time requires a 'Database' from its environment +val a: Database > Envs[Database] = Envs[Database].get + +// use the 'Database' to obtain the count +val b: Int > (Envs[Database] with IOs) = a.map(_.count) + +// a 'Database' mock implementation +val db = new Database { + def count = 1 +} + +// handle the 'Envs' effect +val c: Int > IOs = Envs[Database].run(db)(b) +``` + +A computation can also require multiple values from its environment. + +```scala +// a second interface to be injected +trait Cache { + def clear: Unit > IOs +} + +// a computation that requires two values +val a: Unit > (Envs[Database] with Envs[Cache] with IOs) = + Envs[Database].get.map { db => + db.count.map { + case 0 => Envs[Cache].map(_.clear) + case _ => () + } + } +``` + +### IOs: Side Effects + +As you might have noticed, Kyo is unlike traditional effect systems since its base type doesn't assume that the computation might perform side effects. The `IOs` effect is introduced whenever a side effect needs to be performed. + +```scala +// 'apply' is used to suspend side effects +val a: Int > IOs = IOs(System.currentTimeMillis) + +// 'value' is a shorthand to widen a pure value to IOs +val b: Int > IOs = IOs.value(42) + +// 'fail' is returns a computation that will fail once IOs is handled +val c: Int > IOs = IOs.fail(new Exception) +``` + +> Note: Kyo's effects and public APIs are designed so any side effect is properly suspended via `IOs`, providing safe building blocks for pure computations. + +Users shouldn't typically handle the `IOs` effect directly since it triggers the execution of side effects, which breaks referential transparency. Prefer `kyo.App` instead. + +In some specific cases where Kyo isn't used as the main effect system of an application, it might make sense for the user to handle the `IOs` effect directly. The `run` method can only be used if `IOs`` is the only pending effect. + +```scala +val a: Int > IOs = IOs(42) +val b: Int = IOs.run(a).pure +``` + +The `runLazy` method accepts computations with other effects but it doesn't garantee that all side effects are performed before the method returns. If other effects still have to be handled, the side effects can be executed later once the other effects are handled. This a low-level API that must be used with caution. + +```scala +// computation with an 'Options' and then an 'IOs' suspensions +val a: Int > (Options with IOs) = + Options.get(Some(42)).map(v => IOs(println(v))) + +// handle the 'IOs' effect lazily +val b: Int > Options = IOs.runLazy(a) + +// since the computation is suspended withe 'Options' effect first, +// the lazy IOs execution will be triggered once 'Options' is handled +val c: Option[Int] = Options.run(b).pure +``` + +> IMPORTANT: Avoid handling the `IOs` effect directly since it breaks referential transparency. + +### Locals: Scoped Values + +The `Locals` effect operates on top of the `IOs` effect and enables the definition of scoped values. This mechanism is typically used to store contextual information of a computation. For example, in request processing, locals can be used to store the user who performed the request. In a library for database access, locals can be used to propagate transactions. + +To use `Locals`, first create a new `Local` instance with a default value. + +```scala +// locals need to be initialized with a default value +val myLocal: Local[Int] = Locals.init(42) + +// the 'get' method returns the current value of the local +val a: Int > IOs = myLocal.get + +// the 'let' method assigns a value to a local within the +// scope of a computation. This code produces 43 (42 + 1) +val b: Int > IOs = + myLocal.let(42)(a.map(_ + 1)) +``` + +### Resources: Resource Safety + + + +> Note: Kyo's effects are designed so locals are always properly propagated. For example, they're automatically inherited by forked computations in `Fibers`. + + License ------- diff --git a/kyo-core/shared/src/main/scala/kyo/KyoApp.scala b/kyo-core/shared/src/main/scala/kyo/KyoApp.scala index ef99e80f0..ee12c4fea 100644 --- a/kyo-core/shared/src/main/scala/kyo/KyoApp.scala +++ b/kyo-core/shared/src/main/scala/kyo/KyoApp.scala @@ -38,7 +38,7 @@ object KyoApp { val v6: T > (IOs with Fibers) = Timers.run(v5) val v7: T > (IOs with Fibers with Timers) = Fibers.timeout(timeout)(v6) val v8: T > (IOs with Fibers) = Timers.run(v6) - val v9: Fiber[T] > IOs = Fibers.run(IOs.lazyRun(v8)) + val v9: Fiber[T] > IOs = Fibers.run(IOs.runLazy(v8)) IOs.run(v9) } } diff --git a/kyo-core/shared/src/main/scala/kyo/ios.scala b/kyo-core/shared/src/main/scala/kyo/ios.scala index 1be08b2b5..1de8d1657 100644 --- a/kyo-core/shared/src/main/scala/kyo/ios.scala +++ b/kyo-core/shared/src/main/scala/kyo/ios.scala @@ -78,22 +78,22 @@ object ios { } /*inline*/ - def lazyRun[T, S](v: T > (IOs with S)): T > S = { - def lazyRunLoop(v: T > (IOs with S)): T > S = { + def runLazy[T, S](v: T > (IOs with S)): T > S = { + def runLazyLoop(v: T > (IOs with S)): T > S = { val safepoint = Safepoint.noop[IO, IOs] v match { case kyo: Kyo[IO, IOs, Unit, T, S with IOs] @unchecked if (kyo.effect eq IOs) => - lazyRunLoop(kyo((), safepoint, Locals.State.empty)) + runLazyLoop(kyo((), safepoint, Locals.State.empty)) case kyo: Kyo[MX, EX, Any, T, S with IOs] @unchecked => new KyoCont[MX, EX, Any, T, S](kyo) { def apply(v: Any, s: Safepoint[MX, EX], l: Locals.State) = - lazyRunLoop(kyo(v, s, l)) + runLazyLoop(kyo(v, s, l)) } case _ => v.asInstanceOf[T] } } - lazyRunLoop(v) + runLazyLoop(v) } private[kyo] def ensure[T, S](f: => Unit > IOs)(v: => T > S): T > (IOs with S) = { diff --git a/kyo-core/shared/src/test/scala/kyoTest/concurrent/fibersTest.scala b/kyo-core/shared/src/test/scala/kyoTest/concurrent/fibersTest.scala index 84b72710a..443be7bf2 100644 --- a/kyo-core/shared/src/test/scala/kyoTest/concurrent/fibersTest.scala +++ b/kyo-core/shared/src/test/scala/kyoTest/concurrent/fibersTest.scala @@ -296,7 +296,7 @@ class fibersTest extends KyoTest { for { l <- Latches.init(1) - fiber <- Fibers.run(IOs.lazyRun(Fibers.fork(task(l)))) + fiber <- Fibers.run(IOs.runLazy(Fibers.fork(task(l)))) _ <- Fibers.sleep(10.millis) interrupted <- fiber.interrupt _ <- l.await diff --git a/kyo-core/shared/src/test/scala/kyoTest/iosTest.scala b/kyo-core/shared/src/test/scala/kyoTest/iosTest.scala index bcd179483..c4adf8079 100644 --- a/kyo-core/shared/src/test/scala/kyoTest/iosTest.scala +++ b/kyo-core/shared/src/test/scala/kyoTest/iosTest.scala @@ -25,7 +25,7 @@ class iosTest extends KyoTest { } assert(!called) checkEquals[Int, Nothing]( - IOs.lazyRun(v), + IOs.runLazy(v), 1 ) assert(called) @@ -40,7 +40,7 @@ class iosTest extends KyoTest { } } assert(!called) - val v2 = IOs.lazyRun(v) + val v2 = IOs.runLazy(v) assert(!called) checkEquals[Int, Nothing]( Envs[Int].run(1)(v2), @@ -59,7 +59,7 @@ class iosTest extends KyoTest { IOs(IOs(1)).map(_ => fail) ) ios.foreach { io => - assert(Try(IOs.lazyRun(io)) == Try(fail)) + assert(Try(IOs.runLazy(io)) == Try(fail)) } succeed } @@ -73,7 +73,7 @@ class iosTest extends KyoTest { i } checkEquals[Int, Nothing]( - IOs.lazyRun(loop(0)), + IOs.runLazy(loop(0)), frames ) } diff --git a/kyo-core/shared/src/test/scala/kyoTest/resourcesTest.scala b/kyo-core/shared/src/test/scala/kyoTest/resourcesTest.scala index 4504a2299..5e42321ae 100644 --- a/kyo-core/shared/src/test/scala/kyoTest/resourcesTest.scala +++ b/kyo-core/shared/src/test/scala/kyoTest/resourcesTest.scala @@ -47,7 +47,7 @@ class resourcesTest extends KyoTest { val r1 = Resource(1) val r2 = Resource(2) val r = - IOs.lazyRun { + IOs.runLazy { Resources.run[Int, IOs with Envs[Int]](Resources.acquire(r1()).map { _ => assert(r1.closes == 0) Envs[Int].get @@ -101,7 +101,7 @@ class resourcesTest extends KyoTest { val r1 = Resource(1) val r2 = Resource(2) val r: Int > Envs[Int] = - IOs.lazyRun { + IOs.runLazy { Resources.run[Int, IOs with Envs[Int]] { val io: Int > (Resources with IOs with Envs[Int]) = for { diff --git a/kyo-zio/src/main/scala/kyo/KyoZioApp.scala b/kyo-zio/src/main/scala/kyo/KyoZioApp.scala index e8740fa3b..b9c98c696 100644 --- a/kyo-zio/src/main/scala/kyo/KyoZioApp.scala +++ b/kyo-zio/src/main/scala/kyo/KyoZioApp.scala @@ -56,7 +56,7 @@ object KyoZioApp { val v5: T > (IOs with Fibers with ZIOs) = Timers.run(v4) val v6: T > (IOs with ZIOs) = inject[T, Fiber, Task, Fibers, ZIOs, IOs](Fibers, ZIOs)(v5) - val v7: T > ZIOs = IOs.lazyRun(v6) + val v7: T > ZIOs = IOs.runLazy(v6) ZIOs.run(v7) } }