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.
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.
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.
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)
- 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