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 ofe
can be replaced with the result of evaluatinge
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.
IO[A]
The solution - 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
- Cats IO docs: https://typelevel.org/cats-effect/datatypes/io.html
- What is FP and why should I care? https://docs.google.com/presentation/d/1PS_dFGmjAi9tWrhVlg_TUqXCvwd0eDDs0F6mRyoIFJ0