This blog is a follow up from the previous one and builds up on our small effect experiment. As good as our abstraction goes it still wraps a plain Scala Future
which is eager. Meaning that as soon as you create a Future
it starts executing on the provided ExecutionContext
.
Our effect wrapper F
suffers the exact same issue as it just wraps a Future
.
final case class F[+E, +A](value: Future[Either[E, A]]) extends AnyVal
Why is that a problem? Simply because it makes it harder to reason about the code.
Take the following well known example:
for {
a <- Future { /* do something */ }
b <- Future { /* do something else */ }
} yield a + b
and now consider this snippet:
for {
a <- Future { /* do something */ }
b = Future { /* do something else */ }
} yield a + b
They look very similar. However there is a difference, you see that sneaky =
on the second line of the for-comprehension. That makes the second Future
start before the first one completes.
That’s also a problem for writing combinators. Let’s I have a Future
and I want to run it again. Well I can’t do something like this:
val f = Future(println("Hi"))
def repeat[A](f: Future[A]): Future[A] = f.flatMap(_ => f)
repeat(f)
Well I can actually do it but it’s not going to repeat f
. It only prints Hi
once because a Future
memorises its results.
So what can we do about it?
If you worked with Future
before there’s probably something that annoyed you … yes, those pesky ExecutionContext
that needs to be passed implicitly everywhere a Future
is used.
That kind of makes sense, right? a Future
needs an ExecutionContext
to run and if we pass it implicitly it’s a bit less hassle for the developers.
So a Future
is something that needs an ExecutionContext
to run and we can capture that requirement in our effect type.
final case class F[+E, +A](run: ExecutionContext => Future[Either[E, A]])) extends AnyVal
Well know it’s just a wrapper around a function so we can as well just use a function
type F[E, A] = ExecutionContext => Future[Either[E, A]]
Now our effect type F
is just a function that takes an ExecutionContext
and returns a Future
.
This doesn’t seem much but it has a lot of implications. Now when we create an F
we just create a function. And that function doesn’t start running straight away – it has to wait for us to pass it an ExecutionContext
.
Our repeat example now works
val f = (ec: ExecutionContext) => Future { Right(println("Hi")) }
// nothing printed yet : )
def repeat[E, A](f: F[E, A]): F[E, A] = f.flatMap(_ => f)
val run = repeat(f)
// still nothing printed :D
run(scala.concurrent.ExecutionContext.global)
// now it says: "Hi Hi"
Awesome! And as a bonus no more need to carry an ExecutionContext
everywhere. Yes, much cleaner code!
Need to run on a different ExecutionContext
? easy:
def on[E, A](ec: ExecutionContext)(f: F[E, A]) = (_: ExecutionContext) => f(ec)
We can also add some fancy combinators
def repeat[E, A](n: Int)(f: F[E, A]): F[E, A] =
if (n > 0) f.flatMap(_ => repeat(n - 1)(f))
else f
def forever[E, A](f: F[E, A]): F[E, Nothing] = f.flatMap(_ => forever(f))
Notice that forever
returns a F[E, Nothing]
because it could never finish successfully.
You can do something similar for retrying a computation:
def retry[E, A](n: int)(f: F[E, A]]): F[E, A] =
if (n > 0) f.handleErrorWith(_ => retry(n - 1)(f))
else f
def retryForever[E, A](f: F[E, A]): F[E, A] = f.handleErrorWith(_ => retryForever(f))
Speaking of retries it’d be nice if we can delay the retries. So it’d be nice if we could have a delay
combinator. Unfortunately we would need something more powerful than an ExecutionContext
, like a Scheduler
.
If we start changing the input of our function that’s probably because we need another type parameter in our effect. Something like F[C, E, A]
and I guess you can see where this is coming. Yes, this starts to resemble a lot to the famous ZIO
(which btw I do recommend to use instead of this little experiment).
That’s all for today and as always if you find it fun you can have a look at this gist.