├── project ├── build.properties └── plugins.sbt ├── src └── main │ ├── scala │ └── org │ │ └── adamhearn │ │ ├── package.scala │ │ ├── app │ │ └── Main.scala │ │ ├── 03-Streams.scala │ │ ├── 02-Concurrency.scala │ │ ├── 01-Effects.scala │ │ └── 00-Data.scala │ └── resources │ └── logback.xml ├── slides ├── out │ ├── talk.pdf │ └── talk_with_build.pdf ├── snippets │ ├── io.scala │ ├── abort.scala │ └── env.scala └── talk.md ├── .gitignore ├── .scalafmt.conf └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.10.2 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") 2 | -------------------------------------------------------------------------------- /src/main/scala/org/adamhearn/package.scala: -------------------------------------------------------------------------------- 1 | package org.adamhearn 2 | 3 | type ??? = Any 4 | -------------------------------------------------------------------------------- /slides/out/talk.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hearnadam/kyo-workshop/HEAD/slides/out/talk.pdf -------------------------------------------------------------------------------- /slides/out/talk_with_build.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hearnadam/kyo-workshop/HEAD/slides/out/talk_with_build.pdf -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d %-5level [%thread] %logger: %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # sbt specific 5 | dist/* 6 | target/ 7 | lib_managed/ 8 | src_managed/ 9 | project/boot/ 10 | project/plugins/project/ 11 | project/local-plugins.sbt 12 | .history 13 | .ensime 14 | .ensime_cache/ 15 | .sbt-scripted/ 16 | local.sbt 17 | 18 | # scala-cli 19 | .scala-build/ 20 | 21 | # Bloop 22 | .bsp 23 | 24 | # VS Code 25 | .vscode/ 26 | 27 | # Metals 28 | .bloop/ 29 | .metals/ 30 | metals.sbt 31 | 32 | # IDEA 33 | .idea 34 | .idea_modules 35 | /.worksheet/ 36 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.8.3" 2 | runner.dialect = scala3 3 | 4 | rewrite.scala3.convertToNewSyntax = true 5 | 6 | maxColumn = 100 7 | align.preset = more 8 | rewrite.rules = [Imports, SortImports, RedundantBraces, RedundantParens, PreferCurlyFors] 9 | trailingCommas = multiple 10 | assumeStandardLibraryStripMargin = true 11 | align.stripMargin = true 12 | rewrite.imports.expand = false 13 | rewrite.imports.sort = scalastyle 14 | 15 | project.excludePaths = ["glob:**/metals.sbt"] 16 | project.includePaths = [ 17 | "glob:**/*.scala", 18 | "glob:**/*.sbt", 19 | "glob:**/*.sc" 20 | ] -------------------------------------------------------------------------------- /slides/snippets/io.scala: -------------------------------------------------------------------------------- 1 | //> using dep io.getkyo::kyo-core:0.13.2 2 | 3 | import kyo.* 4 | 5 | case class Person(name: String, age: Int) 6 | class SQL[A](sql: String) extends AnyVal 7 | 8 | extension (sc: StringContext) 9 | def sql[A](args: Any*): SQL[A] = new SQL(sc.s(args*)) 10 | 11 | object DB: 12 | private val local = Local.init(()) 13 | def query[A](sql: SQL[A]): Chunk[A] < Sync = local.get.map(_ => Sync.defer(println(s"querying $sql"))).as(Chunk.empty) 14 | 15 | object MyApp extends KyoApp: 16 | val x: Chunk[Person] < Any = 17 | import AllowUnsafe.embrace.danger 18 | IO.Unsafe.run(DB.query(sql"select * from person limit 5")) 19 | run {42} -------------------------------------------------------------------------------- /slides/snippets/abort.scala: -------------------------------------------------------------------------------- 1 | //> using dep io.getkyo::kyo-core:0.13.2 2 | 3 | import kyo.* 4 | 5 | case class User(email: String) 6 | object User extends KyoApp: 7 | import UserError._ 8 | enum UserError: 9 | case InvalidEmail 10 | case AlreadyExists 11 | 12 | def from(email: String): User < Abort[UserError] = 13 | if !email.contains('@') then Abort.fail(InvalidEmail) 14 | else User(email) 15 | 16 | val x: Unit < IO = 17 | Abort 18 | .run(from("adam@veak.co")) 19 | .map: 20 | case Result.Success(user) => Console.println(s"Success! $user") 21 | case Result.Fail(InvalidEmail) => Console.println("Bad email!") 22 | -------------------------------------------------------------------------------- /src/main/scala/org/adamhearn/app/Main.scala: -------------------------------------------------------------------------------- 1 | package org.adamhearn.app 2 | 3 | import kyo.* 4 | import sttp.tapir.* 5 | import sttp.tapir.server.netty.* 6 | 7 | object Main extends KyoApp: 8 | val app = 9 | for { 10 | port <- System.property[Int]("PORT", 80) 11 | options = NettyKyoServerOptions 12 | .default(enableLogging = false) 13 | .forkExecution(false) 14 | config = NettyConfig.default.withSocketKeepAlive 15 | .copy(lingerTimeout = None) 16 | server = NettyKyoServer(options, config) 17 | .host("0.0.0.0") 18 | .port(port) 19 | _ <- Console.printLine(s"Starting... 0.0.0.0:$port") 20 | _ <- Routes 21 | .add( 22 | _.get 23 | .in("echo" / path[String]) 24 | .out(stringBody) 25 | )(s => s) 26 | } yield () 27 | 28 | run(Routes.run(app)) 29 | -------------------------------------------------------------------------------- /slides/snippets/env.scala: -------------------------------------------------------------------------------- 1 | //> using dep io.getkyo::kyo-core:0.13.2 2 | 3 | import kyo.* 4 | 5 | case class Coordinates(latitude: Double, longitude: Double) 6 | case class Reading() 7 | abstract class Sensor: 8 | def read: Reading < Sync = Sync.defer(Reading()) 9 | object Sensor extends Sensor 10 | 11 | abstract class Drone: 12 | def fly(coordinates: Coordinates): Unit < Sync = Sync.defer(println(s"$coordinates")) 13 | object Drone extends Drone 14 | 15 | abstract class Weather: 16 | def record(coordinates: Coordinates): Reading < Sync 17 | 18 | object Weather extends KyoApp: 19 | val live: Weather < (Env[Drone] & Env[Sensor]) = 20 | for 21 | drone <- Env.get[Drone] 22 | sensor <- Env.get[Sensor] 23 | yield new Weather: 24 | def record(coordinates: Coordinates): Reading < Sync = 25 | drone.fly(coordinates).andThen(sensor.read) 26 | 27 | run: 28 | Env.runTypeMap(TypeMap(Drone, Sensor))(live).map(_.record(Coordinates(0, 0))) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scala.io 2024: Kyo Workshop 2 | 3 | ## Setup 4 | 1. Install SBT (Simple Build Tool). Instructions can be found [here](https://www.scala-sbt.org/download/). 5 | 2. Clone the repo and cd into the directory 6 | 3. Open any IDE, though I recommend VS Code + Metals: 7 | * [VS Code](https://code.visualstudio.com/) 8 | * [Scala Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=scala-lang.scala) 9 | * [Metals](https://marketplace.visualstudio.com/items?itemName=scalameta.metals) 10 | 11 | ```bash 12 | git clone git@github.com:hearnadam/kyo-workshop.git && 13 | cd kyo-workshop 14 | ``` 15 | 16 | ## Workshop Flow 17 | 18 | The workshop is designed to be completed in order of the files, 00 -> 03. 19 | Each file has a set of test suites, with a few exercises interspersed. 20 | 21 | After completing a test, you can remove the `@@ ignore` to ensure the test is run. To run the tests, start an SBT shell with `sbt`, then run `run` in the shell. It will prompt you for which Main class to run. 22 | 23 | ## Solutions 24 | 25 | Solutions can be found in the `solutions` branch of this repo. It will be kept up to date as exercises are added/removed. 26 | 27 | ## Contributions 28 | 29 | If you find any issues, bugs, or have any suggestions, please open an issue or submit a PR! -------------------------------------------------------------------------------- /src/main/scala/org/adamhearn/03-Streams.scala: -------------------------------------------------------------------------------- 1 | package org.adamhearn 2 | 3 | import kyo.* 4 | import kyo.Result.* 5 | import kyo.test.KyoSpecDefault 6 | import zio.test.assertTrue 7 | import zio.test.TestAspect.ignore 8 | import zio.test.TestResult 9 | 10 | /** Kyo Streams provide a powerful mechanism for processing sequences of data in a memory-conscious 11 | * and composable manner. 12 | * 13 | * They offer a rich set of operations for transforming, filtering, and combining streams of data, 14 | * all while maintaining laziness and ensuring stack safety. 15 | */ 16 | object `03_Streams` extends KyoSpecDefault { 17 | def spec = suite("Streams")( 18 | test("init/take/drop") { 19 | 20 | /** Exercise: Basic Stream Operations 21 | * 22 | * - Initialize a Stream from a given Seq 23 | * - Take the first 5 elements, then run + eval to a Chunk 24 | * - Drop the first 5 elements, then run + eval to a Chunk 25 | */ 26 | val seq: Seq[Int] = 1 to 10 27 | lazy val stream: Stream[Int, Any] = Stream.init(seq) 28 | lazy val first5: Stream[Int, Any] = ??? 29 | lazy val last5: Stream[Int, Any] = ??? 30 | assertTrue(first5.run.eval == Chunk(1, 2, 3, 4, 5)) && 31 | assertTrue(last5.run.eval == Chunk(6, 7, 8, 9, 10)) 32 | } @@ ignore, 33 | test("effectful") { 34 | 35 | /** Exercise: Effectful Streams 36 | * 37 | * - Initialize a Stream from a given Seq 38 | * - use `Stream#map` to update a `Var[Int]`, then return the original element 39 | * - Run the Stream to a Chunk, then run the Pending effects to get the final sum & Chunk. 40 | */ 41 | val stream: Stream[Int, Any] = Stream.init(0 until 1000 by 100) 42 | lazy val summed: Stream[Int, Var[Int]] = ??? 43 | lazy val effect: Chunk[Int] < Var[Int] = ??? 44 | lazy val (sum, chunk) = Var.runTuple(0)(effect).eval 45 | assertTrue(sum == 4500) && 46 | assertTrue(chunk == Chunk(0, 100, 200, 300, 400, 500, 600, 700, 800, 900)) 47 | } @@ ignore, 48 | test("run*") { 49 | import AllowUnsafe.embrace.danger // for running IO unsafely 50 | 51 | /** Exercise: run* 52 | * 53 | * - Stream includes several run related methods for different use cases 54 | * - `run` & `runFold`, generally useful for collecting the result of the Stream 55 | * - `runDiscard` is useful for effectfully processing the Stream without retaining the 56 | * result 57 | * - `runForEach` is useful for peaking at each element of the Stream. 58 | */ 59 | val stream = Stream.init(1 to 10) 60 | 61 | // run: collect a Chunk of the results 62 | lazy val run: Chunk[Int] < Any = ??? 63 | 64 | // runFold: collect a List of the results, reversing the order of the values. 65 | lazy val runFold: List[Int] < Any = ??? 66 | 67 | // runDiscard: run the Stream, not for the Result but for the Effects 68 | // Update a `Var`, summing the values of the Stream 69 | lazy val runDiscard: Unit < Var[Int] = ??? 70 | 71 | // runForeach: run the Stream, applying the effect to each element 72 | // Use `Console.print` to print each element 73 | lazy val runForeach: Unit < (Abort[java.io.IOException] & Sync) = ??? 74 | 75 | assertTrue(run.eval == Chunk(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) && 76 | assertTrue(runFold.eval == List(10, 9, 8, 7, 6, 5, 4, 3, 2, 1)) && 77 | assertTrue(Var.runTuple(0)(runDiscard).eval == (55, ())) && 78 | assertTrue(Abort.run(Sync.Unsafe.run(runForeach)).eval == Result.unit) 79 | } @@ ignore, 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/main/scala/org/adamhearn/02-Concurrency.scala: -------------------------------------------------------------------------------- 1 | package org.adamhearn 2 | 3 | import kyo.* 4 | import kyo.Result.* 5 | import kyo.test.KyoSpecDefault 6 | import zio.test.assertTrue 7 | import zio.test.TestAspect.ignore 8 | import zio.test.TestResult 9 | 10 | /** Async effect allows for the asynchronous execution of computations via a managed thread pool. 11 | * 12 | * The core function, `Async.run`, forks a new 'green thread', known as a fiber, to execute the 13 | * computation. 14 | */ 15 | object `02_Async` extends KyoSpecDefault { 16 | def spec = suite("Async")( 17 | test("delay") { 18 | 19 | /** Exercise: Async.delay 20 | * 21 | * Async.delay executes a computation after waiting for the specified duration. This is 22 | * useful for testing timeouts and simulating long operations. 23 | * 24 | * Implement computation to: 25 | * - Take an Int input 26 | * - Delay for 50.millis 27 | * - Return the input multiplied by 2 28 | */ 29 | def computation(i: Int): Int < Async = ??? 30 | 31 | computation(21).map(result => assertTrue(result == 42)) 32 | } @@ ignore, 33 | test("parallel") { 34 | 35 | /** Exercise: Async.zip 36 | * 37 | * Async.zip executes computations in parallel and collects their results into a tuple. 38 | * If any computation fails, the error is propagated and remaining computations are 39 | * interrupted. 40 | * 41 | * Implement computation to: 42 | * - Take an Int input and return it doubled after a delay 43 | * - Fail with Abort if the input is negative 44 | * - Use a 100.millis delay 45 | */ 46 | def computation(i: Int): Int < (Async & Abort[String]) = ??? 47 | 48 | lazy val parallel: (Int, Int, Int) < (Abort[String] & Async) = ??? 49 | 50 | Abort.run(parallel).map(result => assertTrue(result == Success((2, 4, 6)))) 51 | } @@ ignore, 52 | test("race") { 53 | 54 | /** Exercise: Async.race 55 | * 56 | * Async.race evaluates multiple computations concurrently and returns the first successful 57 | * result. When one computation succeeds, all others are interrupted. 58 | * 59 | * Implement computation to: 60 | * - Delay proportionally to the input (50.millis * i) 61 | * - Fail with `Abort` if i is even 62 | * - Return i if odd 63 | */ 64 | def computation(i: Int): Int < (Async & Abort[String]) = ??? 65 | 66 | lazy val race: Int < (Abort[String] & Async) = ??? 67 | 68 | // note: `kyo-test` currently doesn't support `Abort[String]`, so we handle it directly 69 | Abort.run(race).map(result => assertTrue(result == Success(3))) 70 | } @@ ignore, 71 | test("timeout") { 72 | 73 | /** Exercise: Async.timeout 74 | * 75 | * Async.timeout interrupts a computation after a specified duration with a Timeout error. 76 | * The error gets added to the Abort effect's error type. 77 | * 78 | * Create a computation that: 79 | * - Takes longer than the timeout (use Async.delay with 100.millis) 80 | * - Has a timeout of 50 milliseconds 81 | * - Should result in a Timeout error 82 | */ 83 | lazy val computation: Int < (Abort[Timeout] & Async) = ??? 84 | 85 | Abort.run(computation).map(result => assertTrue(result.isFailure)) 86 | } @@ ignore, 87 | test("run") { 88 | 89 | /** Exercise: Fiber.init 90 | * 91 | * Fiber.init forks a computation on a separate fiber, allowing for concurrent execution. The 92 | * returned Fiber can be used to monitor or control the computation. 93 | * 94 | * Note: Fiber[E, A] is parameterized by error type E and success type A. The E type 95 | * parameter corresponds to the error type in Abort[E] in the forked computation. 96 | * 97 | * Implement work to: 98 | * - Take an Int input 99 | * - Delay for 50 milliseconds 100 | * - Fail with "negative not allowed" if input is negative 101 | * - Return input * 2 if positive 102 | * - Fork the computation with Async.run 103 | */ 104 | def computation(i: Int): Fiber[Int, Abort[String]] < Sync = ??? 105 | 106 | // `fiber#get` awaits the result, translating the `Fiber` error channel back to `Abort ` 107 | // Note how Fiber's `E` channel is translated to/from Abort. 108 | lazy val async = computation(21).map(_.get) 109 | Abort 110 | .run(async) 111 | .map(result => assertTrue(result == Success(42))) 112 | } @@ ignore, 113 | test("interruption") { 114 | 115 | /** Exercise: Fiber Interruption 116 | * 117 | * Running fibers can be interrupted, causing them to complete with a panic. Interruption is 118 | * useful for cancelling long-running operations. 119 | * 120 | * Complete the program to: 121 | * - Start a fiber that delays for 100ms returning 42 122 | * - Interrupt it immediatelly 123 | * - Return the result via fiber.get 124 | */ 125 | lazy val computation: Int < Async = ??? 126 | 127 | Abort.run(computation).map(result => assertTrue(result.isPanic)) 128 | } @@ ignore, 129 | ) 130 | } 131 | 132 | object `02_Structures` extends KyoSpecDefault { 133 | def spec = 134 | suite("structures")( 135 | suite("Atomic")( 136 | test("Ref") { 137 | 138 | /** Exercise: AtomicRef 139 | * 140 | * AtomicRef provides thread-safe mutable references. Operations on AtomicRef are atomic, 141 | * preventing race conditions. 142 | * 143 | * Create a program that: 144 | * - Initializes an AtomicRef with 0 145 | * - Updates it by adding 1 146 | * - Returns the new value 147 | */ 148 | lazy val computation: Int < Sync = ??? 149 | 150 | computation.map(v => assertTrue(v == 1)) 151 | } @@ ignore 152 | ), 153 | suite("Queue")( 154 | test("bounded") { 155 | 156 | /** Exercise: Bounded Queue 157 | * 158 | * A bounded queue has a maximum capacity. Once full, offers will fail. This provides 159 | * natural backpressure for producer-consumer scenarios. 160 | * 161 | * Create a program that demonstrates: 162 | * - Creating a queue with capacity 2 163 | * - Filling it to capacity 164 | * - Checking it's full 165 | * - Taking an element 166 | * - Verifying it's no longer full 167 | */ 168 | lazy val computation: (Boolean, Maybe[Int], Boolean) < (Sync & Abort[Closed]) = ??? 169 | 170 | computation.map((full, first, afterPoll) => 171 | assertTrue( 172 | full && 173 | first == Maybe(1) && 174 | !afterPoll 175 | ) 176 | ) 177 | } @@ ignore 178 | ), 179 | ) 180 | } 181 | -------------------------------------------------------------------------------- /src/main/scala/org/adamhearn/01-Effects.scala: -------------------------------------------------------------------------------- 1 | package org.adamhearn 2 | 3 | import kyo.* 4 | import kyo.Result.* 5 | import kyo.test.KyoSpecDefault 6 | import zio.test.assertTrue 7 | import zio.test.assertCompletes 8 | import zio.test.TestAspect.ignore 9 | import zio.test.TestResult 10 | import java.io.IOException 11 | 12 | /** kyo-prelude & kyo-core contain Algebraic/Functional Effects. 13 | * 14 | * These exercises are intented to give a strong intuition about effects. Since effects can be 15 | * implemented outside the core library (in your codebase), a comprehensive description of all 16 | * effects is not possible. For that reason, this section is focused on intuition and examples. 17 | */ 18 | object `01_Composition` extends KyoSpecDefault { 19 | def spec = 20 | suite("Composition")( 21 | test("branching") { 22 | 23 | /** Exercise: Branching 24 | * 25 | * Kyo enables effectful branching via implicit widening. Pure values can be widened to Kyo 26 | * Computations. 27 | * 28 | * Write a function `even`: 29 | * - If the input is even, returns the input. 30 | * - If the input is odd, aborts with "odd". 31 | */ 32 | def even(i: Int): Int < Abort[String] = ??? 33 | 34 | for 35 | e <- Abort.run(even(42)) 36 | o <- Abort.run(even(43)) 37 | yield assertTrue(e == Result.succeed(42)) && 38 | assertTrue(o == Result.fail("odd")) 39 | } @@ ignore, 40 | test("multiple") { 41 | 42 | /** Exercise: multiple effects 43 | * 44 | * Try combining multiple effects in a `for`. 45 | * 46 | * What is the combined type? Can the test run this effect? 47 | */ 48 | val aborting: Int < Abort[Throwable] = 42 49 | val sideEffecting: Unit < Sync = Sync.defer(println("hello")) 50 | val mutating: Int < Var[Int] = Var.update((i: Int) => i + 42) 51 | val result: TestResult = assertCompletes 52 | 53 | lazy val combined: TestResult < ??? = ??? 54 | // note: `kyo-test` cannot run all effects, specifically those that require input (Var, Env, etc). 55 | // To get the test to compile, we can manually 'run' Var[Int]: 56 | lazy val withoutVar = Var.run(0)(combined) 57 | withoutVar 58 | } @@ ignore, 59 | ) 60 | } 61 | 62 | object `01_Effects` extends KyoSpecDefault { 63 | def spec = 64 | suite("effects")( 65 | test("Abort") { 66 | 67 | /** Exercise: Abort 68 | * 69 | * Abort is short circuiting effect that can fail with a value of type `E`. 70 | * 71 | * Effects generally include a `run` method that will handle that effect, returning a new 72 | * Kyo computation. When all effects are handled, the final result is `A < Any`. `.eval` 73 | * will convert a `A < Any` to `A`, evaluating any remaining suspensions. 74 | */ 75 | case class Fatal() extends Exception 76 | lazy val fail: Int < Abort[String] = ??? 77 | lazy val panic: Int < Abort[String] = ??? 78 | 79 | assertTrue(Abort.run(fail).eval == Result.fail("fail")) && 80 | assertTrue(Abort.run(panic).eval == Result.panic(Fatal())) 81 | } @@ ignore, 82 | test("Env") { 83 | 84 | /** Exercise: Env 85 | * 86 | * Env is an effect that allows dependency injection of values. It can inject single values 87 | * or multiple values using a typemap. 88 | * 89 | * Try providing a single value, then multiple with a TypeMap. What happens if you don't 90 | * provide all the dependencies? 91 | */ 92 | val single: Int < Env[Int] = Env.get[Int] 93 | val multiple: String < (Env[Boolean] & Env[String]) = 94 | for 95 | b <- Env.get[Boolean] 96 | s <- Env.get[String] 97 | yield s"$b $s" 98 | 99 | lazy val singleProvided: Int < Any = ??? 100 | lazy val multipleProvided: String < Any = ??? 101 | 102 | for 103 | a <- singleProvided 104 | b <- multipleProvided 105 | yield assertTrue(a == 24) && assertTrue(b == "true hello") 106 | } @@ ignore, 107 | test("Var") { 108 | 109 | /** Exercise: Var 110 | * 111 | * Var enables maintaining updatable state without mutation. Var will maintain state 112 | * throughout the computation. 113 | * 114 | * Use var to write a recursive method to compute the nth fibonacci number. 115 | */ 116 | def fib(n: Int): Long < Var[Chunk[Long]] = ??? 117 | 118 | lazy val fifty: Long = Var.run(Chunk(0L, 1L))(fib(50)).eval 119 | assertTrue(fifty == 12586269025L) 120 | } @@ ignore, 121 | test("Resource") { 122 | 123 | /** Exercise: Scope 124 | * 125 | * Scope is an effect that manages a resource. It can be used to manage lifetime of 126 | * resourceful values like Files. 127 | * 128 | * Scope is acquired when Scope.acquireRelease is called and released when the computation 129 | * completes. This allows for flexible control over resource lifetimes. 130 | */ 131 | 132 | // used to peak at files after they are closed. 133 | val files = AtomicRef.Unsafe.init(Chunk.empty[File])(using AllowUnsafe.embrace.danger).safe 134 | 135 | case class File(path: String, closed: AtomicBoolean, reads: AtomicInt): 136 | def read: String < (Sync & Abort[IOException]) = 137 | for 138 | c <- closed.get 139 | _ <- Abort.when(c)(new IOException("File already closed")) 140 | r <- reads.incrementAndGet 141 | yield s"$path read $r times" 142 | def close: Unit < Sync = Sync.defer(closed.set(true)) 143 | def state: (String, Boolean, Int) < Sync = 144 | for 145 | c <- closed.get 146 | r <- reads.get 147 | yield (path, c, r) 148 | 149 | object File: 150 | def open(path: String): File < Sync = 151 | for 152 | closed <- AtomicBoolean.init(false) 153 | reads <- AtomicInt.init 154 | file = File(path, closed, reads) 155 | _ <- files.updateAndGet(_.append(file)) // for testing 156 | yield file 157 | 158 | // define a function to open a file, acquiring and releasing a resource. 159 | def open(path: String): File < (Sync & Scope) = 160 | Scope.acquireRelease(File.open(path))(_.close) 161 | 162 | // Open 2 files: 163 | // `first`, open a file named 'one', then invoke 'read' wrapping full expression in `Scope.run`. 164 | // `second`, open file named 'two' wrapped in `Scope.run`, then invoke 'read' 165 | 166 | lazy val one = ??? 167 | lazy val two = ??? 168 | 169 | for 170 | o <- Abort.run(one) 171 | t <- Abort.run(two) 172 | fs <- files.get.map(chunk => Kyo.foreach(chunk)(_.state)) 173 | yield assertTrue(fs == Chunk(("one", true, 1), ("two", true, 0))) && 174 | assertTrue(o.isSuccess) && 175 | assertTrue(t.isFailure) 176 | } @@ ignore, 177 | test("Emit") { 178 | 179 | /** Exercise: Emit 180 | * 181 | * Emit is an effect that is used to accumulate values. It's useful to maintain a record of 182 | * the computation you create. 183 | * 184 | * Write a function to emit `n` numbers, doubling each number and looping until `n` is less 185 | * than or equal to 0. 186 | */ 187 | def emitN(n: Int): Unit < Emit[Int] = ??? 188 | 189 | Emit 190 | .run(emitN(10)) 191 | .map: 192 | case (chunk, _) => 193 | assertTrue(chunk == Chunk(20, 18, 16, 14, 12, 10, 8, 6, 4, 2)) 194 | } @@ ignore, 195 | ) 196 | } 197 | -------------------------------------------------------------------------------- /slides/talk.md: -------------------------------------------------------------------------------- 1 | build-lists: true 2 | slidenumbers: true 3 | autoscale: false 4 | 5 | 6 | 7 | # [fit] Introduction to Kyo 8 | ## Adam Hearn 9 | 10 | 18 | 19 | --- 20 | 21 | # What is Kyo? 22 | 23 | - Kyo is a powerful toolkit for developing with Scala 24 | - Built from a series of standalone modules: 25 | - `kyo-data`: Low allocation, performant structures 26 | - `kyo-prelude`: Side-effect free **Algebraic effects** 27 | - `kyo-core`: Effects for IO, Async, & Concurrency 28 | - `kyo-scheduler`: high performance adaptive scheduler 29 | - `kyo-scheduler-zio`: boost your ZIO App! 30 | - `kyo-zio` & `kyo-cats` (& soon `kyo-monix`) 31 | 32 | --- 33 | # What are Algebraic Effects? 34 | 35 | ^ Before we dive into what Algebraic effects, let's talk about Effects in general. 36 | 37 | --- 38 | # What are ~~Algebraic~~ Effects? 39 | 40 | * Effects 41 | * Descriptions of what you want 42 | * Produce what you want **when run** 43 | * Programs as values! 44 | * Effects are backed by **suspension** 45 | * Suspension defers a computation until later 46 | * Separation of execution from definition: 47 | * Flexibility in execution (Retry, Delay, Interrupt) 48 | * Delayed implementation (`Clock.live` vs `Clock.withTimeControl`) 49 | 50 | ^ Effects are probably the most overloaded term in FP 51 | ^ Most people end up describing Side-Effects, not functional effects 52 | 53 | --- 54 | 62 | # What are Algebraic Effects? 63 | 64 | 65 | * Extensible & Composable Effects! 66 | * Fine-grained control over effect handling 67 | * Trivial combination of various abilities 68 | * Separation of effect declaration and implementation 69 | * User defined effects! 70 | * Handlers: Define how effects are interpreted 71 | 72 | 73 | 74 | ^ Often you may see languages refer to specific Effects as Abilities 75 | ^ Since most of Kyo doesn't do that, I won't but it's a useful mental model. 76 | 77 | --- 78 | # Why use Algebraic Effects? 79 | 80 | --- 81 | 82 | # Why use Kyo? 83 | 84 | - Includes flexible algebraic effects in **Scala** 85 | - Designed for simplicity and performance 86 | - Effect handling is not restricted to core effects 87 | 88 | --- 89 | # Kyo Syntax 90 | 91 | ```scala 92 | val _: String < Sync = Sync.defer("Hello scala.io!") 93 | ``` 94 | 95 | * Infix 'Pending' Type: `Result < Effects` 96 | * `String < Sync` 97 | * Effects are represented as unordered set: 98 | * `File < (Sync & Resource)`' 99 | 100 | --- 101 | 102 | #[fit] Effects! 103 | 104 | --- 105 | ### IO: Side-Effect Suspension 106 | 107 | ```scala 108 | object DB: 109 | def query[A](sql: SQL[A]): Chunk[A] < IO = ??? 110 | 111 | object Query: 112 | val _: Chunk[Person] < Any = 113 | import AllowUnsafe.embrace.danger 114 | IO.Unsafe.run(DB.query(sql"select * from person limit 5")) 115 | ``` 116 | 117 | - `IO` are handled individually (`IO.Unsafe.run`) 118 | - Unsafe APIs require an `AllowUnsafe` evidence 119 | 120 | ^ The above expression is not fully evaluated and may be pending further suspensions. 121 | ^ You might note the `< Any` remaining. 122 | ^ These are any pending suspensions which are not yet handled or labeled. 123 | ^ Suspensions can be introduced internally by Kyo, or usages of APIs which introduce suspensions, eg Local. 124 | 125 | --- 126 | ## Abort: Short Circuit 127 | ```scala 128 | case class User(email: String) 129 | object User extends KyoApp: 130 | import UserError._ 131 | enum UserError: 132 | case InvalidEmail 133 | case AlreadyExists 134 | 135 | def from(email: String): User < Abort[UserError] = 136 | if !email.contains('@') then Abort.fail(InvalidEmail) 137 | else User(email) 138 | 139 | val x: Unit < IO = 140 | Abort 141 | .run(from("adam@veak.co")) 142 | .map: 143 | case Result.Success(user) => Console.println(s"Success! $user") 144 | case Result.Fail(InvalidEmail) => Console.println("Bad email!") 145 | ``` 146 | 147 | ^ Abort enables ZIO style short circuiting 148 | 149 | --- 150 | ## Env: Dependency Injection 151 | 152 | ```scala 153 | abstract class Weather: 154 | def record(coordinates: Coordinates): Reading < IO 155 | 156 | object Weather: 157 | val live: Weather < (Env[Drone] & Env[Sensor]) = 158 | for 159 | drone <- Env.get[Drone] 160 | sensor <- Env.get[Sensor] 161 | yield new Weather: 162 | def record(coordinates: Coordinates): Reading < IO = 163 | drone.fly(coordinates).andThen(sensor.read) 164 | ``` 165 | 166 | --- 167 | ## Kyo: Effect Widening 168 | 169 | ```scala 170 | val a: String < IO = "Hello" 171 | val b: String < (IO & Abort[Exception]) = a 172 | val c: String < (IO & Abort[Exception] & Resource) = b 173 | ``` 174 | 175 | - Computations can be widened to include more effects 176 | - Allows for flexible and composable code 177 | - Plain values can be widened to Kyo computation 178 | - Widened values are not suspended 179 | - Widened values do not allocate [^1] 180 | 181 | [^1]: Primitives widened to Kyo will box as Scala 3 does not support proper specialization 182 | 183 | --- 184 | # Kyo: Unnested encoding 185 | 186 | ```scala 187 | object Write: 188 | def apply[S](v: String < S): Unit < (S & IO) = 189 | v.map(Buffer.write(_, "output.txt")) 190 | 191 | object MyApp: 192 | val value: Unit < IO = Write("Hello, World!") 193 | val effect: Unit < (IO & Abort[IOException]) = Write(Console.readLine) 194 | val mapped: Unit < (IO & Abort[IOException]) = value.map(_ => effect) 195 | ``` 196 | 197 | * Widening pure values as effects enables fluent composition. 198 | * Functions can be defined to accept effects, and values can be passed in. 199 | * `F.pure`/`ZIO.succeed` no more! 200 | 201 | --- 202 | # Kyo: Unnested encoding 203 | 204 | ```scala 205 | inline def flatMap[B, S2](inline f: Safepoint ?=> A => B < S2): B < (S & S2) = 206 | map(v => f(v)) 207 | ``` 208 | - Effects can be easily combined using `map`... no need for `flatMap` 209 | - Resulting type includes all unique pending effects 210 | 211 | --- 212 | 213 | # Kyo: Effect Handling 214 | 215 | ```scala 216 | val a: Int < Abort[Exception] = 42 217 | val b: Result[Exception, Int] < Any = Abort.run(a) 218 | val c: Result[Exception, Int] = b.eval 219 | ``` 220 | 221 | * Effects are handled explicitly 222 | * Order of handling can affect the result type and value 223 | 224 | --- 225 | 226 | # Direct Syntax in Kyo 227 | 228 | ```scala 229 | val a: String < (Abort[Exception] & IO) = 230 | defer { 231 | val b: String = await(Sync.defer("hello")) 232 | val c: String = await(Abort.get(Right("world"))) 233 | b + " " + c 234 | } 235 | ``` 236 | 237 | * `defer` and `await` provide a more intuitive syntax 238 | 239 | --- 240 | 241 | ## KyoApp: Running your App 242 | 243 | ```scala 244 | object Main extends KyoApp: 245 | def app = defer: 246 | val port = await(System.property[Int]("PORT", 80)) 247 | val options = NettyKyoServerOptions 248 | .default(enableLogging = false) 249 | .forkExecution(false) 250 | val config = 251 | NettyConfig.default.withSocketKeepAlive 252 | .copy(lingerTimeout = None) 253 | 254 | val server = 255 | NettyKyoServer(options, config) 256 | .host("0.0.0.0") 257 | .port(port) 258 | await(Console.println(s"Starting... 0.0.0.0:$port")) 259 | await(Routes.run(server): 260 | Routes.add( 261 | _.get 262 | .in("echo" / path[String]) 263 | .out(stringBody) 264 | )(input => input) 265 | ) 266 | 267 | run(app) 268 | ``` 269 | 270 | ^ KyoApp is a simple way to run your application. 271 | ^ It will automatically handle all effects and suspend any remaining effects. 272 | 273 | --- 274 | 275 | # Conclusion 276 | 277 | - Kyo provides a powerful yet simple way to work with algebraic effects 278 | - Offers composability, type safety, and performance 279 | - Enables cleaner, more modular functional programming in Scala 280 | 281 | --- 282 | 283 | # Questions? 284 | 285 | 297 | 298 | 299 | 300 | --- 301 | 313 | -------------------------------------------------------------------------------- /src/main/scala/org/adamhearn/00-Data.scala: -------------------------------------------------------------------------------- 1 | package org.adamhearn 2 | 3 | import kyo.{Absent, Maybe, Present} 4 | import kyo.Result 5 | import kyo.TypeMap 6 | import kyo.Chunk 7 | import kyo.test.KyoSpecDefault 8 | import zio.test.assertTrue 9 | import zio.test.Spec 10 | import zio.test.TestAspect.ignore 11 | import scala.annotation.tailrec 12 | import zio.internal.ansi.Color.Red 13 | 14 | /** kyo-data provides optimized collections for common data types. 15 | * 16 | * In these exercises, we'll explore Maybe, Result, Chunk, and TypeMap. These are an important 17 | * foundation, as many of Kyo's APIs use these structures to improve performance. 18 | */ 19 | 20 | object `00_Maybe` extends KyoSpecDefault: 21 | def spec = 22 | suite("Maybe[A]")( 23 | test("nested") { 24 | 25 | /** Exercise: Deep Pattern Matching 26 | * 27 | * Goal: Extract a deeply nested value using pattern matching Learning: Nested `Maybe` 28 | * values allocate at maximum 1 object. 29 | */ 30 | extension [A](self: Maybe[Maybe[Maybe[Maybe[A]]]]) def superFlat: Maybe[A] = ??? 31 | 32 | val present = Maybe(Maybe(Maybe(Maybe("real")))) 33 | val absent = Maybe(Maybe(Maybe(Absent))) 34 | 35 | assertTrue(present.superFlat == Present("real")) && 36 | assertTrue(absent.superFlat == Absent) 37 | } @@ ignore, 38 | test("list") { 39 | 40 | /** Exercise: List[Maybe[A]] -> Maybe[List[A]] 41 | * 42 | * Goal: Implement a conversion from List[Maybe[A]] to Maybe[List[A]] Rules: 43 | * - If ANY element is Absent, return Absent 44 | * - If ALL elements are Present, return Present containing the list of values This 45 | * demonstrates Maybe's strict handling of absence vs Option's propagation 46 | * 47 | * Hint: use tail recursion 48 | */ 49 | extension [A](list: List[Maybe[A]]) def sequence: Maybe[List[A]] = ??? 50 | 51 | val mixed = List(Present(1), Absent, Present(2)) 52 | val present = List(1, 2, 3, 4, 5).map(Present(_)) 53 | val empty = List.empty[Maybe[Int]] 54 | 55 | assertTrue(mixed.sequence == Absent) && 56 | assertTrue(present.sequence == Present(List(1, 2, 3, 4, 5))) && 57 | assertTrue(empty.sequence == Present(Nil)) 58 | } @@ ignore, 59 | ) 60 | 61 | object `00_Result` extends KyoSpecDefault: 62 | def spec = 63 | suite("Result[E, A]")( 64 | test("catching") { 65 | 66 | /** Exercise: Result.catching offers a typesafe way to handle exceptions. 67 | * 68 | * It will catch all exceptions that are subtypes of the type parameter. If the exception 69 | * is not a subtype, it will be untracked (Panic). 70 | */ 71 | case class InvalidRequest(message: String) extends Throwable 72 | case class SQLException() extends Throwable 73 | 74 | def impureLogic(request: String): Int = 75 | if request == "bad" then throw InvalidRequest(request) 76 | else throw SQLException() 77 | 78 | lazy val fail: Result[InvalidRequest, Int] = ??? 79 | lazy val panic: Result[InvalidRequest, Int] = ??? 80 | 81 | assertTrue(fail == Result.fail(InvalidRequest("bad"))) && 82 | assertTrue(panic == Result.panic(SQLException())) 83 | } @@ ignore, 84 | test("panic vs fail") { 85 | case class TrackedError() 86 | case class UntrackedError() extends Throwable 87 | 88 | /** Exercise 2: lift untracked errors to tracked errors 89 | * 90 | * - `Fail` is a `Result[E, Nothing]`, and contains an error `E`. 91 | * - `Panic` is a `Result[Nothing, Nothing]`, but contains a Throwable. 92 | * 93 | * Implement `resurrect` to convert a `Panic` to a `Fail`. 94 | */ 95 | extension [E, A](self: Result[E, A]) def resurrect: Result[E | Throwable, A] = ??? 96 | 97 | lazy val fail: Result[TrackedError, Nothing] = Result.fail(TrackedError()) 98 | lazy val panic: Result[Nothing, Nothing] = Result.panic(UntrackedError()) 99 | lazy val success: Result[Nothing, Int] = Result.succeed(42) 100 | 101 | assertTrue(fail.resurrect.isFailure) && 102 | assertTrue(panic.resurrect.isFailure) && 103 | assertTrue(success.resurrect.isSuccess) 104 | } @@ ignore, 105 | test("error handling") { 106 | 107 | /** Exercise: Handling Errors with Result 108 | * 109 | * Goal: Demonstrate Result's ability to handle multiple error types 110 | */ 111 | import ValidationError.* 112 | enum ValidationError: 113 | case EmptyInput 114 | case InvalidFormat 115 | 116 | import ProcessingError.* 117 | enum ProcessingError: 118 | case CreditCardDecline 119 | case Mismatch(input: String, expected: String) 120 | 121 | def validate(input: String): Result[ValidationError, Int] = 122 | if input.isEmpty then Result.fail(EmptyInput) 123 | else 124 | input.toIntOption match 125 | case Some(id) => Result.succeed(id) 126 | case None => Result.fail(InvalidFormat) 127 | 128 | // If the user ID is `42`, succeed with "Approved" 129 | // If the user ID is `1`, fail with CreditCardDecline 130 | // Otherwise, fail with a Mismatch error 131 | def charge(id: Int): Result[ProcessingError, String] = ??? 132 | 133 | def process(input: String): Result[ValidationError | ProcessingError, String] = 134 | validate(input).flatMap(charge) 135 | 136 | // use pattern matching to convert the result to a string 137 | def handle(result: Result[ValidationError | ProcessingError, String]): String = ??? 138 | assertTrue(handle(process("42")) == "Approved") && 139 | assertTrue(handle(process("1")) == "Transaction Declined") && 140 | assertTrue(handle(process("-1")) == "Mismatch: -1 <> 42") 141 | } @@ ignore, 142 | ) 143 | 144 | object `00_Chunk` extends KyoSpecDefault: 145 | def spec = 146 | suite("Chunk[A]")( 147 | test("apply") { 148 | 149 | /** Exercise: Chunk.apply 150 | * 151 | * Chunks are a specialized version of Seq that optimizes for performance. You can use a 152 | * Chunk wherever you would use a Seq. 153 | * 154 | * Chunks can be created using a varargs constructor, or from an Array or Seq. 155 | */ 156 | lazy val chunk: Chunk[Int] = ??? 157 | lazy val seq: Seq[Int] = ??? 158 | 159 | assertTrue(chunk == Chunk(1, 2, 3, 4, 5)) && 160 | assertTrue(chunk == seq) 161 | } @@ ignore, 162 | test("from") { 163 | 164 | /** Exercise: Chunk.from (Array vs Seq) 165 | * 166 | * Chunk.from offers a safe way to convert an Array or Seq to a Chunk. 167 | * 168 | * Note: elements of an Array must be a subtype of AnyRef. 169 | */ 170 | val array: Array[String] = Array("a", "b", "c") 171 | lazy val chunkArray: Chunk[String] = ??? 172 | val seq: Seq[Int] = 0 to 100 173 | lazy val chunkSeq: Chunk[Int] = ??? 174 | 175 | // Since `Chunk` extends `Seq`, you can check equality with other Seq implementations 176 | assertTrue(chunkArray == array.toSeq) && 177 | assertTrue(chunkSeq == seq) 178 | } @@ ignore, 179 | test("flattenChunk") { 180 | 181 | /** Exercise: Chunk#flattenChunk 182 | * 183 | * - While Chunk does extends Seq, it sometimes offers more efficient implementations 184 | * - `flattenChunk` is one such method. 185 | * - This method will only work for `Chunk[A]` where `A` is a subtype of `Chunk[_]`. 186 | */ 187 | val chunk: Chunk[Chunk[Int]] = Chunk(Chunk(1, 2), Chunk(3, 4), Chunk(5, 6)) 188 | lazy val flattened: Chunk[Int] = ??? 189 | 190 | assertTrue(flattened == chunk.flatten) && // flatten & flattenChunk produce the same result 191 | assertTrue(flattened == Chunk(1, 2, 3, 4, 5, 6)) 192 | } @@ ignore, 193 | ) 194 | object `00_TypeMap` extends KyoSpecDefault: 195 | sealed trait DBConnection: 196 | def maxConnections: Int 197 | 198 | case class Postgres() extends DBConnection: 199 | def maxConnections = 10 200 | 201 | case class Redis() extends DBConnection: 202 | def maxConnections = 50 203 | 204 | case class Mongo() extends DBConnection: 205 | def maxConnections = 20 206 | 207 | def spec = 208 | suite("TypeMap[A]")( 209 | test("add/get") { 210 | 211 | /** Exercise: Create a TypeMap with a Postgres connection. 212 | * 213 | * - What can you get from the map? 214 | * - What happens if you ascribe the type `TypeMap[DBConnection]`? 215 | * - If you add `42`, what's the type of the map? 216 | */ 217 | lazy val connections: TypeMap[Postgres] = ??? 218 | lazy val widened: TypeMap[DBConnection] = ??? 219 | lazy val andInt: TypeMap[??? & Int] = ??? 220 | 221 | assertTrue(connections.get[Postgres] == Postgres()) && 222 | assertTrue( 223 | widened.get[DBConnection].isInstanceOf[Postgres] 224 | ) && // note: cannot request a specific type because we erased that information by widening to the `DBConnection` trait. 225 | assertTrue(andInt.get[Int] == 42) 226 | } @@ ignore, 227 | test("prune") { 228 | 229 | /** Exercise: Experiment with `TypeMap#prune` 230 | * 231 | * First prune to just `Redis`. Then prune to just `DBConnection`. 232 | */ 233 | val original = TypeMap.empty 234 | .add(Postgres()) 235 | .add(Redis()) 236 | .add(Mongo()) 237 | 238 | lazy val redis: TypeMap[Redis] = ??? 239 | lazy val dbConnections: TypeMap[DBConnection] = ??? 240 | 241 | assertTrue(redis.get[Redis] == Redis()) && 242 | assertTrue(redis.size == 1) && 243 | assertTrue(dbConnections.get[DBConnection].isInstanceOf[DBConnection]) 244 | } @@ ignore, 245 | test("union") { 246 | 247 | /** Exercise: Create and combine two TypeMaps 248 | */ 249 | lazy val dbs: TypeMap[Postgres] = TypeMap(Postgres()) 250 | 251 | lazy val config: TypeMap[Int & String & Boolean] = ??? 252 | 253 | lazy val combined: TypeMap[???] = ??? 254 | 255 | assertTrue(combined.size == dbs.size + config.size) 256 | } @@ ignore, 257 | ) 258 | --------------------------------------------------------------------------------