Skip to main content

Why suspend over IO

Other functional ecosystems, Scala and Haskell among others, use a monadic model for side effects. The key component of this model is a wrapper called IO. Arrow has adopted a different model, based on suspend and top-level extension functions over suspend () -> A. This section explains the rationale behind this choice.

The reason for using IO is because you care about writing side-effecting code in a safe and referential transparent manner. Additionally, IO offers powerful concurrent operators and cancellation in addition of offering a referential transparent runtime. These properties are what makes using IO powerful, and suspend offers the exact same properties but natively in the language with support from the compiler.

Arrow and Kotlin

One of the main goals of Arrow is to provide APIs which feel idiomatic to Kotlin developers. This section should be read on that light; what is a good choice in Kotlin may not have the right trade-offs in other ecosystems.

Ergonomics

IO requires a wrapper in the return type: fun number(): IO<Int>, and thus we always have to work with the IO type to access the value we care about within. A typical pattern for this using flatMap, so let's say we want to calculate 3 numbers and return them as a Triple. To make the example concrete we use names inspired by Cats Effect.

fun number(): IO<Int> = IO.pure(1)

fun triple(): IO<Triple<Int, Int, Int>> =
number().flatMap { a ->
number().flatMap { b ->
number().map { c ->
Triple(a, b, c)
}
}
}

So simply to call a function 3 times, and combine the result into a Triple we had to use flatMap twice and map. What that means under the hood we'll discuss in the performance section but in terms of ergonomics this is not ideal. Especially not if we can compare it to the following suspend code. We can see that we can forget about flatMap and map, and construct the Triple and call number() three times directly in the constructor.

suspend fun number(): Int = 1

suspend fun triple(): Triple<Int, Int, Int> =
Triple(number(), number(), number())
Conclusion

The ergonomics of suspend are clearly better here, and this is a very important point in Kotlin since the language aims for high ergonomics and developer friendly constructs.

Safety / Purity / Referential transparency

What guarantees does IO bring, and does suspend offer the same guarantees?

At a conceptual level, IO<A> always results in a successful value, or finishes with an exception. This can be clearly seen in Cats Effect, where unsafeRunAsync results in Either<Throwable, A>. This is done so that any exceptions that occur within the IO API can be safely returned to the user, and it can be recovered from at any point in the code. Important here is that a Throwable that occurs in an async thread is safely captured in the IO as well and can be recovered from at any point.

suspend always results in Result<A> which is equivalent to Either<Throwable, A>, and it can be used to offer the same safety guarantees as IO. So the suspend API can also always return any exception safely to the user, and it can be recovered from at any point in the code. In contrast to IO, we can only find startCoroutine in the standard library, and has the same behavior as unsafeRunAsync. Instead of f: (Either<Throwable, A>) -> Unit you provide f: (Result<A>) -> Unit to run the suspend () -> A program.

Conclusion

suspend () -> A offers us the exact same guarantees as IO<A>.

Effect mixing

In this section we compare the ability to mix two effects, one of them being the ability to perform side-effectful operations. In particular, we compare monad transformers to suspend.

Domain errors

When writing functional code style we often want to express our domain errors as clearly as possible, a popular pattern is to return Either<DomainError, SuccessValue>. Let's assume following domain, and compare two snippets: one using IO<Either<E, A>>, and another suspend () -> Either<E, A>.

import arrow.core.Either
import arrow.core.Either.Left
import arrow.core.Either.Right

/* inline */ class Id(val id: Long)
object PersistenceError

data class User(val email: String, val name: String)
data class ProcessedUser(val id: Id, val email: String, val name: String)

suspend fun fetchUser(): Either<PersistenceError, User> =
Right(User("simon@arrow-kt.io", "Simon"))

suspend fun User.process(): Either<PersistenceError, ProcessedUser> =
if (email.contains(Regex("^(.+)@(.+)$"))) Right(ProcessedUser(Id(1), email, name))
else Left(PersistenceError)
Using IO<Either<E, A>>
import arrow.fx.*

