A lazy future

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.