2. For comprehension
La for comprehension de Scala es la abstracción ideal de la programación funcional para los
programas secuenciales que interactúan con el mundo. Dado que la usaremos mucho, vamos a reaprender
los principios de for y cómo Scalaz puede ayudarnos a escribir código más claro.
Este capítulo no intenta enseñarnos a escribir programas puros y las técnicas son aplicables a bases de código que no sean puramente funcionales.
2.1 Conveniencia sintáctica
for en Scala es simplemente una regla de reescritura, llamada también una conveniencia sintáctica
(azúcar sintáctico) y no tiene ninguna información contextual.
Para ver qué es lo que hace una for comprehension, usamos la característica show y reify de
la REPL (Read-Eval-Print-Loop) para mostrar cómo se ve el código después de la inferencia de tipos.
scala> import scala.reflect.runtime.universe._
scala> val a, b, c = Option(1)
scala> show { reify {
for { i <- a ; j <- b ; k <- c } yield (i + j + k)
} }
res:
$read.a.flatMap(
((i) => $read.b.flatMap(
((j) => $read.c.map(
((k) => i.$plus(j).$plus(k)))))))
Existe mucho “ruido” debido a las conveniencias sintácticas adicionales (por ejemplo, + se
reescribe $plus, etc.). No mostraremos el show y el reify por brevedad cuando la línea del
REPL se muestre como reify, y manualmente limpiaremos el código generado de modo que no se vuelva
una distracción.
reify> for { i <- a ; j <- b ; k <- c } yield (i + j + k)
a.flatMap {
i => b.flatMap {
j => c.map {
k => i + j + k }}}
La regla de oro es que cada <- (llamada un generador) es una invocación anidada de flatMap,
siendo el último generador un map que contiene el cuerpo del yield.
2.1.1 Asignación
Podemos asignar valores al vuelo (inline) como ij = i + j (no es necesaria la palabra
reservada val).
reify> for {
i <- a
j <- b
ij = i + j
k <- c
} yield (ij + k)
a.flatMap {
i => b.map { j => (j, i + j) }.flatMap {
case (j, ij) => c.map {
k => ij + k }}}
Un map sobre la b introduce la ij, sobre la cual se llama un flatMap junto con la j, y
entonces se invoca a un map final sobre el código en el yield.
Desgraciadamente no podemos realizar ninguna asignación antes de algún generador. Esta característica ya ha sido solicitada para que sea soportada por el lenguaje, pero la implementación no se ha llevado a cabo: https://github.com/scala/bug/issues/907
scala> for {
initial = getDefault
i <- a
} yield initial + i
<console>:1: error: '<-' expected but '=' found.
Podemos proporcionar una solución alternativa al definir un val fuera del for
scala> val initial = getDefault
scala> for { i <- a } yield initial + i
o crear un Option a partir de la asignación inicial
scala> for {
initial <- Option(getDefault)
i <- a
} yield initial + i
2.1.2 Filter
Es posible poner sentencias if después de un generador para filtrar los valores usando un
predicado
reify> for {
i <- a
j <- b
if i > j
k <- c
} yield (i + j + k)
a.flatMap {
i => b.withFilter {
j => i > j }.flatMap {
j => c.map {
k => i + j + k }}}
Versiones anteriores de Scala usaban filter, pero Traversable.filter crea nuevas colecciones
para cada predicado, de modo que withFilter se introdujo como una alternativa de mayor
rendimiento. Podemos ocasionar accidentalmente la invocación de withFilter al proporcionar
información de tipo, interpretada como un empate de patrones.
reify> for { i: Int <- a } yield i
a.withFilter {
case i: Int => true
case _ => false
}.map { case i: Int => i }
Como la asignación, un generador puede usar un empate de patrones en el lado izquierdo. Pero a
diferencia de una asignación (que lanza un MatchError al fallar) los generadores son filtrados
y no fallarán en tiempo de ejecución. Sin embargo, existe una aplicación ineficiente, doble, del
patrón.
2.1.3 For Each
Finalmente, si no hay un yield, el compilador usará foreach en lugar de flatMap, que es útil
únicamente por los efectos colaterales.
reify> for { i <- a ; j <- b } println(s"$i $j")
a.foreach { i => b.foreach { j => println(s"$i $j") } }
2.1.4 Resumen
El conjunto completo de métodos soportados por las for comprehensions no comparten un super tipo
común; cada fragmento generado es compilado de manera independiente. Si hubiera un trait, se vería
aproximadamente así:
trait ForComprehensible[C[_]] {
def map[A, B](f: A => B): C[B]
def flatMap[A, B](f: A => C[B]): C[B]
def withFilter[A](p: A => Boolean): C[A]
def foreach[A](f: A => Unit): Unit
}
Si el contexto (C[_]) de una for comprehension no proporciona su propio map y flatMap, no
todo está perdido. Si existe un valor implícito de scalaz.Bind[T] para la T en cuestión, este
proporcionará el map y el flatMap.
2.2 El camino dificultoso
Hasta el momento sólo hemos observado las reglas de reescritura, no lo que está sucediendo en el
map y en el flatMap. Considere lo que sucede cuando el contexto del for decide que no se
puede proceder más.
En el ejemplo del Option, el yield se invoca únicamente cuando todos i, j, k están
definidos.
for {
i <- a
j <- b
k <- c
} yield (i + j + k)
Si cualquiera de a, b, c es None, la comprehension se corto-circuita con None pero no nos
indica qué fue lo que salió mal.
Si usamos Either, entonces un Left ocasionará que la for comprehension se corto circuite con
información extra, mucho mejor que Option para reporte de errores:
scala> val a = Right(1)
scala> val b = Right(2)
scala> val c: Either[String, Int] = Left("sorry, no c")
scala> for { i <- a ; j <- b ; k <- c } yield (i + j + k)
Left(sorry, no c)
Y por último, veamos que sucede con un Future que falla:
scala> import scala.concurrent._
scala> import ExecutionContext.Implicits.global
scala> for {
i <- Future.failed[Int](new Throwable)
j <- Future { println("hello") ; 1 }
} yield (i + j)
scala> Await.result(f, duration.Duration.Inf)
caught java.lang.Throwable
El Futuer que imprime a la terminal nunca se invoca porque, como Option y Either, la for
comprehension se corto circuita.
El corto circuito para el camino difícil es un tema común e importante. Las for comprehensions no
pueden expresar la limpieza de recursos: no hay forma de realizar un try/finally. Esto es bueno,
en PF asigna claramente la responsabilidad por la recuperación inesperada de errores y la limpieza
de recursos en el contexto (que normalmente es una Monad como veremos más tarde), no la lógica del
negocio.
2.3 Gimnasia
Aunque es sencillo reescribir código secuencial simple como una for comprehension, algunas veces
desearemos hacer algo que parezca requerir hacer volteretas mentales. Esta sección recolecta algunos
ejemplos prácticos de cómo lidiar con ellos.
2.3.1 Lógica de respaldo
Digamos que estamos llamando a un método que regresa un Option. Si no es exitoso deseamos ejecutar
otro método como respaldo (y así consecutivamente), como cuando estamos unando un cache:
def getFromRedis(s: String): Option[String]
def getFromSql(s: String): Option[String]
getFromRedis(key) orElse getFromSql(key)
Si tenemos que hacer esto para una versión asíncrona de la misma API
def getFromRedis(s: String): Future[Option[String]]
def getFromSql(s: String): Future[Option[String]]
entonces tenemos que ser cuidadosos de no hacer trabajo extra porque
for {
cache <- getFromRedis(key)
sql <- getFromSql(key)
} yield cache orElse sql
ejecutará ambas consultas. Podemos hacer empate de patrones en el primer resultado pero el tipo es incorrecto
for {
cache <- getFromRedis(key)
res <- cache match {
case Some(_) => cache !!! wrong type !!!
case None => getFromSql(key)
}
} yield res
Necesitamos crear un Future a partir del cache
for {
cache <- getFromRedis(key)
res <- cache match {
case Some(_) => Future.successful(cache)
case None => getFromSql(key)
}
} yield res
Future.successful crea un nuevo Future, de manera muy similar a cómo un Option o un
constructor de List.
2.3.2 Salida temprana
Digamos que tenemos alguna condición que implique la terminación temprana con un valor exitoso.
Si deseamos terminar de manera temprana con un error, es práctica estándar en programación orientada a objectos lanzar una excepción
def getA: Int = ...
val a = getA
require(a > 0, s"$a must be positive")
a * 10
que puede reescribirse de manera asíncrona
def getA: Future[Int] = ...
def error(msg: String): Future[Nothing] =
Future.failed(new RuntimeException(msg))
for {
a <- getA
b <- if (a <= 0) error(s"$a must be positive")
else Future.successful(a)
} yield b * 10
Pero si deseamos terminar temprano con un valor de retorno exitoso, el código síncrono simple:
def getB: Int = ...
val a = getA
if (a <= 0) 0
else a * getB
se traduce a for comprehension anidados cuando nuestras dependencias son asíncronas:
def getB: Future[Int] = ...
for {
a <- getA
c <- if (a <= 0) Future.successful(0)
else for { b <- getB } yield a * b
} yield c
2.4 Sin posibilidad de usar for comprehension
El contexto que sobre el que estamos haciendo la for comprehension debe ser el mismo: no es
posible mezclar contextos.
scala> def option: Option[Int] = ...
scala> def future: Future[Int] = ...
scala> for {
a <- option
b <- future
} yield a * b
<console>:23: error: type mismatch;
found : Future[Int]
required: Option[?]
b <- future
^
Nada puede ayudarnos a mezclar contextos arbitrarios en una for comprehension porque el
significado no está bien definido.
Cuando tenemos contextos anidados la intención es normalmente obvia, pero sin embargo, el compilador no aceptará nuestro código.
scala> def getA: Future[Option[Int]] = ...
scala> def getB: Future[Option[Int]] = ...
scala> for {
a <- getA
b <- getB
} yield a * b
^
<console>:30: error: value * is not a member of Option[Int]
Aquí deseamos que for se ocupe del contexto externo y nos deje escribir nuestro código en el
Option interno. Esconder el contexto externo es exactamente lo que hace un
transformador de mónadas, y Scalaz proporciona implementaciones para Option y Either llamadas
OptionT y EitherT respectivamente.
El contexto externo puede ser cualquier cosa que normalmente funcione en una for comprehension,
pero necesita ser el mismo a través de toda la comprehension.
Aquí creamos un OptionT por cada invocación de método. Esto cambia el contexto del for de un
Future[Option[_]] a OptionT[Future, _].
scala> val result = for {
a <- OptionT(getA)
b <- OptionT(getB)
} yield a * b
result: OptionT[Future, Int] = OptionT(Future(<not completed>))
.run nos devuelve el contexto original.
scala> result.run
res: Future[Option[Int]] = Future(<not completed>)
El transformador de mónadas también nos permite mezclar invocaciones a Future[Option[_]] con
métodos que simplemente devuelven Future mediante el uso de .liftM[OptionT] (proporcionado por
Scalaz):
scala> def getC: Future[Int] = ...
scala> val result = for {
a <- OptionT(getA)
b <- OptionT(getB)
c <- getC.liftM[OptionT]
} yield a * b / c
result: OptionT[Future, Int] = OptionT(Future(<not completed>))
y así podemos mezclar métodos que devuelven un Option simple al envolverlos en un
Future.successful (.pure[Future]) seguido de un OptionT
scala> def getD: Option[Int] = ...
scala> val result = for {
a <- OptionT(getA)
b <- OptionT(getB)
c <- getC.liftM[OptionT]
d <- OptionT(getD.pure[Future])
} yield (a * b) / (c * d)
result: OptionT[Future, Int] = OptionT(Future(<not completed>))
De nuevo, el código es algo enmarañado, pero es mejor que escribir flatMap y map anidados
manualmente. Podríamos limpiarlo un poco con un Domain Specific Language (DSL) que se encargue de
todas las conversiones a OptionT[Future, _] que sean necesarias
def liftFutureOption[A](f: Future[Option[A]]) = OptionT(f)
def liftFuture[A](f: Future[A]) = f.liftM[OptionT]
def liftOption[A](o: Option[A]) = OptionT(o.pure[Future])
def lift[A](a: A) = liftOption(Option(a))
combinados con el operador |>, que aplica la función de la derecha al valor a la izquierda, para
separar visualmente la lógica de los transformadores
scala> val result = for {
a <- getA |> liftFutureOption
b <- getB |> liftFutureOption
c <- getC |> liftFuture
d <- getD |> liftOption
e <- 10 |> lift
} yield e * (a * b) / (c * d)
result: OptionT[Future, Int] = OptionT(Future(<not completed>))
Este enfoque también funciona para EitherT (y otros) como el contexto interno, pero sus métodos
para hacer lifting som más complejos y requieren parámetros. Scalaz proporciona transformadores
de mónadas para muchos de sus propios tipos, de modo que vale la pena verificar si hay alguno
disponible.