Skip to content

Commit

Permalink
Merge branch 'main' into show-type-class
Browse files Browse the repository at this point in the history
  • Loading branch information
johnhungerford committed Dec 10, 2024
2 parents cfc426b + fba3e4b commit 4cc870f
Show file tree
Hide file tree
Showing 29 changed files with 1,673 additions and 406 deletions.
111 changes: 78 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,17 @@ We strongly recommend enabling these Scala compiler flags when working with Kyo
1. `-Wvalue-discard`: Warns when non-Unit expression results are unused.
2. `-Wnonunit-statement`: Warns when non-Unit expressions are used in statement position.
3. `-Wconf:msg=(unused.*value|discarded.*value|pure.*statement):error`: Elevates the warnings from the previous flags to compilation errors.
4. `-language:strictEquality`: Enforces type-safe equality comparisons by requiring explicit evidence that types can be safely compared.

Add these to your `build.sbt`:

```scala
scalacOptions ++= Seq(
"-Wvalue-discard",
"-Wnonunit-statement",
"-Wconf:msg=(unused.*value|discarded.*value|pure.*statement):error")
"-Wconf:msg=(unused.*value|discarded.*value|pure.*statement):error",
"-language:strictEquality"
)
```

These flags help catch two common issues in Kyo applications:
Expand All @@ -106,7 +109,9 @@ These flags help catch two common issues in Kyo applications:

2. **Unused/Discarded non-Unit value**: Most commonly occurs when you pass a computation to a method that can only handle some of the effects that your computation requires. For example, passing a computation that needs both `IO` and `Abort[Exception]` effects as a method parameter that only accepts `IO` can trigger this warning. While this warning can appear in other scenarios (like ignoring any non-Unit value), in Kyo applications it typically signals that you're trying to use a computation in a context that doesn't support all of its required effects.

> Note: You may want to selectively disable these warnings in test code, where it's common to assert side effects without using their returned values.
3. **Values cannot be compared with == or !=**: The strict equality flag ensures type-safe equality comparisons by requiring that compared types are compatible. This is particularly important for Kyo's opaque types like `Maybe`, where comparing values of different types could lead to inconsistent behavior. The flag helps catch these issues at compile-time, ensuring you only compare values that can be meaningfully compared. For example, you cannot accidentally compare a `Maybe[Int]` with an `Option[Int]` or a raw `Int`, preventing subtle bugs. To disable the check for a specific scope, introduce an unsafe evidence: `given [A, B]: CanEqual[A, B] = CanEqual.derived`

> Note: You may want to selectively disable these warnings in test code, where it's common to assert side effects without using their returned values: `Test / scalacOptions --= Seq(options, to, disable)`
### The "Pending" type: `<`

Expand All @@ -126,8 +131,6 @@ Int < Abort[Absent]
String < (Abort[Absent] & IO)
```

> Note: The naming convention for effect types is the plural form of the functionalities they manage.
Any type `T` is automatically considered to be of type `T < Any`, where `Any` denotes an absence of pending effects. In simpler terms, this means that every value in Kyo is automatically a computation, but one without any effects that you need to handle.

This design choice streamlines your code by removing the necessity to differentiate between pure values and computations that may have effects. So, when you're dealing with a value of type `T < Any`, you can safely `eval` the pure value directly, without worrying about handling any effects.
Expand Down Expand Up @@ -419,8 +422,6 @@ The `defer` method in Kyo mirrors Scala's `for`-comprehensions in providing a co

The `kyo-direct` module is constructed as a wrapper around [dotty-cps-async](https://github.com/rssh/dotty-cps-async).

> Note: `defer` is currently the only user-facing macro in Kyo. All other features use regular language constructs.
### Defining an App

`KyoApp` offers a structured approach similar to Scala's `App` for defining application entry points. However, it comes with added capabilities, handling a suite of default effects. As a result, the `run` method within `KyoApp` can accommodate various effects, such as IO, Async, Resource, Clock, Console, Random, Timer, and Aspect.
Expand Down Expand Up @@ -2580,7 +2581,7 @@ val f: Unit < IO =
val g: Unit < IO =
aLong.map(_.lazySet(1L))
val h: Boolean < IO =
aBool.map(_.cas(false, true))
aBool.map(_.compareAndSet(false, true))
val i: String < IO =
aRef.map(_.getAndSet("new"))
```
Expand Down Expand Up @@ -3334,6 +3335,8 @@ trait HelloService:
def sayHelloTo(saluee: String): Unit < (IO & Abort[Throwable])

