Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
fwbrasil committed Sep 15, 2023
1 parent 6d71e7b commit 5c4a869
Show file tree
Hide file tree
Showing 4 changed files with 348 additions and 8 deletions.
342 changes: 341 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,347 @@
![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 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 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 you 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 Effect Management

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.

Kyo's effects and public APIs are designed so any side effect is properly suspended via `IOs`, providing safe building blocks for pure computations.

```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)
```

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 they're 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.
License
-------
Expand Down
2 changes: 1 addition & 1 deletion kyo-core/shared/src/main/scala/kyo/KyoApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 5 additions & 5 deletions kyo-core/shared/src/main/scala/kyo/ios.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) = {
Expand Down
2 changes: 1 addition & 1 deletion kyo-zio/src/main/scala/kyo/KyoZioApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

0 comments on commit 5c4a869

Please sign in to comment.