Looking at some differences between Scalaz Task and Scala Future

Some time ago I was searching for something and came across this Reddit thread about this tweet from Timothy Perrett, who leads Scala teams at Verizon:

“The fact that #scala Future is not lazy just blows my mind. After years of using Scalaz Task, Future is now totally unusable.”

The last part of that tweet is a bit of hyperbole to me, as I’ve been using the Scala Future for a long time myself, and I’ve had no problems using it. That being said, the examples at the top of the Reddit page were interesting, so I decided to try to understand the differences.

The Scala Future example

Here’s the source code for tpolecat’s Scala Future example, turned into a complete Scala App, with a few comments added:

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.Random

object FutureDemo extends App {

    val f1 = {
        val r = new Random(0L)
        val x = Future(r.nextInt)
        for {
            a <- x
            b <- x
        } yield (a, b)
    }

    // Same as f1, but I inlined `x`
    val f2 = {
        val r = new Random(0L)
        for {
            a <- Future(r.nextInt)
            b <- Future(r.nextInt)
        } yield (a, b)
    }

    f1.onComplete(println)  //Success((-1155484576,-1155484576))

    // when you inline `x` you get a different result. this violates the
    // definition of RT.
    f2.onComplete(println)  //Success((-1155484576,-723955400))  <-- not the same

}

As I noted in the comments, you get a different result with the Future when you inline the source code for the definition of x, and this violates the definition of referential transparency.

The Scalaz Task example

Here’s the source code for tpolecat’s Scalaz Task example, turned into a complete Scala App, with a few comments added:

import scalaz.concurrent.Task
import scala.util.Random

object TaskDemo extends App {

    val task1 = {
        val r = new Random(0L)
        val x = Task.delay(r.nextInt)
        for {
            a <- x
            b <- x
        } yield (a, b)
    }

    // same as task1, but x is inlined
    val task2 = {
        val r = new Random(0L)
        for {
            a <- Task.delay(r.nextInt)
            b <- Task.delay(r.nextInt)
        } yield (a, b)
    }

    // `run` is deprecated, use `unsafePerformSync`
    println(task1.unsafePerformSync) // (-1155484576,-723955400)

    // when you inline `x` you get the same result as `task1`,
    // this fits the RT definition.
    println(task2.unsafePerformSync) // (-1155484576,-723955400)

}

As noted in the comments, with Task you get the same result with the inlined version of the code, so this meets the definition of RT. So far, so good.

What happens when you call the Future twice?

What I found with further digging is that the two approaches continue to work differently. For example, if you call f1.onComplete with the Future example, you get the same result you got the first time you called it:

f1.onComplete(println)  //Success((-1155484576,-1155484576))

// if you call f1.onComplete a million times, you'll always get the same
// result. in this way it feels more like a pure function.
// the important point is that Task doesn't work this way.
f1.onComplete(println)  //Success((-1155484576,-1155484576))

“Getting the same result no matter how many times you call it” is part of the definition of a pure function, so this feels correct.

What happens when you call the Task twice?

Conversely, when you call task1.unsafePerformSync multiple times, you get a different result with each call:

println(task1.unsafePerformSync)  //(-1155484576,-723955400)

// if you call task1.unsafePerformSync again, you get a different result.
// this makes it seem like an impure function call.
println(task1.unsafePerformSync)  //(1033096058,-1690734402)
println(task1.unsafePerformSync)  //(-1557280266,1327362106)

As I note in the comments, getting a different result every time you call the function makes it feel like an impure function.

Is one approach correct?

I’m not an FP purist, so what I see are two different approaches that are good in their own way. The features are:

  • Future doesn’t meet the definition of RT, but it returns the same result every time you ask for its result.
  • Task is RT, but returns a different result every time you call it.

From that you can see that Task is RT and also infer that it’s lazy (lazily evaluated).

As you can see from comments on the Twitter post and Reddit page, FP purists clearly prefer that the Scalaz Task is lazy (and of course Scalaz is a library for functional programming), so if you want to be an FP purist, I suggest that you try to fully grok why “RT + lazy” is preferred over Future.

Myself, I like some other features about Task that I’ll write about some time in the future, but for today I just wanted to explore a few of the differences between Task and Future, based on the source code posted on that Reddit page.

Source code

If you’re interested in running these tests (and other tests) on Task and Future, I made the source code I created based on tpolecat’s original code available at this URL: