Coroutines is one of the most liked features in Kotlin. Most people are familiar with the KotlinX Coroutines implementation,
and many are also familiar with suspendCoroutine
from the Kotlin Standard Library and then have also come across Continuation
.
Kotlin has support for CPS, or Continuation Passing Style,
which means that the compiler has a style in which it can automatically pass Continuation between functions that are marked with suspend
.
This means that in every suspend fun
we have access to an implicit Continuation
parameter through suspendCoroutine { effect: Continuation -> ... }
, or any of it’s lower level variants.
This Continuation
only has a single type parameter Continuation<A>
, and the result of running this suspend
/Continuation
based program is either A
, or Throwable
.
This is represented by the Result<A>
type from the Kotlin Standard Library.
In functional programming we often prefer to interrupt the program with a typed value R
instead of Throwable
.
So we’d like a Continuation
that instead of only being able to result in A | Throwable
can result in A | R | Throwable
,
you can never escape that a program can also result in Throwable
and suspend
explicitly takes Throwable
into account to operate correctly.
To achieve this we define a new type interface Effect<R, A>
,
which represents a function of suspend () -> A
that can fail with R
(and Throwable
).
It’s defined by suspend fun <B> fold(f: suspend (R) -> B, g: suspend (A) -> B): B
.
What is interesting about the Effect<R, A>
type is it’s a suspend function fold
, so it doesn’t rely on any wrappers such as Either
, Ior
or Validated
.
Only when fold
is called it will create a Continuation
and run the computation, this means Effect
can encode side-effects
as pure values
.
This makes Effect<R, A>
a very efficient generic runtime.
To construct a Effect<R, A>
we simply call the effect<R, A> { }
constructor, which exposes a rich syntax through the lambda receiver suspend EffectScope<R>.() -> A
.
Working with Effect<R, A>
Let’s write a small program to read a file from disk, and instead of having the program work exception based we want to turn it into a polymorphic type-safe program.
We start by defining a small function that accepts a String
, and does some simply validation to check that the path is not empty.
If the path is empty, we want to program to result in EmptyPath
.
So we’re immediately going to see how we can raise an error of any arbitrary type R
by using the function shift
.
The name shift
comes shifting (or changing, especially unexpectedly), away from the computation and finishing the Continuation
with R
.
object EmptyPath
fun readFile(path: String): Effect<EmptyPath, Unit> = effect {
if (path.isEmpty()) shift(EmptyPath) else Unit
}
Here we see how we can define a Effect<R, A>
which has EmptyPath
for the shifted type R
, and Unit
for the success type A
.
Patterns like validating a Boolean
is very common, and the Effect
DSL offers utility functions like kotlin.require
and kotlin.requireNotNull
. They’re named ensure
and ensureNotNull
to avoid conflicts with the kotlin
namespace. So let’s rewrite the function from above to use the DSL instead.
fun readFile2(path: String?): Effect<EmptyPath, Unit> = effect {
ensureNotNull(path) { EmptyPath }
ensure(path.isNotEmpty()) { EmptyPath }
}
You can get the full code here.
Now that we have the path, we can read from the File
and return it as a domain model Content
.
We also want to take a look at what exceptions reading from a file might occur FileNotFoundException
& SecurityError
, so lets make some domain errors for those too. Grouping them as a sealed interface is useful since that way we can resolve all errors in a type safe manner.
@JvmInline
value class Content(val body: List<String>)
sealed interface FileError
@JvmInline value class SecurityError(val msg: String?) : FileError
@JvmInline value class FileNotFound(val path: String) : FileError
object EmptyPath : FileError {
override fun toString() = "EmptyPath"
}
We can finish our function, but we need to refactor our return value from Unit
to Content
and our error type from EmptyPath
to FileError
.
fun readFile(path: String?): Effect<FileError, Content> = effect {
ensureNotNull(path) { EmptyPath }
ensure(path.isNotEmpty()) { EmptyPath }
try {
val lines = File(path).readLines()
Content(lines)
} catch (e: FileNotFoundException) {
shift(FileNotFound(path))
} catch (e: SecurityException) {
shift(SecurityError(e.message))
}
}
The Effect
returned by readFile
function represents a suspend fun
that will return:
- the
Content
of a givenpath
- a
FileError
- An unexpected fatal
Throwable
(OutOfMemoryException
)
The returned Effect<FileError, Content>
can executed into a value.
suspend fun test() {
readFile("").toEither() shouldBe Either.Left(EmptyPath)
readFile("knit.properties").toValidated() shouldBe Validated.Invalid(FileNotFound("knit.properties"))
readFile("gradle.properties").toIor() shouldBe Ior.Left(FileNotFound("gradle.properties"))
readFile("README.MD").toOption { None } shouldBe None
readFile("build.gradle.kts")
.fold({ _: FileError -> null }, { it })
.shouldBeInstanceOf<Content>()
.body.shouldNotBeEmpty()
}
You can get the full code here.
Handling errors
Handling errors of type R
is the same as handling errors for any other data type in Arrow.
Effect<R, A>
offers handleError
, handleErrorWith
, redeem
, redeemWith
and attempt
.
As you can see in the examples below it is possible to resolve errors of R
or Throwable
in Effect<R, A>
.
val failed: Effect<String, Int> =
effect { shift("failed") }
val resolved: Effect<Nothing, Int> =
failed.handleError { it.length }
val newError: Effect<List<Char>, Int> =
failed.handleErrorWith { str ->
effect { shift(str.reversed().toList()) }
}
val redeemed: Effect<Nothing, Int> =
failed.redeem({ str -> str.length }, ::identity)
val captured: Effect<String, Result<Int>> = effect<String, Int> {
1
}.attempt()
suspend fun test() {
failed.toEither() shouldBe Either.Left("failed")
resolved.toEither() shouldBe Either.Right(6)
newError.toEither() shouldBe Either.Left(listOf('d', 'e', 'l', 'i', 'a', 'f'))
redeemed.toEither() shouldBe Either.Right(6)
captured.toEither() shouldBe Either.Right(Result.success(1))
}
You can get the full code here.
Effect<E, A> and Either<E, A>
We’ve briefly mentioned the difference between Either<E, A>
and Effect<E, A>
but let’s compare them in some more detail.
Either<String, Int>
represents a value of either a String
or an Int
,
while Effect<String, Int>
represents a function that will result in an Int
, interrupt with a String
, or throw a Throwable
.
So Effect<String, Int>
can represent a function that results in either a String
, an Int
or throw a Throwable
.
That is exactly what the either
DSL of Arrow Core does, and let’s see what the implementation might look like with Effect
.
Since Effect<E, A>
can interrupt with a String
we can implement bind
for Either<String, Int>
.
Here we implement bind
how it’s implemented in the library, it’s defined in EffectScope<E>
which is the DSL available inside effect { }
.
interface EffectScope<E> {
suspend fun <A> shift(e: e): A
suspend fun <A> Either<E, A>.bind(): A =
when(this) {
is Either.Right -> value
is Either.Left -> shift(value)
}
}
When we encounter Either.Right
we can simply return the value, and when we encounter Either.Left
we short-circuit (interrupt) the function.
This allows us to use the effect { }
DSL similarly to how we use either { }
from Arrow Core.
suspend fun test() {
effect<String, Int> {
val x: Int = 1.right().bind()
val y: Int = shift("Failed")
}.toEither() shouldBe Either.Left("failed")
}
You can get the full code here.
At the end of our DSL we call toEither
to finally turn the Effect
result into Either
.
So we can re-define the either
DSL from Arrow with following function:
suspend fun <E, A> either(block: suspend EffectScope<E>.() -> A): Either<E, A> = effect(block).toEither()
So we can consider Effect
a higher abstraction to write programs that can result in Either
, Validated
, Option
, Ior
, Result
, etc
Structured Concurrency
Effect<R, A>
can automatically operate with KotlinX’s Structured Concurrency by leveraging kotlin.cancellation.CancellationException
introduced in Kotlin 1.4.
It’s used by shift
to raise error values of type R
inside the Continuation
since it effectively cancels/short-circuits it.
For this reason shift
adheres to the same rules as Structured Concurrency
For more details see the Effect<R, A>
documentation
Conclusion
The Effect
type will soon become available in Arrow!
There is also RestrictedCont
which exposes the same functionality without requiring suspend
.
Thank you for reading the first informal (test) blog on my Github Pages, I hope you enjoyed it! Feel free to reach out on KotlinLang Slack for feedback or questions.
Stay tuned for more informal blogs!