Cont
represents a function of suspend () -> A
that can fail with R
(and Throwable
), so it's defined by suspend fun fold(f: suspend (R) -> B, g: suspend (A) -> B): B
.
So to construct a Cont
we simply call the cont
DSL, which exposes a rich syntax through the lambda receiver suspend ContEffect
.
What is interesting about the Cont
type is that it doesn't rely on any wrappers such as Either
, Ior
or Validated
. Instead Cont
represents a suspend function, and only when we call fold
it will actually create a Continuation
and runs the computation (without intrecepting). This makes Cont
a very efficient generic runtime.
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'll 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
.
import arrow.Cont
import arrow.cont
object EmptyPath
fun readFile(path: String): Cont<EmptyPath, Unit> = cont {
if (path.isNotEmpty()) shift(EmptyPath) else Unit
}
Here we see how we can define a Cont
which has EmptyPath
for the shift type R
, and Unit
for the success type A
.
Patterns like validating a Boolean
is very common, and the Cont
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?): Cont<EmptyPath, Unit> = cont {
ensure(!path.isNullOrBlank()) { 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.
import arrow.Cont
import arrow.cont
import arrow.ensureNotNull
import arrow.core.None
import java.io.File
import java.io.FileNotFoundException
import kotlinx.coroutines.runBlocking
@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?): Cont<FileError, Content> = cont {
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 readFile
function defines a suspend fun
that will return:
- the
Content
of a givenpath
- a
FileError
- An unexpected fatal error (
OutOfMemoryException
)
Since these are the properties of our Cont
function, we can turn it into a value.
fun main() = runBlocking<Unit> {
readFile("").toEither().also(::println)
readFile("not-found").toValidated().also(::println)
readFile("gradle.properties").toIor().also(::println)
readFile("not-found").toOption { None }.also(::println)
readFile("nullable").fold({ _: FileError -> null }, { it }).also(::println)
}
You can get the full code here.
Either.Left(EmptyPath)
Validated.Invalid(FileNotFound(path=not-found))
Ior.Left(FileNotFound(path=gradle.properties))
Option.None
null
The functions above our available out of the box, but it's easy to define your own extension functions in terms of fold
. Implementing the toEither()
operator is as simple as:
import arrow.Cont
import arrow.core.identity
import arrow.core.Either
import arrow.core.Option
import arrow.core.None
import arrow.core.Some
suspend fun <R, A> Cont<R, A>.toEither(): Either<R, A> =
fold({ Either.Left(it) }) { Either.Right(it) }
suspend fun <A> Cont<None, A>.toOption(): Option<A> =
fold(::identity) { Some(it) }
You can get the full code here.
Adding your own syntax to ContEffect
is tricky atm, but will be easy once "Multiple Receivers" become available.
context(ContEffect
)
suspend fun
Either
.bind(): A =
when (this) {
is Either.Left -> shift(value)
is Either.Right -> value
}
context(ContEffect
)
fun
Option
.bind(): A = fold({ shift(it) }, ::identity)
Handling errors
Handling errors of type R
is the same as handling errors for any other data type in Arrow. Cont
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 Cont
in a generic manner. There is no need to run Cont
into Either
before you can access R
, you can simply call the same functions on Cont
as you would on Either
directly.
import arrow.Cont import arrow.cont import arrow.core.identity import kotlinx.coroutines.runBlocking val failed: Cont<String, Int> = cont { shift("failed") } val resolved: Cont<Nothing, Int> = failed.handleError { it.length } val newError: Cont<List<Char>, Int> = failed.handleErrorWith { str -> cont { shift(str.reversed().toList()) } } val redeemed: Cont<Nothing, Int> = failed.redeem({ str -> str.length }, ::identity) val captured: Cont<String, Result<Int>> = cont<String, Int> { throw RuntimeException("Boom") }.attempt() fun main() = runBlocking<Unit> { println(failed.toEither()) println(resolved.toEither()) println(newError.toEither()) println(redeemed.toEither()) println(captured.toEither()) }
You can get the full code here.
Either.Left(failed)
Either.Right(6)
Either.Left([d, e, l, i, a, f])
Either.Right(6)
Either.Right(Failure(java.lang.RuntimeException: Boom))
Note: Handling errors can also be done with try/catch
but this is not recommended, it uses CancellationException
which is used to cancel Coroutine
s and is advised not to capture in Kotlin. The CancellationException
from Cont
is ShiftCancellationException
, this type is public so you can distinct the exceptions if necessary.
Structured Concurrency
Cont
relies on kotlin.cancellation.CancellationException
to shift
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
Let's overview below how shift
behaves with the different concurrency builders from Arrow Fx & KotlinX Coroutines.
Arrow Fx Coroutines
All operators in Arrow Fx Coroutines run in place, so they have no way of leaking shift
. It's there always safe to compose cont
with any Arrow Fx combinator. Let's see some small examples below.
parZip
import arrow.cont
import arrow.fx.coroutines.parZip
import kotlinx.coroutines.delay
suspend fun parZip(): Unit = cont<String, Int> {
parZip({
delay(1_000_000) // Cancelled by shift
}, { shift<Int>("error") }) { _, int -> int }
}.fold(::println, ::println) // "error"
You can get the full code here.
parTraverse
import arrow.cont
import arrow.fx.coroutines.parTraverse
import kotlinx.coroutines.delay
suspend fun parTraverse() = cont<String, List<Int>> {
(0..100).parTraverse { index -> // running tasks
if(index == 50) shift<Int>("error")
else index.also { delay(1_000_000) } // Cancelled by shift
}
}.fold(::println, ::println) // "error"
You can get the full code here.
raceN
import arrow.cont
import arrow.core.merge
import arrow.fx.coroutines.raceN
import kotlinx.coroutines.delay
suspend fun race() = cont<String, Int> {
raceN({
delay(1_000_000) // Cancelled by shift
5
}) { shift<Int>("error") }
.merge() // Flatten Either
result from race into Int
}.fold(::println, ::println) // "error"
You can get the full code here.
bracketCase / Resource
import arrow.cont
import arrow.fx.coroutines.ExitCase
import arrow.fx.coroutines.bracketCase
import arrow.fx.coroutines.Resource
import arrow.fx.coroutines.fromAutoCloseable
import java.io.BufferedReader
import java.io.File
suspend fun bracketCase() = cont<String, Int> {
bracketCase(
acquire = { File("gradle.properties").bufferedReader() },
use = { reader ->
// some logic
shift("file doesn't contain right content")
},
release = { reader, exitCase ->
reader.close()
println(exitCase) // ExitCase.Cancelled(ShiftCancellationException("Shifted Continuation"))
}
)
}.fold(::println, ::println) // "file doesn't contain right content"
// Available from Arrow 1.1.x
fun <A> Resource.releaseCase(releaseCase: (A, ExitCase) -> Unit): Resource<A> =
flatMap { a -> Resource({ a }, releaseCase) }
fun bufferedReader(path: String): Resource<BufferedReader> =
Resource.fromAutoCloseable {
File(path).bufferedReader()
}.releaseCase { _, exitCase -> println(exitCase) }
suspend fun resource() = cont<String, Int> {
bufferedReader("gradle.properties").use { reader ->
// some logic
shift("file doesn't contain right content")
} // ExitCase.Cancelled(ShiftCancellationException("Shifted Continuation")) printed from release
}
You can get the full code here.
KotlinX
withContext
It's always safe to call shift
from withContext
since it runs in place, so it has no way of leaking shift
. When shift
is called from within withContext
it will cancel all Job
s running inside the CoroutineScope
of withContext
.
import arrow.cont
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
suspend fun withContext() = cont<String, Int> {
withContext(Dispatchers.IO) {
launch { delay(1_000_000) } // launch gets cancelled due to shift(FileNotFound("failure"))
val sleeper = async { delay(1_000_000) } // async gets cancelled due to shift(FileNotFound("failure"))
readFile("failure").bind()
sleeper.await()
}
}.fold(::println, ::println) // FileNotFound("failure")
async
When calling shift
from async
you should always call await
, otherwise shift
can leak out of its scope.
launch
NOTE Capturing shift
into a lambda, and leaking it outside of Cont
to be invoked outside will yield unexpected results. Below we capture shift
from inside the DSL, and then invoke it outside its context `ContEffectz
cont<String, suspend () -> Unit> {
suspend { shift("error") }
}.fold({ }, { leakedShift -> leakedShift.invoke() })
The same violation is possible in all DSLs in Kotlin, including Structured Concurrency.
val leakedAsync = coroutineScope<suspend () -> Deferred<Unit>> {
suspend {
async {
println("I am never going to run, until I get called invoked from outside")
}
}
}
leakedAsync.invoke().await()