fun ioProgram(): IO<Either<PersistenceError, ProcessedUser>> =
IO.fx {
val res = !IO.effect { fetchUser() }

!res.fold({ error ->
IO.just(Either.Left(error))
}, { user ->
IO.effect { user.process() }
})
}

// Or unwrapped in `suspend`
suspend suspendedIOProgram(): Either<PersistenceError, ProcessedUser> =
ioProgram().suspended()
Using suspend () -> Either<E, A>
import arrow.core.raise.either

suspend fun suspendProgram(): Either<PersistenceError, ProcessedUser> =
either {
val user = fetchUser().bind()
val processed = user.process().bind()
processed
}

The above two examples demonstrate how much simpler suspend is over its IO counterpart and how the either computation block allows us to bind values of Either to extract their right side all while inside suspend. Arrow allows intermixing effects in suspension. What otherwise would have required the EitherT transformer over IO now it can just be expressed by wrapping in either instead

Dependency injection

We can use extension functions to do functional dependency injection with similar semantics as Reader or Kleisli. They allow us to elegantly define syntax for a certain type.

Let's reuse our previous domain ofUser, ProcessedUser, but let's introduce Repo and Persistence layers to mimic what could be a small app with a couple layers.

interface Repo {
suspend fun fetchUsers(): List<User>
}

interface Persistence {
suspend fun User.process(): Either<PersistenceError, ProcessedUser>

suspend fun List<User>.process(): Either<PersistenceError, List<ProcessedUser>> =
either { map { it.process().bind() } }
}

Given the above defined layers we can easily compose them by creating a product which implements the dependencies by delegation.

class DataModule(
persistence: Persistence,
repo: Repo
) : Persistence by persistence, Repo by repo

We can also define top-level functions based on constraints on the receiver. Here we define getProcessedUsers which can only be called where R is both Repo and Persistence.

/**
* Generic top-level function based on syntax enabled by [Persistence] & [Repo] constraint
*/
suspend fun <R> R.getProcessedUsers(): Either<PersistenceError, List<ProcessedUser>>
where R : Repo,
R : Persistence = fetchUsers().process()
Context receivers

Context receivers offer a nicer approach to composition of layers.

Performance

In short

suspend is extremely fast in comparison to IO<A>, since IO<A> is built at runtime and suspend is built by the compiler.

Working with an actual data type such as IO<A> implies that each composition of our program has some allocation cost. This happens because IO requires different data classes to move computation from the stack to the heap in order to compose them and preserve properties such as lazy evaluation semantics. In contrast, when using suspend, the Kotlin compiler is aware of function composition on each suspension point and can desugar and specializes the program into more efficient target code. The code generated by the Kotlin compiler is better in terms of allocations and throughput when compared to other implementations of IO in the JVM.

Let's take our previous example from ergonomics:

import arrow.fx.IO

fun number(): IO<Int> = IO.pure(1)

fun triple(): IO<Triple<Int, Int, Int>> =
number().flatMap { a ->
number().flatMap { b ->
number().map { c ->
Triple(a, b, c)
}
}
}

If we translate this piece of code to the data class used, for example, in Cats Effect, we need no less than 6 IO values.

fun number(): IO<Int> = IO.Just(1)

fun triple(): IO<Triple<Int, Int, Int>> =
IO.FlatMap(IO.Pure(1)) { a ->
IO.FlatMap(IO.Pure(1)) { b ->
IO.Map(IO.Pure(1)) { c -> Triple(a, b, c) }
}
}

This is necessary so when unsafeRun is invoked the IO program can find the branch representing the kind of operation of IO that needs to be interpreted. In the example above IO.FlatMap, IO.Map or IO.Pure

In contrast, suspend can simply be wired by the Kotlin compiler eliminating the need for additional sealed class declarations and allocations keeping computations in the stack instead of maintaining value level in memory representations of our program. The Kotlin compiler rewrites the suspend program to a super fast runtime which uses a switch table and mutable state machine to run the suspend program. Furthermore, the Kotlin compiler applies other optimizations focused on the speed of suspend and memory usage of suspension, making it suitable for hot spots and places where otherwise allocations-based data types are not an option.