Skip to content

Instantly share code, notes, and snippets.

@dcastro
Last active May 27, 2022 17:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save dcastro/c451883ff8aac44c57233ef1c6fd75ee to your computer and use it in GitHub Desktop.
Save dcastro/c451883ff8aac44c57233ef1c6fd75ee to your computer and use it in GitHub Desktop.
IO vs Future and Referential transparency

The problem

These programs are safe to refactor:

object p1 {
  val x = 1 + 123
  val y = 1 + 123
}

object p2 {
  val x = 1 + 123
  val y = x
}
val list: List[Int] = ???

object p1 {
  list.map(_+1) ++ List(1) ++ list.map(_+1)
}

object p2 {
  val listPlus1 = list.map(_+1)
  listPlus1 ++ List(1) ++ listPlus1
}

These ones are not:

object p1 {
  val x = readLine()
  val y = readLine()

}

object p2 {
  val x = readLine()
  val y = x
}
def saveInDb(user: User): Future[Unit] = ???
def getUserCount: Future[Int] = ???

// executes sequentially.
object p1 {
  for {
    _         <- saveInDb(user)
    userCount <- getUserCount
  } yield userCount
}

// executes in parallel.
// we now have a race condition:
//  if call1 executes faster than call2, the `userCount` will be 1
//  if call2 executes faster than call1, the `userCount` will be 0
object p2 {
  val call1 = saveInDb(user)
  val call2 = getUserCount

  for {
    _         <- call1
    userCount <- call2
  } yield userCount
}

These 2 programs are not safe to refactor because they're not referentially transparent.

Referential transparency

An expression e is referentially transparent if every occurrence of e can be replaced with the result of evaluating e without changing the meaning of the program.

val x = e

In other words, e is referentially transparent if x and e are interchangeable.

Referential transparency:

  • allows for "equational reasoning" (e.g. if x = y, and y = z, then x = z)
  • makes programs trivial to refactor.
  • much easier to maintain over time.

The solution - IO[A]

import cats.effect._
import cats.implicits._
import scala.concurrent._

implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global)

Futures begin executing immediately. An IO[A], on the other hand, is simply a recipe for how to obtain a value of type A at a later date.

You can think of Futures as baking a cake, and IOs as recipes for baking a cake.

When you evaluate a method and obtain an IO[A], nothing has been actually executed yet. Actual execution only begins when you call unsafeRunSync / unsafeRunAsync.

def callService(id: Int): IO[Int] = ???


val x: IO[Int] = callService(123)

// here is when execution begins
// it's only at this point that we lose referential transparency
x.unsafeRunSync()

That means that, up until the point where we call unsafeRunX, IO[A] is safe to refactor, allows equational reasoning, etc.

def saveInDb(user: User): IO[Unit] = ???
def getUserCount: IO[Int] = ???

// executes sequentially.
object p1 {
  val program: IO[Int] = 
    for {
      _         <- saveInDb(user)
      userCount <- getUserCount
    } yield userCount

  program.unsafeRunSync()
}

// executes sequentially.
object p2 {
  val call1 = saveInDb(user)
  val call2 = getUserCount

  val program: IO[Int] = 
    for {
      _         <- call1
      userCount <- call2
    } yield userCount

  program.unsafeRunSync()
}

Note: unsafeRunX should be the very last thing you do in your application. If it's a command line app, this would be in your main method.

In a Akka http server, this would be in your router. At this point, when we're handing control back over to Akka, we don't care about losing referential transparency anymore.

IO[A] API

Future.successful("hi")
Future.failed(new Throwable)

IO("hi")
IO.raiseError(new Throwable)

Converting between Future and IO:

def callHttpServer: Future[String] = ???
val callHttpServerIO: IO[String] = IO.fromFuture(IO(callHttpServer))

val callHttpServerFuture: Future[String] = callHttpServerIO.unsafeToFuture()

Combining IOs sequentially:

val callServices: IO[String] = 
  for {
    x <- callService(1)
    y <- callService(2)
  } yield s"Results: $x and $y"



// keep only the result from the 2nd IO and ignore the result from the 1st
val callServices: IO[Int] =
  callService(1) >> callService(2)



val callServices: IO[String] = 
  (callService(1), callService(2)).mapN { (x, y) =>
    s"Results: $x and $y"
  }



val ids: List[Int] = List(1,2,3,4)

val callManyServices: IO[List[Int]] =
  ids.traverse(id => callService(id))

Combining IOs in parallel:

val callServices: IO[String] = 
  (callService(1), callService(2)).parMapN { (x, y) =>
    s"Results: $x and $y"
  }



val ids: List[Int] = List(1,2,3,4)

val callManyServices: IO[List[Int]] =
  ids.parTraverse(id => callService(id))

Recovering from errors:

callService(1)
  .attempt
  .map {
    case Right(res) => s"Success: $res"
    case Left(ex)   => s"Failed: ${ex.getMessage}"
  }
  
  
callService(1) orElse callService(2)

Because, unlike Futures, IOs are just values (like the string "hello", or an Option[Int]), it's really easy to compose and manipulate IOs into more complex ones:

// Repeat a recipe n times
def nTimes[A](io: IO[A], n: Int): IO[List[A]] =
  List.fill(n)(io).sequence
  
// Retry a recipe up to n times
def retry[A](io: IO[A], n: Int): IO[A] =
  if (n <= 1)
    io
  else
    io orElse retry(io, n - 1)

// Execute a recipe forever
def forever[A](io: IO[A]): IO[A] =
  io.flatMap(_ => io)

Additional reading

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment