The future of effects in Scala?
Recently Jack Viers and Raul Raja from 47 Degrees posted a pre-SIP proposal to add Kotlin-like suspend functions to Scala. The proposal itself is an interesting read, but it is also followed by a long and insightful discussion, reaching in scope far beyond the initial SIP (for full disclosure, in part I'm responsible for some branching of the topic).
It might seem that the thread quickly went off-topic, but I would say that on the contrary, it shifted to try to define the core problem. Adding suspended functions and continuations to Scala 3 is a solution to a problem. But as it turns out, there's no consensus as to what the problem actually is! And I think we'll all agree that it's best to first define the problem and then look at the possible solutions.
As the discussion is quite lengthy, but might be vital to the future of Scala, below you can find a subjective summary of subjectively selected sub-threads, mixed with my comments. Hopefully, this will serve as a useful TL;DR for some (not that my summary is that short...).
The pre-SIP itself is an effect of positive experiences of the authors with the Kotlin implementation of co-routines and its
suspend functions. The main motivation comes from the fact that code involving asynchrony or I/O operations is usually written in Scala using a monadic style, with eagerly-evaluated
Futures or lazily-evaluated
IO data types.
This indirect, "wrapped" approach enables a lot of interesting features, such as composable error handling, structured concurrency, handling non-determinism, introducing parallelism and taming concurrency. However, it also comes at the expense of using a lot of
flatMap operations, which impacts code readability and prohibits using regular control structures, such as loops, try-catch, or tail recursion directly.
Instead, the proposal introduces
suspend functions and continuations, which allow programming in a direct style, while keeping the above mentioned features of the monadic style. Let's take a look at an example:
object NotFound case class Country(code: Option[String]) case class Address(country: Option[Country]) case class Person(name: String, address: Either[NotFound.type, Address]) suspend def getCountryCodeDirect(futurePerson: Future[Person]) (using Structured, Control[NotFound.type | None.type]): String = val person = futurePerson.bind val address = person.address.bind val country = address.country.bind country.code.bind
The proposal goes into some more detail as to how the
Control types and the
bind method work. I don't think it makes sense to repeat everything here, so it might be a good starting to point to simply read through the first post.
From the strictly proposal-related discussion that followed, it seems that there are two main areas that would need clarification:
bindwould interact with higher-order functions,
bindwould work in a non-Loom (see also below) environment, and whether it would be able to escape the colored function problem - would e.g.
.mapneed both suspendable and non-suspendable variants? See these posts: 10 to 17.
However, I think there's one deeper issue before accepting it as a SIP and incorporating it into Scala.
Scala is a flexible language, which means there's often more than one way to perform a given task. This can be viewed as a strength or a weakness; but what matters here is that this also involves the way effects are handled. As mentioned above, we've got at least three available (and used) programming models:
Future-based (including Akka), "functional" using an
IO type, such as cats-effects or ZIO, and "direct" style, using synchronous, blocking calls.
If Scala were to add a fourth style (here: suspensions and continuations), I think it should be designated as the "recommended" one to avoid further ecosystem fragmentation. And for that to happen, we need to be really thorough; while some approaches might work well for Kotlin, Scala with its more functional bias, might have a better route ahead. I'm not saying that the proposal should be rejected, but rather that we should carefully survey other possible solutions (having first defined the problem), and only then commit to language-level changes.
Coroutines in Kotlin
Before continuing our investigation of effect tracking in Scala, I think it's worth noting that Kotlin's coroutines aren't universally acknowledged as a success. No solution is perfect and there are always trade-offs, but still, it's good to be aware of the shortcomings.
One problem that John de Goes pointed out is that Kotlin decided to implement coroutines at the language level instead of modifying the runtime. This caused Kotlin to face the well-known colored functions problem (in this case, the colors are suspendable and normal functions). And we are now close to getting a better solution, without the above problem, in the form of project Loom.
If suspend functions were to be added to Scala, we'd also import the colored functions problem into the Scala language; currently, there's a lot of coloring in Scala (using
IO), but it's done at the library level. Not that it’s necessarily a problem, as Alex points out:
When talking of async functions, is the blue versus red functions a real concern in static type systems?— Alex Nedelcu (@alexelcu) December 21, 2021
Moreover, Matthew De Detrich reports that some people in the Kotlin community point out to the weaknesses of coroutines in Kotlin, notably their interaction with HOF (much more frequently used in Scala than in Java or Kotlin), and that while the direct style works great for synchronous code, concurrency and interruption are more tricky to implement (however, this might be addressed in the proposal by integrating with existing effect systems).
Effects in Scala
When considering if, how, and which effects should be tracked in Scala, I think there are two main questions to answer.
For the time being, let's consider a method that sends a request over HTTP, which seems to be a relatively uncontroversial side-effect to track. Such a method performs an I/O operation, which might fail in unpredictable ways, such as taking an arbitrarily long time to complete, failing intermittently, or throwing connection exceptions.
The first question to answer is:
Should the signature of the method indicate that it performs side-effects?
I think all participants of the discussion would answer "yes" to this one, however, proposing different solutions. And solutions include:
- in Java, checked exceptions:
def x(...): T throws IOException
IOwrapper, marking the functions as one that might run asynchronously, perform arbitrary side effects and throw arbitrary exceptions:
ZIOwrapper, which uses its error channel to specify if and what errors might occur when evaluating the program, but otherwise also indicates that any side effect might happen during evaluation:
ZIO[Any, IOException, T]
CanThrowcapability, along with capture checking experimental Scala features, which adds polymorphism to error handling:
def x(...): T throws IOException(yes, looks the same as Java, but works differently)
- finally, Martin Odersky is starting a research program, Caprese to investigate possible ways to track effects in Scala, so this might yield new options.
Some of the above approaches are fundamentally different. And this brings us to the second question:
Should the information about the effect propagate to callers all the way up to the boundaries of the application, or should it be eliminatable?
In other words, should the effect indicator be "viral", causing any method that calls an effectful method to inherit the indicator? That is the case for
IOs. While it is technically possible to eliminate a future using
Await.result, or an
.unsafeRunSync, this is considered bad practice and frowned upon. In general, the elimination of these types should happen at "the end of the world", preferably in the
This means that if any method in your call chain performs side effects, it will impact the signature of the calling method - all the way up. And of course, this might be considered a feature, or a problem.
Alternatively, the side-effect information might be eliminated. To eliminate the
throws clauses, both in the Java and Scala variant, one might use
try ... catch.
ZIO is a hybrid: the
ZIO type itself is viral, however, errors might be eliminated using dedicated combinators.
Eliminatable effects allow considering them as an implementation detail of a method, while viral ones might be seen as exposing implementation details to the caller. But maybe the fact that an effect happens inside a method is not an implementation detail, but a characteristic of the computation that should propagate to callers?
As we will also see later, there's no consensus as to whether the second question should be answered "yes" or "no". And if we are to introduce a new way of handling effects in Scala, while it's probably not possible to achieve agreement (as there are too many diverse participants for that), it would be beneficial to openly state how the Scala language answers the above two problems.
What's worth tracking?
"If" and "how" to track is one side of the coin; the other is what should be tracked. There are several possibilities, and I think the answer to that question evolved as the discussion proceeded.
The first candidate was tracking asynchronous computations - that is, the "effect indicator" would tell us if the computation used any asynchronous operations. An example here is
Future in its current implementation, which is a handle to a computation running asynchronously. However, this isn't useful anymore on a JVM 19 runtime, which includes Loom. With Loom, most operations that have been run asynchronously for performance can now be run synchronously (even if behind the scenes, on a low level, an async implementation is used). Hence, tracking asynchronous operations in the type system no longer makes sense.
Another candidate, proposed by Martin Odersky is to track suspension points. This would properly cover tracking blocking calls, written using the direct style in a Loom JVM. However, there's a number of cases when suspension happens:
- I/O operations
- blocking on concurrency primitives, e.g.
As mentioned in the previous section, the first category is rather uncontroversial. However, do we want to track in our signatures occurences of the other two categories of suspension points? Is it interesting that somewhere in the call stack, a
Thread.sleep is being used?
Finally, my proposition would be to track remote calls. That would boil down to tracking I/O, as any I/O is usually a network call nowadays. I think this is the only interesting category to represent in a type signature, from the ones mentioned above.
Moreover, this addresses something known as the RPC fallacy: that a remote call can be made to work the same as a local one. Many frameworks and languages have attempted to implement such functionality, however, all have failed. An important note here: not all participants of the discussion agree that it's really a problem; see for example this post by Li Haoyi.
What makes remote calls different? While a remote call might be made to look the same as a normal method call - its failure modes are quite different. It can take an arbitrary amount of time or fail randomly. Thus, even if the invocation itself looks the same, the overall code that one has to write when performing a remote call is different. Note that this might go beyond normal error handling, including functionalities such as retries or timeouts.
And it's this requirement to write different code for remote calls compared to local ones that, in my view, makes it beneficial to track in type signatures.
Effects or errors?
John de Goes argues that we shouldn't focus on tracking effects, as such an approach is "commercially useless". He points out that it doesn't bring any actionable insights. Li Haoyi seconds this opinion.
Instead, John proposes composable error handling as a solution. Probably without much surprise, this is the approach taken by ZIO and implemented in the library. John points out that the ZIO approach to handling errors has three main benefits over checked exceptions, the design of which didn't stand the test of time:
- value-based, which allows polymorphic abstraction
- fully inferred
- recoverable and non-recoverable errors are separated using the type system
The already mentioned canThrow capability that is an experimental Scala 3 feature only features one of the above characteristics (polymorphism), so maybe this can be a good benchmark for whatever is the result of the Caprese research program?
Previously, we've classified specifying possible error types as a way to track effects - and indeed, you can view it as such. The important distinction might be that when people talk about "effect tracking", they usually mean the "viral", non-eliminatable variant, whereas errors are always eliminatable, be it through
Impact of Loom
Finally, an important part of the discussion has been taken by the impact of the upcoming Loom project, which will be previewed in JDK 19. Loom implements lightweight (virtual) threads on the JVM, making it possible to quickly start thousands of them, without the overhead that has been present so far. Moreover, Loom reimplements all of Java's blocking calls so that they only block the virtual threads. Underneath, Loom is based on continuations, with multiple
VirtualThreads multiplexed to a limited number of physical
Why would this concern Scala? The primary platform on which Scala is used, is the JVM. If we are looking for a way to program using the direct instead of the "wrapped" monadic style, maybe Loom is the answer? After all, we don't need to use asynchronous calls for performance anymore since we can cheaply block, and the runtime will handle everything for us.
In fact, the prototype suspend functions implementation mentioned in the proposal is based on Loom!
The fact that Loom offers the direct style, which is the main substance of the above proposal, is definitely tempting. However, Scala is a multi-platform language - there's also Scala.JS and Scala Native, which don't have green threads implemented in their runtimes. Should Scala then attempt to be completely decoupled from the runtime it's running on, and provide its own solutions to problems such as keeping the performance of async calls while using the direct programming style? Or should it embrace the fact that the JVM is where the vast majority of Scala code runs, and leverage what that platform offers to the maximum extent?
While speaking of the JVM, Alexandru Nedelcu mentioned that organisations are very slow to adopt new JVM versions, where JVM 8 is still a popular choice. This would mean that any new Scala features, taking advantage of Loom, could only become mainstream in 10 years or so. However, I think an important factor that is overlooked here is that there's not that much benefit in upgrading from JVM 8 to JVM 11 or JVM 17 from a Scala perspective - of course, there are better GC algorithms and other runtime improvements, but using a newer JVM won't impact the developer's productivity. Here, the situation might be different.
Will Loom replace wrappers?
At some point, the discussion forked, separating one of the threads of the discussion: What impact will Loom have on functional effects? Will
IOs become completely obsolete and be replaced by direct calls?
Time will tell, however, it seems that
Future in its current incarnation will probably be completely replaced. While a value representing a computation running in the background will still be more than useful as an abstraction, it will have a different implementation. The way of using a
Future will change: instead of a callback-based interface, that is sequencing operations using
flatMap, you'll be able to simply use a blocking
Future.get call. Hence, what is currently an anti-pattern will probably become the primary way of using the abstraction.
IOs seem to be in a different position. Being lazily evaluated descriptions of computations, their main purpose was not only improving performance by making working with asynchronously running code more convenient (or at all possible), but also introducing a different programming model, which offers:
- a composable way to describe computations, including sequential and parallel combinators
- safe, structured concurrency
- resource safety, composable retries and timeouts
- decoupling computation ordering from definition ordering
- interruptibility with clearly defined interruption points, where each
IOvalue is a step
See also my article from two years ago where I try to investigate the topic a bit deeper: Will project Loom obliterate Java Futures?.
However, we still need to see what library authors will offer when it comes to integrating functional effect libraries with Loom. ZIO 2.0 tries to pave the way here, but I suspect we'll see other contenders as well.
Please note that the above is just a selective summary and an opinionated selection from the quite large Scala-contributors thread.
The discussion hasn't reached any conclusions, however, it's still an excellent overview of the current landscape of different programming models in Scala. Ultimately, it's up to the Scala stewards - Martin Odersky, EPFL, and the Scala Center - to decide which road Scala will take. Probably not everyone will like it, but sometimes you've got to make some hard decisions! And also probably, these decisions won't be fast - the mentioned research project is set to take 5 years, but then as Scala tries to improve the status quo rather than mimic others, if that's how long it must take, then so be it.
Should Scala focus solely on the JVM and embrace Loom fully? Or should it follow Kotlin's footprints and provide an implementation of coroutines for all platforms? Will programming in the monadic style using
IO data types become even more niche with the advent of Loom, or will it provide tangible benefits to the programmers? What new features will Scala 3 bring us in the area of effect tracking or error handling?
As always, exciting times for all Scala developers out there :)
If you'd like to start your adventure with Scala, we've got you covered: scala.page