object HelloService:
val live = Layer(Live)

object Live extends HelloService:
override def sayHelloTo(saluee: String): Unit < (IO & Abort[Throwable]) =
Kyo.suspendAttempt { // Adds IO & Abort[Throwable] effect
Expand All @@ -3342,59 +3345,101 @@ object HelloService:
end Live
end HelloService

val keepTicking: Nothing < (Console & Async & Abort[IOException]) =
val keepTicking: Nothing < (Async & Abort[IOException]) =
(Console.print(".") *> Kyo.sleep(1.second)).forever

val effect: Unit < (Console & Async & Resource & Abort[Throwable] & Env[NameService]) =
val effect: Unit < (Async & Resource & Abort[Throwable] & Env[HelloService]) =
for
nameService <- Kyo.service[NameService] // Adds Env[NameService] effect
_ <- keepTicking.forkScoped // Adds Console, Async, and Resource effects
saluee <- Console.readLine // Uses Console effect
nameService <- Kyo.service[HelloService] // Adds Env[NameService] effect
_ <- keepTicking.forkScoped // Adds Async, Abort[IOException], and Resource effects
saluee <- Console.readln
_ <- Kyo.sleep(2.seconds) // Uses Async (semantic blocking)
_ <- nameService.sayHelloTo(saluee) // Adds Abort[Throwable] effect
_ <- nameService.sayHelloTo(saluee) // Lifts Abort[IOException] to Abort[Throwable]
yield ()
end for
end effect

// There are no combinators for handling IO or blocking Async, since this should
// be done at the edge of the program
IO.Unsafe.run { // Handles IO
Async.runAndBlock(Duration.Inf) { // Handles Async
IO.Unsafe.run { // Handles IO
Async.runAndBlock(Duration.Inf) { // Handles Async
Kyo.scoped { // Handles Resource
effect
.provideAs[HelloService](HelloService.Live) // Handles Env[HelloService]
.catchAbort((thr: Throwable) => // Handles Abort[Throwable]
Kyo.debug(s"Failed printing to console: ${throwable}")
)
.provideDefaultConsole // Handles Console
Memo.run: // Handles Memo (introduced by .provide, below)
effect
.catching((thr: Throwable) => // Handles Abort[Throwable]
Kyo.debug(s"Failed printing to console: ${throwable}")
)
.provide(HelloService.live) // Works like ZIO[R,E,A]#provide, but adds Memo effect
}
}
}
```

### Failure conversions
### Error handling

Whereas ZIO has a single channel for describing errors, Kyo has different effect types that can describe failure in the basic sense of "short-circuiting": `Abort` and `Choice` (an empty `Seq` being equivalent to a short-circuit). `Abort[Absent]` can also be used like `Choice` to model short-circuiting an empty result.

One notable departure from the ZIO API worth calling out is a set of combinators for converting between failure effects. Whereas ZIO has a single channel for describing errors, Kyo has different effect types that can describe failure in the basic sense of "short-circuiting": `Abort` and `Choice` (an empty `Seq` being equivalent to a short-circuit). `Abort[Absent]` can also be used like `Choice` to model short-circuiting an empty result. It's useful to be able to move between these effects easily, so `kyo-combinators` provides a number of extension methods, usually in the form of `def effect1ToEffect2`.
For each of these, to handle the effect, lifting the result type to `Result`, `Seq`, and `Maybe`, use `.result`, `.handleChoice`, and `.maybe` respectively. Alternatively, you can convert between these different error types using methods usually in the form of `def effect1ToEffect2`, where `effect1` and `effect2` can be "abort" (`Abort[?]`), "absent" (`Abort[Absent]`), "empty" (`Choice`, when reduced to an empty sequence), and "throwable" (`Abort[Throwable]`).

Some examples:

```scala
val abortEffect: Int < Abort[String] = ???
val abortEffect: Int < Abort[String] = 1

// Converts failures to empty failure
val maybeEffect: Int < Abort[Absent] = abortEffect.abortToEmpty
val maybeEffect: Int < Abort[Absent] = abortEffect.abortToAbsent

// Converts an aborted Absent to an empty "choice"
val choiceEffect: Int < Choice = maybeEffect.absentToEmpty

// Converts empty failure to a single "choice" (or Seq)
val choiceEffect: Int < Choice = maybeEffect.emptyAbortToChoice
// Fails with exception if empty
val newAbortEffect: Int < (Choice & Abort[Throwable]) = choiceEffect.emptyToThrowable
```

To swallow errors à la ZIO's `orDie` and `resurrect` methods, you can use `orPanic` and `unpanic` respectively:

```scala
import kyo.*
import java.io.IOException

// Fails with Nil#head exception if empty and succeeds with Seq.head if non-empty
val newAbortEffect: Int < Abort[Throwable] = choiceEffect.choiceToThrowable
val abortEffect: Int < Abort[String | Throwable] = 1

// Throws a throwable Abort failure (will actually throw unless suspended)
val unsafeEffect: Int < Any = newAbortEffect.implicitAborts
// unsafeEffect will panic with a `PanicException(err)`
val unsafeEffect: Int < Any = abortEffect.orPanic

// Catch any suspended throws
val safeEffect: Int < Abort[Throwable] = unsafeEffect.explicitAborts
val safeEffect: Int < Abort[Throwable] = unsafeEffect.unpanic

// Use orPanic after forAbort[E] to swallow only errors of type E
val unsafeForThrowables: Int < Abort[String] = abortEffect.forAbort[Throwable].orPanic
```

Other error-handling methods are as follows:

```scala
import kyo.*

trait A
trait B
trait C

val effect: Int < Abort[A | B | C] = 1

val handled: Result[A | B | C, Int] < Any = effect.result
val mappedError: Int < Abort[String] = effect.mapAbort(_.toString)
val caught: Int < Any = effect.catching(_.toString.size)
val partiallyCaught: Int < Abort[A | B | C] = effect.catchingSome { case err if err.toString.size > 5 => 0 }

// Manipulate single types from within the union
val handledA: Result[A, Int] < Abort[B | C] = effect.forAbort[A].result
val caughtA: Int < Abort[B | C] = effect.forAbort[A].catching(_.toString.size)
val partiallyCaughtA: Int < Abort[A | B | C] = effect.forAbort[A].catchingSome { case err if err.toString.size > 5 => 0 }
val aToAbsent: Int < Abort[Absent | B | C] = effect.forAbort[A].toAbsent
val aToEmpty: Int < (Choice & Abort[B | C]) = effect.forAbort[A].toEmpty
val aToThrowable: Int < (Abort[Throwable | B | C]) = effect.forAbort[A].toThrowable
```


## Acknowledgements

Kyo's development was originally inspired by the paper ["Do Be Do Be Do"](https://arxiv.org/pdf/1611.09259.pdf) and its implementation in the [Unison](https://www.unison-lang.org/learn/language-reference/abilities-and-ability-handlers/) programming language. Kyo's design evolved from using interface-based effects to suspending concrete values associated with specific effects, making it more efficient when executed on the JVM.
Expand All @@ -3409,4 +3454,4 @@ License
-------

See the [LICENSE](https://github.com/getkyo/kyo/blob/master/LICENSE.txt) file for details.


22 changes: 20 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ lazy val kyoJVM = project
.aggregate(
`kyo-scheduler`.jvm,
`kyo-scheduler-zio`.jvm,
`kyo-scheduler-cats`.jvm,
`kyo-data`.jvm,
`kyo-prelude`.jvm,
`kyo-core`.jvm,
Expand Down Expand Up @@ -186,6 +187,22 @@ lazy val `kyo-scheduler-zio` = sbtcrossproject.CrossProject("kyo-scheduler-zio",
scalacOptions ++= scalacOptionToken(ScalacOptions.source3).value,
crossScalaVersions := List(scala3Version, scala212Version, scala213Version)
)
lazy val `kyo-scheduler-cats` =
crossProject(JVMPlatform)
.withoutSuffixFor(JVMPlatform)
.crossType(CrossType.Full)
.dependsOn(`kyo-scheduler`)
.in(file("kyo-scheduler-cats"))
.settings(
`kyo-settings`,
libraryDependencies += "org.typelevel" %%% "cats-effect" % catsVersion,
libraryDependencies += "org.scalatest" %%% "scalatest" % scalaTestVersion % Test
)
.jvmSettings(mimaCheck(false))
.settings(
scalacOptions ++= scalacOptionToken(ScalacOptions.source3).value,
crossScalaVersions := List(scala3Version, scala212Version, scala213Version)
)

lazy val `kyo-data` =
crossProject(JSPlatform, JVMPlatform, NativePlatform)
Expand Down Expand Up @@ -289,8 +306,8 @@ lazy val `kyo-stats-otel` =
.dependsOn(`kyo-core`)
.settings(
`kyo-settings`,
libraryDependencies += "io.opentelemetry" % "opentelemetry-api" % "1.44.1",
libraryDependencies += "io.opentelemetry" % "opentelemetry-sdk" % "1.44.1" % Test,
libraryDependencies += "io.opentelemetry" % "opentelemetry-api" % "1.45.0",
libraryDependencies += "io.opentelemetry" % "opentelemetry-sdk" % "1.45.0" % Test,
libraryDependencies += "io.opentelemetry" % "opentelemetry-exporters-inmemory" % "0.9.1" % Test
)
.jvmSettings(mimaCheck(false))
Expand Down Expand Up @@ -460,6 +477,7 @@ lazy val `kyo-bench` =
.dependsOn(`kyo-sttp`)
.dependsOn(`kyo-stm`)
.dependsOn(`kyo-scheduler-zio`)
.dependsOn(`kyo-scheduler-cats`)
.disablePlugins(MimaPlugin)
.settings(
`kyo-settings`,
Expand Down
10 changes: 6 additions & 4 deletions kyo-bench/src/main/scala/kyo/bench/Bench.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ abstract class Bench[A](val expectedResult: A):
else
zio.Runtime.default.unsafe
end zioRuntime

given ioRuntime: cats.effect.unsafe.IORuntime =
if System.getProperty("replaceCatsExecutor", "false") == "true" then
kyo.KyoSchedulerIORuntime.global
else
cats.effect.unsafe.implicits.global
end Bench

object Bench:
Expand All @@ -61,9 +67,7 @@ object Bench:

@Benchmark
def forkCats(warmup: CatsForkWarmup): A =
import cats.effect.unsafe.implicits.global
cats.effect.IO.cede.flatMap(_ => catsBench()).unsafeRunSync()
end forkCats

@Benchmark
def forkZIO(warmup: ZIOForkWarmup): A = zio.Unsafe.unsafe(implicit u =>
Expand All @@ -84,9 +88,7 @@ object Bench:

@Benchmark
def syncCats(warmup: CatsSyncWarmup): A =
import cats.effect.unsafe.implicits.global
catsBench().unsafeRunSync()
end syncCats

@Benchmark
def syncZIO(warmup: ZIOSyncWarmup): A = zio.Unsafe.unsafe(implicit u =>
Expand Down
4 changes: 2 additions & 2 deletions kyo-bench/src/main/scala/kyo/bench/RendezvousBench.scala
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class RendezvousBench extends Bench.ForkOnly(10000 * (10000 + 1) / 2):
def produce(waiting: AtomicRef[Any], n: Int = 0): Unit < Async =
if n <= depth then
Promise.init[Nothing, Unit].flatMap { p =>
waiting.cas(null, (p, n)).flatMap {
waiting.compareAndSet(null, (p, n)).flatMap {
case false =>
waiting.getAndSet(null).flatMap {
_.asInstanceOf[Promise[Nothing, Int]].complete(Result.success(n))
Expand All @@ -85,7 +85,7 @@ class RendezvousBench extends Bench.ForkOnly(10000 * (10000 + 1) / 2):
def consume(waiting: AtomicRef[Any], n: Int = 0, acc: Int = 0): Int < Async =
if n <= depth then
Promise.init[Nothing, Int].flatMap { p =>
waiting.cas(null, p).flatMap {
waiting.compareAndSet(null, p).flatMap {
case false =>
waiting.getAndSet(null).flatMap {
case (p2: Promise[Nothing, Unit] @unchecked, i: Int) =>
Expand Down
Loading

0 comments on commit 4cc870f

Please sign in to comment.