Comparison to Future
We can clarify what we have learned so far by comparing ZIO{ZIO} with Future from the Scala standard library.
We will discuss other differences between ZIO and Future later in this book when we discuss concurrency, but for now, there are three primary differences to keep in mind.
A Future is a Running Effect
Section titled “A Future is a Running Effect”Unlike a functional effect like ZIO, a Future models a running effect. To go back to our example from the beginning of the chapter, consider this snippet:
import scala.concurrent.Futureimport scala.concurrent.ExecutionContext.Implicits.global
val goShoppingFuture: Future[Unit] = Future(println("Going to the grocery store"))Just like our original example, as soon as goShoppingFuture is defined this effect will begin executing. Future does not suspend evaluation of code wrapped in it.
Because of this tangling between the what and the how, we don’t have much power when using Future. For example, it would be nice to be able to define a delay operator on Future, just like we have for ZIO. But we can’t do that because it would be a method on Future, and if we have a Future, then it is already running, so it’s too late to delay it.
Similarly, we can’t retry a Future in the event of failure, like we can for ZIO, because a Future isn’t a blueprint for doing something—it is an executing computation. So if a Future fails, there is nothing else to do. We can only retrieve the failure.
In contrast, since a ZIO effect is a blueprint for a concurrent workflow, if we execute the effect once and it fails, we can always try executing it again, or executing it as many times as we would like.
One case in which this distinction is particularly obvious is the persistent requirement that you have an implicit ExecutionContext in scope whenever you call methods on Future.
For example, here is the signature of Future#flatMap, which like flatMap on ZIO, allows us to compose sequential effects:
import scala.concurrent.ExecutionContext
trait Future[+A] { def flatMap[B](f: A => Future[B])(implicit ec: ExecutionContext ): Future[B]}Future#flatMap requires an ExecutionContext because it represents a running effect, so we need to provide the ExecutionContext on which this subsequent code should be immediately run.
As discussed before, this conflates what should be done with how it should be done. In contrast, none of the codes involving ZIO we have seen require an Executor because it is just a blueprint.
ZIO blueprints can be run on any Executor we want, but we don’t have to specify this until we actually run the effect (or, later we will see how you can “lock” an effect to run in a specific execution context, for those rare cases where you need to be explicit about this).
Future has an Error Type Fixed to Throwable
Section titled “Future has an Error Type Fixed to Throwable”Future has an error type fixed to Throwable. We can see this in the signature of the Future#onComplete:
import scala.util.Try
trait Future[+A] { def onComplete[B](f: Try[A] => B): Unit}The result of a Future can either be a Success with an A value or a Failure with a Throwable. When working with legacy code that can fail for any Throwable, this can be convenient, but it has much less expressive power than a polymorphic error type.
First, we don’t know by looking at the type signature how or even if an effect can fail. Consider the multiplication example we looked at when discussing the ZIO error type implemented with Future:
def parseInt: Future[Int] = ???Notice how we had to define this as a def instead of a val because a Future is a running effect. So, if we defined it as a val, we would immediately be reading and parsing input from the user. Then, when we used parseInt, we’d always get back the same value instead of prompting the user for a new value and parsing that.
Putting this aside, we have no idea how this future can fail by looking at the type signature. Could it return a NumberFormatException from parsing? Could it return an IOException? Could it not fail at all because it handles its own errors, perhaps by retrying until the user enters a valid integer? We just don’t know unless we dig into the code and study it at length.
This makes it much harder for developers who call this method because they don’t know what type of errors can occur, so to be safe, they need to do “defensive programming” and handle any possible Throwable.
This problem is especially annoying when we handle all possible failure scenarios of a Future, but nothing changes about the type.
For example, we can handle parseInt errors by using the Future method fallbackTo:
import scala.concurrent.Future
def parseIntOrZero: Future[Int] = parseInt.fallBackTo(Future.successful(0))Here, parseIntOrZero cannot fail because if parseInt fails, we will replace it with a successful result of 0. But the type signature doesn’t tell us this. As far as the type signature is concerned, this method could fail in infinitely many ways, just like parseInt!
From the perspective of the compiler, fallBackTo hasn’t changed anything about the fallibility of the Future. In contrast, in ZIO, parseInt would have a type of IO[NumberFormatException, Int], and parseIntOrZero would have a type of UIO[Int], indicating precisely how parseInt can fail and that parseIntOrZero cannot fail.
Future Does not Have a Way to Model the Dependencies of an Effect
Section titled “Future Does not Have a Way to Model the Dependencies of an Effect”The final difference between ZIO and Future that we have seen so far is that Future does not have any way to model the dependencies of an effect. This requires other solutions to dependency injection, which are usually manual (they cannot be inferred) or depend on third-party libraries.
We will spend much more time on this later in the book, but for now, just note that ZIO has direct support for dependency injection, but Future does not. This means that in practice, most Future code in the real world is not very testable because it requires too much plumbing and boilerplate.
