Sequential Composition
As discussed above, ZIO effects are blueprints for describing concurrent workflows, and we build more sophisticated effects that come closer to solving our business problem by transforming and combining smaller, simpler effects.
We saw how the delay operator could be used to transform one effect into another effect whose execution is delayed into the future. In addition to delay, ZIO has dozens of other powerful operators that transform and combine effects to solve common problems in modern application development.
We will learn about most of these operators in subsequent chapters, but one of the most important operators that we need to introduce is called flatMap.
The flatMap method of ZIO effects represents sequential composition of two effects, allowing us to create a second effect based on the output of the first effect.
A simplified type signature for flatMap looks something like this:
trait ZIO[R, E, A] { ... def flatMap[B](andThen: A => ZIO[R, E, B]): ZIO[R, E, B] = ... ...}In effect, flatMap says, “Run the first effect, then run a second effect that depends on the result of the first one”. Using this sequential operator, we can describe a simple workflow that reads user input and then displays the input back to the user, as shown in the following snippet:
import scala.io.StdIn
val readLine = ZIO.attempt(StdIn.readLine())
def printLine(line: String) = ZIO.attempt(println(line))
val echo = readLine.flatMap(line => printLine(line))Notice how what we print on the console depends on what we read from the console: so we are doing two things in sequence, and the second thing that we do depends on the value produced by the first thing we do.
The flatMap operator is fundamental because it captures the way statements are executed in a procedural program: later statements depend on results computed by previous statements, which is exactly the relationship that flatMap describes.
For reference, here is the above program written in a procedural style:
val line = Console.readLineConsole.println(line)This relationship between procedural programming and the flatMap operator is so precise that we can actually translate any procedural program into ZIO by wrapping each statement in a constructor like ZIO.attempt and then gluing the statements together using flatMap.
For example, let’s say we have the procedural program shown in the following snippet:
val data = doQuery(query)val response = generateResponse(data)writeResponse(response)We can translate this program into ZIO as follows:
ZIO.attempt(doQuery(query)).flatMap(data => ZIO.attempt(generateResponse(data)).flatMap(response => ZIO.attempt(writeResponse(response)) ))Although a straightforward transformation, once you exceed two or three flatMap operations in a row, the nesting of the code becomes somewhat hard to follow. Fortunately, Scala has a feature called for comprehensions, which allow us to express sequential composition in a way that looks like procedural programming.
In the next section, we’ll explore for comprehensions at length.
For Comprehensions
Section titled “For Comprehensions”Using for comprehensions, we can take the following Scala snippet:
readLine.flatMap(line => printLine(line))And rewrite it into the following for comprehension:
import zio._
val echo = for { line <- readLine _ <- printLine(line) } yield ()As you can see from this short snippet, there is no nesting, and each line in the comprehension looks similar to a statement in procedural programming.
For comprehensions have the following structure:
- They are introduced by the keyword
for, followed by a code block, and terminated by the keywordyield, which is followed by a single parameter, representing the success value of the effect. - Each line of the for comprehension is written using the format
result <- effect, whereeffectreturns an effect, andresultis a variable that will hold the success value of the effect. If the result of the effect is not needed, then the underscore may be used as the variable name.
A for comprehension with n lines is translated by Scala into n - 1 calls to flatMap methods on the effects, followed by a final call to a map method on the last effect.
So, for example, if we have the following for comprehension:
for { x <- doA y <- doB(x) z <- doC(x, y)} yield x + y + zThen Scala will translate it into the following code:
doA.flatMap(x => doB(x).flatMap(y => doC(x, y).map(z => x + y + z)))Many Scala developers find that for comprehensions are easier to read than long chains of nested calls to flatMap. In this book, except for very short snippets, we will prefer for comprehensions over explicit calls to flatMap.
