Tabla de contenidos
- Acerca de este libro
- Aviso de Copyleft
- Agradecimientos
- Aspectos prácticos
- 1. Introducción
- 2. Comprensión for
- 3. Diseño de aplicaciones
- 4. Datos y funcionalidad
- 5. Scalaz Typeclasses
- 6. Tipos de datos de Scalaz
- 7. Mónadas avanzadas
- 8. Derivación de typeclasses
- 9. Alambrando la aplicación
- Tabla de typeclasses
- Haskell
- Licencias de terceros
“El amor es sabio; el odio es tonto. En este mundo, que está interconectado cada vez más, tenemos que aprender a tolerarnos unos a otros, tenemos que aprender a soportar el hecho de que algunas personas dirán cosas que no nos agraden. Es la única manera en la que podemos vivir juntos. Pero si hemos de vivir juntos, y no morir juntos, debemos aprender una clase de caridad y clase de tolerancia, que es absolutamente vital para la continuidad de la vida humana en este planeta.”
― Bertrand Russell
Acerca de este libro
Este libro es para el desarrollador de Scala típico, probablemente con conocimientos previos de Java, que tiene escepticismo y curiosidad sobre el paradigma de Programación Funcional (PF). Este libro justifica cada concepto con ejemplos prácticos, incluyendo la escritura de una aplicación web.
Este libro usa Scalaz 7.2, la librería para Programación Funcional en Scala más exhaustiva, popular, estable y basada en principios.
Este libro está diseñado para leerse de principio a fin, en el orden presentado, con un descanso entre capítulos. Los primeros capítulos incentivan estilos de programación que más tarde serán desacreditados: de manera similar a cómo aprendimos la teoría la gravedad de Newton cuando eramos niños, y progresamos a Riemann/Einstein/Maxwell si nos convertimos en estudiantes de física.
No es necesaria una computadora mientras se lee el libro, pero se recomienda el estudio del código fuente de Scalaz. Algunos de los ejemplos de código más complejos se encuentran con el código fuente del libro y se anima a aquellos que deseen ejercicios prácticos a reimplementar Scalaz (y la aplicación de ejemplo) usando las descripciones parciales presentadas en este libro.
También recomendamos El libro rojo como lectura adicional. Éste libro enseña cómo escribir una librería de Programación Funcional en Scala usando principios fundamentales.
Aviso de Copyleft
Este libro es Libre y sigue la filosofía de Free Software: usted puede usar este libro como desee, el código fuente está disponible y puede redistribuir este libro y puede distribuir su propia versión. Esto significa que puede imprimirlo, fotocopiarlos, enviarlo por correo electrónico, subirlo a sitios web, cambiarlo, traducirlo, cobrar por él, mezclarlo, borrar partes de él, y dibujar encima de él.
Este libro es Copyleft: si usted cambia el libro y distribuye su propia version, también debe pasar estas libertades a quienes lo reciban.
Este libro usa la licencia Atribución/Reconocimiento-CompartirIgual 4.0 Internacional (CC BY-SA 4.0).
Todos los fragmentos de código en este libro están licenciadas de manera separada de acuerdo con CC0, y usted puede usarlos sin restricción. Los fragmentos de Scalaz y librerías relacionadas mantienen su propia licencia, que se reproduce de manera completa en el apéndice.
La aplicación de ejemplo drone-dynamic-agents se distribuye bajo los términos
de la licencia GPLv3: sólo los
fragmentos de código en este libro están disponibles sin restricción alguna.
Agradecimientos
Diego Esteban Alonso Blas, Raúl Raja Martínez y Peter Neyens de 47 degrees, Rúnar Bjarnason, Tony Morris, John de Goes y Edward Kmett por su ayuda explicando los principios de la Programación Funcional. Kenji Yoshida y Jason Zaugg por ser los principales autores de Scalaz, y Paul Chiusano / Miles Sabin por arreglar un error crítico en el compilador de Scala (SI-2712).
Muchas gracias a los lectores que dieron retroalimentación de los primeros bosquejos de este texto.
Cierto material fue particularmente valioso para mi propio entendimiento de los conceptos que están en este libro. Gracias a Juan Manuel Serrano por All Roads Lead to Lambda, Pere Villega por On Free Monads, Dick Wall y Josh Suereth por For: What is it Good For?, Erik Bakker por Options in Futures, how to unsuck them, Noel Markham por ADTs for the Win!, Sukant Hajra por Classy Monad Transformers, Luka Jacobowitz por Optimizing Tagless Final, Vincent Marquez por Index your State, Gabriel Gonzalez por The Continuation Monad, y Yi Lin Wei / Zainab Ali por sus tutoriales en los meetups Hack The Tower.
A las almas serviciales que pacientemente me explicaron cosas a mi: Merlin Göttlinger, Edmund Noble, Fabio Labella, Adelbert Chang, Michael Pilquist, Paul Snively, Daniel Spiewak, Stephen Compall, Brian McKenna, Ryan Delucchi, Pedro Rodriguez, Emily Pillmore, Aaron Vargo, Tomas Mikula, Jean-Baptiste Giraudeau, Itamar Ravid, Ross A. Baker, Alexander Konovalov, Harrison Houghton, Alexandre Archambault, Christopher Davenport, Jose Cardona, Isaac Elliott.
Aspectos prácticos
Para configurar un proyecto que use las librerías presentadas en este libro, use
una versión reciente de Scala con características específicas de Programación
Funcional habilitadas (por ejemplo, en build.sbt):
scalaVersion in ThisBuild := "2.12.6"
scalacOptions in ThisBuild ++= Seq(
"-language:_",
"-Ypartial-unification",
"-Xfatal-warnings"
)
libraryDependencies ++= Seq(
"com.github.mpilquist" %% "simulacrum" % "0.13.0",
"org.scalaz" %% "scalaz-core" % "7.2.26"
)
addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.7")
addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full)
Con el objetivo de mantener la brevedad en los fragmentos de código, omitiremos
la sección de import. A menos que se diga lo contrario, asumimos que todos los
fragmentos tienen las siguientes sentencias de import:
import scalaz._, Scalaz._
import simulacrum._
1. Introducción
Es parte de la naturaleza humana permanecer escéptico a un nuevo paradigma. Para poner en perspectiva qué tan lejos hemos llegado, y los cambios que ya hemos aceptado en la JVM (de las siglas en inglés para Máquina Virtual de Java), empecemos recapitulando brevemente los últimos 20 años.
Java 1.2 introdujo la API de Colecciones, permitiéndonos escribir métodos que abstraen sobre colecciones mutables. Era útil para escribir algoritmos de propósito general y era el fundamento de nuestras bases de código.
Pero había un problema, teníamos que hacer casting en tiempo de ejecución:
public String first(Collection collection) {
return (String)(collection.get(0));
}
En respuesta, los desarrolladores definieron objectos en su lógica de negocios
que eran efectivamente ColeccionesDeCosas, y la API de Colecciones se
convirtió en un detalle de implementación.
En 2005, Java 5 introdujo los genéricos, permitiéndonos definir una
Coleccion<Cosa>, abstrayendo no sólo el contenedor sino sus elementos. Los
genéricos cambiaron la forma en que escribimos Java.
El autor del compilador de genéricos para Java, Martin Odersky, creó Scala después, con un systema de tipo más fuerte, estructuras inmutables y herencia múltiple. Esto trajo consigo una fusión de la Programación Orientada a Objectos (POO) y la Programación Funcional (PF).
Para la mayoría de los desarrolladores, PF significa usar datos inmutables tanto como sea posible, pero consideran que el estado mutable todavía es un mal necesario que debe aislarse y controlarse, por ejemplo con actores de Akka o clases sincronizadas. Este estilo de PF resulta en programas simples que son fáciles de paralelizar y distribuir: definitivamente es una mejora con respecto a Java. Pero este enfoque solo toca la amplia superficie de beneficios de PF, como descubriremos en este libro.
Scala también incorpora Future, haciendo simple escribir aplicaciones
asíncronas. Pero cuando un Future se usa en un tipo de retorno, todo
necesita reescribirse para usarlos, incluyendo las pruebas, que ahora están
sujetas a timeouts arbitrarios.
Nos encontramos con un problema similar al que se tuvo con Java 1.0: no hay forma de abstraer la ejecución, así como no se tenía una manera de abstraer sobre las colecciones.
1.1 Abstrayendo sobre la ejecución
Suponga que deseamos interactuar con el usuario a través de la línea de
comandos. Podemos leer (read) lo que el usuario teclea y entonces podemos
escribirle (write) un mensaje.
trait TerminalSync {
def read(): String
def write(t: String): Unit
}
trait TerminalAsync {
def read(): Future[String]
def write(t: String): Future[Unit]
}
¿Cómo podemos escribir código genérico que haga algo tan simple como un eco de la entrada del usuario, ya sea de manera síncrona o asíncrona, dependiendo de nuestra implementación para tiempo de ejecución?
Podríamos escribir una versión síncrona y envolverla en un Future pero ahora
tendríamos que preocuparnos por cuál thread pool debemos usar para ejecutar el
trabajo, o podríamos esperar el Future con Await.result y bloquear el
thread. En ambos casos, es mucho código tedioso y fundamentalmente estamos
lidiando con dos APIs que no están unificadas.
Podríamos resolver el problema, como con Java 1.2, con un padre común usando el soporte de Scala para higher-kinded types (HKT).
Deseamos definir Terminal para un constructor de tipo C[_]. Si definimos
Now (en español, ahora) de modo que sea equivalente a su parámetro de tipo
(como Id), es posible implementar una interfaz común para terminales síncronas
y asíncronas:
trait Terminal[C[_]] {
def read: C[String]
def write(t: String): C[Unit]
}
type Now[X] = X
object TerminalSync extends Terminal[Now] {
def read: String = ???
def write(t: String): Unit = ???
}
object TerminalAsync extends Terminal[Future] {
def read: Future[String] = ???
def write(t: String): Future[Unit] = ???
}
Podemos pensar en C como un Contexto porque decimos “en el contexto de
ejecución Now (ahora)” o “en el Futuro”.
Pero no sabemos nada sobre C y tampoco podemos hacer algo con un C[String].
Lo que necesitamos es un contexto/ambiente de ejecución que nos permita invocar
un método que devuelva C[T] y después sea capaz de hacer algo con la T,
incluyendo la invocación de otro método sobre Terminal. También necesitamos
una forma de envolver un valor como un C[_]. La siguiente signatura funciona
bien:
trait Execution[C[_]] {
def chain[A, B](c: C[A])(f: A => C[B]): C[B]
def create[B](b: B): C[B]
}
que nos permite escribir:
def echo[C[_]](t: Terminal[C], e: Execution[C]): C[String] =
e.chain(t.read) { in: String =>
e.chain(t.write(in)) { _: Unit =>
e.create(in)
}
}
Ahora podemos compartir la implementación de echo en código síncrono y
asíncrono. Podemos escribir una implementación simulada de Terminal[Now] y
usarla en nuestras pruebas sin ningún tiempo límite (timeout).
Las implementaciones de Execution[Now] y Execution[Future] son reusables por
métodos genéricos como echo.
Pero el código para echo es horrible!
La característica de clases implícitas del lenguaje Scala puede darle a C
algunos métodos. Llamaremos a estos métodos flatMap y map por razones que
serán más claras en un momento. Cada método acepta un implicit Execution[C].
Estos no son más que los métodos flatMap y map a los que estamos
acostumbrados a usar con Seq, Option y Future.
object Execution {
implicit class Ops[A, C[_]](c: C[A]) {
def flatMap[B](f: A => C[B])(implicit e: Execution[C]): C[B] =
e.chain(c)(f)
def map[B](f: A => B)(implicit e: Execution[C]): C[B] =
e.chain(c)(f andThen e.create)
}
}
def echo[C[_]](implicit t: Terminal[C], e: Execution[C]): C[String] =
t.read.flatMap { in: String =>
t.write(in).map { _: Unit =>
in
}
}
Ahora podemos revelar porqué usamos flatMap como el nombre del método: nos
permite usar una for comprehension, que es una conveniencia sintáctica
(syntax sugar) para flatMap y map anidados.
def echo[C[_]](implicit t: Terminal[C], e: Execution[C]): C[String] =
for {
in <- t.read
_ <- t.write(in)
} yield in
Nuestro contexto Execution tiene las mismas signaturas que una trait en
Scalaz llamada Monad, excepto que chain es bind y create es pure.
Decimos que C es monádica cuando hay una Monad[C] implícita disponible en
el ámbito. Además, Scalaz tiene el alias de tipo Id.
En resumen, si escribimos métodos que operen en tipos monádicos, entonces
podemos escribir código sequencial que puede abstraer sobre su contexto de
ejecución. Aquí, hemos mostrado una abstracción sobre la ejecución síncrona y
asíncrona pero también puede ser con el propósito de conseguir un manejo más
riguroso de los errores (donde C[_] es Either[Error, ?]), administrar o
controlar el acceso a estado volátil, realizar I/O, o la revisión de la sesión.
1.2 Programación Funcional Pura
La programación funcional es el acto de escribir programas con funciones puras. Las funciones puras tienen tres propiedades:
- Totales: devuelven un valor para cada entrada posible.
- Deterministas: devuelven el mismo valor para la misma entrada.
- Inculpable: no interactúan (directamente) con el mundo o el estado del programa.
Juntas, estas propiedades nos dan una habilidad sin precedentes para razonar sobre nuestro código. Por ejemplo, la validación de entradas es más fácil de aislar gracias a la totalidad, el almacenamiento en caché es posible cuando las funciones son deterministas, y la interacción con el mundo es más fácil de controlar, y probar, cuando las funciones son inculpables.
Los tipos de cosas que violan estas propiedades son efectos laterales: el
acceso o cambio directo de estado mutable (por ejemplo, mantener una var en
una clase o el uso de una API antigua e impura), la comunicación con recursos
externos (por ejemplo, archivos o una búsqueda en la red), o lanzar y atrapar
excepciones.
Escribimos funciones puras mediante evitar las excepciones, e interactuando con
el mundo únicamente a través de un contexto de ejecución F[_] seguro.
En la sección previa, se hizo una abstracción sobre la ejecución y se definieron
echo[Id] y echo[Future]. Tal vez esperaríamos, razonablemente, que la
invocación de cualquier echo no realizara ningún efecto lateral, porque es
puro. Sin embargo, si usamos Future o Id como nuestro contexto de ejecución,
nuestra aplicación empezará a escuchar a stdin:
val futureEcho: Future[String] = echo[Future]
Estaríamos violando la pureza y no estaríamos escribiendo código puramente
funcional: futureEchoes el resultado de invocar echo una vez. Future
combina la definición de un programa con su interpretación (su ejecución).
Como resultado, es más difícil razonar sobre las aplicaciones construidas con
Future.
Podemos definir un contexto de ejecución simple F[_]:
final class IO[A](val interpret: () => A) {
def map[B](f: A => B): IO[B] = IO(f(interpret()))
def flatMap[B](f: A => IO[B]): IO[B] = IO(f(interpret()).interpret())
}
object IO {
def apply[A](a: =>A): IO[A] = new IO(() => a)
}
que evalúa un thunk de manera perezosa (o por necesidad). IO es simplemente
una estructura de datos que referencia (posiblemente) código impuro, y no está,
en realidad, ejecutando algo. Podemos implementar Terminal[IO]:
object TerminalIO extends Terminal[IO] {
def read: IO[String] = IO { io.StdIn.readLine }
def write(t: String): IO[Unit] = IO { println(t) }
}
e invocar echo[IO] para obtener de vuelta el valor
val delayed: IO[String] = echo[IO]
Este val delayed puede ser reutilizado, es simplemente la definición del
trabajo que debe hacerse. Podemos mapear la cadena y componer (en el sentido
funcional) programas, tal como podríamos mapear un Futuro. IO nos mantiene
honestos al hacer explícito que dependemos de una interacción con el mundo, pero
no nos detiene de acceder a la salida de tal interacción.
El código impuro dentro de IO solo es evaluado cuando se invoca .interpret()
sobre el valor, y se trata de una acción impura
delayed.interpret()
Una aplicación compuesta de programas IO solamente se interpreta una vez, en
el método main, que tambié se llama el fin del mundo.
En este libro, expandiremos los conceptos introducidos en este capítulo y mostraremos cómo escribir funciones mantenibles y puras, que logren nuestros objetivos de negocio.
2. Comprensión for
La comprensión for 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 Future 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.
3. Diseño de aplicaciones
En este capítulo escribiremos la lógica de negocio y las pruebas para una
aplicación de servidor puramente funcional. El código fuente para esta
aplicación se incluye bajo el directorio example junto con la fuente del
libro, aunque se recomienda no leer el código fuente hasta el final del capítulo
porque habrá refactorizaciones significativas a medida que aprendamos más sobre
la programación funcional.
3.1 Especificación
Nuestra aplicación administrará una granja de compilación just-in-time (justo a tiempo) con un presupuesto limitado. Escuchará al servidor de integración continua Drone, y creará agentes trabajadores usando Google Container Engine (GKE) para lograr las demandas de la cola de trabajo.
El drone recibe trabajo cuando un contribuidor manda un pull request de github a uno de los proyectos administrados. El dron asigna el trabajo a sus agentes, cada uno procesando una actividad a la vez.
La meta de nuestra aplicación es asegurar que hay suficientes agentes para completar el trabajo, con un límite de capacidad para el número de agentes, a la vez que se minimiza el costo total. Nuestra aplicación necesita conocer el número de artículos en el backlog y el número disponible de agentes.
Google puede crear nodos, y cada uno puede hospedar múltiples agentes de dron. Cuando un agente inicia, se registra a sí mismo con un dron y el dron se encarga del ciclo de vida (incluyendo las invocaciones de supervivencia para detectar los agentes removidos).
GKE cobra una cuota por minuto de tiempo de actividad, redondeado (hacia arriba) al la hora más cercana para cada nodo. No se trata de simplemente crear un nuevo nodo por cada trabajo en la cola de actividades, debemos reusar nodos y retenerlos haste su minuto # 58 para obtener el mayor valor por el dinero.
Nuestra aplicación necesita ser capaz de empezar y detener nodos, así como verificar su estatus (por ejemplo, los tiempos de actividad, y una lista de los nodos inactivos) y conocer qué tiempos GKE piensa que debe haber.
Además, no hay una API para hablar directamente con un agente de modo que no sabemos si alguno de los agentes individuales está realizando algún trabajo para el servidor de drones. Si accidentalmente detenemos un agente mientras está realizando trabajo, es inconveniente y requiere que un humano reinicie el trabajo.
Los contribuidores pueden añadir agentes manualmente a la granja, de modo que contar agentes y nodos no es equivalente. No es necesario proporcionar algún nodo si hay agentes disponibles.
El modo de falla siempre debería tomar al menos la opción menos costosa.
Tanto Drone como GKE tienen una interfaz JSON sobre una API REST con autenticación OAuth 2.0.
3.2 Interfaces / Algebras
Ahora codificaremos el diagrama de arquitectura de la sección previa. Primeramente, necesitamos definir un tipo de datos simple para almacenar un momento (tiempo) en milisegundos porque este concepto simple no existe ni en la librería estándar de Java ni en la de Scala:
import scala.concurrent.duration._
final case class Epoch(millis: Long) extends AnyVal {
def +(d: FiniteDuration): Epoch = Epoch(millis + d.toMillis)
def -(e: Epoch): FiniteDuration = (millis - e.millis).millis
}
En PF, una álgebra toma el lugar de una interface en Java, o el conjunto de
mensajes válidos para un Actor de Akka. Esta es la capa donde definimos todas
las interacciones colaterales de nuestro sistema.
Existe una interacción estrecha entre la escritura de la lógica de negocio y su álgebra: es un buen nivel de abstracción para diseñar un sistema.
trait Drone[F[_]] {
def getBacklog: F[Int]
def getAgents: F[Int]
}
final case class MachineNode(id: String)
trait Machines[F[_]] {
def getTime: F[Epoch]
def getManaged: F[NonEmptyList[MachineNode]]
def getAlive: F[Map[MachineNode, Epoch]]
def start(node: MachineNode): F[MachineNode]
def stop(node: MachineNode): F[MachineNode]
}
Ya hemos usado NonEmptyList, creado fácilmente mediante la invocación de
.toNel sobre un objecto List de la librería estándar (que devuelve un
Option[NonEmptyList]), y el resto debería resultar familiar.
3.3 Lógica de negocios
Ahora escribiremos la lógica de negocios que define el comportamiento de la aplicación, considerando únicamente la situación más positiva.
Necesitamos una clase WorldView para mantener una instantánea de nuestro
conocimiento del mundo. Si estuvieramos diseñando esta aplicación en Akka,
WorldView probablemente sería un var en un Actor con estado.
WorldView acumula los valores de retorno de todos los métodos en las álgebras,
y agrega un campo pendiente (pending) para darle seguimiento a peticiones que
no han sido satisfechas.
final case class WorldView(
backlog: Int,
agents: Int,
managed: NonEmptyList[MachineNode],
alive: Map[MachineNode, Epoch],
pending: Map[MachineNode, Epoch],
time: Epoch
)
Ahora estamos listos para escribir nuestra lógica de negocio, pero necesitamos
indicar que dependemos de Droney de Machines.
Podemos escribir la interfaz para nuestra lógica de negocio
trait DynAgents[F[_]] {
def initial: F[WorldView]
def update(old: WorldView): F[WorldView]
def act(world: WorldView): F[WorldView]
}
e implementarla con un módulo. Un módulo depende únicamente de otros módulos,
álgebras y funciones puras, y puede ser abstraída sobre F. Si una
implementación de una interfaz algebraica está acoplada a cierto tipo
específico, por ejemplo, IO, se llama un intérprete.
final class DynAgentsModule[F[_]: Monad](D: Drone[F], M: Machines[F])
extends DynAgents[F] {
El límite de contexto Monad significa que F es monádico, permitiéndonos
usar map, pure y, por supuesto, flatMap por medio de for comprehensions.
Requerimos acceso al álgebra de Drone y Machines como D y M,
respectivamente. El uso de una sola letra mayúscula para el nombre es una
convención de nombre común para las implementaciones de mónadas y álgebras.
Nuestra lógica de negocio se ejecutará en un ciclo infinito (pseudocódigo)
state = initial()
while True:
state = update(state)
state = act(state)
3.3.1 initial
En initial llamamos a todos los servicios externos y acumulamos sus resultados
en un WorldView. Por default se asigna el campo pending a un Map vacío.
def initial: F[WorldView] = for {
db <- D.getBacklog
da <- D.getAgents
mm <- M.getManaged
ma <- M.getAlive
mt <- M.getTime
} yield WorldView(db, da, mm, ma, Map.empty, mt)
Recuerde del Capítulo 1 que flatMap (es decir, cuando usamos el generador
<-) nos permite operar sobre un valor que se calcula en tiempo de ejecución.
Cuando devolvemos un F[_] devolvemos otro programa que será interpretado en
tiempo de ejecución, sobre el cual podemos a continuación invocar flatMap. Es
de esta manera como encadenamos secuencialmente código con efectos colaterales,
al mismo tiempo que somos capaces de proporcionar una implementación pura para
las pruebas. PF podría ser descrita como Extreme Mocking.
3.3.2 update
update debería llamar a initial para refrescar nuestra visión del mundo,
preservando acciones pending conocidas.
Si un nodo ha cambiado su estado, la quitamos de pending y si una acción
pendiente está tomando más de 10 minutos para lograr algo, asumimos que ha
fallado y olvidamos que se solicitó trabajo al mismo.
def update(old: WorldView): F[WorldView] = for {
snap <- initial
changed = symdiff(old.alive.keySet, snap.alive.keySet)
pending = (old.pending -- changed).filterNot {
case (_, started) => (snap.time - started) >= 10.minutes
}
update = snap.copy(pending = pending)
} yield update
private def symdiff[T](a: Set[T], b: Set[T]): Set[T] =
(a union b) -- (a intersect b)
Funciones concretas como .symdiff no requieren intérpretes de prueba, tienen
entradas y salidas explícitas, de modo que podríamos mover todo el código puro a
métodos autónomos en un object sin estado, que se puede probar en aislamiento.
Estamos conformes con probar únicamente los métodos públicos, prefiriendo que
nuestra lógica de negocios sea fácil de leer.
3.3.3 act
El método act es ligeramente más complejo, de modo que lo dividiremos en dos
partes por claridad: la detección de cúando es necesario tomar una acción,
seguida de la ejecución de la acción. Esta simplificación significa que
únicamente podemos realizar una acción por invocación, pero esto es razonable
porque podemos controlar las invocaciones y podemos escoger ejecutar nuevamente
act hasta que no se tome acción alguna.
Escribiremos los detectores de los diferentes escenarios como extractores para
WorldView, los cuáles no son más que formas expresivas de escribir condiciones
if / else.
Necesitamos agregar agentes a la granja si existe una lista de trabajo pendiente (backlog), no tenemos agentes, no tenemos nodos vivos, y no hay acciones pendientes. Regresamos un nodo candidato que nos gustaría iniciar:
private object NeedsAgent {
def unapply(world: WorldView): Option[MachineNode] = world match {
case WorldView(backlog, 0, managed, alive, pending, _)
if backlog > 0 && alive.isEmpty && pending.isEmpty
=> Option(managed.head)
case _ => None
}
}
Si no hay backlog, deberíamos detener todos los nodos que están detenidos (no están haciendo ningún trabajo). Sin embargo, dado que Google cobra por hora nosotros únicamente apagamos las máquinas en su minuto 58, para obtener el máximo de nuestro dinero. Devolvemos una lista no vacía de los nodos que hay que detener.
Como una red de seguridad financiera, todos los nodos deben tener un tiempo de vida máximo de 5 horas.
private object Stale {
def unapply(world: WorldView): Option[NonEmptyList[MachineNode]] = world match {
case WorldView(backlog, _, _, alive, pending, time) if alive.nonEmpty =>
(alive -- pending.keys).collect {
case (n, started) if backlog == 0 && (time - started).toMinutes % 60 >= 58 => n
case (n, started) if (time - started) >= 5.hours => n
}.toList.toNel
case _ => None
}
}
Ahora que hemos detectado los escenario que pueden ocurrir, podemos escribir el
método act. Cuando se planea que un nodo se inicie o se detenga, lo agregamos
a pending tomando nota del tiempo en el que se programó la acción.
def act(world: WorldView): F[WorldView] = world match {
case NeedsAgent(node) =>
for {
_ <- M.start(node)
update = world.copy(pending = Map(node -> world.time))
} yield update
case Stale(nodes) =>
nodes.foldLeftM(world) { (world, n) =>
for {
_ <- M.stop(n)
update = world.copy(pending = world.pending + (n -> world.time))
} yield update
}
case _ => world.pure[F]
}
Dado que NeedsAgent y Stale no cubren todas las situaciones posibles,
requerimos de un case _ que atrape todas las situaciones posibles restantes, y
que no haga nada. Recuerde del Capítulo 2 que .pure crea el contexto monádico
del for a partir de un valor.
foldLeftM es como foldLeft, pero cada iteración de un fold puede devolver un
valor monádico. En nuestro caso, cada iteración del fold devuelve
F[WorldView]. El M es por Monádico. Nos encontraremos con más de estos
métodos lifted (alzados) que se comportan como uno esperaría, tomando valores
monádicos en lugar de valores.
3.4 Unit Tests
El enfoque de FP de escribir aplicaciones es el sueño de un diseñador: delegar la escritura de las implementaciones algebraicas a otros miembros del equipo mientras que se enfoca en lograr que la lógica de negocios cumpla con los requerimientos.
Nuestra aplicación es altamente dependiente de la temporización y de los servicios web de terceros. Si esta fuera una aplicación POO tradicional, crearíamos mocks para todas las invocaciones de métodos, o probaríamos los buzones de salida de los actores. El mocking en PF es equivalente a proporcionar implementaciones alternativas de las álgebras dependientes. Las álgebras ya aislan las partes del sistema que necesitan tener un mock, por ejemplo, interpretándolas de manera distinta en las pruebas unitarias.
Empezaremos con algunos datos de prueba
object Data {
val node1 = MachineNode("1243d1af-828f-4ba3-9fc0-a19d86852b5a")
val node2 = MachineNode("550c4943-229e-47b0-b6be-3d686c5f013f")
val managed = NonEmptyList(node1, node2)
val time1: Epoch = epoch"2017-03-03T18:07:00Z"
val time2: Epoch = epoch"2017-03-03T18:59:00Z" // +52 mins
val time3: Epoch = epoch"2017-03-03T19:06:00Z" // +59 mins
val time4: Epoch = epoch"2017-03-03T23:07:00Z" // +5 hours
val needsAgents = WorldView(5, 0, managed, Map.empty, Map.empty, time1)
}
import Data._
Implementamos algebras al extender Drone y Machines con un contexto monádico
específico, siendo Id el más simple.
Nuestras implementaciones mock simplemente repiten un WorldView fijo. Ya
hemos aislado el estado de nuestro sistema, de modo que podemos usar var para
almacenar el estado:
class Mutable(state: WorldView) {
var started, stopped: Int = 0
private val D: Drone[Id] = new Drone[Id] {
def getBacklog: Int = state.backlog
def getAgents: Int = state.agents
}
private val M: Machines[Id] = new Machines[Id] {
def getAlive: Map[MachineNode, Epoch] = state.alive
def getManaged: NonEmptyList[MachineNode] = state.managed
def getTime: Epoch = state.time
def start(node: MachineNode): MachineNode = { started += 1 ; node }
def stop(node: MachineNode): MachineNode = { stopped += 1 ; node }
}
val program = new DynAgentsModule[Id](D, M)
}
Cuando escribimos una prueba unitaria (aquí usando FlatSpec desde ScalaTest),
creamos una instancia de Mutable y entonces importamos todos sus miembros.
Tanto nuestro drone y machine implícitos usan el contexto de ejecución Id
y por lo tanto interpretar este programa con ellos devuelve un Id[WorldView]
sobre el cual podemos hacer aserciones.
En este caso trivial simplemente verificamos que el método initial devuelva el
mismo valor que usamos en nuestras implementaciones estáticas:
"Business Logic" should "generate an initial world view" in {
val mutable = new Mutable(needsAgents)
import mutable._
program.initial shouldBe needsAgents
}
Entonces podemos crear pruebas más avanzadas de los métodos update y act,
ayudándonos a eliminar bugs y refinar los requerimientos:
it should "remove changed nodes from pending" in {
val world = WorldView(0, 0, managed, Map(node1 -> time3), Map.empty, time3)
val mutable = new Mutable(world)
import mutable._
val old = world.copy(alive = Map.empty,
pending = Map(node1 -> time2),
time = time2)
program.update(old) shouldBe world
}
it should "request agents when needed" in {
val mutable = new Mutable(needsAgents)
import mutable._
val expected = needsAgents.copy(
pending = Map(node1 -> time1)
)
program.act(needsAgents) shouldBe expected
mutable.stopped shouldBe 0
mutable.started shouldBe 1
}
Sería aburrido ejecutar el conjunto de pruebas completo. Las siguientes pruebas serían fáciles de implementar usando el mismo enfoque:
- No solicitar agentes cuando haya pendientes
- No apagar los agentes si los nodos son muy jóvenes
- Apagar los agentes cuando no hay backlog y los nodos ocasionarán costos pronto
- No apague a los agentes si hay acciones pendientes
- Apague a los agentes cuando no hay backlog si son muy viejos
- Apague a los agentes, incluso si potencialmente están haciendo trabajo, si son muy viejos
- Ignore las acciones pendientes que no responden durante las actualizaciones
Todas estas pruebas son síncronas y aisladas al hilo de ejecutor de prueba (que podría estar ejecutando pruebas en paralelo). Si hubieramos diseñado nuestro conjunto de pruebas en Akka, nuestras pruebas estarían sujetas a tiempos de espera arbitrarias y las fallas estarían ocultas en los archivos de registro.
El disparo en la productividad de las pruebas simples para la lógica de negocios no puede ser exagerada. Considere que el 90% del tiempo ocupado por el desarrollador de aplicaciones usado en la interacción con el cliente está en la refinación, actualización y fijación de estas reglas de negocios. Todo lo demás es un detalle de implementación.
3.5 Paralelismo
La aplicación que hemos diseñado ejecuta cada uno de sus métodos algebraicos secuencialmente. Pero hay algunos lugares obvios donde el trabajo puede ejecutarse en paralelo.
3.5.1 initial
En nuestra definición de initial podríamos solicitar toda la información que
requerimos a la vez en lugar de hacer una consulta a la vez.
En contraste con flatMap para operaciones secuenciales, Scalaz usa la sintaxis
Apply para operaciones paralelas:
^^^^(D.getBacklog, D.getAgents, M.getManaged, M.getAlive, M.getTime)
y también puede usar notación infija:
(D.getBacklog |@| D.getAgents |@| M.getManaged |@| M.getAlive |@| M.getTime)
Si cada una de las operaciones paralelas regresa un valor en el mismo contexto
monádico, podemos aplicar una función a los resultados cuando todos ellos sean
devueltos. Reescribiendo initial para tomar ventaja de esto:
def initial: F[WorldView] =
^^^^(D.getBacklog, D.getAgents, M.getManaged, M.getAlive, M.getTime) {
case (db, da, mm, ma, mt) => WorldView(db, da, mm, ma, Map.empty, mt)
}
3.5.2 act
En la lógica actual para act, estamos deteniéndonos en cada nodo
secuencialmente, esperando por el resultado, y entonces procediendo. Pero
podríamos detener todos los nodos en paralelo y entonces actualizar nuestra
vista del mundo.
Una desventaja de hacerlo de esta manera es que cualquier falla ocasionará que
se paren los cómputos antes de actualizar el campo pending. Pero se trata de
una concesión razonable dado que nuestra función update manejará el caso
cuando un node se apague inesperadamente.
Necesitamos un método que funcione en NonEmptyList que nos permita hacer un
map sobre cada elemento en un F[MachineNode], devolviendo un
F[NonEmptyList[MachineNode]]. El método se llama traverse, y cuando
invoquemos un flatMap sobre este tendremos un NonEmptyList[MachineNode] con
el cuál lidiaremos de una manera sencilla:
for {
stopped <- nodes.traverse(M.stop)
updates = stopped.map(_ -> world.time).toList.toMap
update = world.copy(pending = world.pending ++ updates)
} yield update
Podría argumentarse, que este código es más fácil de entender que la versión secuencial.
3.6 Summary
- Las algebras definen las interfaces entre sistemas.
- Los módulos son implementaciones de un álgebra en términos de otras álgebras.
- Los intérpretes son implementaciones concretas de un álgebra para una
F[_]fija. - Los intérpretes de prueba pueden reemplazar las partes con efectos colaterales de un sistema, proporcionando un grado elevado de cobertura de las pruebas.
4. Datos y funcionalidad
De la POO estamos acostumbrados a pensar en datos y funcionalidad a la vez: las jerarquías de clases llevan métodos, y mediante el uso de traits podemos demandar que existan campos de datos. El polimorfismo en tiempo de ejecución se da en términos de relaciones “is a” (“es un”), requiriendo que las clases hereden de interfaces comunes. Esto puede llegar a complicarse a medida que la base de código crece. Los tipos de datos simples se vuelven obscuros al complicarse con cientos de líneas de métodos, los mixins con traits sufren a causa del orden de inicialización, y las pruebas y mocking de componentes altamente acoplados se convierte en una carga.
La PF toma un enfoque distinto, definiendo los datos y la funcionalidad de manera separada. En este capítulo, se estudiará lo básico de los tipos de datos y las ventajas de restringirnos a un subconjunto del lenguaje de programación Scala. También descubriremos las typeclasses como una forma de lograr polimorfismo en tiempo de compilación: pensando en la funcionalidad de una estructura de datos en términos de “has a” (“tiene un”), más bien que relaciones del tipo “es un”.
4.1 Datos
Los bloques de construcción fundamentales de los tipos son
-
final case classtambién conocidos como productos -
sealed abstract classtambién conocidos como coproductos -
case objecteInt,Double,String, (etc.), valores.
sin métodos o campos más que los parámetros del constructor. Preferimos usar
abstract class a trait para lograr mejor compatibilidad binaria y para
evitar la mezcla de traits.
El nombre colectivo para productos, coproductos y valores es Tipo de Datos Algebraico (del inglés Algebraic Data Type o ADT).
Formamos la composición de tipos de datos a partir de AND y XOR (OR
exclusivos) del álgebra booleana: un producto contiene cada uno de los tipos de
los que está compuesto, pero un coproducto puede ser únicamente uno de ellos.
Por ejemplo
- producto:
ABC = a AND b AND c - coproducto:
XYZ = x XOR y XOR z
escrito en Scala
// values
case object A
type B = String
type C = Int
// product
final case class ABC(a: A.type, b: B, c: C)
// coproduct
sealed abstract class XYZ
case object X extends XYZ
case object Y extends XYZ
final case class Z(b: B) extends XYZ
4.1.1 Estructuras de datos recursivas
Cuando un ADT se refiere a sí misma, la llamamos Tipo de Datos Algebraico Recursivo.
scalaz.IList, una alternativa segura a List de la librería estándar, es
recursiva porque ICons contiene una referencia a IList.:
sealed abstract class IList[A]
final case class INil[A]() extends IList[A]
final case class ICons[A](head: A, tail: IList[A]) extends IList[A]
4.1.2 Funciones sobre ADTs
Las ADTs pueden tener funciones puras
final case class UserConfiguration(accepts: Int => Boolean)
Pero las ADTs que contienen funciones tienen consigo algunas limitaciones dado
que no se pueden traducir de manera perfecta a la JVM. Por ejemplo, los antiguos
métodos Serializable, hashCode, equals y toString no se comportan como
uno podría razonablemente esperar.
Desgraciadamente, Serializable es usado por frameworks populares, a pesar de
que existen alternativas superiores. Una trampa común es olvidar que
Serializable podría intentar serializar la cerradura completa de una función,
lo que podría ocasionar el fallo total de servidores de producción. Una trampa
similar aplica a clases de Java antiguas tales como Throwable, que pueden
llevar consigo referencias a objetos arbitrarios.
Exploraremos alternativas a los métodos antiguos cuando discutamos Scalaz en el próximo capítulo, a costa de perder interoperabilidad con algo de código antiguo de Java y Scala.
4.1.3 Exhaustividad
Es importante que usemos sealed abstract class, no simplemente abstract
class, cuando definimos un tipo de datos. Sellar una class significa que
todos los subtipos deben estar definidos en el mismo archivo, permitiendo que el
compilador pueda verificar de manera exhaustiva durante el proceso de empate de
patrones y en macros que eliminen el código repetitivo. Por ejemplo,
scala> sealed abstract class Foo
final case class Bar(flag: Boolean) extends Foo
final case object Baz extends Foo
scala> def thing(foo: Foo) = foo match {
case Bar(_) => true
}
<console>:14: error: match may not be exhaustive.
It would fail on the following input: Baz
def thing(foo: Foo) = foo match {
^
Esto muestra al desarrollador qué ha fallado cuando añaden un nuevo producto a
la base de código. Aquí se está usando -Xfatal-warnings, porque de otra manera
esto es solamente un warning o advertencia.
Sin embargo, el compilador no realizará un chequeo exhaustivo si la clase no está sellada o si existen guardas, por ejemplo
scala> def thing(foo: Foo) = foo match {
case Bar(flag) if flag => true
}
scala> thing(Baz)
scala.MatchError: Baz (of class Baz$)
at .thing(<console>:15)
para conseguir seguridad, es mejor evitar las guardas en los tipos sellados.
La bandera
-Xstrict-patmat-analysis se ha
propuesto como una mejora al lenguaje para realizar chequeos adicionales durante
el proceso de empate de patrones.
4.1.4 Productos alternativos y coproductos
Otra forma de producto es la tupla, que es como una final case class sin
etiqueta o nombre.
(A.type, B, C) es equivalente a ABC en el ejemplo de arriba, pero es mejor
usar final case class cuando se trate de una parte de alguna ADT porque es
algo difícil lidiar con la falta de nombres, y case class tiene mucho mejor
performance para valores primitivos.
Otra forma de coproducto es aquella que se logra al anidar tipos Either, por
ejemplo
Either[X.type, Either[Y.type, Z]]
y es equivalente a la clase abstracta sellada XYZ. Se puede lograr una
sintáxis más limpia al definir tipos Either anidados al crear un alias de tipo
que termine en dos puntos, permitiendo así el uso de notación infija con
asociación hacia la derecha:
type |:[L,R] = Either[L, R]
X.type |: Y.type |: Z
Esto es útil al crear coproductos anónimos cuando no podemos poner todas las implementaciones en el mismo archivo de código fuente.
type Accepted = String |: Long |: Boolean
Otra forma de coproducto alternativa es creat un sealed abstract class
especial con definiciones final case class que simplemente envuelvan a los
tipos deseados:
sealed abstract class Accepted
final case class AcceptedString(value: String) extends Accepted
final case class AcceptedLong(value: Long) extends Accepted
final case class AcceptedBoolean(value: Boolean) extends Accepted
El empate de patrones en estas formas de coproducto puede ser tediosa, razón por la cual los tipos Unión están explorandose en la siguiente generación del compilador de Scala, Dotty. Macros tales como totalitarian y iotaz existen como formas alternativas de codificar coproductos anónimos.
4.1.5 Transmisión de información
Además de ser contenedores para información de negocios necesaria, los tipos de datos pueden usarse para codificar restricciones. Por ejemplo,
final case class NonEmptyList[A](head: A, tail: IList[A])
nunca puede estar vacía. Esto hace que scalaz.NonEmptyList sea un tipo de
datos útil a pesar de que contiene la misma información que IList.
Los tipos producto con frecuencia contienen tipos que son mucho más generales de lo que en realidad se requiere. En programación orientada a objetos tradicional, la situación se manejaría con validación de datos de entrada a través de aserciones:
final case class Person(name: String, age: Int) {
require(name.nonEmpty && age > 0) // breaks Totality, don't do this!
}
En lugar de hacer esto, podríamos usar el tipo de datos Either para
proporcionar Right[Person] para instancias válidas y protegernos de que
instancias inválidas se propaguen. Note que el constructor es privado
(private):
final case class Person private(name: String, age: Int)
object Person {
def apply(name: String, age: Int): Either[String, Person] = {
if (name.nonEmpty && age > 0) Right(new Person(name, age))
else Left(s"bad input: $name, $age")
}
}
def welcome(person: Person): String =
s"${person.name} you look wonderful at ${person.age}!"
for {
person <- Person("", -1)
} yield welcome(person)
4.1.5.1 Tipos de datos refinados
Una forma limpia de restringir los valores de un tipo general es con la librería
refined, que provee una conjunto de restricciones al contenido de los datos.
Para instalar refined, agregue la siguiente línea a su archivo build.sbt
libraryDependencies += "eu.timepit" %% "refined-scalaz" % "0.9.2"
y los siguientes imports
import eu.timepit.refined
import refined.api.Refined
Refined permite definir Person usando tipos ad hoc refinados para capturar
los requerimientos de manera exacta, escrito A Refined B.
import refined.numeric.Positive
import refined.collection.NonEmpty
final case class Person(
name: String Refined NonEmpty,
age: Int Refined Positive
)
El valor subyacente puede obtenerse con .value. Podemos construir un valor en
tiempo de ejecución usando .refineV, que devuelve un Either
scala> import refined.refineV
scala> refineV[NonEmpty]("")
Left(Predicate isEmpty() did not fail.)
scala> refineV[NonEmpty]("Sam")
Right(Sam)
Si agregamos el siguiente import
import refined.auto._
podemos construir valores válidos en tiempo de compilación y obtener errores si el valor provisto no cumple con los requerimientos
scala> val sam: String Refined NonEmpty = "Sam"
Sam
scala> val empty: String Refined NonEmpty = ""
<console>:21: error: Predicate isEmpty() did not fail.
Es posible codificar requerimientos más completos, por ejemplo podríamos usar la
regla MaxSize que con los siguientes imports
import refined.W
import refined.boolean.And
import refined.collection.MaxSize
que captura los requerimientos de que String debe no ser vacía y además tener
un máximo de 10 caracteres.
type Name = NonEmpty And MaxSize[W.`10`.T]
final case class Person(
name: String Refined Name,
age: Int Refined Positive
)
Es fácil definir requerimientos a la medida que no estén cubiertos por la
librería refined. Por ejemplo en drone-dynamic-agents necesitaremos una manera
de asegurarnos de que String tenga contenido
application/x-www-form-urlencoded. Podríamos crear una regla de Refined
usando la librería de expresiones regulares de Java:
sealed abstract class UrlEncoded
object UrlEncoded {
private[this] val valid: Pattern =
Pattern.compile("\\A(\\p{Alnum}++|[-.*_+=&]++|%\\p{XDigit}{2})*\\z")
implicit def urlValidate: Validate.Plain[String, UrlEncoded] =
Validate.fromPredicate(
s => valid.matcher(s).find(),
identity,
new UrlEncoded {}
)
}
4.1.6 Simple de compartir
Al no proporcionar ninguna funcionalidad, las ADTs pueden tener un conjunto mínimo de dependencias. Esto hace que sean fáciles de publicar y de compartir con otros desarrolladores. Al usar un lenguaje de modelado de datos simple, se hace posible la interacción con equipos interdisciplinarios, tales como DBAs, desarrolladores de interfaces de usuario y analistas de negocios, usando el código fuente actual en lugar de un documento escrito manualmente como la fuente de la verdad.
Más aún, las herramientas pueden ser escritas más fácilmente para producir o consumir esquemas de otros lenguajes de programación y protocolos físicos.
4.1.7 Contando la complejidad
La complejidad de un tipo de datos es la cuenta de los valores que puede tener. Un buen tipo de datos tiene la cantidad mínima de complejidad requerida para contener la información que transmite, y nada más.
Los valores tienen una complejidad inherente:
-
Unittien un solo valor (razón por la cual se llama “unit”) -
Booleantiene dos valores -
Inttiene 4,294,967,295 valores -
Stringtiene valores infinitos, de manera práctica
Para encontrar la complejidad de un producto, multiplicamos la complejidad de cada parte
-
(Boolean, Boolean)tiene 4 valores, (2 * 2) -
(Boolean, Boolean, Boolean)tiene 8 valores (2 * 2 * 2)
Para encontrar la complejidad de un coproducto, sumamos la complejidad de cada parte
-
(Boolean |: Boolean)tiene 4 valores (2 + 2) -
(Boolean |: Boolean |: Boolean) tiene 6 valores (2 + 2 + 2)
Para encontrar la complejidad de un ADT con un parámetro de tipo, multiplique cada parte por la complejidad del parámetro de tipo:
-
Option[Boolean]tiene 3 valores,Some[Boolean]yNone. (2 + 1).
En la PF, las funciones son totales y deben devolver el mismo valor para cada
entrada, no una Exception. Minimizar la complejidad de las entradas y las
salidas es la mejor forma de lograr totalidad. Como regla de oro, es un signo de
una función con mal dise;o cuando la complejidad del valor de retorno es más
grande que el producto de sus entradas: es una fuente de entropía.
La complejidad de una función total es el número de funciones posibles que pueden satisfacer la signatura del tipo: la salida a la potencia de la entrada.
-
Unit => Booleantiene complejidad 2 -
Boolean => Booleantiene complejidad 4 -
Option[Boolean] => Option[Boolean]tiene complejidad 27 -
Boolean => Intes como un quintillón, aproximándose a un sextillón. -
Int => Booleanes tan grande que si a todas las implementaciones se les asignara un número único, cada una requeriría 4 gigabytes para representarla.
En la práctica, Int => Boolean será algo tan simple como isOdd, isEven o
un BitSet disperso. Esta función, cuando se usa en una ADT, será mejor
reemplazarla con un coproducto que asigne un nombre al conjunto de funciones que
son relevantes.
Cuando la complejidad es “infinito a la entrada, infinito a la salida”
deberíamos introducir tipos de datos que restrictivos y validaciones más
cercanos al punto de entrada con Refined de la sección anterior.
La habilidad de contar la complejidad de una signatura de tipo tiene otra aplicación práctica: podemos encontrar signaturas de tipo más simples usando Álgebra de la secundaria! Para ir de una signatura de tipos al álgebra de las complejidades, simplemente reemplace
-
Either[A, B]cona + b -
(A, B)cona * b -
A => Bconb ^ a
hacer algunos rearreglos, y convertir de vuelta. Por ejemplo, digamos que hemos diseñado un framework basado en callbacks y que hemos logrado llegar a la situación donde tenemos la siguiente signatura de tipos:
(A => C) => ((B => C) => C)
Podríamos convertir y reordenar
(c ^ (c ^ b)) ^ (c ^ a)
= c ^ ((c ^ b) * (c ^ a))
= c ^ (c ^ (a + b))
y convertir de vuelta a tipos y obtener
(Either[A, B] => C) => C
que es mucho más simple: sólo es necesario pedir a los usuarios de nuestro
framework que proporcionen un Either[A, B] => C.
La misma línea de razonamiento puede ser usada para probar que
A => B => C
es equivalente a
(A, B) => C
que también se conoce como Currying.
4.1.8 Prefiera coproducto en vez de producto
Un problema de modelado típico que surge con frecuencia es el que tenemos cuando
hay parámetros de configuración mutuamente exclusivos a, b, y c. El
producto (a: Boolean, b: Boolean, c: Boolean) tiene complejidad 8, mientras
que el coproducto
sealed abstract class Config
object Config {
case object A extends Config
case object B extends Config
case object C extends Config
}
tiene una complejidad de 3. Es mejor modelar estos parámetros de configuración como un coproducto más bien que permitir la existencia de 5 estados inválidos.
La complejidad de un tipo de datos también tiene implicaciones a la hora de hacer pruebas. Es prácticamente imposible probar cada entrada a una función, pero es fácil probar una muestra de los valores con el framework para hacer pruebas de propiedades con Scalacheck.
Si una muestra aleatoria de un tipo de datos tiene una probabilidad baja de ser válida, tenemos una señal de que los datos están modelados incorrectamente.
4.1.9 Optimizaciones
Una gran ventaja de usar un conjunto simplificado del lenguaje Scala para representar tipos de datos es que el tooling puede optimizar la representación en bytecodes de la JVM.
Por ejemplo, podríamos empacar campos Boolean y Option en un Array[Byte],
hacer un caché con los valores, memoizar hashCode, optimizar equals, usar
sentencias @switch al momento de realizar empate de patrones, y mucho más.
Estas optimizaciones no son aplicables a jerarquías de clases en POO que tal vez manipulen el estado, lancen excepciones, o proporcionen implementaciones ad hoc de los métodos.
4.2 Funcionalidad
Las funciones puras están definidas típicamente como métodos en un object.
package object math {
def sin(x: Double): Double = java.lang.Math.sin(x)
...
}
math.sin(1.0)
Sin embargo, el uso de métodos en objects puede ser un poco torpe, dado que se
lee de dentro hacia afuera, y no de izquierda a derecha. Además, una función en
un object se roba el namespace. Si tuvieramos que definir sin(t: T) en algún
otro lugar, tendríamos errores por referencias ambiguas. Este es el mismo
problema de los métodos estáticos de Java vs los métodos de clase.
Con ayuda de la característica del lenguaje implicit class, (también conocida
como metodología de extensión o sintaxis), y un poco de código tedioso, es
posible lograr un estilo más familiar:
scala> implicit class DoubleOps(x: Double) {
def sin: Double = math.sin(x)
}
scala> (1.0).sin
res: Double = 0.8414709848078965
Con frecuencia es mejor saltarnos la definición de un object e ir directo con
una implicit class, manteniendo el código repetitivo al mínimo:
implicit class DoubleOps(x: Double) {
def sin: Double = java.lang.Math.sin(x)
}
4.2.1 Funciones polimórficas
La clase de funciones más comunes es la función polimórfica, que vive en una typeclass. Una typeclass es un trait que:
- no tiene estado
- tiene un parámetro de tipo
- tiene al menos un método abstracto (combinador primitivo)
- puede contener métodos generalizadores (combinadores derivados)
- puede extender a otros typeclases
Sólo puede existir una implementación de una typeclass para un parámetro de tipo específico correspondiente, una propiedad conocida también como coherencia de typeclass. Las typeclasses se ven superficialmente similares a las interfaces algebraicas del capítulo anterior, pero las álgebras no tienen que ser coherentes.
Las typeclasses son usadas en la librería estándar de Scala. Exploraremos una
versión simplificada de scala.math.Numeric para demostrar el principio:
trait Ordering[T] {
def compare(x: T, y: T): Int
def lt(x: T, y: T): Boolean = compare(x, y) < 0
def gt(x: T, y: T): Boolean = compare(x, y) > 0
}
trait Numeric[T] extends Ordering[T] {
def plus(x: T, y: T): T
def times(x: T, y: T): T
def negate(x: T): T
def zero: T
def abs(x: T): T = if (lt(x, zero)) negate(x) else x
}
Podemos ver las características clave de una typeclass en acción:
- no hay estado
-
OrderingyNumerictienen un parámetro de tipoT -
Orderingtiene uncompareabstracto, yNumerictiene unplus,times,negateyzeroabstractos. -
Orderingdefine unltgeneralizado, ygtbasados encompare,Numericdefineabsen términos delt,negateyzero. -
NumericextiendeOrdering.
Ahora podemos escribir funciones para tipos que “tengan un(a)” typeclass
Numeric:
def signOfTheTimes[T](t: T)(implicit N: Numeric[T]): T = {
import N._
times(negate(abs(t)), t)
}
Ya no dependemos de la jerarquía POO de nuestros tipos de entrada, es decir, no
demandamos que nuestra entrada sea (“is a”) Numeric, lo cual es vitalmente
importante si deseamos soportar clases de terceros que no podemos redefinir.
Otra ventaja de las typeclasses es que la asociación de funcionalidad a los datos se da en tiempo de compilación, en oposición del despacho dinámico en tiempo de ejecución de la POO.
Por ejemplo, mientras que la clase List solo puede tener una implementación de
un método, una typeclasss nos permite tener diferentes implementaciones
dependiendo del contenido de List y por lo tanto nos permite descargar el
trabajo al tiempo de compilación en lugar de dejarlo al tiempo de ejecución.
4.2.2 Sintaxis
La sintaxis para escribir signOfTheTimes es un poco torpe, y hay algunas cosas
que podemos hacer para limpiarla.
Los usuarios finales de nuestro código, preferirán que nuestro métod use
context bounds, dado que la signatura se lee limpiamente como “toma una T
que tiene un Numeric”
def signOfTheTimes[T: Numeric](t: T): T = ...
pero ahora tenemos que usar implicitly[Numeric[T]] en todos lados. Al definir
el código repetitivo en el companion object de la typeclass
object Numeric {
def apply[T](implicit numeric: Numeric[T]): Numeric[T] = numeric
}
podemos obtener el implícito con menos ruido
def signOfTheTimes[T: Numeric](t: T): T = {
val N = Numeric[T]
import N._
times(negate(abs(t)), t)
}
Pero es todavía peor para nosotros como los implementadores. Tenemos el problema
sintáctico de métodos estáticos afuera vs métodos de la clase. Una forma de
lidiar con esta situación es mediante introducir ops en el companion de la
typeclass:
object Numeric {
def apply[T](implicit numeric: Numeric[T]): Numeric[T] = numeric
object ops {
implicit class NumericOps[T](t: T)(implicit N: Numeric[T]) {
def +(o: T): T = N.plus(t, o)
def *(o: T): T = N.times(t, o)
def unary_-: T = N.negate(t)
def abs: T = N.abs(t)
// duplicated from Ordering.ops
def <(o: T): T = N.lt(t, o)
def >(o: T): T = N.gt(t, o)
}
}
}
Note que -x se expande a x.unary_- mediante las conveniencias sintácticas
del compilador, razón por la cual definimos unary_- como un método de
extensión. Podemos ahora escribir la forma mucho más clara:
import Numeric.ops._
def signOfTheTimes[T: Numeric](t: T): T = -(t.abs) * t
La buena noticia es que nunca necesitamos escribir este tipo de código
repetitivo porque Simulacrum
proporciona una anotación @typeclass que genera automáticamente los métodos
apply y ops. Incluso nos permite definir nombres alternativos (normalmente
simbólicos) para métodos comunes. Mostrando el código completo:
import simulacrum._
@typeclass trait Ordering[T] {
def compare(x: T, y: T): Int
@op("<") def lt(x: T, y: T): Boolean = compare(x, y) < 0
@op(">") def gt(x: T, y: T): Boolean = compare(x, y) > 0
}
@typeclass trait Numeric[T] extends Ordering[T] {
@op("+") def plus(x: T, y: T): T
@op("*") def times(x: T, y: T): T
@op("unary_-") def negate(x: T): T
def zero: T
def abs(x: T): T = if (lt(x, zero)) negate(x) else x
}
import Numeric.ops._
def signOfTheTimes[T: Numeric](t: T): T = -(t.abs) * t
Cuando existe un operador simbólico personalizado (@op), se puede pronunciar
como el nombre del método, por ejemplo, < se pronuncia “menor que”, y no
“paréntesis angular izquierdo”.
4.2.3 Instancias
Las instancias de Numeric (que también son instancias de Ordering) se
definen como un implicit val que extiende a la typeclass, y que proporciona
implementaciones optimizadas pra los métodos generalizados:
implicit val NumericDouble: Numeric[Double] = new Numeric[Double] {
def plus(x: Double, y: Double): Double = x + y
def times(x: Double, y: Double): Double = x * y
def negate(x: Double): Double = -x
def zero: Double = 0.0
def compare(x: Double, y: Double): Int = java.lang.Double.compare(x, y)
// optimised
override def lt(x: Double, y: Double): Boolean = x < y
override def gt(x: Double, y: Double): Boolean = x > y
override def abs(x: Double): Double = java.lang.Math.abs(x)
}
Aunque estamos usando +, *, unary_- , < y > aquí, que están en el ops
(y podría ser un loop infinito!) estos métodos ya existen en Double. Los
métodos de una clase siempre se usan en preferencia a los métodos de extensión.
En realidad, el compilador de Scala realiza un manejo especial de los tipos
primitivos y convierte estas llamadas en instrucciones directas dadd, dmul,
dcmpl, y dcmpg, respectivamente.
También podemos implementar Numeric para la clase de Java BigDecimal (evite
`scala.BigDecimal, it is fundamentally
broken)
import java.math.{ BigDecimal => BD }
implicit val NumericBD: Numeric[BD] = new Numeric[BD] {
def plus(x: BD, y: BD): BD = x.add(y)
def times(x: BD, y: BD): BD = x.multiply(y)
def negate(x: BD): BD = x.negate
def zero: BD = BD.ZERO
def compare(x: BD, y: BD): Int = x.compareTo(y)
}
Podríamos crear nuestra propia estructura de datos para números complejos:
final case class Complex[T](r: T, i: T)
y construir un Numeric[Complex[T]] si Numeric[T] existe. Dado que estas
instancias dependen del parámetro de tipo, es un def y no un val.
implicit def numericComplex[T: Numeric]: Numeric[Complex[T]] =
new Numeric[Complex[T]] {
type CT = Complex[T]
def plus(x: CT, y: CT): CT = Complex(x.r + y.r, x.i + y.i)
def times(x: CT, y: CT): CT =
Complex(x.r * y.r + (-x.i * y.i), x.r * y.i + x.i * y.r)
def negate(x: CT): CT = Complex(-x.r, -x.i)
def zero: CT = Complex(Numeric[T].zero, Numeric[T].zero)
def compare(x: CT, y: CT): Int = {
val real = (Numeric[T].compare(x.r, y.r))
if (real != 0) real
else Numeric[T].compare(x.i, y.i)
}
}
El lector observador podrá notar que abs no es lo que un matemático esperaría.
El valor de retorno correcto para abs debería ser de tipo T, y no
Complex[T].
scala.math.Numeric intenta hacer muchas cosas y no generaliza adecuadamente
más allá de los números reales. esta es una buena lección que muestra que
typeclasses pequeñas, bien definidas, son con frecuencia mejores que una
colección monolítica de características demasiado específicas.
4.2.4 Resolución implícita
Ya hemos discutido los implícitos bastante: esta sección es para clarificar qué son los implícitos y de cómo funcionan.
Los parámetros implícitos se usan cuando un método solicita que una instancia única de un tipo particular exista en el ámbito/alcance implícito (implicit scope) del que realiza la llamada al método, con una sintáxis especial para las instancias de las typeclasses. Los parámetros implícitos son una manera limpia de pasar configuración a una aplicación.
En este ejemplo, foo requiere que las instancias de typeclass de Numeric y
Typeable estén disponibles para A, así como un objecto implícito Handler
que toma dos parámetros de tipo
def foo[A: Numeric: Typeable](implicit A: Handler[String, A]) = ...
Las conversiones implícitas se usan cuando existe un implicit def. Un uso
posible de dichas conversiones implícitas es para habilitar la metodología de
extensión. Cuando el compilador está calculando cómo invocar un método, primero
verifica si el método existe en el tipo, luego en sus ancestros (reglas
similares a las de Java). Si no encuentra un emparejamiento, entonces buscará en
conversiones implícitas en el alcance implícito, y entonces buscará métodos
sobre esos tipos.
Otro uso de las conversiones implícitas es en la derivación de typeclasses. En
la sección anterior escribimos una implicit def que construía un
Numeric[Complex[T]] si existía un Numeric[T] en el alcance implícito. Es
posible encadenar juntas muchas implicit def (incluyendo las que pudieran
invocarse de manera recursiva) y esto forma la bese de la programación con
tipos, permitiendo que cálculos se realicen en tiempo de compilación más bien
que en tiempo de ejecución.
El pegamento que combina parámetros implícitos (receptores) con conversiones implícitas (proveedores) es la resolución implícita.
Primero, se buscan implícitos en el ámbito/alcance de variables normales, en orden:
- alcance local, incluyendo imports dentro del alcance (por ejemplo, en el bloque o el método)
- alcance externo, incluyendo imports detro del alcance (por ejemplo miembros de la clase)
- ancestros (por ejemplo miembros en la super clase)
- el objeto paquete actual
- el objeto paquete de los ancestros (cuando se usan paquetes anidados)
- los imports del archivo
Si se falla en encontrar un emparejamiento, se busca en el alcance especial, la cual se realiza para encontrar instancias implícitas dentro del companion del tipo, su paquete de objetos, objetos externos (si están anidados), y entonces se repite para sus ancestros. Esto se realiza, en orden, para:
- el parámetro de tipo dado
- el parámetro de tipo esperado
- el parámetro de tipo (si hay alguno)
Si se encuentran dos implícitos que emparejen en la misma fase de resolución implícita, se lanza un error de implícito ambiguo (ambiguous implicit).
Los implícitos con frecuencia se definen un un `trait, el cual se extiende con un objecto. Esto es para controlar la prioridad de un implícito con referencia a otro más específico, para evitar implícitos ambiguos.
La Especificación del lenguaje de Scala es un tanto vaga para los casos poco
comunes, y en realidad la implementación del compilador es el estándar real. Hay
algunas reglas de oro que usaremos en este libro, por ejemplo preferir implicit
val en lugar de implicit object a pesar de la tentación de teclear menos. Es
una capricho de la resolución
implícita que los implicit object
en los objetos companion no sean tratados de la misma manera que un implicit
val.
La resolución implícita se queda corta cuando existe una jerarquía de
typeclasses, como Ordering y Numeric. Si escribimos una función que tome un
implícito de Ordering, y la llamamos para un tipo primitivo que tiene una
instancia de Numeric definido en el companion Numeric, el compilador fallará
en encontrarlo.
La resolución implícita es particularmente mala si se usan aliases de
tipo donde la forma de los
parámetros implícitos son cambiados. Por ejemplo un parámetro implícito usando
un alias tal como type Values[A] = List[Option[A]] probablemente fallará al
encontrar implícitos definidos como un List[Option[A]] porque la forma se
cambió de una cosa de cosas de A a una cosa de As.
4.3 Modelling OAuth2
Terminaremos este capítulo con un ejemplo práctico de modelado de datos y derivación de typeclasses, combinado con el diseño del álgebra/módulo del capítulo anterior.
En nuestra aplicación drone-dynamic-agents, debemos comunicarnos con Drone y
Google Cloud usando JSON sobre REST. Ambos servicios usan
OAuth2 para la autenticación.
Hay muchas maneras de interpretar OAuth2, pero nos enfocaremos en la versión que funciona para Google Cloud (la versión para Drone es aún más sencilla).
4.3.1 Descripción
Cada aplicación de Google Cloud necesita tener una OAuth 2.0 Client key configurada en
https://console.developers.google.com/apis/credentials?project={PROJECT_ID}
y así se obtiene un Client ID y un Client secret.
La aplicación puede entonces un código para usarse una sola vez, al hacer que el usuario realice una Authorization Request (petición de autorización) en su navegador (sí, de verdad, en su navegador). Necesitamos hacer que esta página se abra en el navegador:
https://accounts.google.com/o/oauth2/v2/auth?\
redirect_uri={CALLBACK_URI}&\
prompt=consent&\
response_type=code&\
scope={SCOPE}&\
access_type=offline&\
client_id={CLIENT_ID}
El código se entrega al {CALLBACK_URI} en una petición GET. Para
capturarla en nuestra aplicación, necesitamos un servidor web escuchando en
localhost.
Una vez que tenemos el código, podemos realizar una petición Acess Token Request:
POST /oauth2/v4/token HTTP/1.1
Host: www.googleapis.com
Content-length: {CONTENT_LENGTH}
content-type: application/x-www-form-urlencoded
user-agent: google-oauth-playground
code={CODE}&\
redirect_uri={CALLBACK_URI}&\
client_id={CLIENT_ID}&\
client_secret={CLIENT_SECRET}&\
scope={SCOPE}&\
grant_type=authorization_code
y entonces se tiene una respuesta JSON en el payload
{
"access_token": "BEARER_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN"
}
Todas las peticiones al servidor, provenientes del usuario, deben incluir el encabezado
Authorization: Bearer BEARER_TOKEN
después de sustituir el BEARER_TOKEN real.
Google hace que expiren todas excepto las 50 más recientes bearer tokens, de modo que los tiempos de expiración son únicamente una guía. Los refresh tokens persisten entre sesiones y pueden expirar manualmente por el usuario. Por lo tanto podemos tener una aplicación de configuración que se use una sola vez para obtener el refresh token y entonces incluirlo como una configuración para la instalación del usuario del servidor headless.
Drone no implementa el endpoint /auth, o el refresco, y simplemente
proporciona un BEARER_TOKEN a través de su interfaz de usuario.
4.3.2 Datos
El primer paso es modelar los datos necesarios para OAuth2. Creamos un ADT con
los campos teniendo el mismo nombre como es requerido por el servidor OAuth2.
Usaremos String y Long por brevedad, pero podríamos usar tipos refinados
import refined.api.Refined
import refined.string.Url
final case class AuthRequest(
redirect_uri: String Refined Url,
scope: String,
client_id: String,
prompt: String = "consent",
response_type: String = "code",
access_type: String = "offline"
)
final case class AccessRequest(
code: String,
redirect_uri: String Refined Url,
client_id: String,
client_secret: String,
scope: String = "",
grant_type: String = "authorization_code"
)
final case class AccessResponse(
access_token: String,
token_type: String,
expires_in: Long,
refresh_token: String
)
final case class RefreshRequest(
client_secret: String,
refresh_token: String,
client_id: String,
grant_type: String = "refresh_token"
)
final case class RefreshResponse(
access_token: String,
token_type: String,
expires_in: Long
)
4.3.3 Funcionalidad
Es necesario serializar las clases de datos que definimos en la sección previa en JSON, URL, y las formas codificadas POST. Dado que esto requiere de polimorfismo, necesitaremos typeclasses.
jsonformat
es una librería JSON simple que estudiaremos en más detalle en un capítulo
posterior, dado que ha sido escrita con principios de PF y facilidad de lectura
como sus objetivos de diseño primario. Consiste de una AST JSON y typeclasses de
codificadores/decodificadores:
package jsonformat
sealed abstract class JsValue
final case object JsNull extends JsValue
final case class JsObject(fields: IList[(String, JsValue)]) extends JsValue
final case class JsArray(elements: IList[JsValue]) extends JsValue
final case class JsBoolean(value: Boolean) extends JsValue
final case class JsString(value: String) extends JsValue
final case class JsDouble(value: Double) extends JsValue
final case class JsInteger(value: Long) extends JsValue
@typeclass trait JsEncoder[A] {
def toJson(obj: A): JsValue
}
@typeclass trait JsDecoder[A] {
def fromJson(json: JsValue): String \/ A
}
Necesitamos instancias de JsDecoder[AccessResponse] y
JsDecoder[RefreshResponse]. Podemos hacer esto mediante el uso de una función
auxiliar:
implicit class JsValueOps(j: JsValue) {
def getAs[A: JsDecoder](key: String): String \/ A = ...
}
Ponemos las instancias de los companions en nuestros tipos de datos, de modo que siempre estén en el alcance/ámbito implícito:
import jsonformat._, JsDecoder.ops._
object AccessResponse {
implicit val json: JsDecoder[AccessResponse] = j =>
for {
acc <- j.getAs[String]("access_token")
tpe <- j.getAs[String]("token_type")
exp <- j.getAs[Long]("expires_in")
ref <- j.getAs[String]("refresh_token")
} yield AccessResponse(acc, tpe, exp, ref)
}
object RefreshResponse {
implicit val json: JsDecoder[RefreshResponse] = j =>
for {
acc <- j.getAs[String]("access_token")
tpe <- j.getAs[String]("token_type")
exp <- j.getAs[Long]("expires_in")
} yield RefreshResponse(acc, tpe, exp)
}
Podemos entonces parsear una cadena en un AccessResponse o una
RefreshResponse
scala> import jsonformat._, JsDecoder.ops._
scala> val json = JsParser("""
{
"access_token": "BEARER_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN"
}
""")
scala> json.map(_.as[AccessResponse])
AccessResponse(BEARER_TOKEN,Bearer,3600,REFRESH_TOKEN)
Es necesario escribir nuestra propia typeclass para codificación de URL y POST. El siguiente fragmento de código es un diseño razonable:
// URL query key=value pairs, in un-encoded form.
final case class UrlQuery(params: List[(String, String)])
@typeclass trait UrlQueryWriter[A] {
def toUrlQuery(a: A): UrlQuery
}
@typeclass trait UrlEncodedWriter[A] {
def toUrlEncoded(a: A): String Refined UrlEncoded
}
Es necesario proporcionar instancias de una typeclass para los tipos básicos:
import java.net.URLEncoder
object UrlEncodedWriter {
implicit val encoded: UrlEncodedWriter[String Refined UrlEncoded] = identity
implicit val string: UrlEncodedWriter[String] =
(s => Refined.unsafeApply(URLEncoder.encode(s, "UTF-8")))
implicit val long: UrlEncodedWriter[Long] =
(s => Refined.unsafeApply(s.toString))
implicit def ilist[K: UrlEncodedWriter, V: UrlEncodedWriter]
: UrlEncodedWriter[IList[(K, V)]] = { m =>
val raw = m.map {
case (k, v) => k.toUrlEncoded.value + "=" + v.toUrlEncoded.value
}.intercalate("&")
Refined.unsafeApply(raw) // by deduction
}
}
Usamos Refined.unsafeApply cuando podemos deducir lógicamente que el contenido
de una cadena ya está codificado como una url, dejando de hacer verificaciones
adicionales.
ilist es un ejemplo de derivación de typeclass simple, así como derivamos
Numeric[Complex] de la representación numérica subyacente. El método
.intercalate es como .mkString pero más general.
En un capítulo dedicado a la derivación de typeclasses calcularemos instancias
de UrlQueryWriter automáticamente, y también limpiaremos lo que ya hemos
escrito, pero por ahora escribiremos el código repetitivo para los tipos que
deseemos convertir:
import UrlEncodedWriter.ops._
object AuthRequest {
implicit val query: UrlQueryWriter[AuthRequest] = { a =>
UriQuery(List(
("redirect_uri" -> a.redirect_uri.value),
("scope" -> a.scope),
("client_id" -> a.client_id),
("prompt" -> a.prompt),
("response_type" -> a.response_type),
("access_type" -> a.access_type))
}
}
object AccessRequest {
implicit val encoded: UrlEncodedWriter[AccessRequest] = { a =>
List(
"code" -> a.code.toUrlEncoded,
"redirect_uri" -> a.redirect_uri.toUrlEncoded,
"client_id" -> a.client_id.toUrlEncoded,
"client_secret" -> a.client_secret.toUrlEncoded,
"scope" -> a.scope.toUrlEncoded,
"grant_type" -> a.grant_type.toUrlEncoded
).toUrlEncoded
}
}
object RefreshRequest {
implicit val encoded: UrlEncodedWriter[RefreshRequest] = { r =>
List(
"client_secret" -> r.client_secret.toUrlEncoded,
"refresh_token" -> r.refresh_token.toUrlEncoded,
"client_id" -> r.client_id.toUrlEncoded,
"grant_type" -> r.grant_type.toUrlEncoded
).toUrlEncoded
}
}
4.3.4 Módulo
Esto concluye con el modelado de los datos y funcionalidad requeridos para implementar OAuth2. Recuerde del capítulo anterior que definimos componentes que necesitan interactuar con el mundo como álgebras, y debemos definir la lógica de negocio en un módulo, de modo que pueda ser probada por completo.
Definimos nuestra dependencia de las álgebras, y usamos los límites de contexto
para mostrar que nuestras respuestas deben tener un JsDecoder y nuestro
payload POST debe tener un UrlEncodedWriter:
trait JsonClient[F[_]] {
def get[A: JsDecoder](
uri: String Refined Url,
headers: IList[(String, String)]
): F[A]
def post[P: UrlEncodedWriter, A: JsDecoder](
uri: String Refined Url,
payload: P,
headers: IList[(String, String]
): F[A]
}
Note que nosotros únicamente definimos el camino fácil en la API JsonClient.
Veremos como lidiar con los errores en un capítulo posterior.
Obtener un CodeToken de un servidor OAuth2 de Google envuelve
- iniciar un servidor HTTP en la máquina local, y obtener su número de puerto.
- hacer que el usuario abra una página web en su navegador, lo que les permite identificarse con sus credenciales de Google y autorizar la aplicación, con una redirección de vuelta a la máquina local.
- capturar el código, informando al usuario de los siguientes pasos, y cerrar el servidor HTTP.
Podemos modelar esto con tres métodos en una álgebra UserInteraction
final case class CodeToken(token: String, redirect_uri: String Refined Url)
trait UserInteraction[F[_]] {
def start: F[String Refined Url]
def open(uri: String Refined Url): F[Unit]
def stop: F[CodeToken]
}
Casi suena fácil cuando lo escribimos de esta manera.
También requerimos de un álgebra para abstraer el sistema local de tiempo
trait LocalClock[F[_]] {
def now: F[Epoch]
}
E introducimos los tipos de datos que usaremos en la lógica de refresco
final case class ServerConfig(
auth: String Refined Url,
access: String Refined Url,
refresh: String Refined Url,
scope: String,
clientId: String,
clientSecret: String
)
final case class RefreshToken(token: String)
final case class BearerToken(token: String, expires: Epoch)
y ahora podemos escribir un módulo cliente para OAuth2:
import http.encoding.UrlQueryWriter.ops._
class OAuth2Client[F[_]: Monad](
config: ServerConfig
)(
user: UserInteraction[F],
client: JsonClient[F],
clock: LocalClock[F]
) {
def authenticate: F[CodeToken] =
for {
callback <- user.start
params = AuthRequest(callback, config.scope, config.clientId)
_ <- user.open(params.toUrlQuery.forUrl(config.auth))
code <- user.stop
} yield code
def access(code: CodeToken): F[(RefreshToken, BearerToken)] =
for {
request <- AccessRequest(code.token,
code.redirect_uri,
config.clientId,
config.clientSecret).pure[F]
msg <- client.post[AccessRequest, AccessResponse](
config.access, request)
time <- clock.now
expires = time + msg.expires_in.seconds
refresh = RefreshToken(msg.refresh_token)
bearer = BearerToken(msg.access_token, expires)
} yield (refresh, bearer)
def bearer(refresh: RefreshToken): F[BearerToken] =
for {
request <- RefreshRequest(config.clientSecret,
refresh.token,
config.clientId).pure[F]
msg <- client.post[RefreshRequest, RefreshResponse](
config.refresh, request)
time <- clock.now
expires = time + msg.expires_in.seconds
bearer = BearerToken(msg.access_token, expires)
} yield bearer
}
4.4 Resumen
- Los ADTs (tipos de datos algebraicos) están definidos como productos
(
final case class) y coproductos (sealed abstract class). - Los tipos
refinedhacen cumplir las invariantes/restricciones sobre los valores. - Las funciones concretas pueden definirse en una clase implícita, para mantener el flujo de izquierda a derecha.
- Las funciones polimórficas están definidas en typeclasses. La funcionalidad se proporciona por medio de límites de contexto (“has a”), más bién que por medio de jerarquías de clases.
- Las instancias de typeclasses son implementaciones de una typeclass.
-
@simulacrum.typeclassgenera.opsen el companion, proporcionando sintaxis conveniente para las funciones de la typeclass. - La derivación de typeclasses es una composición en tiempo de compilación de instancias de typeclass.
5. Scalaz Typeclasses
En este capítulo tendremos un tour de la mayoría de las typeclasses en
scalaz-core. No usamos todas en drone-dynamic-agents de modo que daremos
ejemplos standalone cuando sea apropiado.
Ha habido críticas con respecto a los nombres usados en Scalaz, y en la
programación funcional en general. La mayoría de los nombres siguen las
convenciones introducidas en el lenguaje de programación funcional Haskell,
basándose en la Teoría de las Categorías. Siéntase libre de crear type
aliases si los verbos basados en la funcionalidad primaria son más fáciles de
recordar cuando esté aprendiendo (por ejemplo, Mappable, Pureable,
FlatMappable).
Antes de introducir la jerarquía de typeclasses, echaremos un vistazo a los cuatro métodos más importantes desde una perspectiva de control de flujo: los métodos que usaremos más en las aplicaciones típicas de PF:
| Typeclass | Method | Desde | Dado | Hacia |
|---|---|---|---|---|
Functor |
map |
F[A] |
A => B |
F[B] |
Applicative |
pure |
A |
F[A] |
|
Monad |
flatMap |
F[A] |
A => F[B] |
F[B] |
Traverse |
sequence |
F[G[A]] |
G[F[A]] |
Sabemos que las operaciones que regresan un F[_] pueden ejecutarse
secuencialmente en una comprensión for mediante .flatMap definida en su
Monad[F]. El contexto F[_] puede considerarse como un contenedor para un
efecto intencional que tiene una A como la salida: flatMap nos permite
generar nuevos efectos F[B] en tiempo de ejecución basándose en los resultados
de evaluar efectos previos.
Por supuesto, no todos los constructores de tipo F[_] tienen efectos, incluso
si tienen una Monad[_]. Con frecuencia son estructuras de datos. Mediante el
uso de la abstracción menos específica, podemos reusar código para List,
Either, Future y más.
Si únicamente necesitamos transformar la salida de un F[_], esto simplemente
es un map, introducido por Functor. En el capítulo 3, ejecutamos efectos en
paralelo mediante la creación de un producto y realizando un mapeo sobre ellos.
En programación funcional, los cómputos paralelizables son considerados
menos poderosos que los secuenciales.
Entre Monad y Functor se encuentra Applicative, que define pure que nos
permite alzar un valor en un efecto, o la creación de una estructura de datos a
partir de un único valor.
.sequence es útil para rearreglar constructores de tipo. Si tenemos un
F[G[_]] pero requerimos un G[F[_]], por ejemplo, List[Future[Int]] pero
requerimos un Future[List[_]], entonces ocupamos .sequence.
5.1 Agenda
Este capítulo es más largo de lo usual y está repleto de información: es perfectamente razonable abordarlo en varias sesiones de estudio. Recordar todo requeriría poderes sobrehumanos, de modo que trate este capítulo como una manera de buscar más información.
Es notable la ausencia de typeclasses que extienden Monad. Estas tendrán su
propio capítulo más adelante.
Scalaz usa generación de código, no simulacrum. Sin embargo, por brevedad,
presentaremos los fragmentos de código con @typeclass. La sintaxis
equivalente estará disponible cuando hagamos un import scalaz._, Scalaz._ y
estará disponible en el paquete scalaz.syntax en el código fuente de Scalaz.
5.2 Cosas que pueden agregarse
@typeclass trait Semigroup[A] {
@op("|+|") def append(x: A, y: =>A): A
def multiply1(value: F, n: Int): F = ...
}
@typeclass trait Monoid[A] extends Semigroup[A] {
def zero: A
def multiply(value: F, n: Int): F =
if (n <= 0) zero else multiply1(value, n - 1)
}
@typeclass trait Band[A] extends Semigroup[A]
Un Semigroup puede definirse para un tipo si dos valores pueden combinarse. El
operador debe ser asociativo, es decir, que el orden de las operaciones
anidadas no debería importar, es decir
(a |+| b) |+| c == a |+| (b |+| c)
(1 |+| 2) |+| 3 == 1 |+| (2 |+| 3)
Un Monoid es un Semigroup con un elemento zero (también llamado empty
–vacío– o identity –identidad–). Combinar zero con cualquier otra a
debería dar otra a .
a |+| zero == a
a |+| 0 == a
Esto probablemente le traiga memorias sobre Numeric del capítulo 4. Existen
implementaciones de Monoid para todos los tipos numéricos primitivos, pero el
concepto de cosas que se pueden agregar es útil más allá de los números.
scala> "hello" |+| " " |+| "world!"
res: String = "hello world!"
scala> List(1, 2) |+| List(3, 4)
res: List[Int] = List(1, 2, 3, 4)
Band tiene la ley de que la operación append de dos elementos iguales es
idempotente, es decir devuelve el mismo valor. Ejemplos de esto pueden ser
cualesquier cosa que sólo pueda tener un valor, tales como Unit, los límites
superiores más pequeños, o un Set (conjunto). Band no proporciona métodos
adicionales y sin embargo los usuarios pueden aprovechar las garantías que
brinda con fines de optimización del rendimiento.
Como un ejemplo realista de Monoid, considere un sistema de comercio que tenga
una base de datos grande de plantillas de transacciones comerciales
reutilizables. Llenar las plantillas por default para una nueva transacción
implica la selección y combinación de múltiples plantillas, con la regla del
“último gana” para realizar uniones si dos plantillas proporcionan un valor para
el mismo campo. El trabajo de “seleccionar” trabajo ya se realiza por nosotros
mediante otro sistema, es nuestro trabajo combinar las plantillas en orden.
Crearemos un esquema simple de plantillas para demostrar el principio, pero tenga en mente que un sistema realista tendría un ADT más complicado.
sealed abstract class Currency
case object EUR extends Currency
case object USD extends Currency
final case class TradeTemplate(
payments: List[java.time.LocalDate],
ccy: Option[Currency],
otc: Option[Boolean]
)
Si escribimos un método que tome templates: List[TradeTemplate], entonces
necesitaremos llamar únicamente
val zero = Monoid[TradeTemplate].zero
templates.foldLeft(zero)(_ |+| _)
¡y nuestro trabajo está hecho!
Pero para poder usar zero o invocar |+| debemos tener una instancia de
Monoid[TradeTemplate]. Aunque derivaremos genéricamente este en un capítulo
posterior, por ahora crearemos la instancia en el companion:
object TradeTemplate {
implicit val monoid: Monoid[TradeTemplate] = Monoid.instance(
(a, b) => TradeTemplate(a.payments |+| b.payments,
a.ccy |+| b.ccy,
a.otc |+| b.otc),
TradeTemplate(Nil, None, None)
)
}
Sin embargo, esto no hace lo que queremos porque Monoid[Option[A]] realizará
una agregación de su contenido, por ejemplo,
scala> Option(2) |+| None
res: Option[Int] = Some(2)
scala> Option(2) |+| Option(1)
res: Option[Int] = Some(3)
mientras que deseamos implementar la regla del “último gana”. Podríamos hacer un
override del valor default Monoid[Option[A]] con el nuestro propio:
implicit def lastWins[A]: Monoid[Option[A]] = Monoid.instance(
{
case (None, None) => None
case (only, None) => only
case (None, only) => only
case (_ , winner) => winner
},
None
)
Ahora todo compila, de modo que si lo intentamos…
scala> import java.time.{LocalDate => LD}
scala> val templates = List(
TradeTemplate(Nil, None, None),
TradeTemplate(Nil, Some(EUR), None),
TradeTemplate(List(LD.of(2017, 8, 5)), Some(USD), None),
TradeTemplate(List(LD.of(2017, 9, 5)), None, Some(true)),
TradeTemplate(Nil, None, Some(false))
)
scala> templates.foldLeft(zero)(_ |+| _)
res: TradeTemplate = TradeTemplate(
List(2017-08-05,2017-09-05),
Some(USD),
Some(false))
Todo lo que tuvimos que hacer fue implementar una pieza de lógica de negocios y,
!el Monoid se encargó de todo por nosotros!
Note que la lista de payments se concatenó. Esto es debido a que el
Monoid[List] por default usa concatenación de elementos y simplemente ocurre
que este es el comportamiento deseado aquí. Si el requerimiento de negocios
fuera distinto, la solución sería proporcionar un Monoid[List[LocalDate]]
personalizado. Recuerde del capítulo 4 que con el polimorfismo de tiempo de
compilación tenemos una implementacion distinta de append dependiendo de la
E en List[E], no sólo de la clase de tiempo de ejecución List.
5.3 Cosas parecidas a objetos
En el capítulo sobre Datos y Funcionalidad dijimos que la noción de la JVM de
igualdad se derrumba para muchas cosas que podemos poner en una ADT. El problema
es que la JVM fue diseñada para Java, y equals está definido sobre
java.lang.Object aunque esto tenga sentido o no. No existe manera de remover
equals y no hay forma de garantizar que esté implementado.
Sin embargo, en PF preferimos el uso de typeclasses para tener funcionalidad polimórfica e incluso el concepto de igualdad es capturado en tiempo de compilación.
@typeclass trait Equal[F] {
@op("===") def equal(a1: F, a2: F): Boolean
@op("/==") def notEqual(a1: F, a2: F): Boolean = !equal(a1, a2)
}
En verdad === (triple igual) es más seguro desde la perspectiva de tipos que
==(doble igual) porque únicamente puede compilarse cuando los tipos son los
mismos en ambos lados de la comparación. Esto atrapa muchos errores.
equal tiene los mismos requisitos de implementación que Object.equals
-
conmutativo
f1 === f2implicaf2 === f1 -
reflexivo
f === f -
transitivo
f1 === f2 && f2 === f3implica quef1 === f3
Al desechar el concepto universal de Object.equals no damos por sentado el
concepto de igualdad cuando construimos un ADT, y nos detiene en tiempo de
compilación de esperar igualdad cuando en realidad no existe tal.
Continuando con la tendencia de reemplazar conceptos viejos de Java, más bien
que considerar que los datos son un java.lang.Comparable, ahora tienen un
Order de acuerdo con
@typeclass trait Order[F] extends Equal[F] {
@op("?|?") def order(x: F, y: F): Ordering
override def equal(x: F, y: F): Boolean = order(x, y) == Ordering.EQ
@op("<" ) def lt(x: F, y: F): Boolean = ...
@op("<=") def lte(x: F, y: F): Boolean = ...
@op(">" ) def gt(x: F, y: F): Boolean = ...
@op(">=") def gte(x: F, y: F): Boolean = ...
def max(x: F, y: F): F = ...
def min(x: F, y: F): F = ...
def sort(x: F, y: F): (F, F) = ...
}
sealed abstract class Ordering
object Ordering {
case object LT extends Ordering
case object EQ extends Ordering
case object GT extends Ordering
}
Order implementa .equal en términos de la primitiva nueva .order. Cuando
una typeclass implementa el combinador primitivo de su padre con un combinador
derivado, se agrega una ley implicación de sustitución para el typeclass.
Si una instancia de Order fuera a hacer un override de .equal por razones de
desempeño, debería comportase de manera idéntica a la original.
Las cosas que tienen un orden también podrían ser discretas, permitiéndonos avanzar o retroceder hacia un sucesor o predecesor:
@typeclass trait Enum[F] extends Order[F] {
def succ(a: F): F
def pred(a: F): F
def min: Option[F]
def max: Option[F]
@op("-+-") def succn(n: Int, a: F): F = ...
@op("---") def predn(n: Int, a: F): F = ...
@op("|->" ) def fromToL(from: F, to: F): List[F] = ...
@op("|-->") def fromStepToL(from: F, step: Int, to: F): List[F] = ...
@op("|=>" ) def fromTo(from: F, to: F): EphemeralStream[F] = ...
@op("|==>") def fromStepTo(from: F, step: Int, to: F): EphemeralStream[F] = ...
}
scala> 10 |--> (2, 20)
res: List[Int] = List(10, 12, 14, 16, 18, 20)
scala> 'm' |-> 'u'
res: List[Char] = List(m, n, o, p, q, r, s, t, u)
Discutiremos EphemeralStream en el siguiente capítulo, por ahora sólo
necesitamos saber que se trata de una estructura de datos potencialmente
infinita que evita los problemas de retención de memoria en el tipo Stream de
la librería estándar.
De manera similar a Object.equals, el concepto de .toString en toda clases
no tiene sentido en Java. Nos gustaría hacer cumplir el concepto de “poder
representar como cadena” en tiempo de compilación y esto es exactamente lo que
se consigue con Show:
trait Show[F] {
def show(f: F): Cord = ...
def shows(f: F): String = ...
}
Exploraremos Cord con más detalle en el capítulo que trate los tipos de datos,
por ahora sólo necesitamos saber que es una estructura de datos eficiente para
el almacenamiento y manipulación de String.
5.4 Cosas que se pueden mapear o transformar
Ahora nos enfocamos en las cosas que pueden mapearse, o recorrerse, en cierto sentido:
5.4.1 Functor
@typeclass trait Functor[F[_]] {
def map[A, B](fa: F[A])(f: A => B): F[B]
def void[A](fa: F[A]): F[Unit] = map(fa)(_ => ())
def fproduct[A, B](fa: F[A])(f: A => B): F[(A, B)] = map(fa)(a => (a, f(a)))
def fpair[A](fa: F[A]): F[(A, A)] = map(fa)(a => (a, a))
def strengthL[A, B](a: A, f: F[B]): F[(A, B)] = map(f)(b => (a, b))
def strengthR[A, B](f: F[A], b: B): F[(A, B)] = map(f)(a => (a, b))
def lift[A, B](f: A => B): F[A] => F[B] = map(_)(f)
def mapply[A, B](a: A)(f: F[A => B]): F[B] = map(f)((ff: A => B) => ff(a))
}
El único método abstracto es map, y debe ser posible hacer una composición,
es decir, mapear con una f y entonces nuevamente con una g es lo mismo que
hacer un mapeo una única vez con la composición de f y g:
fa.map(f).map(g) == fa.map(f.andThen(g))
El map también debe realizar una operación nula si la función provista es la
identidad (es decir, x => x)
fa.map(identity) == fa
fa.map(x => x) == fa
Functor define algunos métodos convenientes alrededor de map que pueden
optimizarse para algunas instancias específicas. La documentación ha sido
intencionalmente omitida en las definiciones arriba para incentivar el análisis
de lo que hace un método antes de que vea la implementación. Por favor,
deténgase unos momentos estudiando únicamente las signaturas de tipo de los
siguientes métodos antes de avanzar más:
def void[A](fa: F[A]): F[Unit]
def fproduct[A, B](fa: F[A])(f: A => B): F[(A, B)]
def fpair[A](fa: F[A]): F[(A, A)]
def strengthL[A, B](a: A, f: F[B]): F[(A, B)]
def strengthR[A, B](f: F[A], b: B): F[(A, B)]
// harder
def lift[A, B](f: A => B): F[A] => F[B]
def mapply[A, B](a: A)(f: F[A => B]): F[B]
-
voidtoma una instancia deF[A]y siempre devuelve unF[Unit], y se olvida de todos los valores a la vez que preserva la estructura. -
fproducttoma la misma entrada quemappero devuelveF[(A, B)], es decir, devuelve el contenido dentro de una tupla, con el resultado obtenido al aplicar la función. Esto es útil cuando deseamos retener la entrada. -
fpairrepite todos los elementos deAen una tuplaF[(A, A)] -
strengthLempareja el contenido de unaF[B]con una constanteAa la izquierda. -
strenghtRempareja el contenido de unaF[A]con una constanteBa la derecha. -
lifttoma una funciónA => By devuelve unaF[A] => F[B]. En otras palabras, toma una función del contenido de unaF[A]y devuelve una función que opera en elF[A]directamente. -
mapplynos obliga a pensar un poco. Digamos que tenemos unaF[_]de funcionesA => By el valorA, entonces podemos obtener unF[B]. Tiene una firma/signatura similar a la depurepero requiere que el que hace la llamada proporcioneF[A => B].
fpair, strenghL y strengthR tal vez parezcan inútiles, pero mostrarán su
utilidad cuando deseemos retener algo de información que de otra manera se
perdería en el ámbito.
Functor tiene una sintaxis especial:
implicit class FunctorOps[F[_]: Functor, A](self: F[A]) {
def as[B](b: =>B): F[B] = Functor[F].map(self)(_ => b)
def >|[B](b: =>B): F[B] = as(b)
}
.as y >| es una forma de reemplazar la salida con una constante.
En nuestra aplicación de ejemplo, como un hack espantoso (que no admitimos hasta
ahora), definimos start y stop de modo que devolvieran su entrada:
def start(node: MachineNode): F[MachineNode]
def stop (node: MachineNode): F[MachineNode]
Esto nos permitió escribir lógica breve de negocios como
for {
_ <- m.start(node)
update = world.copy(pending = Map(node -> world.time))
} yield update
y
for {
stopped <- nodes.traverse(m.stop)
updates = stopped.map(_ -> world.time).toList.toMap
update = world.copy(pending = world.pending ++ updates)
} yield update
pero este hack nos obliga a poner complejidad innecesaria en las
implementaciones. Es mejor si dejamos que nuestras álgebras regresen F[Unit] y
usar as:
m.start(node) as world.copy(pending = Map(node -> world.time))
y
for {
stopped <- nodes.traverse(a => m.stop(a) as a)
updates = stopped.map(_ -> world.time).toList.toMap
update = world.copy(pending = world.pending ++ updates)
} yield update
5.4.2 Foldable
Técnicamente, Foldable es para estructuras de datos que pueden recorrerse y
producir un valor que las resuma. Sin embargo, esto no dice lo suficiente sobre
el hecho de que se trata de un arma poderosa proporcionada por las typeclasses
que nos puede proporcionar la mayoría de lo que esperamos ver en una API de
colecciones.
Hay tantos métodos que necesitaremos revisarlos en partes, empezando con los métodos abstractos:
@typeclass trait Foldable[F[_]] {
def foldMap[A, B: Monoid](fa: F[A])(f: A => B): B
def foldRight[A, B](fa: F[A], z: =>B)(f: (A, =>B) => B): B
def foldLeft[A, B](fa: F[A], z: B)(f: (B, A) => B): B = ...
Una instancia de Foldable necesita implementar únicamente foldMap y
foldRight para obtener toda la funcionalidad en esta typeclass, aunque los
métodos están típicamente optimizqados para estructuras de datos específicas.
.foldMap tiene un nombre usado en mercadotecnia: MapReduce. Dada una
F[A], una función de A a B, y una forma de combinar una B (proporcionada
por el Monoid, junto con el zero B), podemos producir el valor resumen de
tipo B. No existe un orden impuesto en las operaciones, permitiendonos
realizar cómputos paralelos.
foldRight no requiere que sus parámetros tengan un Monoid, significando esto
que necesita un valor inicial z y una manera de combinar cada elemento de la
estructura de datos con el valor resumen. El orden en el que se recorren los
elementos es de derecha a izquierda y es por esta razón que no puede
paralelizarse.
foldLeft recorre los elementos de izquierda a derecha. foldLeft puede
implementarse en términos de foldMap, pero la mayoría de las instancias
escogen implementarlas porque se trata de una operación básica. Dado que
normalmente se implementa con recursión de cola, no existen parámetros byname.
La única ley para Foldable es que foldLeft y foldRight deberían ser
consistentes con foldMap para operaciones monoidales, por ejemplo, agregando
un elemento a una lista para foldLeft y anteponiendo un elemento a la lista
para foldRight. Sin embargo, foldLeft y foldRight no necesitan ser
consistentes la una con la otra: de hecho con frecuencia producen el inverso que
produce el otro.
La cosa más sencilla que se puede hacer con foldMap es usar la función
identity (identidad), proporcionando fold (la suma natural de los elementos
monoidales), con variantes derecha/izquierda para permitirnos escoger basándonos
en criterios de rendimiento:
def fold[A: Monoid](t: F[A]): A = ...
def sumr[A: Monoid](fa: F[A]): A = ...
def suml[A: Monoid](fa: F[A]): A = ...
Recuerde que cuando aprendimos sobre Monoid, escribimos lo siguiente:
scala> templates.foldLeft(Monoid[TradeTemplate].zero)(_ |+| _)
Sabemos que esto es tonto y que pudimos escribir:
scala> templates.toIList.fold
res: TradeTemplate = TradeTemplate(
List(2017-08-05,2017-09-05),
Some(USD),
Some(false))
.fold no funciona en la List estándar debido a que ya tiene un método
llamado fold que hace su propia cosa a su manera especial.
El método llamado de manera extraña intercalate inserta una A específica
entre cada elemento antes de realizar el fold
def intercalate[A: Monoid](fa: F[A], a: A): A = ...
que es una versión generalizada del método de la librería estándar mkString:
scala> List("foo", "bar").intercalate(",")
res: String = "foo,bar"
El foldLeft proporciona el medio para obtener cualquier elemento mediante un
índice para recorrer la estructura, incluyendo un grupo grande de métodos
relacionados:
def index[A](fa: F[A], i: Int): Option[A] = ...
def indexOr[A](fa: F[A], default: =>A, i: Int): A = ...
def length[A](fa: F[A]): Int = ...
def count[A](fa: F[A]): Int = length(fa)
def empty[A](fa: F[A]): Boolean = ...
def element[A: Equal](fa: F[A], a: A): Boolean = ...
Scalaz es una librería funcional pura que tiene únicamente funciones totales.
Mientras que List(0) puede lanzar excepciones, Foldable.index devuelve una
Option[A] con el método conveniente .indexOr regreseando una A cuando se
proporciona un valor por default. .element es similar al método de la librería
estándar .contains pero usa Equal más bien que la mal definida igualdad de
la JVM.
Estos métods realmente suenan como una API de colecciones. Y, por supuesto,
toda cosa con una instancia de Foldable puede convertirse en una List
def toList[A](fa: F[A]): List[A] = ...
También existen conversiones a otros tipos de datos de la librería estándar de
Scala y de Scalaz, tales como .toSet, .toVector, .toStream, .to[T <:
TraversableLike], toIList y la lista continúa.
Existen verificadores de predicados útiles
def filterLength[A](fa: F[A])(f: A => Boolean): Int = ...
def all[A](fa: F[A])(p: A => Boolean): Boolean = ...
def any[A](fa: F[A])(p: A => Boolean): Boolean = ...
filterLenght es una forma de contar cuántos elementos son true para el
predicado, all y any\ devuelven true is todos (o algún) elemento cumple
con el predicado, y pueden terminar de manera temprana.
Podemos dividir en partes una F[A] que resulten en la misma B con splitBy
def splitBy[A, B: Equal](fa: F[A])(f: A => B): IList[(B, Nel[A])] = ...
def splitByRelation[A](fa: F[A])(r: (A, A) => Boolean): IList[Nel[A]] = ...
def splitWith[A](fa: F[A])(p: A => Boolean): List[Nel[A]] = ...
def selectSplit[A](fa: F[A])(p: A => Boolean): List[Nel[A]] = ...
def findLeft[A](fa: F[A])(f: A => Boolean): Option[A] = ...
def findRight[A](fa: F[A])(f: A => Boolean): Option[A] = ...
por ejemplo
scala> IList("foo", "bar", "bar", "faz", "gaz", "baz").splitBy(_.charAt(0))
res = [(f, [foo]), (b, [bar, bar]), (f, [faz]), (g, [gaz]), (b, [baz])]
haciendo la observación de que sólamente existen dos valores indexados por
'b'.
splitByRelation evita la necesidad de tener una Equal pero debemos
proporcionar el operador de comparación.
splitWith divide los elementos en grupos que alternativamente satisfacen y no
el predicado. selectSplit selecciona grupos de elementos que satisfacen el
predicado, descartando los otros. Este es uno de esos casos raros en donde dos
métodos comparten la misma firma/signatura, pero tienen significados distintos.
findLeft y findRight sirven para extraer el primer elemento (de la izquierda
o de la derecha) que cumpla un predicado.
Haciendo uso adicional de Equal y Order, tenemos métodos distinct que
devuelven agrupaciones.
def distinct[A: Order](fa: F[A]): IList[A] = ...
def distinctE[A: Equal](fa: F[A]): IList[A] = ...
def distinctBy[A, B: Equal](fa: F[A])(f: A => B): IList[A] =
distinct se implementa de manera más eficiente que distinctE debido a que
puede usar el ordenamiento y por lo tanto usar un algoritmo tipo quicksort que
es mucho más rápido que la implementación ingenua de List.distinct. Las
estructuras de datos (tales como los conjuntos) pueden implementar distinct y
su Foldable sin realizar ningún trabajo.
distinctBy permite la agrupación mediante la aplicación de una función a sus
elementos. Por ejemplo, agrupar nombres por su letra inicial.
Podemos hacer uso adicional de Order al extraer el elemento mínimo o máximo (o
ambos extremos) incluyendo variaciones usando el patrón Of o By para mapear
primero a otro tipo o usar un tipo diferente para hacer la otra comparación.
def maximum[A: Order](fa: F[A]): Option[A] = ...
def maximumOf[A, B: Order](fa: F[A])(f: A => B): Option[B] = ...
def maximumBy[A, B: Order](fa: F[A])(f: A => B): Option[A] = ...
def minimum[A: Order](fa: F[A]): Option[A] = ...
def minimumOf[A, B: Order](fa: F[A])(f: A => B): Option[B] = ...
def minimumBy[A, B: Order](fa: F[A])(f: A => B): Option[A] = ...
def extrema[A: Order](fa: F[A]): Option[(A, A)] = ...
def extremaOf[A, B: Order](fa: F[A])(f: A => B): Option[(B, B)] = ...
def extremaBy[A, B: Order](fa: F[A])(f: A => B): Option[(A, A)] =
Por ejemplo podríamos preguntar cuál String es máxima By (por) longitud, o
cuál es la máxima longitud Of (de) los elementos.
scala> List("foo", "fazz").maximumBy(_.length)
res: Option[String] = Some(fazz)
scala> List("foo", "fazz").maximumOf(_.length)
res: Option[Int] = Some(4)
Esto concluye con las características clave de Foldable. El punto clave a
recordar es que cualquier cosa que esperaríamos encontrar en una librearía de
colecciones está probablemente en Foldable y si no está ahí, probablemente
debería estarlo.
Concluiremos con algunas variantes de los métodos que ya hemos visto. Primero,
existen métodos que toman un Semigroup en lugar de un Monoid:
def fold1Opt[A: Semigroup](fa: F[A]): Option[A] = ...
def foldMap1Opt[A, B: Semigroup](fa: F[A])(f: A => B): Option[B] = ...
def sumr1Opt[A: Semigroup](fa: F[A]): Option[A] = ...
def suml1Opt[A: Semigroup](fa: F[A]): Option[A] = ...
...
devolviendo Option para tomar encuenta las estructuras de datos vacías
(recuerde que Semigroup no tiene un zero).
La typeclass Foldable1 contiene muchas más variantes usando Semigroup de los
métodos que usan Monoid mostrados aquí (todos ellos con el sufijo 1) y
tienen sentido para estructuras de datos que nunca están vacías, sin requerrir
la existencia de un Monoid para los elementos.
De manera importante, existen variantes que toman valores de retorno monádicos.
Ya hemos usado foldLeft cuando escribimos por primera vez la lógica de
negocios de nuestra aplicación, ahora sabemos que proviene de Foldable:
def foldLeftM[G[_]: Monad, A, B](fa: F[A], z: B)(f: (B, A) => G[B]): G[B] = ...
def foldRightM[G[_]: Monad, A, B](fa: F[A], z: =>B)(f: (A, =>B) => G[B]): G[B] = ...
def foldMapM[G[_]: Monad, A, B: Monoid](fa: F[A])(f: A => G[B]): G[B] = ...
def findMapM[M[_]: Monad, A, B](fa: F[A])(f: A => M[Option[B]]): M[Option[B]] = ...
def allM[G[_]: Monad, A](fa: F[A])(p: A => G[Boolean]): G[Boolean] = ...
def anyM[G[_]: Monad, A](fa: F[A])(p: A => G[Boolean]): G[Boolean] = ...
...
5.4.3 Traverse
Traverse es lo que sucede cuando hacemos el cruce de un Functor con un
Foldable
trait Traverse[F[_]] extends Functor[F] with Foldable[F] {
def traverse[G[_]: Applicative, A, B](fa: F[A])(f: A => G[B]): G[F[B]]
def sequence[G[_]: Applicative, A](fga: F[G[A]]): G[F[A]] = ...
def reverse[A](fa: F[A]): F[A] = ...
def zipL[A, B](fa: F[A], fb: F[B]): F[(A, Option[B])] = ...
def zipR[A, B](fa: F[A], fb: F[B]): F[(Option[A], B)] = ...
def indexed[A](fa: F[A]): F[(Int, A)] = ...
def zipWithL[A, B, C](fa: F[A], fb: F[B])(f: (A, Option[B]) => C): F[C] = ...
def zipWithR[A, B, C](fa: F[A], fb: F[B])(f: (Option[A], B) => C): F[C] = ...
def mapAccumL[S, A, B](fa: F[A], z: S)(f: (S, A) => (S, B)): (S, F[B]) = ...
def mapAccumR[S, A, B](fa: F[A], z: S)(f: (S, A) => (S, B)): (S, F[B]) = ...
}
Al principio del capítulo mostramos la importancia de traverse y sequence
para invertir los constructores de tipo para que se ajusten a un requerimiento
(por ejemplo, de List[Future[_]] a Future[List[_]]).
En Foldable no fuimos capaces de asumir que reverse fuera un concepto
universal, pero ahora podemos invertir algo.
También podemos hacer un zip de dos cosas que tengan un Traverse, obteniendo
un None cuando uno de los dos lados se queda sin elementos, usando zipL o
zipR para decidir cuál lado truncar cuando las longitudes no empatan. Un caso
especial de zip es agregar un índice a cada entrada con indexed.
zipWithL y zipWithR permiten la combinación de dos lados de un zip en un
nuevo tipo, y entonces devuelven simplemente un F[C].
mapAccumL y mapAccumR son map regular combinado con un acumulador. Si nos
topamos con la situación de que nuestra costumbre vieja proveniente de Java nos
quiere hacer usar una var, y deseamos referirnos a ella en un map,
deberíamos estar usando mapAccumL.
Por ejemplo, digamos que tenemos una lista de palabras y que deseamos borrar las palabras que ya hemos encontrado. El algoritmo de filtrado no permite procesar las palabras de la lista una segunda vez de modo que pueda escalarse a un stream infinito:
scala> val freedom =
"""We campaign for these freedoms because everyone deserves them.
With these freedoms, the users (both individually and collectively)
control the program and what it does for them."""
.split("\\s+")
.toList
scala> def clean(s: String): String = s.toLowerCase.replaceAll("[,.()]+", "")
scala> freedom
.mapAccumL(Set.empty[String]) { (seen, word) =>
val cleaned = clean(word)
(seen + cleaned, if (seen(cleaned)) "_" else word)
}
._2
.intercalate(" ")
res: String =
"""We campaign for these freedoms because everyone deserves them.
With _ _ the users (both individually and collectively)
control _ program _ what it does _ _"""
Finalmente Traversable1, como Foldable1, proporciona variantes de estos
métodos para las estructuras de datos que no pueden estar vacías, aceptando
Semigroup (más débil) en lugar de un Monoid, y un Apply en lugar de un
Applicative. Recuerde que Semigroup no tiene que proporcionar un .empty, y
Apply no tiene que proporcionar un .point.
5.4.4 Align
Align es sobre unir y rellenar algo con un Functor. Antes de revisar
Align, conozca al tipo de datos \&/ (pronunciado como These, o ¡Viva!).
sealed abstract class \&/[+A, +B]
final case class This[A](aa: A) extends (A \&/ Nothing)
final case class That[B](bb: B) extends (Nothing \&/ B)
final case class Both[A, B](aa: A, bb: B) extends (A \&/ B)
es decir, se trata de una codificación con datos de un OR lógico inclusivo.
A o B o ambos A y B.
@typeclass trait Align[F[_]] extends Functor[F] {
def alignWith[A, B, C](f: A \&/ B => C): (F[A], F[B]) => F[C]
def align[A, B](a: F[A], b: F[B]): F[A \&/ B] = ...
def merge[A: Semigroup](a1: F[A], a2: F[A]): F[A] = ...
def pad[A, B]: (F[A], F[B]) => F[(Option[A], Option[B])] = ...
def padWith[A, B, C](f: (Option[A], Option[B]) => C): (F[A], F[B]) => F[C] = ...
alignWith toma una función de ya sea una A o una B (o ambos) a una C y
devuelve una función alzada de una tupla de F[A] y F[B] a una F[C].
align construye \&/ a partir de dos F[_].
merge nos permite combinar dos F[A] cuando A tiene un Semigroup. Por
ejemplo, la implementación de Semigroup[Map[K, V]] delega a un Semigroup[V],
combinando dos entradas de resultados en la combinación de sus valores, teniendo
la consecuencia de que Map[K, List[A]] se comporta como un multimapa:
scala> Map("foo" -> List(1)) merge Map("foo" -> List(1), "bar" -> List(2))
res = Map(foo -> List(1, 1), bar -> List(2))
y un Map[K, Int] simplemente totaliza sus contenidos cuando hace la unión
scala> Map("foo" -> 1) merge Map("foo" -> 1, "bar" -> 2)
res = Map(foo -> 2, bar -> 2)
.pad y .padWith son para realizar una unión parcial de dos estructuras de
datos que puedieran tener valores faltantes en uno de los lados. Por ejemplo si
desearamos agregar votos independientes y retener el conocimiento de donde
vinieron los votos
scala> Map("foo" -> 1) pad Map("foo" -> 1, "bar" -> 2)
res = Map(foo -> (Some(1),Some(1)), bar -> (None,Some(2)))
scala> Map("foo" -> 1, "bar" -> 2) pad Map("foo" -> 1)
res = Map(foo -> (Some(1),Some(1)), bar -> (Some(2),None))
Existen variantes convenientes de align que se aprovechan de la estructura de
\&/
...
def alignSwap[A, B](a: F[A], b: F[B]): F[B \&/ A] = ...
def alignA[A, B](a: F[A], b: F[B]): F[Option[A]] = ...
def alignB[A, B](a: F[A], b: F[B]): F[Option[B]] = ...
def alignThis[A, B](a: F[A], b: F[B]): F[Option[A]] = ...
def alignThat[A, B](a: F[A], b: F[B]): F[Option[B]] = ...
def alignBoth[A, B](a: F[A], b: F[B]): F[Option[(A, B)]] = ...
}
las cuáles deberían tener sentido a partir de las firmas/signaturas de tipo. Ejemplos:
scala> List(1,2,3) alignSwap List(4,5)
res = List(Both(4,1), Both(5,2), That(3))
scala> List(1,2,3) alignA List(4,5)
res = List(Some(1), Some(2), Some(3))
scala> List(1,2,3) alignB List(4,5)
res = List(Some(4), Some(5), None)
scala> List(1,2,3) alignThis List(4,5)
res = List(None, None, Some(3))
scala> List(1,2,3) alignThat List(4,5)
res = List(None, None, None)
scala> List(1,2,3) alignBoth List(4,5)
res = List(Some((1,4)), Some((2,5)), None)
Note que las variantes A y B usan OR inclusivo, mientras que las variantes
This y That son exclusivas, devolviendo None si existe un valor en ambos
lados, o ningún valor en algún lado.
5.5 Variancia
Debemos regresar a Functor por un momento y discutir un ancestro que
previamente habíamos ignorado:
InvariantFunctor, también conocido como el functor exponencial, tiene un
método xmap que dice que dada una función de A a B, y una función de B a
A, podemos entonces convertir F[A] a F[B].
Functor es una abreviación de lo que deberíamos llamar functor covariante.
Pero dado que Functor es tan popular este conserva la abreviación. De manera
similar, Contravariant debería ser llamado functor contravariante.
Functor implementa xmap con map e ignora la función de B a A.
Contravariant, por otra parte, implementa xmap con contramap e ignora la
función de A a B:
@typeclass trait InvariantFunctor[F[_]] {
def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B]
...
}
@typeclass trait Functor[F[_]] extends InvariantFunctor[F] {
def map[A, B](fa: F[A])(f: A => B): F[B]
def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B] = map(fa)(f)
...
}
@typeclass trait Contravariant[F[_]] extends InvariantFunctor[F] {
def contramap[A, B](fa: F[A])(f: B => A): F[B]
def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B] = contramap(fa)(g)
...
}
Es importante notar que, aunque están relacionados a nivel teórico, las palabras
covariante, contravariante e invariante no se refieren directamente a la
variancia de tipos en Scala (es decir, con los prefijos + y - que pudieran
escribirse en las firmas/signaturas de los tipos). Invariancia aquí significa
que es posible mapear el contenido de la estructura F[A] en F[B]. Usando la
función identidad (identity) podemos ver que A puede convertirse de manera
segura en una B dependiendo de la variancia del functor.
.map puede entenderse por medio del contrato “si me das una F de A y una
forma de convertir una B en una A, entonces puedo darte una F de B”.
Consideraremos un ejemplo: en nuestra aplicación introducimos tipos específicos
del dominio Alpha, Beta, Gamma, etc, para asegurar que no estemos
mezclando números en un cálculo financiero:
final case class Alpha(value: Double)
pero ahora nos encontramos con el problema de que no tenemos ninguna typeclass
para estos nuevos tipos. Si usamos los valores en los documentos JSON, entonces
tenemos que escribir instancias de JsEncoder y JsDecoder.
Sin embargo, JsEncoder tiene un Contravariant y JsDecoder tiene un
Functor, de modo que es posible derivar instancias. Llenando el contrato:
- “si me das un
JsDecoderpara unDouble, y una manera de ir de unDoublea unAlpha, entonces yo puedo darte unJsDecoderpara unAlpha”. - “si me das un
JsEncoderpar unDouble, y una manera de ir de unAlphaa unDouble, entonces yo puedo darte unJsEncoderpara unAlpha”.
object Alpha {
implicit val decoder: JsDecoder[Alpha] = JsDecoder[Double].map(_.value)
implicit val encoder: JsEncoder[Alpha] = JsEncoder[Double].contramap(_.value)
}
Los métodos en una typeclass pueden tener sus parámetros de tipo en posición
contravariante (parámetros de método) o en posición covariante (tipo de
retorno). Si una typeclass tiene una combinación de posiciones covariantes y
contravariantes, tal vez también tenga un functor invariante. Por ejemplo,
Semigroup y Monoid tienen un InvariantFunctor, pero no un Functor o un
Contravariant.
5.6 Apply y Bind
Considere ésto un calentamiento para Applicative y Monad
5.6.1 Apply
Apply extiende Functor al agregar un método llamado ap que es similar a
map en el sentido de que aplica una función a valores. Sin embargo, con ap,
la función está en el mismo contexto que los valores.
@typeclass trait Apply[F[_]] extends Functor[F] {
@op("<*>") def ap[A, B](fa: =>F[A])(f: =>F[A => B]): F[B]
...
Vale la pena tomarse un momento y considerar lo que significa para una
estructura de datos simple como Option[A], el que tenga la siguiente
implementación de .ap
implicit def option[A]: Apply[Option[A]] = new Apply[Option[A]] {
override def ap[A, B](fa: =>Option[A])(f: =>Option[A => B]) = f match {
case Some(ff) => fa.map(ff)
case None => None
}
...
}
Para implementar .ap primero debemos extraer la función ff: A => B de f:
Option[A => B], y entonces podemos mapear sobre fa. La extracción de la
función a partir del contexto es el poder importante que Apply tiene,
permitiendo que múltiples funciones se combinen dentro del contexto.
Regresando a Apply, encontramos la función auxiliar .applyX que nos permite
combinar funciones paralelas y entonces mapear sobre la salida combinada:
@typeclass trait Apply[F[_]] extends Functor[F] {
...
def apply2[A,B,C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C): F[C] = ...
def apply3[A,B,C,D](fa: =>F[A],fb: =>F[B],fc: =>F[C])(f: (A,B,C) =>D): F[D] = ...
...
def apply12[...]
Lea .apply2 como un contrato con la promesa siguiente: “si usted me da una F
de A y una F de B, con una forma de combinar A y B en una C,
entonces puedo devolverle una F de C”. Existen muchos casos de uso para este
contrato y los dos más importantes son:
- construir algunas typeclasses para el tipo producto
Ca partir de sus constituyentesAyB - ejecutar efectos en paralelo, como en las álgebras del drone y de google que creamos en el Capítulo 3, y entonces combinando sus resultados.
En verdad, Apply es tan útil que tiene una sintaxis especial:
implicit class ApplyOps[F[_]: Apply, A](self: F[A]) {
def *>[B](fb: F[B]): F[B] = Apply[F].apply2(self,fb)((_,b) => b)
def <*[B](fb: F[B]): F[A] = Apply[F].apply2(self,fb)((a,_) => a)
def |@|[B](fb: F[B]): ApplicativeBuilder[F, A, B] = ...
}
class ApplicativeBuilder[F[_]: Apply, A, B](a: F[A], b: F[B]) {
def tupled: F[(A, B)] = Apply[F].apply2(a, b)(Tuple2(_))
def |@|[C](cc: F[C]): ApplicativeBuilder3[C] = ...
sealed abstract class ApplicativeBuilder3[C](c: F[C]) {
..ApplicativeBuilder4
...
..ApplicativeBuilder12
}
que es exactamente lo que se usó en el Capítulo 3:
(d.getBacklog |@| d.getAgents |@| m.getManaged |@| m.getAlive |@| m.getTime)
La sintaxis <*y *> (el ave hacia la izquierda y hacia la derecha) ofrece una
manera conveniente de ignorar la salida de uno de dos efectos paralelos.
Desgraciadamente, aunque la syntaxis con |@| es clara, hay un problema pues se
crea un nuevo objeto de tipo ApplicativeBuilder por cada efecto adicional. Si
el trabajo es principalmente de naturaleza I/O, el costo de la asignación de
memoria es insignificante. Sin embargo, cuando el trabajo es mayormente de
naturaleza computacional, es preferible usar la sintaxis alternativa de
alzamiento con aridad, que no produce ningún objeto intermedio:
def ^[F[_]: Apply,A,B,C](fa: =>F[A],fb: =>F[B])(f: (A,B) =>C): F[C] = ...
def ^^[F[_]: Apply,A,B,C,D](fa: =>F[A],fb: =>F[B],fc: =>F[C])(f: (A,B,C) =>D): F[D] = ...
...
def ^^^^^^[F[_]: Apply, ...]
y se usa así:
^^^^(d.getBacklog, d.getAgents, m.getManaged, m.getAlive, m.getTime)
o haga una invocación directa de `applyX
Apply[F].apply5(d.getBacklog, d.getAgents, m.getManaged, m.getAlive, m.getTime)
A pesar de ser más común su uso con efectos, Applyfunciona también con
estructuras de datos. Considere reescribir
for {
foo <- data.foo: Option[String]
bar <- data.bar: Option[Int]
} yield foo + bar.shows
como
(data.foo |@| data.bar)(_ + _.shows)
Si nosotros deseamos únicamente la salida combinada como una tupla, existen métodos para hacer sólo eso:
@op("tuple") def tuple2[A,B](fa: =>F[A],fb: =>F[B]): F[(A,B)] = ...
def tuple3[A,B,C](fa: =>F[A],fb: =>F[B],fc: =>F[C]): F[(A,B,C)] = ...
...
def tuple12[...]
(data.foo tuple data.bar) : Option[(String, Int)]
Existen también versiones generalizadas de ap para más de dos parámetros:
def ap2[A,B,C](fa: =>F[A],fb: =>F[B])(f: F[(A,B) => C]): F[C] = ...
def ap3[A,B,C,D](fa: =>F[A],fb: =>F[B],fc: =>F[C])(f: F[(A,B,C) => D]): F[D] = ...
...
def ap12[...]
junto con métodos .lift que toman funciones normales y las alzan al contexto
F[_], la generalización de Functor.lift
def lift2[A,B,C](f: (A,B) => C): (F[A],F[B]) => F[C] = ...
def lift3[A,B,C,D](f: (A,B,C) => D): (F[A],F[B],F[C]) => F[D] = ...
...
def lift12[...]
y .apF, una sintáxis parcialmente aplicada para ap
def apF[A,B](f: =>F[A => B]): F[A] => F[B] = ...
Finalmente .forever
def forever[A, B](fa: F[A]): F[B] = ...
que repite el efecto sin detenerse. La instancia de Apply debe tener un uso
seguro del stack o tendremos StackOverflowError.
5.6.2 Bind
Bind introduces .bind, que es un sinónimo de .flatMap, que permite
funciones sobre el resultado de un efecto regresar un nuevo efecto, o para
funciones sobre los valores de una estructura de datos devolver nuevas
estructuras de datos que entonces son unidas.
@typeclass trait Bind[F[_]] extends Apply[F] {
@op(">>=") def bind[A, B](fa: F[A])(f: A => F[B]): F[B]
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] = bind(fa)(f)
override def ap[A, B](fa: =>F[A])(f: =>F[A => B]): F[B] =
bind(f)(x => map(fa)(x))
override def apply2[A, B, C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C): F[C] =
bind(fa)(a => map(fb)(b => f(a, b)))
def join[A](ffa: F[F[A]]): F[A] = bind(ffa)(identity)
def mproduct[A, B](fa: F[A])(f: A => F[B]): F[(A, B)] = ...
def ifM[B](value: F[Boolean], t: =>F[B], f: =>F[B]): F[B] = ...
}
El método .join debe ser familiar a los usuarios de .flatten en la librería
estándar, y toma un contexto anidado y lo convierte en uno sólo.
Combinadores derivados se introducen para .ap y .apply2 que requieren
consistencia con .bind. Veremos más adelante que esta ley tiene consecuencias
para las estrategias de paralelización.
mproduct es como Functor.fproduct y empareja la entrada de una función con
su salida, dentro de F.
ifM es una forma de construir una estructura de datos o efecto condicional:
scala> List(true, false, true).ifM(List(0), List(1, 1))
res: List[Int] = List(0, 1, 1, 0)
ifM y ap están optimizados para crear un cache y reusar las ramas de código,
compare a la forma más larga
scala> List(true, false, true).flatMap { b => if (b) List(0) else List(1, 1) }
que produce una nueva List(0) o List(1, 1) cada vez que se invoca una
alternativa.
Bind también tiene sintaxis especial
implicit class BindOps[F[_]: Bind, A] (self: F[A]) {
def >>[B](b: =>F[B]): F[B] = Bind[F].bind(self)(_ => b)
def >>: F[A] = Bind[F].bind(self)(a => f(a).map(_ => a))
}
>> se usa cuando deseamos descartar la entrada a bind y >>! cuando
deseamos ejecutar un efecto pero descartar su salida.
5.7 Applicative y Monad
Desde un punto de vista de funcionalidad, Applicative es Apply con un método
pure, y Monad extiende Applicative con Bind.
@typeclass trait Applicative[F[_]] extends Apply[F] {
def point[A](a: =>A): F[A]
def pure[A](a: =>A): F[A] = point(a)
}
@typeclass trait Monad[F[_]] extends Applicative[F] with Bind[F]
En muchos sentidos, Applicative y Monad son la culminación de todo lo que
hemos visto en este capítulo. .pure (o .point como se conoce más comúnmente
para las estructuras de datos) nos permite crear efectos o estructuras de datos
a partir de valores.
Las instancias de Applicative deben satisfacer algunas leyes, asegurándose así
de que todos los métodos sean consistentes:
-
Identidad:
fa <*> pure(identity) === fa, (dondefaestá enF[A]) es decir, aplicarpure(identity)no realiza ninguna operación. -
Homomorfismo:
pure(a) <*> pure(ab) === pure(ab(a))(dondeabes unaA => B), es decir aplicar una funciónpurea un valorpurees lo mismo que aplicar la función al valor y entonces usarpuresobre el resultado. -
Intercambio:
pure(a) <*> fab === fab <*> pure (f => f(a)), (dondefabes unaF[A => B]), es decirpurees la identidad por la izquierda y por la derecha. -
Mapeo:
map(fa)(f) === fa <*> pure(f)
Monad agrega leyes adicionales:
-
Identidad por la izquierda:
pure(a).bind(f) === f(a) -
Identidad por la derecha:
a.bind(pure(_)) === a -
Asociatividad:
fa.bind(f).bind(g) === fa.bind(a => f(a).bind(g))dondefaes unaF[A],fes unaA => F[B]yges unaB => F[C].
La asociatividad dice que invocaciones repetidas de bind deben resultar en lo
mismo que invocaciones anidadas de bind. Sin embargo, esto no significa que
podamos reordenar, lo que sería conmutatividad. Por ejemplo, recordando que
flatMap es un alias de bind, no podemos reordenar
for {
_ <- machine.start(node1)
_ <- machine.stop(node1)
} yield true
como
for {
_ <- machine.stop(node1)
_ <- machine.start(node1)
} yield true
start y stop no son conmutativas, porque ¡el efecto deseado de
iniciar y luego detener un nodo es diferente a detenerlo y luego iniciarlo!
Pero start es conmutativo consigo mismo, y stop es conmutativo consigo
mismo, de modo que podemos reescribir
for {
_ <- machine.start(node1)
_ <- machine.start(node2)
} yield true
como
for {
_ <- machine.start(node2)
_ <- machine.start(node1)
} yield true
que son equivalentes para nuestra álgebra, pero no en general. Aquí se están haciendo muchas suposiciones sobre la API de Google Container, pero es una elección razonable que podemos hacer.
Una consecuencia práctica es que una Monad debe ser conmutativa si sus
métodos applyX pueden ser ejecutados en paralelo. En el capítulo 3 hicimos
trampa cuando ejecutamos estos efectos en paralelo
(d.getBacklog |@| d.getAgents |@| m.getManaged |@| m.getAlive |@| m.getTime)
porque sabemos que son conmutativos entre sí. Cuando tengamos que interpretar nuestra aplicación, más adelante en el libro, tendremos que proporcionar evidencia de que estos efectos son de hecho conmutativos, o una implementación asíncrona podría escoger efectuar las operaciones de manera secuencial por seguridad.
Las sutilezas de cómo lidiar con el reordenamiento de efectos, y cuáles son estos efectos, merece un capítulo dedicado sobre mónadas avanzadas.
5.8 Divide y conquistarás
Divide es el análogo Contravariant de Apply
@typeclass trait Divide[F[_]] extends Contravariant[F] {
def divide[A, B, C](fa: F[A], fb: F[B])(f: C => (A, B)): F[C] = divide2(fa, fb)(f)
def divide1[A1, Z](a1: F[A1])(f: Z => A1): F[Z] = ...
def divide2[A, B, C](fa: F[A], fb: F[B])(f: C => (A, B)): F[C] = ...
...
def divide22[...] = ...
divide dice que si podemos partir una C en una A y una B, y se nos da
una F[A] y una F[B], entonces podemos tener una F[C]. De ahí la frase
divide y conquistarás.
Esta es una gran manera de generar instancias contravariantes de una typeclass
para tipos producto mediante la separación de los productos en sus partes
constituyentes. Scalaz tiene una instancia de Divide[Equal], así que vamos a
construir un Equal para un nuevo tipo producto Foo
scala> case class Foo(s: String, i: Int)
scala> implicit val fooEqual: Equal[Foo] =
Divide[Equal].divide2(Equal[String], Equal[Int]) {
(foo: Foo) => (foo.s, foo.i)
}
scala> Foo("foo", 1) === Foo("bar", 1)
res: Boolean = false
Siguiendo los patrones en Apply, Divide también tiene una sintaxis clara
para las tuplas. Es un enfoque más suave que divide de modo que podamos reinar
con el objetivo de dominar el mundo:
...
def tuple2[A1, A2](a1: F[A1], a2: F[A2]): F[(A1, A2)] = ...
...
def tuple22[...] = ...
}
Generalmente, si las typeclasses de un codificador pueden proporcionar una
instancia de Divide, más bien que detenerse en Contravariant, entonces se
hace posible la derivación de instancias para cualquier case class. De manera
similar, las typeclasses de decodificadores pueden proporcionar una instancia de
Apply. Exploraremos esto en un capítulo dedicado a la derivación de
typeclasses.
Divisible es el análogo contravariante (Contravariant) de Applicative e
introduce .conquer, el equivalente a .pure
@typeclass trait Divisible[F[_]] extends Divide[F] {
def conquer[A]: F[A]
}
.conquer también permite la creación trivial de implementaciones donde el
parámetro de tipo es ignorado. Tales valores se llaman universalmente
cuantificados. Por ejemplo, Divisible[Equal].conquer[INil[String]] devuelve
una implementación de Equal para una lista vacía de String que siempre es
true.
5.9 Plus
Plus es un Semigroup pero para constructores de tipo, y PlusEmpty es el
equivalente de Monoid (incluso tienen las mismas leyes) mientras que IsEmpty
es novedoso y nos permite consultar si un F[A] está vacío:
@typeclass trait Plus[F[_]] {
@op("<+>") def plus[A](a: F[A], b: =>F[A]): F[A]
}
@typeclass trait PlusEmpty[F[_]] extends Plus[F] {
def empty[A]: F[A]
}
@typeclass trait IsEmpty[F[_]] extends PlusEmpty[F] {
def isEmpty[A](fa: F[A]): Boolean
}
Aunque superficialmente pueda parecer como si <+> se comportara como |+|
scala> List(2,3) |+| List(7)
res = List(2, 3, 7)
scala> List(2,3) <+> List(7)
res = List(2, 3, 7)
es mejor pensar que este operador funciona únicamente al nivel de F[_], nunca
viendo al contenido. Plus tiene la convención de que debería ignorar las
fallas y “escoger al primer ganador”. <+> puede por lo tanto ser usado como un
mecanismo de salida temprana (con pérdida de información) y de manejo de fallas
mediante alternativas:
scala> Option(1) |+| Option(2)
res = Some(3)
scala> Option(1) <+> Option(2)
res = Some(1)
scala> Option.empty[Int] <+> Option(1)
res = Some(1)
Por ejemplo, si tenemos una NonEmptyList[Option[Int]] y deseamos ignorar los
valores None (fallas) y escoger el primer ganador (Some), podemos invocar
<+> de Foldable1.foldRight1:
scala> NonEmptyList(None, None, Some(1), Some(2), None)
.foldRight1(_ <+> _)
res: Option[Int] = Some(1)
De hecho, ahora que sabemos de Plus, nos damos cuenta de que no era necesaria
violar la coherencia de typeclases (cuando definimos un Monoid[Option[A]] con
alcance local) en la sección sobre Cosas que se pueden agregar. Nuestro
objectivo era “escoger el último ganador”, que es lo mismo que “escoge al
ganador” si los argumentos se intercambian. Note el uso del interceptor TIE para
ccy y otc con los argumentos intercambiados.
implicit val monoid: Monoid[TradeTemplate] = Monoid.instance(
(a, b) => TradeTemplate(a.payments |+| b.payments,
b.ccy <+> a.ccy,
b.otc <+> a.otc),
TradeTemplate(Nil, None, None)
)
Applicative y Monad tienen versiones especializadas de PlusEmpty
@typeclass trait ApplicativePlus[F[_]] extends Applicative[F] with PlusEmpty[F]
@typeclass trait MonadPlus[F[_]] extends Monad[F] with ApplicativePlus[F] {
def unite[T[_]: Foldable, A](ts: F[T[A]]): F[A] = ...
def withFilter[A](fa: F[A])(f: A => Boolean): F[A] = ...
}
.unite nos permite hacer un fold de una estructura de datos usando el
contenedor externo PlusEmpy[F].monoid más bien que el contenido interno
Monoid. Para List[Either[String, Int]] esto significa que los valores se
convierten en .empty, cuando todo se concatena. Una forma conveniente de
descartar los errores:
scala> List(Right(1), Left("boo"), Right(2)).unite
res: List[Int] = List(1, 2)
scala> val boo: Either[String, Int] = Left("boo")
boo.foldMap(a => a.pure[List])
res: List[String] = List()
scala> val n: Either[String, Int] = Right(1)
n.foldMap(a => a.pure[List])
res: List[Int] = List(1)
withFilternos permite hacer uso del soporte para for comprehension de Scala,
como se discutió en el capítulo 2. Es justo decir que el lenguaje Scala tiene
soporte incluído para MonadPlus, no sólo Monad!
Regresando a Foldable por un momento, podemos revelar algunos métodos que no
discutimos antes:
@typeclass trait Foldable[F[_]] {
...
def msuml[G[_]: PlusEmpty, A](fa: F[G[A]]): G[A] = ...
def collapse[X[_]: ApplicativePlus, A](x: F[A]): X[A] = ...
...
}
msuml realiza un fold utilizando el Monoidde PlusEmpty[G] y collapse
realiza un foldRight usando PlusEmpty del tipo target:
scala> IList(Option(1), Option.empty[Int], Option(2)).fold
res: Option[Int] = Some(3) // uses Monoid[Option[Int]]
scala> IList(Option(1), Option.empty[Int], Option(2)).msuml
res: Option[Int] = Some(1) // uses PlusEmpty[Option].monoid
scala> IList(1, 2).collapse[Option]
res: Option[Int] = Some(1)
5.10 Lobos solitarios
Algunas typeclasses en Scalaz existen por sí mismas y no son parte de una jerarquía más grande.
5.10.1 Zippy
@typeclass trait Zip[F[_]] {
def zip[A, B](a: =>F[A], b: =>F[B]): F[(A, B)]
def zipWith[A, B, C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C)
(implicit F: Functor[F]): F[C] = ...
def ap(implicit F: Functor[F]): Apply[F] = ...
@op("<*|*>") def apzip[A, B](f: =>F[A] => F[B], a: =>F[A]): F[(A, B)] = ...
}
El método esencial zip que es una versión menos poderosa que Divide.tuple2,
y si un Functor[F] se proporciona entonces zipWithpuede comportarse como
Apply.apply2. En verdad, un Apply[F] puede crearse a partir de Zip[F] y un
Functor[F] mediante invocar ap.
apzip toma un F[A] y una función elevada de F[A] => F[B], produciendo un
F[(A, B)] similar a Functor.fproduct.
@typeclass trait Unzip[F[_]] {
@op("unfzip") def unzip[A, B](a: F[(A, B)]): (F[A], F[B])
def firsts[A, B](a: F[(A, B)]): F[A] = ...
def seconds[A, B](a: F[(A, B)]): F[B] = ...
def unzip3[A, B, C](x: F[(A, (B, C))]): (F[A], F[B], F[C]) = ...
...
def unzip7[A ... H](x: F[(A, (B, ... H))]): ...
}
El método central es unzip con firsts y seconds que permite elegir ya sea
el primer o segundo elemento de una tupla en la F. De manera importante,
unzip es lo opuesto de zip.
Los métodos unzip3 a unzip7 son aplicaciones repetidas de unzip para
evitar escribir código repetitivo. Por ejemplo, si se le proporcionara un
conjunto de tuplas anidadas, el Unzip[Id] es una manera sencilla de deshacer
la anidación:
scala> Unzip[Id].unzip7((1, (2, (3, (4, (5, (6, 7)))))))
res = (1,2,3,4,5,6,7)
En resumen, Zip y Unzip son versiones menos poderosas de Divide y Apply,
y proporcionan características poderosas sin requerir que F haga demasiadas
promesas.
5.10.2 Optional
Optional es una generalización de estructuras de datos que opcionalmente
pueden contener un valor, como Option y Either.
Recuerde que una \/ (disjunción) es la versión mejorada de Scalaz de
Scalaz.Either. También veremos Maybe de Scalaz, que es la versión mejorada
de scala.Option.
sealed abstract class Maybe[A]
final case class Empty[A]() extends Maybe[A]
final case class Just[A](a: A) extends Maybe[A]
@typeclass trait Optional[F[_]] {
def pextract[B, A](fa: F[A]): F[B] \/ A
def getOrElse[A](fa: F[A])(default: =>A): A = ...
def orElse[A](fa: F[A])(alt: =>F[A]): F[A] = ...
def isDefined[A](fa: F[A]): Boolean = ...
def nonEmpty[A](fa: F[A]): Boolean = ...
def isEmpty[A](fa: F[A]): Boolean = ...
def toOption[A](fa: F[A]): Option[A] = ...
def toMaybe[A](fa: F[A]): Maybe[A] = ...
}
Estos métodos le deberían ser familiares, con la excepción, quizá, de
pextract, que es una forma de dejar que F[_] regrese una implementación
específica F[B] o el valor. Por ejemplo, Optional[Option].pextract devuelve
Option[Nothing] \/ A, es decir, None \/ A.
Scalaz proporciona un operador ternario para las cosas que tienen un Optional
implicit class OptionalOps[F[_]: Optional, A](fa: F[A]) {
def ?[X](some: =>X): Conditional[X] = new Conditional[X](some)
final class Conditional[X](some: =>X) {
def |(none: =>X): X = if (Optional[F].isDefined(fa)) some else none
}
}
por ejemplo
scala> val knock_knock: Option[String] = ...
knock_knock ? "who's there?" | "<tumbleweed>"
5.11 Co-cosa
Una co-cosa típicamente tiene la firma/signatura de tipo opuesta a lo que sea que una cosa hace, pero no necesariamente a la inversa. Para enfatizar la relación entre una cosa y una co-cosa, incluiremos la firma/signatura de la cosa siempre que sea posible.
5.11.1 Cobind
@typeclass trait Cobind[F[_]] extends Functor[F] {
def cobind[A, B](fa: F[A])(f: F[A] => B): F[B]
//def bind[A, B](fa: F[A])(f: A => F[B]): F[B]
def cojoin[A](fa: F[A]): F[F[A]] = ...
//def join[A](ffa: F[F[A]]): F[A] = ...
}
cobind (también conocido como coflatmap) toma una F[A] => B que actúa en
una F[A] más bien que sobre sus elementos. Pero no se trata necesariamente de
una fa completa, es con frecuencia alguna subestructura como la define un
cojoin (también conocida como coflatten) que expande una estructura de
datos.
Los casos de uso interesantes para Cobind son raros, aunque cuando son
mostrados en la tabla de permutación de la tabla Functor (para F[_], A, y
B) es difícil discutir porqué cualquier método debería ser menos importante
que los otros:
| método | parámetro |
|---|---|
map |
A => B |
contramap |
B => A |
xmap |
(A => B, B => A) |
ap |
F[A => B] |
bind |
A => F[B] |
cobind |
F[A] => B |
5.11.2 Comonad
@typeclass trait Comonad[F[_]] extends Cobind[F] {
def copoint[A](p: F[A]): A
//def point[A](a: =>A): F[A]
}
.copoint (también coonocido como .copure) desenvuelve un elemento de su
contexto. Los efectos no tienen típicamente una instancia de Comonad dado que
se violaría la transparencia referencial al interpretar una IO[A] en una A.
Pero para estructuras de datos similares a colecciones, es una forma de
construir una vista de todos los elementos al mismo tiempo que de su vecindad.
Considere la vecindad de una lista que contiene todos los elementos a la
izquierda de un elemento (lefts), el elemento en sí mismo (el focus), y
todos los elementos a su derecha (rights).
final case class Hood[A](lefts: IList[A], focus: A, rights: IList[A])
Los lefts y los rights deberían ordenarse con el elemento más cercano al
focus en la cabeza, de modo que sea posible recuperar la lista original
IList por medio de .toList.
object Hood {
implicit class Ops[A](hood: Hood[A]) {
def toIList: IList[A] = hood.lefts.reverse ::: hood.focus :: hood.rights
Podemos escribir métodos que nos dejen mover el foco una posición a la izquierda
(previous), y una posición a la derecha (next)
...
def previous: Maybe[Hood[A]] = hood.lefts match {
case INil() => Empty()
case ICons(head, tail) =>
Just(Hood(tail, head, hood.focus :: hood.rights))
}
def next: Maybe[Hood[A]] = hood.rights match {
case INil() => Empty()
case ICons(head, tail) =>
Just(Hood(hood.focus :: hood.lefts, head, tail))
}
Mediante la introducción de more para aplicar repetidamente una función
opcional a Hood podemos calcular todas las positions (posiciones) que
Hood puede tomar en la lista
...
def more(f: Hood[A] => Maybe[Hood[A]]): IList[Hood[A]] =
f(hood) match {
case Empty() => INil()
case Just(r) => ICons(r, r.more(f))
}
def positions: Hood[Hood[A]] = {
val left = hood.more(_.previous)
val right = hood.more(_.next)
Hood(left, hood, right)
}
}
Ahora podemos implementar una Comonad[Hood]
...
implicit val comonad: Comonad[Hood] = new Comonad[Hood] {
def map[A, B](fa: Hood[A])(f: A => B): Hood[B] =
Hood(fa.lefts.map(f), f(fa.focus), fa.rights.map(f))
def cobind[A, B](fa: Hood[A])(f: Hood[A] => B): Hood[B] =
fa.positions.map(f)
def copoint[A](fa: Hood[A]): A = fa.focus
}
}
cojoin nos da proporciona una Hood[Hood[IList]] que contiene todas las
posibles vecindades en nuestra IList.
scala> val middle = Hood(IList(4, 3, 2, 1), 5, IList(6, 7, 8, 9))
scala> middle.cojoin
res = Hood(
[Hood([3,2,1],4,[5,6,7,8,9]),
Hood([2,1],3,[4,5,6,7,8,9]),
Hood([1],2,[3,4,5,6,7,8,9]),
Hood([],1,[2,3,4,5,6,7,8,9])],
Hood([4,3,2,1],5,[6,7,8,9]),
[Hood([5,4,3,2,1],6,[7,8,9]),
Hood([6,5,4,3,2,1],7,[8,9]),
Hood([7,6,5,4,3,2,1],8,[9]),
Hood([8,7,6,5,4,3,2,1],9,[])])
En verdad, ¡cojoin es simplemente positions! Podríamos hacer un override
con una implementación más directa y eficiente
override def cojoin[A](fa: Hood[A]): Hood[Hood[A]] = fa.positions
Comonad generaliza el concepto de Hood a estructuras de datos arbitrarias.
Hood es un ejemplo de un zipper (que no está relacionado a Zip). Scalaz
viene con un tipo de datos Zipper para los streams (es decir , estructuras de
datos infinitas unidimensionales), que discutiremos en el siguiente capítulo.
Una aplicación de zipper es para automatas celulares, que calculan el valor de cada celda en la siguiente generación mediante realizar un cómputo basándose en la vecindad de dicha celda.
5.11.3 Cozip
@typeclass trait Cozip[F[_]] {
def cozip[A, B](x: F[A \/ B]): F[A] \/ F[B]
//def zip[A, B](a: =>F[A], b: =>F[B]): F[(A, B)]
//def unzip[A, B](a: F[(A, B)]): (F[A], F[B])
def cozip3[A, B, C](x: F[A \/ (B \/ C)]): F[A] \/ (F[B] \/ F[C]) = ...
...
def cozip7[A ... H](x: F[(A \/ (... H))]): F[A] \/ (... F[H]) = ...
}
Aunque se llame cozip, quizá es más apropiado enfocar nuestra atención en su
simetría con unzip. Mientras que unzip divide F[_] de tuplas (productos)
en tuplas de F[_], cozip divide F[_] de disjunciones (coproductos) en
disjunciones de F[_].
5.12 Bi-cosas
Algunas veces podríamos encontrar una cosa que tiene dos hoyos de tipo y
deseemos realizar un map en ambos lados. Por ejemplo, podríamos estar
rastreando las fallas a la izquierda de un Either y tal vez querríamos hacer
algo con los mensajes de error.
La typeclass Functor/Foldable/Traverse tienen parientes extraños que nos
permiten hacer un mapeo de ambas maneras.
@typeclass trait Bifunctor[F[_, _]] {
def bimap[A, B, C, D](fab: F[A, B])(f: A => C, g: B => D): F[C, D]
@op("<-:") def leftMap[A, B, C](fab: F[A, B])(f: A => C): F[C, B] = ...
@op(":->") def rightMap[A, B, D](fab: F[A, B])(g: B => D): F[A, D] = ...
@op("<:>") def umap[A, B](faa: F[A, A])(f: A => B): F[B, B] = ...
}
@typeclass trait Bifoldable[F[_, _]] {
def bifoldMap[A, B, M: Monoid](fa: F[A, B])(f: A => M)(g: B => M): M
def bifoldRight[A,B,C](fa: F[A, B], z: =>C)(f: (A, =>C) => C)(g: (B, =>C) => C): C
def bifoldLeft[A,B,C](fa: F[A, B], z: C)(f: (C, A) => C)(g: (C, B) => C): C = ...
def bifoldMap1[A, B, M: Semigroup](fa: F[A,B])(f: A => M)(g: B => M): Option[M] = ...
}
@typeclass trait Bitraverse[F[_, _]] extends Bifunctor[F] with Bifoldable[F] {
def bitraverse[G[_]: Applicative, A, B, C, D](fab: F[A, B])
(f: A => G[C])
(g: B => G[D]): G[F[C, D]]
def bisequence[G[_]: Applicative, A, B](x: F[G[A], G[B]]): G[F[A, B]] = ...
}
Aunque las signaturas de tipo son verbosas, no son más que los métodos
esenciales de Functor, Foldable, y Bitraverse que toman dos funciones en
vez de una sola, con frecuencia requiriendo que ambas funciones devuelvan el
mismo tipo de modo que sus resultados puedan ser combinados con un Monoid o un
Semigroup.
scala> val a: Either[String, Int] = Left("fail")
val b: Either[String, Int] = Right(13)
scala> b.bimap(_.toUpperCase, _ * 2)
res: Either[String, Int] = Right(26)
scala> a.bimap(_.toUpperCase, _ * 2)
res: Either[String, Int] = Left(FAIL)
scala> b :-> (_ * 2)
res: Either[String,Int] = Right(26)
scala> a :-> (_ * 2)
res: Either[String, Int] = Left(fail)
scala> { s: String => s.length } <-: a
res: Either[Int, Int] = Left(4)
scala> a.bifoldMap(_.length)(identity)
res: Int = 4
scala> b.bitraverse(s => Future(s.length), i => Future(i))
res: Future[Either[Int, Int]] = Future(<not completed>)
Adicionalmente, podemos revisitar MonadPlus (recuerde que se trata de un
Monad con la habilidad extra de realizar un filterWith y unite) y ver que
puede separar el contenido Bifoldable de un Monad.
@typeclass trait MonadPlus[F[_]] {
...
def separate[G[_, _]: Bifoldable, A, B](value: F[G[A, B]]): (F[A], F[B]) = ...
...
}
Esto es muy útil se tenemos una colección de bi-cosas y desamos reorganizarlas
en una colección de\ A y una colección de B.
scala> val list: List[Either[Int, String]] =
List(Right("hello"), Left(1), Left(2), Right("world"))
scala> list.separate
res: (List[Int], List[String]) = (List(1, 2), List(hello, world))
5.13 Resumen
!Esto fue bastante material! Hemos apenas explorado la librería estándar de funcionalidad polimórfica. Pero para poner el asunto en perspectiva: hay más traits en la API de collecciones de la librería estándar de Scala que typeclasses en Scalaz.
Es normal que una aplicación de PF usar un porcentaje pequeño de la jerarquía de tipos, con la mayoría de su funcionalidad viniendo de álgebras particulares del dominio y de typeclasses. Inclusive si las typeclasses del dominio específico son simples clones especializados de algo que ya existe en Scalaz, está bien refactorizarlo después.
Para ayudar al lector, hemos incluído un sumario de las typeclasses y sus métodos primarios en el apéndice, tomando inspiración del sumario cuyo autor es Adam Rosien’s: Scalaz Cheatsheet.
Para ayudarle, Valentin Kasas explica cómo combinar
Ncosas:
6. Tipos de datos de Scalaz
¿Quién no ama una buena estructura de datos? La respuesta es nadie, porque las estructuras de datos son increíbles.
En este capítulo exploraremos los tipos de datos en Scalaz que son similares a colecciones, así como los tipos de datos que aumentan Scala, como lenguaje, con semántica y seguridad de tipos adicional.
La razón principal por la que nos preocupamos por tener muchas colecciones a nuestra disposición es por rendimiento. Un vector y una lista pueden hacer lo mismo, pero sus características son diferentes: un vector tiene un costo de búsqueda constante mientras que una lista debe ser recorrida.
Todas las colecciones presentadas aquí son persistentes: si agregamos o removemos un elemento todavía podemos usar la versión anterior. Compartir estructuralmente es esencial para el rendimiento de las estructuras de datos persistentes, de otra manera la colección entera se reconstruye con cada operación.
A diferencia de las colecciones de Java y Scala, no hay jerarquía de datos en Scalaz: estas colecciones son mucho más simples de entender. La funcionalidad polimórfica se proporciona por instancias opmitizadas de typeclases que estudiamos en el capítulo anterior. Esto simplifica cambiar implementaciones por razones de rendimiento, y proporcionar la propia.
6.1 Variancia de tipo
Muchos de los tipos de datos de Scalaz son invariantes en sus parámetros de
tipo. Por ejemplo, IList[A] no es un subtipo de IList[B] cuando A <: B.
6.1.1 Covariancia
El problema con los tipos de parámetro covariantes, tales como class
List[+A], es que List[A] es un subtipo de List[Any] y es fácil perder
accidentalmente información de tipo.
scala> List("hello") ++ List(' ') ++ List("world!")
res: List[Any] = List(hello, , world!)
Note que la segunda lista es una List[Char] y que el compilador ha inferido
incorrectamente el LUB (Least Upper Bound, o límite mínimo inferior) es Any.
Compare con IList, que requiere una aplicación explícita de .widen[Any] para
ejecutar el crimen atroz:
scala> IList("hello") ++ IList(' ') ++ IList("world!")
<console>:35: error: type mismatch;
found : Char(' ')
required: String
scala> IList("hello").widen[Any]
++ IList(' ').widen[Any]
++ IList("world!").widen[Any]
res: IList[Any] = [hello, ,world!]
De manera similar, cuando el compilador infiere el tipo como with Product with
Serializable es un indicador fuerte de que un ensanchamiento accidental ha
ocurrido debido a la covariancia.
Desafortunadamente debemos ser cuidadosos cuando construimos tipos de datos invariantes debido a que los cálculos LUB se realizan sobre los parámetros:
scala> IList("hello", ' ', "world")
res: IList[Any] = [hello, ,world]
Otro problema similar surge a partir del tipo Nothing, el cuál es un subtipo
del resto de tipos, incluyendo ADTs selladas (sealed), clases finales
(final), primitivas y null.
No hay valores de tipo Nothing: las funciones que toman un Nothing como un
parámetro no pueden ejecutarse y las funciones que devuelven Nothing nunca
devuelven un valor (o terminan). Nothing fue introducido como un mecanismo
para habilitar los tipos de parámetros covariantes, pero una consecuencia es que
podemos escribir código que no puede ejecutarse, por accidente. Scalaz dice que
no necesitamos parámetros de tipo covariantes lo que significa que nos estamos
limitando a nosotros mismos a escribir código práctico que puede ser ejecutado.
6.1.2 Contrarivarianza
Por otro lado, los tipos de parámetro contravariantes, tales como trait
Thing[-A] pueden poner de manifiesto errores devastadores en el
compilador. Considere la
demostración de Paul Phillips’ (ex miembro del equipo del compilador scalac)
de lo que él llama contrarivarianza:
scala> :paste
trait Thing[-A]
def f(x: Thing[ Seq[Int]]): Byte = 1
def f(x: Thing[List[Int]]): Short = 2
scala> f(new Thing[ Seq[Int]] { })
f(new Thing[List[Int]] { })
res = 1
res = 2
Como era de esperarse, el compilador está encontrando el argumento más
específico en cada invocación de f. Sin embargo, la resolución implícita da
resultados inesperados:
scala> :paste
implicit val t1: Thing[ Seq[Int]] =
new Thing[ Seq[Int]] { override def toString = "1" }
implicit val t2: Thing[List[Int]] =
new Thing[List[Int]] { override def toString = "2" }
scala> implicitly[Thing[ Seq[Int]]]
implicitly[Thing[List[Int]]]
res = 1
res = 1
La resolución implícita hace un intercambio de su definición de “más específico” para los tipos contravariantes, logrando que sean inútiles para los typeclases o para cualquier cosa que requiera funcionalidad polimórfica. Este comportamiento es fijo en Dotty.
6.1.3 Las limitaciones del mecanismo de subclases
scala.Option tiene un método .flatten que los dejará convertir
Option[Option[B]] en un Option[B]. Sin embargo, el sistema de tipos de Scala
es incapaz de dejarnos escribir la signatura de tipos requerida. Considere lo
siguiente que parece correcto, pero que tiene un error sutil:
sealed abstract class Option[+A] {
def flatten[B, A <: Option[B]]: Option[B] = ...
}
The A introducida en .flatten está ocultando la A introducida por la
clase. Es equivalente a escribir
sealed abstract class Option[+A] {
def flatten[B, C <: Option[B]]: Option[B] = ...
}
que no es la restricción deseada.
Para resolver esta limitación, Scala define clases infijas <:< y =:= junto
con la evidencia implícita que siempre crea un testigo (witness).
sealed abstract class <:<[-From, +To] extends (From => To)
implicit def conforms[A]: A <:< A = new <:<[A, A] { def apply(x: A): A = x }
sealed abstract class =:=[ From, To] extends (From => To)
implicit def tpEquals[A]: A =:= A = new =:=[A, A] { def apply(x: A): A = x }
=:= puede usarse para requerir que dos parámetros de tipo sean exactamente
iguales y <:< se usa para describir relaciones de subtipo, dejandonos
implementar .flatten como
sealed abstract class Option[+A] {
def flatten[B](implicit ev: A <:< Option[B]): Option[B] = this match {
case None => None
case Some(value) => ev(value)
}
}
final case class Some[+A](value: A) extends Option[A]
case object None extends Option[Nothing]
Scalaz mejora sobre <:< y =:= con Liskov (que tiene el alias <~<) y
Leibniz (====).
sealed abstract class Liskov[-A, +B] {
def apply(a: A): B = ...
def subst[F[-_]](p: F[B]): F[A]
def andThen[C](that: Liskov[B, C]): Liskov[A, C] = ...
def onF[X](fa: X => A): X => B = ...
...
}
object Liskov {
type <~<[-A, +B] = Liskov[A, B]
type >~>[+B, -A] = Liskov[A, B]
implicit def refl[A]: (A <~< A) = ...
implicit def isa[A, B >: A]: A <~< B = ...
implicit def witness[A, B](lt: A <~< B): A => B = ...
...
}
// type signatures have been simplified
sealed abstract class Leibniz[A, B] {
def apply(a: A): B = ...
def subst[F[_]](p: F[A]): F[B]
def flip: Leibniz[B, A] = ...
def andThen[C](that: Leibniz[B, C]): Leibniz[A, C] = ...
def onF[X](fa: X => A): X => B = ...
...
}
object Leibniz {
type ===[A, B] = Leibniz[A, B]
implicit def refl[A]: Leibniz[A, A] = ...
implicit def subst[A, B](a: A)(implicit f: A === B): B = ...
implicit def witness[A, B](f: A === B): A => B = ...
...
}
Además de los métodos generalmente útiles y las conversiones implícitas, la
evidencia de Scalaz <~< y === usa mejores principios que en la librería
estándar.
6.2 Evaluación
Java es un lenguaje con evaluación estricta: todos los parámetros para un
método deben evaluarse a un valor antes de llamar el método. Scala introduce
la noción de parámetros por nombre (by-name) en los métodos con la sintaxis
a: => A. Estos parámetros se envuelven en una finción de cero argumentos que
se invoca cada vez que se referencia la a. Hemos visto por nombre muchas
veces en las typeclases.
Scala también tiene por necesidad (by-need), con la palabra lazy: el cómputo
se evalúa a lo sumo una vez para producir el valor. Desgraciadamente, Scala no
soporta la evaluación por necesidad de los parámetros del método.
Scalaz formaliza los tres estrategias de evaluación con un ADT
sealed abstract class Name[A] {
def value: A
}
object Name {
def apply[A](a: =>A) = new Name[A] { def value = a }
...
}
sealed abstract class Need[A] extends Name[A]
object Need {
def apply[A](a: =>A): Need[A] = new Need[A] {
private lazy val value0: A = a
def value = value0
}
...
}
final case class Value[A](value: A) extends Need[A]
La forma de evaluación más débil es Name, que no proporciona garantías
computacionales. A continuación está Need, que garantiza la evaluación como
máximo una vez.
Si deseamos ser super escrupulosos podríamos revisar todas las typeclases y
hacer sus métodos tomar parámetros por Name, Need o Value. En vez de esto,
podemos asumir que los parámetros normales siempre pueden envolverse en un
Value, y que los parámetros (by-name) por nombre pueden envolverse con
Name.
Cuando escribimos programas puros, somos libres de reemplazar cualquier Name
con Need o Value, y viceversa, sin necesidad de cambiar lo correcto del
programa. Esta es la esencia de la transparencia referencial: la habilidad de
cambiar un cómputo por su valor, o un valor por su respectivo cómputo.
En la programación funcional casi siempre deseamos Value o Need (también
conocido como estricto y perezoso): hay poco valor en Name. Debido a que
no hay soporte a nivel de lenguaje para parámetros de método perezosos, los
métodos típicamente solicitan un parámetro por nombre y entonces lo convierten
a Need de manera interna, proporcionando un aumento en el rendimiento.
Name proporciona instancias de las siguientes typeclases
MonadComonadTraverse1Align-
Zip/Unzip/Cozip
6.3 Memoisation
Scalaz tiene la capacidad de memoizar funciones, formalizada por Memo, que no
hace ninguna garantía sobre la evaluación debido a la diversidad de
implementaciones:
sealed abstract class Memo[K, V] {
def apply(z: K => V): K => V
}
object Memo {
def memo[K, V](f: (K => V) => K => V): Memo[K, V]
def nilMemo[K, V]: Memo[K, V] = memo[K, V](identity)
def arrayMemo[V >: Null : ClassTag](n: Int): Memo[Int, V] = ...
def doubleArrayMemo(n: Int, sentinel: Double = 0.0): Memo[Int, Double] = ...
def immutableHashMapMemo[K, V]: Memo[K, V] = ...
def immutableTreeMapMemo[K: scala.Ordering, V]: Memo[K, V] = ...
}
memo nos permite crear implementaciones de Memo, nilMemo no memoiza,
evaluando la función normalmente. Las implementaciones resultantes interceptan
llamadas a la función y guardan en cache los resultados obtenidos en una
implementación que usa colecciones de la librería estándar.
Para usar Memo simplemente envolvemos una función con una implementación de
Memo y entonces invocamos a la función memoizada:
scala> def foo(n: Int): String = {
println("running")
if (n > 10) "wibble" else "wobble"
}
scala> val mem = Memo.arrayMemo[String](100)
val mfoo = mem(foo)
scala> mfoo(1)
running // evaluated
res: String = wobble
scala> mfoo(1)
res: String = wobble // memoised
Si la función toma más de un parámetro, entonces debemos invocar .tupled
sobre el método, con la versión memoisada tomando una tupla.
scala> def bar(n: Int, m: Int): String = "hello"
val mem = Memo.immutableHashMapMemo[(Int, Int), String]
val mbar = mem((bar _).tupled)
scala> mbar((1, 2))
res: String = "hello"
Memo típicamente se trata como un artificio especial y la regla usual sobre la
pureza se relaja para las implementaciones. La pureza únicamente requiere que
nuestras implementaciones de Memo sean referencialmente transparentes en la
evaluación de K => V. Podríamos usar datos mutables y realizar I/O en la
implementación de Memo, por ejemplo usando un LRU o un caché distribuido, sin
tener que declarar un efecto en las signaturas de tipo. Otros lenguajes de
programación funcional tienen memoización manejada por el ambiente de runtime y
Memo es nuestra manera de extender la JVM para tener un soporte similar,
aunque desgraciadamente únicamente de una manera que requiere opt-in.
6.4 Tagging (etiquetar)
En la sección que se introdujo Monoid construimos un Monoid[TradeTeamplate]
y nos dimos cuenta de que Scalaz no hace lo que desearíamos con
Monoid[Option[A]]. Este no es una omisión de Scalaz, con frecuencia podemos
notar que un tipo de datos puede implementar una tipeclass fundamental en
múltiples formas válidas y que la implementación por default no hace lo que
deseariamos, o simplemente no está definida.
Los ejemplos básicos son Monoid[Boolean] (la conjunción && vs la disjunción
||) y Monoid[Int] (multiplicación vs adición).
Para implementar Monoid[TradeTemplate] tuvimos que romper la coherencia de
typeclases, o usar una typeclass distinta.
scalaz.Tag está diseñada para lidiar con el problema de coherencia que surge
con múltiples implementaciones de typeclases, sin romper la coherencia de las
typeclases.
La definición es algo compleja, pero la sintáxis al usar scalaz.Tag es
bastante limpia. Así es como logramos convencer al compilador para que nos
permita definir el tipo infijo A @@ T que es borrado en A en tiempo de
ejecución:
type @@[A, T] = Tag.k.@@[A, T]
object Tag {
@inline val k: TagKind = IdTagKind
@inline def apply[A, T](a: A): A @@ T = k(a)
...
final class TagOf[T] private[Tag]() { ... }
def of[T]: TagOf[T] = new TagOf[T]
}
sealed abstract class TagKind {
type @@[A, T]
def apply[A, T](a: A): A @@ T
...
}
private[scalaz] object IdTagKind extends TagKind {
type @@[A, T] = A
@inline override def apply[A, T](a: A): A = a
...
}
Algunas etiquetas/tags útiles se proporcionan en el objeto Tags
object Tags {
sealed trait First
val First = Tag.of[First]
sealed trait Last
val Last = Tag.of[Last]
sealed trait Multiplication
val Multiplication = Tag.of[Multiplication]
sealed trait Disjunction
val Disjunction = Tag.of[Disjunction]
sealed trait Conjunction
val Conjunction = Tag.of[Conjunction]
...
}
First/Last se usan para seleccionar instancias d Monoid que escogen el
primero o el último (diferente de cero) operando. Multiplication es para
multiplicación numérica en vez de adición. Disjunction/Conjuntion son para
seleccionar && o ||, respectivamente.
En nuestro TradeTemplate, en lugar de usar Option[Currency] podríamos usar
Option[Currency] @@ Tags.Last. En verdad, este uso es tan común que podemos
usar el alias previamente definido, LastOption.
type LastOption[A] = Option[A] @@ Tags.Last
y esto nos permite escribir una implementación mucho más limpia de
Monoid[TradeTemplate]
final case class TradeTemplate(
payments: List[java.time.LocalDate],
ccy: LastOption[Currency],
otc: LastOption[Boolean]
)
object TradeTemplate {
implicit val monoid: Monoid[TradeTemplate] = Monoid.instance(
(a, b) =>
TradeTemplate(a.payments |+| b.payments,
a.ccy |+| b.ccy,
a.otc |+| b.otc),
TradeTemplate(Nil, Tag(None), Tag(None))
)
}
Para crear un valor de tipo LastOption, aplicamos Tag a un Option. Aquí
estamos invocando Tag(None).
En el capítulo sobre derivación de typeclases, iremos un paso más allá y
derivaremos automáticamente el monoid.
Es tentador usar Tag para marcar tipos de datos para alguna forma de
validación (por ejemplo, String @@ PersonName), pero deberíamos evitar esto
porque no existe un chequeo del contenido en tiempo de ejecución. Tag
únicamente debería ser usado para propósitos de selección de typeclases.
Prefiera el uso de la librería Refined, que se introdujo en el Capítulo 4,
para restringir valores.
6.5 Transformaciones naturales
Una función de un tipo a otro se escribe en Scala como A => B, y se trata de
una conveniencia sintáctica para Function1[A, B]. Scalaz proporciona una
conveniencia sintáctica F ~> G para funciones sobre los constructores de tipo
F[_] a G[_].
Esta notación, F ~ G, se conoce como transformación natural y son
universalmente cuantificables debido a que no nos importa el contenido de
F[_].
type ~>[-F[_], +G[_]] = NaturalTransformation[F, G]
trait NaturalTransformation[-F[_], +G[_]] {
def apply[A](fa: F[A]): G[A]
def compose[E[_]](f: E ~> F): E ~> G = ...
def andThen[H[_]](f: G ~> H): F ~> H = ...
}
Un ejemplo de una transformación natural es la función que convierte una IList
en una List.
scala> val convert = new (IList ~> List) {
def apply[A](fa: IList[A]): List[A] = fa.toList
}
scala> convert(IList(1, 2, 3))
res: List[Int] = List(1, 2, 3)
O, de manera más concisa, usando las conveniencias sintácticas proporcionadas
por el plugin kind-projector:
scala> val convert = λ[IList ~> List](_.toList)
scala> val convert = Lambda[IList ~> List](_.toList)
Sin embargo, en el desarrollo del día a día, es mucho más probable que usemos
una transformación natural para mapear entre álgebras. Por ejemplo, en
drone-dynamic-agents tal vez deseemos implementar nuestra álgebra de
Machines que usa Google Container Engine con una álgebra ad-hoc,
BigMachines. En lugar de cambiar toda nuestra lógica de negocio y probar
usando esta nueva interfaz BigMachines, podríamos escribir una transformación
natural de Machines ~> BigMachines. Volveremos a esta idea en el capítulo de
Mónadas Avanzadas.
6.6 Isomorphism (isomorfismos)
Algunas veces tenemos dos tipos que en realidad son equivalentes (la misma cosa), lo que ocasiona problemas de compatibiliad debido a que el compilador no sabe lo que nosotros sí sabemos. Esto pasa típicamente cuando usamos código de terceros que tiene algo que ya tenemos.
Ahí es cuando Isomorphism nos puede ayudar. Un isomorfismo define una relación
formal “es equivalente a” entre dos tipos. Existen tres variantes, para
object Isomorphism {
trait Iso[Arr[_, _], A, B] {
def to: Arr[A, B]
def from: Arr[B, A]
}
type IsoSet[A, B] = Iso[Function1, A, B]
type <=>[A, B] = IsoSet[A, B]
object IsoSet {
def apply[A, B](to: A => B, from: B => A): A <=> B = ...
}
trait Iso2[Arr[_[_], _[_]], F[_], G[_]] {
def to: Arr[F, G]
def from: Arr[G, F]
}
type IsoFunctor[F[_], G[_]] = Iso2[NaturalTransformation, F, G]
type <~>[F[_], G[_]] = IsoFunctor[F, G]
object IsoFunctor {
def apply[F[_], G[_]](to: F ~> G, from: G ~> F): F <~> G = ...
}
trait Iso3[Arr[_[_, _], _[_, _]], F[_, _], G[_, _]] {
def to: Arr[F, G]
def from: Arr[G, F]
}
type IsoBifunctor[F[_, _], G[_, _]] = Iso3[~~>, F, G]
type <~~>[F[_, _], G[_, _]] = IsoBifunctor[F, G]
...
}
Los aliases de tipos IsoSet, IsoFunctor e IsoBifunctor cubren los casos
comunes: una función regular, una transformación natural y binatural. Las
funciones de conveniencia nos permiten generar instancias de funciones
existentes o transformaciones naturales. Sin embargo, con frecuencia es más
fácil usar una de las clases abstractas Template para definir un isomorfismo.
Por ejemplo:
val listIListIso: List <~> IList =
new IsoFunctorTemplate[List, IList] {
def to[A](fa: List[A]) = fromList(fa)
def from[A](fa: IList[A]) = fa.toList
}
Si introducimos un isomorfismo, con frecuencia podemos generar muchas de las typeclases estándar. Por ejemplo,
trait IsomorphismSemigroup[F, G] extends Semigroup[F] {
implicit def G: Semigroup[G]
def iso: F <=> G
def append(f1: F, f2: =>F): F = iso.from(G.append(iso.to(f1), iso.to(f2)))
}
lo que nos permite derivar un Semigroup[F] para un tipo F si ya tenemos un
isomorfismo F <=> G y un semigrupo Semigroup[G]. Casi todas las typeclases
en la jerarquía proporcionan una invariante isomórfica. Si nos encontramos en la
situación en la que copiamos y pegamos una implementación de una typeclass, es
útil considerar si Isomorphism es una mejor solución.
6.7 Contenedores
6.7.1 Maybe
Ya nos hemos encontrado con la mejora de Scalaz sobre scala.Option, que se
llama Maybe. Es una mejora porque es invariante y no tiene ningún método
inseguro como Option.get, el cual puede lanzar una excepción.
Con frecuencia se usa para representar una cosa que puede o no estar presente sin proporcionar ninguna información adicional de porqué falta información.
sealed abstract class Maybe[A] { ... }
object Maybe {
final case class Empty[A]() extends Maybe[A]
final case class Just[A](a: A) extends Maybe[A]
def empty[A]: Maybe[A] = Empty()
def just[A](a: A): Maybe[A] = Just(a)
def fromOption[A](oa: Option[A]): Maybe[A] = ...
def fromNullable[A](a: A): Maybe[A] = if (null == a) empty else just(a)
...
}
Los métodos .empty y .just en el objeto compañero son preferidas en lugar de
usar Empty o Just debido a que regresan Maybe, ayudándonos con la
inferencia de tipos. Este patrón con frecuencia es preferido a regresar un tipo
suma, que es cuando tenemos múltiples implementaciones de un sealed trait
pero nunca usamos un subtipo específico en una signatura de método.
Una clase implícita conveniente nos permite invocar.just sobre cualquier valor
y recibir un Maybe.
implicit class MaybeOps[A](self: A) {
def just: Maybe[A] = Maybe.just(self)
}
Maybe tiene una instancia de typeclass para todas las cosas
AlignTraverse-
MonadPlus/IsEmpty Cobind-
Cozip/Zip/Unzip Optional
e instancias delegadas que dependen de A
-
Monoid/Band -
Equal/Order/Show
Además de las instancias arriba mencionadas, Maybe tiene funcionalidad que no
es soportada por una typeclass polimórfica.
sealed abstract class Maybe[A] {
def cata[B](f: A => B, b: =>B): B = this match {
case Just(a) => f(a)
case Empty() => b
}
def |(a: =>A): A = cata(identity, a)
def toLeft[B](b: =>B): A \/ B = cata(\/.left, \/-(b))
def toRight[B](b: =>B): B \/ A = cata(\/.right, -\/(b))
def <\/[B](b: =>B): A \/ B = toLeft(b)
def \/>[B](b: =>B): B \/ A = toRight(b)
def orZero(implicit A: Monoid[A]): A = getOrElse(A.zero)
def orEmpty[F[_]: Applicative: PlusEmpty]: F[A] =
cata(Applicative[F].point(_), PlusEmpty[F].empty)
...
}
.cata proporciona una alternativa más simple para .map(f).gerOrElse(b) y
tiene la forma más simple | si el mapeo es identity (es decir, simplemente
.getOrElse).
.toLeft y .toRight, y sus aliases simbólicos, crean una disjunción
(explicada en la sección siguiente) al tomar un valor por default para el caso
Empty.
.orZero toma un Monoid para definir el valor por defecto.
.orEmpty usa un ApplicativePlus para crear un elemento único o un contenedor
vacío, sin olvidar que ya tenemos soporte para las colecciones de la librería
estándar con el método .to que viene de la instancia Foldable.
scala> 1.just.orZero
res: Int = 1
scala> Maybe.empty[Int].orZero
res: Int = 0
scala> Maybe.empty[Int].orEmpty[IList]
res: IList[Int] = []
scala> 1.just.orEmpty[IList]
res: IList[Int] = [1]
scala> 1.just.to[List] // from Foldable
res: List[Int] = List(1)
6.7.2 Either
La mejora de Scalaz sobre scalaz.Either es simbólica, pero es común hablar de
este caso como either o una Disjunction.
sealed abstract class \/[+A, +B] { ... }
final case class -\/[+A](a: A) extends (A \/ Nothing)
final case class \/-[+B](b: B) extends (Nothing \/ B)
type Disjunction[+A, +B] = \/[A, B]
object \/ {
def left [A, B]: A => A \/ B = -\/(_)
def right[A, B]: B => A \/ B = \/-(_)
def fromEither[A, B](e: Either[A, B]): A \/ B = ...
...
}
con la sintaxis correspondiente
implicit class EitherOps[A](val self: A) {
final def left [B]: (A \/ B) = -\/(self)
final def right[B]: (B \/ A) = \/-(self)
}
y permite una construcción sencilla de los valores. Note que los métodos de
extensión toman el tipo del otro lado. De modo que si deseamos crear un valor
de tipo String \/ Int y tenemos un Int, debemos pasar String cuando
llamamos .right.
scala> 1.right[String]
res: String \/ Int = \/-(1)
scala> "hello".left[Int]
res: String \/ Int = -\/(hello)
La naturaleza simbólica de \/ hace que sea de fácil lectura cuando se muestra
con notación infija. Note que los tipos simbólicos en Scala se asocian desde el
lado izquierdo y que \/ deben tener paréntesis, por ejemplo (A \/ (B \/ (C \/
D)).
\/ tiene instancias sesgadas a la derecha (es decir, flatMap aplica a \/-)
para:
-
Monad/MonadError -
Traverse/Bitraverse PlusOptionalCozip
y dependiendo del contenido
-
Equal/Order -
Semigroup/Monoid/Band
Además, hay métodos especiales
sealed abstract class \/[+A, +B] { self =>
def fold[X](l: A => X, r: B => X): X = self match {
case -\/(a) => l(a)
case \/-(b) => r(b)
}
def swap: (B \/ A) = self match {
case -\/(a) => \/-(a)
case \/-(b) => -\/(b)
}
def |[BB >: B](x: =>BB): BB = getOrElse(x) // Optional[_]
def |||[C, BB >: B](x: =>C \/ BB): C \/ BB = orElse(x) // Optional[_]
def +++[AA >: A: Semigroup, BB >: B: Semigroup](x: =>AA \/ BB): AA \/ BB = ...
def toEither: Either[A, B] = ...
final class SwitchingDisjunction[X](right: =>X) {
def <<?:(left: =>X): X = ...
}
def :?>>[X](right: =>X) = new SwitchingDisjunction[X](right)
...
}
.fold es similar a Maybe.cata y requiere que tanto el lado derecho como el
lado izquierdo se mapeen al mismo tipo.
.swap intercambia los lados izquierdo y derecho.
El alias | para getOrElse parece similar a Maybe.
The | alias to getOrElse appears similarly to Maybe. We also get
||| as an alias to orElse.
+++ es para combinar disjunciones con los lados izquierdos tomando precedencia
sobre los lados derechos:
-
right(v1) +++ right(v2)daright(v1 |+| v2) -
right(v1) +++ left (v2)daleft (v2) -
left (v1) +++ right(v2)daleft (v1) -
left (v1) +++ left (v2)daleft (v1 |+| v2)
.toEither está para proporcionar compatibilidad hacia atrás con la librería
estándar de Scala.
La combinación de :?>> y <<? son una sintáxis conveniente para ignorar el
contenido de una \/, pero escoger un valor por default basándose en su tipo.
scala> 1 <<?: foo :?>> 2
res: Int = 2 // foo is a \/-
scala> 1 <<?: foo.swap :?>> 2
res: Int = 1
6.7.3 Validation
A primera vista, Validation (con alias simbólico \?/, Elvis feliz) aparece
ser un clon de Disjunction:
sealed abstract class Validation[+E, +A] { ... }
final case class Success[A](a: A) extends Validation[Nothing, A]
final case class Failure[E](e: E) extends Validation[E, Nothing]
type ValidationNel[E, +X] = Validation[NonEmptyList[E], X]
object Validation {
type \?/[+E, +A] = Validation[E, A]
def success[E, A]: A => Validation[E, A] = Success(_)
def failure[E, A]: E => Validation[E, A] = Failure(_)
def failureNel[E, A](e: E): ValidationNel[E, A] = Failure(NonEmptyList(e))
def lift[E, A](a: A)(f: A => Boolean, fail: E): Validation[E, A] = ...
def liftNel[E, A](a: A)(f: A => Boolean, fail: E): ValidationNel[E, A] = ...
def fromEither[E, A](e: Either[E, A]): Validation[E, A] = ...
...
}
Con sintáxis conveniente
implicit class ValidationOps[A](self: A) {
def success[X]: Validation[X, A] = Validation.success[X, A](self)
def successNel[X]: ValidationNel[X, A] = success
def failure[X]: Validation[A, X] = Validation.failure[A, X](self)
def failureNel[X]: ValidationNel[A, X] = Validation.failureNel[A, X](self)
}
Sin embargo, la estructura de datos no es la historia completa. En Scalaz,
Validation no tiene una instancia de ninguna Monad, y esto es intencional,
restringiéndose a versiones sesgadas a la derecha de:
Applicative-
Traverse/Bitraverse CozipPlusOptional
y dependiendo del contenido
-
Equal/Order Show-
Semigroup/Monoid
La gran ventaja de restringirnos a Applicative es que Validation es
explícitamente para situaciones donde deseamos reportar todas las fallas,
mientras que Disjunction se usa para detenernos en el primer fallo. Para
lograr la acumulación de fallas, una forma popular de Validation es
ValidationNel, teniendo un NonEmptyList[E] en la posición de fracaso.
Considere realizar validación de datos proporcionados por un usuario con ayuda
de Disjunction and flatMap:
scala> :paste
final case class Credentials(user: Username, name: Fullname)
final case class Username(value: String) extends AnyVal
final case class Fullname(value: String) extends AnyVal
def username(in: String): String \/ Username =
if (in.isEmpty) "empty username".left
else if (in.contains(" ")) "username contains spaces".left
else Username(in).right
def realname(in: String): String \/ Fullname =
if (in.isEmpty) "empty real name".left
else Fullname(in).right
scala> for {
u <- username("sam halliday")
r <- realname("")
} yield Credentials(u, r)
res = -\/(username contains spaces)
Si usamos la sintáxis |@|
scala> (username("sam halliday") |@| realname("")) (Credentials.apply)
res = -\/(username contains spaces)
Todavía obtenemos el primer error. Esto es porque Disjuntion es una Monad, y
sus métodos .applyX deben ser consistentes con .flatMap y no asumir que
ninguna operación pueden realizarse fuera de orden. Compare con:
scala> :paste
def username(in: String): ValidationNel[String, Username] =
if (in.isEmpty) "empty username".failureNel
else if (in.contains(" ")) "username contains spaces".failureNel
else Username(in).success
def realname(in: String): ValidationNel[String, Fullname] =
if (in.isEmpty) "empty real name".failureNel
else Fullname(in).success
scala> (username("sam halliday") |@| realname("")) (Credentials.apply)
res = Failure(NonEmpty[username contains spaces,empty real name])
Esta vez, tenemos todas las fallas!
Validation tiene muchos de los métodos en Disjunction, tales como .fold,
.swap y +++, además de algunas extra:
sealed abstract class Validation[+E, +A] {
def append[F >: E: Semigroup, B >: A: Semigroup](x: F \?/ B]): F \?/ B = ...
def disjunction: (E \/ A) = ...
...
}
.append (con el alias simbólico +|+) tiene la misma signatura de tipo que
+++ pero da preferencia al caso de éxito
-
failure(v1) +|+ failure(v2)dafailure(v1 |+| v2) -
failure(v1) +|+ success(v2)dasuccess(v2) -
success(v1) +|+ failure(v2)dasuccess(v1) -
success(v1) +|+ success(v2)dasuccess(v1 |+| v2)
.disjunction convierte un valor Validated[A, B] en un A \/ B. Disjunction
tiene los métodos .validation y .validationNel para convertir en una
Validation, permitiendo la fácil conversión entre acumulación sequencial y
paralela de errores.
\/ y Validation son las versiones de PF con mayor rendimiento, equivalentes
a una excepción de validación de entrada, evitando tanto un stacktrace y
requiriendo que el que realiza la invocación lidie con las fallas resultando en
sistemas más robustos.
6.7.4 These
Encontramos These, un codificación en forma de datos de un OR lógicamente
inclusivo, cuando aprendimos sobre Align
sealed abstract class \&/[+A, +B] { ... }
object \&/ {
type These[A, B] = A \&/ B
final case class This[A](aa: A) extends (A \&/ Nothing)
final case class That[B](bb: B) extends (Nothing \&/ B)
final case class Both[A, B](aa: A, bb: B) extends (A \&/ B)
def apply[A, B](a: A, b: B): These[A, B] = Both(a, b)
}
y con una sintáxis para una construcción conveniente
implicit class TheseOps[A](self: A) {
final def wrapThis[B]: A \&/ B = \&/.This(self)
final def wrapThat[B]: B \&/ A = \&/.That(self)
}
implicit class ThesePairOps[A, B](self: (A, B)) {
final def both: A \&/ B = \&/.Both(self._1, self._2)
}
These tiene instancias de una typeclass para
MonadBitraverseTraverseCobind
y dependiendo del contenido
-
Semigroup/Monoid/Band -
Equal/Order Show
These (\&/) tiene muchos de los métodos que que esperamos de ``Disjunction
(\/) y Validation (?/`)
sealed abstract class \&/[+A, +B] {
def fold[X](s: A => X, t: B => X, q: (A, B) => X): X = ...
def swap: (B \&/ A) = ...
def append[X >: A: Semigroup, Y >: B: Semigroup](o: =>(X \&/ Y)): X \&/ Y = ...
def &&&[X >: A: Semigroup, C](t: X \&/ C): X \&/ (B, C) = ...
...
}
.append tiene 9 posibles arreglos y los datos nunca se tiran porque los casos
de This y That siempre pueden convertirse en Both.
.flatMap está sesgado hacia la derecha (Both y That), tomando un
Semigroup del contenido de la izquierda (This) para combinar en lugar de
fallar temprano. &&& es una manera conveniente de hacer un binding sobre dos
valores de tipo These, creando una tupla a la derecha y perdiendo datos si no
está presente en cada uno de estos (these).
Aunque es tentador usar \&/ en los tipos de retorno, el uso excesivo es un
antipatrón. La razón principal para usar \&/ es para combinar o dividir
streams de datos potencialmente infinitos en memoria finita. En el objeto
compañero existen funciones convenientes para lidiar con EphemeralStream (con
un alias para que quepan en una sola línea) o cualquier cosa con un MonadPlus.
type EStream[A] = EphemeralStream[A]
object \&/ {
def concatThisStream[A, B](x: EStream[A \&/ B]): EStream[A] = ...
def concatThis[F[_]: MonadPlus, A, B](x: F[A \&/ B]): F[A] = ...
def concatThatStream[A, B](x: EStream[A \&/ B]): EStream[B] = ...
def concatThat[F[_]: MonadPlus, A, B](x: F[A \&/ B]): F[B] = ...
def unalignStream[A, B](x: EStream[A \&/ B]): (EStream[A], EStream[B]) = ...
def unalign[F[_]: MonadPlus, A, B](x: F[A \&/ B]): (F[A], F[B]) = ...
def merge[A: Semigroup](t: A \&/ A): A = ...
...
}
6.7.5 Higher Kinded Either
El tipo de datos Coproduct (no confunda con el concepto más general de un
coproducto en un ADT) envuelve una Disjunction para los constructores de
tipo:
final case class Coproduct[F[_], G[_], A](run: F[A] \/ G[A]) { ... }
object Coproduct {
def leftc[F[_], G[_], A](x: F[A]): Coproduct[F, G, A] = Coproduct(-\/(x))
def rightc[F[_], G[_], A](x: G[A]): Coproduct[F, G, A] = Coproduct(\/-(x))
...
}
Las instancias de un typeclass simplemente delegan a aquellos de F[_] y
G[_].
El caso de uso más popular para un Coproduct es cuando deseamos crear un
coproducto anónimo de múltiples ADTs.
6.7.6 No tan estricta
Las tuplas de Scala integradas, y los tipos de datos básicos como Maybe y la
Disjunction son tipos de valores evaluados de manera estricta.
Por conveniencia, las alternativas por nombre para Name se proporcionan,
teniendo las instancias de typeclass esperadas:
sealed abstract class LazyTuple2[A, B] {
def _1: A
def _2: B
}
...
sealed abstract class LazyTuple4[A, B, C, D] {
def _1: A
def _2: B
def _3: C
def _4: D
}
sealed abstract class LazyOption[+A] { ... }
private final case class LazySome[A](a: () => A) extends LazyOption[A]
private case object LazyNone extends LazyOption[Nothing]
sealed abstract class LazyEither[+A, +B] { ... }
private case class LazyLeft[A, B](a: () => A) extends LazyEither[A, B]
private case class LazyRight[A, B](b: () => B) extends LazyEither[A, B]
El lector astuto notará que Lazy no está bien nombrado, y estos tipos de datos
quizá deberían ser: ByNameTupleX, ByNameOption y ByNameEither.
6.7.7 Const
Const, para constante, es un envoltorio para un valor de tipo A, junto con
un parámetro de tipo B.
final case class Const[A, B](getConst: A)
Const proporciona una instancia de Applicative[Const[A, ?]] si hay un
Monoid[A] disponible:
implicit def applicative[A: Monoid]: Applicative[Const[A, ?]] =
new Applicative[Const[A, ?]] {
def point[B](b: =>B): Const[A, B] =
Const(Monoid[A].zero)
def ap[B, C](fa: =>Const[A, B])(fbc: =>Const[A, B => C]): Const[A, C] =
Const(fbc.getConst |+| fa.getConst)
}
La cosa más importante sobre este Applicative es que ignora los parámetros
B, continuando sin fallar y únicamente combinando los valores constantes que
encuentra.
Volviendo atrás a nuestra aplicación de ejemplo drone-dynamic-agents,
deberíamos refactorizar nuestro archivo logic.scala para usar Applicative en
lugar de Monad. Escribimos logic.scala antes de que supieramos sobre
Applicative y ahora lo sabemos mejor:
final class DynAgentsModule[F[_]: Applicative](D: Drone[F], M: Machines[F])
extends DynAgents[F] {
...
def act(world: WorldView): F[WorldView] = world match {
case NeedsAgent(node) =>
M.start(node) >| world.copy(pending = Map(node -> world.time))
case Stale(nodes) =>
nodes.traverse { node =>
M.stop(node) >| node
}.map { stopped =>
val updates = stopped.strengthR(world.time).toList.toMap
world.copy(pending = world.pending ++ updates)
}
case _ => world.pure[F]
}
...
}
Dado que nuestra lógica de negocio únicamente requiere de un Applicative,
podemos escribir implementaciones simuladas con F[a] como Const[String, a].
En tal caso, devolvemos los nombres de la función que se invoca:
object ConstImpl {
type F[a] = Const[String, a]
private val D = new Drone[F] {
def getBacklog: F[Int] = Const("backlog")
def getAgents: F[Int] = Const("agents")
}
private val M = new Machines[F] {
def getAlive: F[Map[MachineNode, Epoch]] = Const("alive")
def getManaged: F[NonEmptyList[MachineNode]] = Const("managed")
def getTime: F[Epoch] = Const("time")
def start(node: MachineNode): F[Unit] = Const("start")
def stop(node: MachineNode): F[Unit] = Const("stop")
}
val program = new DynAgentsModule[F](D, M)
}
Con nuestra interpretación de nuestro programa, podemos realizar aserciones sobre los métodos que son invocados:
it should "call the expected methods" in {
import ConstImpl._
val alive = Map(node1 -> time1, node2 -> time1)
val world = WorldView(1, 1, managed, alive, Map.empty, time4)
program.act(world).getConst shouldBe "stopstop"
}
De manera alternativa, podríamos haber contado el total de métodos al usar
Const[Int, ?] o en un IMap[String, Int].
Con esta prueba, hemos ido más allá de realizar pruebas con implementaciones
simuladas con una prueba Const que hace aserciones sobre lo que se invoca
sin tener que proporcionar implementaciones. Esto es útil si nuestra
especificación demanda que hagamos ciertas llamadas para ciertas entradas, por
ejemplo, para propósitos de contabilidad. Además, hemos conseguido esto con
seguridad en tiempo de compilación.
Tomando esta línea de pensamiento un poco más allá, digamos que deseamos
monitorear (en tiempo de producción) los nodos que estamos deteniendo en act.
Podemos crear implementaciones de Drone y Machines con Const, invocándolos
desde nuestra versión envuelta de act
final class Monitored[U[_]: Functor](program: DynAgents[U]) {
type F[a] = Const[Set[MachineNode], a]
private val D = new Drone[F] {
def getBacklog: F[Int] = Const(Set.empty)
def getAgents: F[Int] = Const(Set.empty)
}
private val M = new Machines[F] {
def getAlive: F[Map[MachineNode, Epoch]] = Const(Set.empty)
def getManaged: F[NonEmptyList[MachineNode]] = Const(Set.empty)
def getTime: F[Epoch] = Const(Set.empty)
def start(node: MachineNode): F[Unit] = Const(Set.empty)
def stop(node: MachineNode): F[Unit] = Const(Set(node))
}
val monitor = new DynAgentsModule[F](D, M)
def act(world: WorldView): U[(WorldView, Set[MachineNode])] = {
val stopped = monitor.act(world).getConst
program.act(world).strengthR(stopped)
}
}
Podemos hacer esto porque monitor es puro y ejecutarlo no produce efectos
laterales.
Esto ejecuta el programa con ConstImpl, extrayendo todas las llamadas a
Machines.stop, entonces devolviéndolos junto con la WorldView. Podemos hacer
pruebas unitarias así:
it should "monitor stopped nodes" in {
val underlying = new Mutable(needsAgents).program
val alive = Map(node1 -> time1, node2 -> time1)
val world = WorldView(1, 1, managed, alive, Map.empty, time4)
val expected = world.copy(pending = Map(node1 -> time4, node2 -> time4))
val monitored = new Monitored(underlying)
monitored.act(world) shouldBe (expected -> Set(node1, node2))
}
Hemos usado Const para hacer algo que es como la Programación Orientada a
Aspectos (Aspect Oriented Programming), que alguna vez fue popular en Java.
Construimos encima de nuestra lógica de negocios para soportar una preocupación
de monitoreo, sin tener que complicar la lógica de negocios.
Se pone incluso mejor. Podemos ejecutar ConstImpl en producción para reunir lo
que deseamos para detenernos (stop), y entonces proporcionar una
implementación optimizada de act que puede usar llamadas por batches/lotes
que puede ser de implementación específica.
El héroe siliencioso de esta historia es Applicative. Const nos deja ver lo
que es posible. Si necesitamos cambiar nuestro programa para que requiera una
Monad, no podemos seguir usando Const y es necesario escribir mocks
completos para poder hacer aserciones sobre lo que se llama sobre ciertas
entradas. La Regla del Poder Mínimo demanda que usemos Applicative en lugar
de Monad siempre que podamos.
6.8 Colecciones
A diferencia de la API de colecciones de la librería estándar, Scalaz describe
comportamientos en las colecciones en la jerarquía de typeclases, por ejemplo,
Foldable, Traverse, Monoid. Lo que resta por estudiar son las
implementaciones en términos de estructuras de datos, que tienen características
de rendimiento distintas y métodos muy específicos.
Esta sección estudia detalles de implementación para cada tipo de datos. No es esencial recordar todo lo que se presenta aquí: la meta es ganar entendimiento a un nivel de abstracción alto de cómo funciona cada estructura de datos.
Debido a que los tipos de datos de todas las colecciones nos proporcionan más o menos la misma lista de instancias de typeclases, debemos evitar repetir la lista, que siempre es una variación de la lista:
Monoid-
Traverse/Foldable -
MonadPlus/IsEmpty -
Cobind/Comonad -
Zip/Unzip Align-
Equal/Order Show
Las estructuras de datos que es posible probar que no son vacías pueden proporcionar:
-
Traverse1/Foldable1
y proporcionar Semigroup en lugar de Monoid, Plus en lugar de IsEmpty.
6.8.1 Listas
Ya hemos usado IList[A] y NonEmptyList[A] tantas veces al momento que a
estas alturas deberían ser familiares. Codifican una estructura de datos
clásica, la lista ligada:
sealed abstract class IList[A] {
def ::(a: A): IList[A] = ...
def :::(as: IList[A]): IList[A] = ...
def toList: List[A] = ...
def toNel: Option[NonEmptyList[A]] = ...
...
}
final case class INil[A]() extends IList[A]
final case class ICons[A](head: A, tail: IList[A]) extends IList[A]
final case class NonEmptyList[A](head: A, tail: IList[A]) {
def <::(b: A): NonEmptyList[A] = nel(b, head :: tail)
def <:::(bs: IList[A]): NonEmptyList[A] = ...
...
}
La ventaja principal de IList sobre List de la librería estándar es que no
hay métodos inseguros, como .head que lanza una excepción sobre listas vacías.
Adicionalmente, IList es mucho más simple, sin tener una jerarquía de clases y
un tamaño del bytecode mucho más pequeño. Además, List de la librería estándar
tiene una implementación terrible que usa var para parchar problemas de
rendimiento en el diseño de las colecciones de la librería estándar:
package scala.collection.immutable
sealed abstract class List[+A]
extends AbstractSeq[A]
with LinearSeq[A]
with GenericTraversableTemplate[A, List]
with LinearSeqOptimized[A, List[A]] { ... }
case object Nil extends List[Nothing] { ... }
final case class ::[B](
override val head: B,
private[scala] var tl: List[B]
) extends List[B] { ... }
La creación de una instancia de List requiere de la creación cuidadosa, y
lenta, de sincronización de Threads para asegurar una publicación segura.
IList no requiere de tales hacks y por lo tanto puede superar a List.
6.8.2 EphemeralStream
Stream de la librería estándar es una versión perezosa de List, pero está
plagada con fugas de memoria y métodos inseguros. EphemeralStream no mantiene
referencias a valores calculados, ayudando a mitigar los problemas de retención
de memoria, removiendo los métodos inseguros en el mismo espíritu que IList.
sealed abstract class EphemeralStream[A] {
def headOption: Option[A]
def tailOption: Option[EphemeralStream[A]]
...
}
// private implementations
object EphemeralStream extends EphemeralStreamInstances {
type EStream[A] = EphemeralStream[A]
def emptyEphemeralStream[A]: EStream[A] = ...
def cons[A](a: =>A, as: =>EStream[A]): EStream[A] = ...
def unfold[A, B](start: =>B)(f: B => Option[(A, B)]): EStream[A] = ...
def iterate[A](start: A)(f: A => A): EStream[A] = ...
implicit class ConsWrap[A](e: =>EStream[A]) {
def ##::(h: A): EStream[A] = cons(h, e)
}
object ##:: {
def unapply[A](xs: EStream[A]): Option[(A, EStream[A])] =
if (xs.isEmpty) None
else Some((xs.head(), xs.tail()))
}
...
}
.cons, .unfold e .iterate son mecanismos para la creación de streams, y la
sintaxis conveniente ##:: pone un nuevo elemento en la cabeza de una
referencia por nombre EStream. .unfold es para la creación de un stream
finito (pero posiblemente infinito) al aplicar repetidamente una función f
para obtener el siguiente valor y la entrada para la siguiente f. .iterate
crea un stream infinito al aplicar repetidamente una función f en el elemento
previo.
EStream puede aparecer en patrones de emparejamiento con el símbolo ##::,
haciendo un match para la sintaxis para .cons.
Aunque EStream lidia con el problema de retención de valores de memoria,
todavía es posible sufrir de fugas de memoria lentas si una referencia viva
apunta a la cabeza de un stream infinito. Los problemas de esta naturaleza, así
como la necesidad de realizar composición de streams con efectos, es la razón de
que fs2 exista.
6.8.3 CorecursiveList
La correcursión es cuando empezamos de un estado base y producimos pasos
subsecuentes de manera determinística, como el método EphemeralStream.unfold
que acabamos de estudiar:
def unfold[A, B](b: =>B)(f: B => Option[(A, B)]): EStream[A] = ...
Contraste con una recursion, que pone datos en un estado base y entonces termina.
Una CorecursiveList es una codificación de datos de un
EphemeralStream.unfold, ofreciendo una alternativa a EStream que puede
lograr un rendimiento mejor en algunas circunstancias:
sealed abstract class CorecursiveList[A] {
type S
def init: S
def step: S => Maybe[(S, A)]
}
object CorecursiveList {
private final case class CorecursiveListImpl[S0, A](
init: S0,
step: S0 => Maybe[(S0, A)]
) extends CorecursiveList[A] { type S = S0 }
def apply[S, A](init: S)(step: S => Maybe[(S, A)]): CorecursiveList[A] =
CorecursiveListImpl(init, step)
...
}
La correcursión es útil cuando estamos implementando Comonad.cojoin, como en
nuestro ejemplo de Hood. CorecursiveList es una buena manera de codificar
ecuaciones con recurrencia no lineales como las que se usan en el modelado de
poblaciones de biología, sistemas de control, macro economía, y los modelos de
inversión de bancos.
6.8.4 ImmutableArray
Un simple wrapper alrededor del Array mutable de la librería estándar, con
especializaciones primitivas:
sealed abstract class ImmutableArray[+A] {
def ++[B >: A: ClassTag](o: ImmutableArray[B]): ImmutableArray[B]
...
}
object ImmutableArray {
final class StringArray(s: String) extends ImmutableArray[Char] { ... }
sealed class ImmutableArray1[+A](as: Array[A]) extends ImmutableArray[A] { ... }
final class ofRef[A <: AnyRef](as: Array[A]) extends ImmutableArray1[A](as)
...
final class ofLong(as: Array[Long]) extends ImmutableArray1[Long](as)
def fromArray[A](x: Array[A]): ImmutableArray[A] = ...
def fromString(str: String): ImmutableArray[Char] = ...
...
}
Array no tiene rival en términos de rendimiento al hacer lectura y el tamaño
del heap. Sin embargo, no se está comportiendo memoria estructuralmente cuando
se crean nuevos arreglos, y por lo tanto los arreglos se usan típicamente cuando
no se espera que los contenidos cambien, o como una manera segura de envolver de
manera segura datos crudos de un sistema antiguo/legacy.
6.8.5 Dequeue
Un Dequeue (pronunciado como en “deck of cards”) es una lista ligada que
permite que los elementos se coloquen o se devuelvan del frente (cons) o en la
parte trasera (snoc) en tiempo constante. Remover un elemento de cualquiera de
los extremos es una operación de tiempo constante, en promedio.
sealed abstract class Dequeue[A] {
def frontMaybe: Maybe[A]
def backMaybe: Maybe[A]
def ++(o: Dequeue[A]): Dequeue[A] = ...
def +:(a: A): Dequeue[A] = cons(a)
def :+(a: A): Dequeue[A] = snoc(a)
def cons(a: A): Dequeue[A] = ...
def snoc(a: A): Dequeue[A] = ...
def uncons: Maybe[(A, Dequeue[A])] = ...
def unsnoc: Maybe[(A, Dequeue[A])] = ...
...
}
private final case class SingletonDequeue[A](single: A) extends Dequeue[A] { ... }
private final case class FullDequeue[A](
front: NonEmptyList[A],
fsize: Int,
back: NonEmptyList[A],
backSize: Int) extends Dequeue[A] { ... }
private final case object EmptyDequeue extends Dequeue[Nothing] { ... }
object Dequeue {
def empty[A]: Dequeue[A] = EmptyDequeue()
def apply[A](as: A*): Dequeue[A] = ...
def fromFoldable[F[_]: Foldable, A](fa: F[A]): Dequeue[A] = ...
...
}
La forma en la que funciona es que existen dos listas, una para los datos al
frente y otra para los datos en la parte trasera. Considere una instancia para
mantener los símbolos a0, a1, a2, a3, a4, a5, a6
FullDequeue(
NonEmptyList('a0, IList('a1, 'a2, 'a3)), 4,
NonEmptyList('a6, IList('a5, 'a4)), 3)
que puede visualizarse como
Note que la lista que mantiene back está en orden inverso.
Leer el elemento final, snoc, es una simple lectura en back.head. Añadir un
elemento al final del Dequeue significa añadir un nuevo elemento al frente de
la lista back, y recrear el envoltorio FullDequeue (que incrementará el
tamaño de backSize en uno). Casi toda la estructura original es compartida.
Compare a agregar un nuevo elemento al final de una IList, que envolvería
recrear la estructura completa.
El frontSize y el backSize se usan para rebalancear el front y el back
de modo que casi siempre son del mismo tamaño. Rebalancear significa que algunas
operaciones sean más lentas que otras (por ejemplo, cuando la estructura de
datos debe ser reconstruida) pero debido a que únicamente ocurre ocasionalmente,
podríamos tomar el promedio del costo y decir que es constante.
6.8.6 DList
Las listas ligadas tienen características de rendimiento muy pobres cuando se añaden grandes listas. Considere el trabajo que está envuelto en evaluar lo siguiente:
((as ::: bs) ::: (cs ::: ds)) ::: (es ::: (fs ::: gs))
Esto crea seis listas intermedias, recorriendo y reconstruyendo cada lista tres
veces (con la excepción de gs que se comparte durante todas las etapas).
La DList (de lista por diferencias) es la solución más eficiente para este
escenario. En lugar de realizar cálculos en cada etapa, es representada como la
función IList[A] => IList[A]
final case class DList[A](f: IList[A] => IList[A]) {
def toIList: IList[A] = f(IList.empty)
def ++(as: DList[A]): DList[A] = DList(xs => f(as.f(xs)))
...
}
object DList {
def fromIList[A](as: IList[A]): DList[A] = DList(xs => as ::: xs)
}
El cálculo equivalente es (los símbolos son creados a partir de
DList.fromIList)
(((a ++ b) ++ (c ++ d)) ++ (e ++ (f ++ g))).toIList
que reparte el trabajo en appends asociativos a la derecha (es decir, rápidos)
(as ::: (bs ::: (cs ::: (ds ::: (es ::: (fs ::: gs))))))
utilizando el constructor rápido sobre IList.
Como siempre, no hay nada gratis. Existe un costo extra de asignación dinámica
de memoria que puede reducir la velocidad del código que resulta naturalmente en
appends asociativos a la derecha. El incremento de velocidad más grande ocurre
cuando operaciones IList son asociativas hacia la izquierda, por ejemplo
((((((as ::: bs) ::: cs) ::: ds) ::: es) ::: fs) ::: gs)
Las listas de diferencia sufren de un marketing malo. Tal vez si su nombre fuera
ListBuilderFactory estarían en la librería estándar.
6.8.7 ISet
Las estructuras de árboles son excelentes para almacenar datos ordenados, con cada nodo binario manteniendo elementos que son menores en una rama, y mayores en la otra. Sin embargo, implementaciones ingenuas de la estructura de datos árbol pueden desbalancearse dependiendo del orden de inserción. Es posible mantener un árbol perfectamente balanceado, pero es increíblemente ineficiente dado que cada inserción efectivamente reconstruye el árbol completo.
ISet es una implementación de un árbol con balanceo acotado, significando
que está aproximadamente balanceado, usando el tamaño (size) de cada rama para
balancear el nodo.
sealed abstract class ISet[A] {
val size: Int = this match {
case Tip() => 0
case Bin(_, l, r) => 1 + l.size + r.size
}
...
}
object ISet {
private final case class Tip[A]() extends ISet[A]
private final case class Bin[A](a: A, l: ISet[A], r: ISet[A]) extends ISet[A]
def empty[A]: ISet[A] = Tip()
def singleton[A](x: A): ISet[A] = Bin(x, Tip(), Tip())
def fromFoldable[F[_]: Foldable, A: Order](xs: F[A]): ISet[A] =
xs.foldLeft(empty[A])((a, b) => a insert b)
...
}
ISet requiere que A tenga un Order. La instancia Order[A] debe
permanecer igual entre llamadas o las invariantes internas serán inválidas,
llevándonos a tener datos corrompidos: es decir, estamos asumiendo la coherencia
de typeclases tales que Order[A] es única para cualquier A.
La ADT ISet desgraciadamente permite árboles inválidos. Nos esforzamos por
escribir ADTs que describan completamente lo que es y no es válido usando
restricciones de tipo, pero algunas veces tenemos situaciones donde únicamente
es posible lograrlo con el toque inspirado de un inmortal. En lugar de esto,
Tip / Bin son private, para evitar que los usuarios construyan,
accidentalmente, árboles inválidos. .insert es la única manera de construir un
ISet, y por lo tanto definir lo que es un árbol válido.
sealed abstract class ISet[A] {
...
def contains(x: A)(implicit o: Order[A]): Boolean = ...
def union(other: ISet[A])(implicit o: Order[A]): ISet[A] = ...
def delete(x: A)(implicit o: Order[A]): ISet[A] = ...
def insert(x: A)(implicit o: Order[A]): ISet[A] = this match {
case Tip() => ISet.singleton(x)
case self @ Bin(y, l, r) => o.order(x, y) match {
case LT => balanceL(y, l.insert(x), r)
case GT => balanceR(y, l, r.insert(x))
case EQ => self
}
}
...
}
Los métodos internos .balanceL y .balanceR son espejos uno del otro, de modo
que únicamente estudiamos .balanceL, que también se llama cuando el valor que
estamos insertando es menor que el nodo actual. También se invoca por el
método .delete.
def balanceL[A](y: A, left: ISet[A], right: ISet[A]): ISet[A] = (left, right) match {
...
El balanceo requiere que clasifiquemos los escenarios que pueden ocurrir.
Estudiaremos cada posible escenario, visualizando (y, left, right) al lado
izquierdo de la página, con la estructura balanceada a la derecha, también
conocido como el árbol rotado.
- círculos llenos visualizan un
Tip - tres columbas visualizan los campos
left | value | rightdeBin - los diamantes visualizan cualquier
ISet
El primer escenario es el caso trivial, que es cuando, tanto el lado left y el
right son Tip. De hecho, nunca encontraremos este escenario a partir de
.insert, pero lo encontramos en .delete.
case (Tip(), Tip()) => singleton(y)
El segundo caso es cuando left es un Bin que contiene únicamente a Tip, y
no necesitamos balancear nada, simplemente creamos la conexión obvia:
case (Bin(lx, Tip(), Tip()), Tip()) => Bin(y, left, Tip())
El tercer caso es cuando esto empieza a ponerse interesante: left es un Bin
conteniendo únicamente un Bin a su right.
case (Bin(lx, Tip(), Bin(lrx, _, _)), Tip()) =>
Bin(lrx, singleton(lx), singleton(y))
Pero qué ocurrió a los dos diamantes que están debajo de lrx? No acabamos de
perder información? No, no perdimos información, porque podemos razonar
(basándonos en el balanceo del tamaño) que siempre son Tip! No hay regla en
cuanto a cualquiera de los siguientes escenarios (o en .balanceR) que pueden
producir un árbol donde los diamantes son Bin.
El cuarto caso es el opuesto del tercer caso.
case (Bin(lx, ll, Tip()), Tip()) => Bin(lx, ll, singleton(y))
El quinto caso es cuando tenemos árboles completos en ambos lados del lado
left y de todos modos debemos usar sus tamañaos relativos para decidir cómo
rebalancear.
case (Bin(lx, ll, lr), Tip()) if (2*ll.size > lr.size) =>
Bin(lx, ll, Bin(y, lr, Tip()))
case (Bin(lx, ll, Bin(lrx, lrl, lrr)), Tip()) =>
Bin(lrx, Bin(lx, ll, lrl), Bin(y, lrr, Tip()))
Para la primera rama, 2*ll.size > lr.size
y para la segunda rama 2*ll.size <= lr.size
El sexto escenario introduce un árbol en lado (derecho) right. Cuando el lado
left está vacío creamos la conexión obvia. Ese escenario nunca surge a partir
de .insert porque el lado .left siempre está no vacío:
case (Tip(), r) => Bin(y, Tip(), r)
El escenario final ocurre cuando tenemos árboles no vacíos en ambos lados. A
menos que el lado left sea tres veces o más el tamaño del lado right,
podemos hacer lo más sencillo y crear un nuevo Bin.
case _ if l.size <= 3 * r.size => Bin(y, l, r)
Sin embargo, si el lado left debiera tener más de tres veces el tamaño del
lado right, debemos balancear basándonos en los tamaños relativos de ll y
lr, como en el escenario cinco.
case (Bin(lx, ll, lr), r) if (2*ll.size > lr.size) =>
Bin(lx, ll, Bin(y, lr, r))
case (Bin(lx, ll, Bin(lrx, lrl, lrr)), r) =>
Bin(lrx, Bin(lx, ll, lrl), Bin(y, lrr, r))
Esto concluye nuestro estudio del método .insert y cómo se construye un
ISet. No debería ser sorpresivo que Foldable esté implementado en términos
de una búsqueda en lo profundo, junto con left y right, como es apropiado.
Métodos tales como .minimum y .maximum son óptimos porque la estructura de
datos ya codifica el orden.
Es valioso hacer ntar quealgunos métodos de typeclass no pueden ser
implementados de manera tan eficiente como quisieramos. Considere la signatura
de Foldable.element
@typeclass trait Foldable[F[_]] {
...
def element[A: Equal](fa: F[A], a: A): Boolean
...
}
Una implememntación obiva para .element es (de manera práctica) delegar a la
búsqueda binaria ISet.contains. Sin embargo, no es posible debido a que
.element proporciona Equal mientras que .contains requiere de Order.
ISet es incapaz de proporcionar un Functor por la misma razón. En la
práctica esto es una restricción sensible: realizar un .map significaría
reconstruir toda la estructura de datos completa. Tiene sentido convertir a un
tipo de datos distinto, tales como IList, realizando el .map, y convirtiendo
de vuelta. Una consecuencia es que ya no es posible tener un Traverse[ISet] o
Applicative[Set].
6.8.8 IMap
sealed abstract class ==>>[A, B] {
val size: Int = this match {
case Tip() => 0
case Bin(_, _, l, r) => 1 + l.size + r.size
}
}
object ==>> {
type IMap[A, B] = A ==>> B
private final case class Tip[A, B]() extends (A ==>> B)
private final case class Bin[A, B](
key: A,
value: B,
left: A ==>> B,
right: A ==>> B
) extends ==>>[A, B]
def apply[A: Order, B](x: (A, B)*): A ==>> B = ...
def empty[A, B]: A ==>> B = Tip[A, B]()
def singleton[A, B](k: A, x: B): A ==>> B = Bin(k, x, Tip(), Tip())
def fromFoldable[F[_]: Foldable, A: Order, B](fa: F[(A, B)]): A ==>> B = ...
...
}
¡Esto es muy familiar! En realidad, IMap (un alias para el operador de la
velocidad de la luz) es otro árbol balanceado en tamaño, pero con un campo extra
value: B en cada rama del árbol binario, permitiendo almacenar pares de
llave/valor. Únicamente el tipo clave A necesita un Order y una clase
conveniente de métodos son proporcionados para permitir una actualización fácil
de entradas.
sealed abstract class ==>>[A, B] {
...
def adjust(k: A, f: B => B)(implicit o: Order[A]): A ==>> B = ...
def adjustWithKey(k: A, f: (A, B) => B)(implicit o: Order[A]): A ==>> B = ...
...
}
Tree es una versión by need (por necesidad) de StrictTree con
constructores convenientes
class Tree[A](
rootc: Need[A],
forestc: Need[Stream[Tree[A]]]
) {
def rootLabel = rootc.value
def subForest = forestc.value
}
object Tree {
object Node {
def apply[A](root: =>A, forest: =>Stream[Tree[A]]): Tree[A] = ...
}
object Leaf {
def apply[A](root: =>A): Tree[A] = ...
}
}
Se espera que el usuario de un Rose Tree lo balancee manualmente, lo que lo hace adecuado para casos donde es útil codificar conocimiento especializado de cierta jerarquía del dominio en la estructura de datos. Por ejemplo, en el campo de la inteligencia artificial, un árbol de Rose puede ser usado en algoritmos de agrupamiento para organizar datos en una jerarquía de cosas cada vez más similares. Es posible representar documentos XML con un árbol Rose.
Cuando trabajamos con datos jerárquicos, considere usar un árbol Rose en lugar de hacer una estructura de datos a la medida.
6.8.9 FingerTree
Los árboles de dedo son secuencias generalizadas con un costo de búsqueda
amortizado y concatenación logarítmica. A es el tipo de datos, ignore V por
ahora:
sealed abstract class FingerTree[V, A] {
def +:(a: A): FingerTree[V, A] = ...
def :+(a: =>A): FingerTree[V, A] = ...
def <++>(right: =>FingerTree[V, A]): FingerTree[V, A] = ...
...
}
object FingerTree {
private class Empty[V, A]() extends FingerTree[V, A]
private class Single[V, A](v: V, a: =>A) extends FingerTree[V, A]
private class Deep[V, A](
v: V,
left: Finger[V, A],
spine: =>FingerTree[V, Node[V, A]],
right: Finger[V, A]
) extends FingerTree[V, A]
sealed abstract class Finger[V, A]
final case class One[V, A](v: V, a1: A) extends Finger[V, A]
final case class Two[V, A](v: V, a1: A, a2: A) extends Finger[V, A]
final case class Three[V, A](v: V, a1: A, a2: A, a3: A) extends Finger[V, A]
final case class Four[V, A](v: V, a1: A, a2: A, a3: A, a4: A) extends Finger[V, A]
sealed abstract class Node[V, A]
private class Node2[V, A](v: V, a1: =>A, a2: =>A) extends Node[V, A]
private class Node3[V, A](v: V, a1: =>A, a2: =>A, a3: =>A) extends Node[V, A]
...
}
Visualizando FingerTree como puntos, Finger como cajas y Node como cajas
dentro de cajas:
Agregar elementos al frente de un FingerTree con +: es eficiente porque
Deep simplemente añade un nuevo elemento al dedo del lado left. Si el dedo
es un Four, reconstruimos la espina (spine) para tomar 3 de los elementos
como un Node3. Agregando al final, :+, es lo mismo pero en reversa.
Agregar cosas |+| (también <++>) es más eficiente que agregar un elemento a
la vez debido a que el caso de dos árboles Deep pueden retener las ramas
externas, reconstruyendo la espina basándose en las 16 posibles combinaciones de
los dos valores Finger en la mitad.
En la discusión anterior nos saltamos V. En la descripción de la ADT no se
muestra una implicit measurer: Reducer[A, V] en cada elemento de la ADT.
Reducer es una extensión de Monoid que permite que un solo elemento se
agregue a una M.
class Reducer[C, M: Monoid] {
def unit(c: C): M
def snoc(m: M, c: C): M = append(m, unit(c))
def cons(c: C, m: M): M = append(unit(c), m)
}
Por ejemplo, Reducer[A, IList[A]] puede proporcionar un .cons eficiente
implicit def reducer[A]: Reducer[A, IList[A]] = new Reducer[A, IList[A]] {
override def unit(a: A): IList[A] = IList.single(a)
override def cons(a: A, as: IList[A]): IList[A] = a :: as
}
6.8.9.1 IndSeq
Si usamos Int como V, podemos obtener una secuencia indexada, donde la
medida es el tamaño, permitiéndonos realizar una búsqueda basada en el índice al
comparar el índice deseado con el tamaño de cada rama en la estructura:
final class IndSeq[A](val self: FingerTree[Int, A])
object IndSeq {
private implicit def sizer[A]: Reducer[A, Int] = _ => 1
def apply[A](as: A*): IndSeq[A] = ...
}
Otro uso de FingerTree es una secuencia ordenada, donde la medida almacena el
valor más largo contenido en cada rama:
6.8.9.2 OrdSeq
final class OrdSeq[A: Order](val self: FingerTree[LastOption[A], A]) {
def partition(a: A): (OrdSeq[A], OrdSeq[A]) = ...
def insert(a: A): OrdSeq[A] = ...
def ++(xs: OrdSeq[A]): OrdSeq[A] = ...
}
object OrdSeq {
private implicit def keyer[A]: Reducer[A, LastOption[A]] = a => Tag(Some(a))
def apply[A: Order](as: A*): OrdSeq[A] = ...
}
OrdSeq no tiene instancias de typeclases de modo que únicamente es útil para
construir incrementalmente una secuencia ordenada, con duplicados. Podemos
acceder al FingerTree subyacente cuando sea necesario.
6.8.9.3 Cord
El caso más común de FingerTree es un almacén intermedio para representaciones
de String en Show. Construir una sola String puede ser miles de veces más
rápido que la implementación default de case class de .toString anidadas,
que construye una String para cada capa en la ADT.
final case class Cord(self: FingerTree[Int, String]) {
override def toString: String = {
val sb = new java.lang.StringBuilder(self.measure)
self.foreach(sb.append) // locally scoped side effect
sb.toString
}
...
}
Por ejemplo, la instancia Cord[String] devuelve una Three con la cadena a la
mitad y comillas en ambos lados
implicit val show: Show[String] = s => Cord(FingerTree.Three("\"", s, "\""))
Por lo tanto una String se muestra tal y como se escribe en el código fuente
scala> val s = "foo"
s.toString
res: String = foo
scala> s.show
res: Cord = "foo"
6.8.10 Cola de prioridad Heap
Una cola de prioridad es una estructura de datos que permite una rápida inserción de elementos ordenados, permitiendo duplicados, con un rápido acceso al valor mínimo (la prioridad más alta). La estructura no es necesaria para almacenar los elementos no mínimos en orden. Una implementación ingenua de una cola de prioridad podría ser
final case class Vip[A] private (val peek: Maybe[A], xs: IList[A]) {
def push(a: A)(implicit O: Order[A]): Vip[A] = peek match {
case Maybe.Just(min) if a < min => Vip(a.just, min :: xs)
case _ => Vip(peek, a :: xs)
}
def pop(implicit O: Order[A]): Maybe[(A, Vip[A])] = peek strengthR reorder
private def reorder(implicit O: Order[A]): Vip[A] = xs.sorted match {
case INil() => Vip(Maybe.empty, IList.empty)
case ICons(min, rest) => Vip(min.just, rest)
}
}
object Vip {
def fromList[A: Order](xs: IList[A]): Vip[A] = Vip(Maybe.empty, xs).reorder
}
Este push es muy rápido O(1), pero el reorder (y por lo tanto pop)
depende de IList.sorted con un costo de O(n log n).
Scalaz codifica una cola de prioridad con una estructura de árbol donde cada
nodo tiene un valor menor que sus hijos. Heap tiene un operaciones rápidas
push (insert), union, size, pop (uncons) y peek (minimumO):
sealed abstract class Heap[A] {
def insert(a: A)(implicit O: Order[A]): Heap[A] = ...
def +(a: A)(implicit O: Order[A]): Heap[A] = insert(a)
def union(as: Heap[A])(implicit O: Order[A]): Heap[A] = ...
def uncons(implicit O: Order[A]): Option[(A, Heap[A])] = minimumO strengthR deleteMin
def minimumO: Option[A] = ...
def deleteMin(implicit O: Order[A]): Heap[A] = ...
...
}
object Heap {
def fromData[F[_]: Foldable, A: Order](as: F[A]): Heap[A] = ...
private final case class Ranked[A](rank: Int, value: A)
private final case class Empty[A]() extends Heap[A]
private final case class NonEmpty[A](
size: Int,
tree: Tree[Ranked[A]]
) extends Heap[A]
...
}
Heap está implementado con un Rose Tree de valores Ranked, donde rank es
la profundidad del sub-árbol, permitiéndonos balancear la profundidad del árbol.
Manualmente mantenemos el árbol de modo que el valor mínimo está en la raíz.
Una ventaja de que se codifique el valor mínimo en la estructura de datos es que
minimum0 (conocido también como peek) es una búsqueda inmediata y sin costo
alguno:
def minimumO: Option[A] = this match {
case Empty() => None
case NonEmpty(_, Tree.Node(min, _)) => Some(min.value)
}
Cuando insertamos una nueva entrada, comparamos el valor mínimo actual y lo reemplazamos si la nueva entrada es más pequeña:
def insert(a: A)(implicit O: Order[A]): Heap[A] = this match {
case Empty() =>
NonEmpty(1, Tree.Leaf(Ranked(0, a)))
case NonEmpty(size, tree @ Tree.Node(min, _)) if a <= min.value =>
NonEmpty(size + 1, Tree.Node(Ranked(0, a), Stream(tree)))
...
Las inserciones de valores que no son mínimos resulta en una estructura de datos desordenada en las ramas del nodo mínimo. Cuando encontramos dos o más sub-árboles de rango idéntico, colocamos el valor mínimo de manera optimista al frente:
...
case NonEmpty(size, Tree.Node(min,
(t1 @ Tree.Node(Ranked(r1, x1), xs1)) #::
(t2 @ Tree.Node(Ranked(r2, x2), xs2)) #:: ts)) if r1 == r2 =>
lazy val t0 = Tree.Leaf(Ranked(0, a))
val sub =
if (x1 <= a && x1 <= x2)
Tree.Node(Ranked(r1 + 1, x1), t0 #:: t2 #:: xs1)
else if (x2 <= a && x2 <= x1)
Tree.Node(Ranked(r2 + 1, x2), t0 #:: t1 #:: xs2)
else
Tree.Node(Ranked(r1 + 1, a), t1 #:: t2 #:: Stream())
NonEmpty(size + 1, Tree.Node(Ranked(0, min.value), sub #:: ts))
case NonEmpty(size, Tree.Node(min, rest)) =>
val t0 = Tree.Leaf(Ranked(0, a))
NonEmpty(size + 1, Tree.Node(Ranked(0, min.value), t0 #:: rest))
}
Al evitar un ordenamiento completo del árbol hacemos que insert sea muy
rápido, O(1), de modo que los productores que agregan elementos a la cola no
son penalizados. Sin embargo, el consumidor paga el costo cuando invoca
uncons, con deleteMin teniendo un costo O(log n) debido a que debe buscar
el valor mínimo, y removerlo del árbol al reconstruirlo. Esto es rápido cuando
se compara con una implementación ingenua.
La operación union también retrasa el ordenamiento permitiéndo que se realice
en O(1).
Si Order[Foo] no captura correctamente la prioridad que deseamos para el
Heap[Foo], podemos usar Tag y proporcionar un Order[Foo @@ Custom] ad-hoc
para un Heap[Foo @@ Custom].
6.8.11 Diev (Discrete Intervals)
Podemos codificar eficientemente los valores enteros (desordenados) 6, 9, 2, 13,
8, 14, 10, 7, 5 como intervalos inclusivos [2, 2], [5, 10], [13, 14]. Diev
tiene una codificación eficiente de intervalos para elementos A que tienen
un Enum[A], haciéndose más eficientes a medida que el contenido se hace más
denso.
sealed abstract class Diev[A] {
def +(interval: (A, A)): Diev[A]
def +(value: A): Diev[A]
def ++(other: Diev[A]): Diev[A]
def -(interval: (A, A)): Diev[A]
def -(value: A): Diev[A]
def --(other: Diev[A]): Diev[A]
def intervals: Vector[(A, A)]
def contains(value: A): Boolean
def contains(interval: (A, A)): Boolean
...
}
object Diev {
private final case class DieVector[A: Enum](
intervals: Vector[(A, A)]
) extends Diev[A]
def empty[A: Enum]: Diev[A] = ...
def fromValuesSeq[A: Enum](values: Seq[A]): Diev[A] = ...
def fromIntervalsSeq[A: Enum](intervals: Seq[(A, A)]): Diev[A] = ...
}
Cuando actualizamos el Diev, los intervalos adyacentes se fusionan (y entonces
ordenados) tales que hay una representación única para un conjunto dado de
valores.
scala> Diev.fromValuesSeq(List(6, 9, 2, 13, 8, 14, 10, 7, 5))
res: Diev[Int] = ((2,2)(5,10)(13,14))
scala> Diev.fromValuesSeq(List(6, 9, 2, 13, 8, 14, 10, 7, 5).reverse)
res: Diev[Int] = ((2,2)(5,10)(13,14))
Un gran caso de uso para Diev es para almacenar periodos de tiempo. Por
ejemplo, en nuestro TradeTemplate del capítulo anterior
final case class TradeTemplate(
payments: List[java.time.LocalDate],
ccy: Option[Currency],
otc: Option[Boolean]
)
si encontramos que los payments son muy densos, tal vez deseemos intercambiar
a una representación Diev por razones de rendimiento, sin ningún cambio en
nuestra lógica de negocios porque usamos un Monoid, no ningún método
específico de List. Sin embargo, tendríamos que proporcionar un
Enum[LocalDate], que de otra manera sería algo útil y bueno que tener.
6.8.12 OneAnd
Recuerde que Foldable es el equivalente de Scalaz de una API de colecciones y
que Foldable1 es para colecciones no vacías. Hasta el momento hemos revisado
únicamente NonEmptyList para proporcionar un Foldable1. La estructura de
datos simple OneAnd envuelve cualquier otra colección y la convierte en un
Foldable1.
final case class OneAnd[F[_], A](head: A, tail: F[A])
NonEmptyList[A] podría ser un alias a OneAnd[IList, A]. De manera similar,
podemos crear Stream no vacío, y estructuras DList y Tree. Sin embargo,
podría terminar con las características de ordenamiento y unicidad de la
estructura de datos subyacente: un OneAnd[ISet, A] no es un ISet no vacío,
se trata de un ISet con la garantía de que se tiene un primer elemento que
también puede estar en el ISet.
6.9 Sumario
En este capítulo hemos revisado rápidamente los tipos de datos que Scalaz tiene que ofrecer.
No es necesario recordad todo lo estudiado en este capítulo: piense que cada sección es la semilla o corazón de una idea.
El mundo de las estructuras de datos funcionales es una área activa de investigación. Publicaciones académicas aparecen regularmente con nuevos enfoques a viejos problemas. La implementación de una estructura de datos funcionales a partir de la literatura sería una buena contribución al ecosistema de Scalaz.
7. Mónadas avanzadas
Usted tiene que conocer cosas como las mónadas avanzadas para ser un programador funcional avanzado.
Sin embargo, somos desarrolladores buscando una vida simple, y nuestra idea de
“avanzado” es modesta. Para ponernos en contexto: scala.concurrent.Future es
más complicada y con más matices que cualquier Monad en este capítulo.
En este capítulo estudiaremos algunas de las implementaciones más importantes de
Monad.
7.1 Future siempre está en movimiento
El problema más grande con Future es que programa trabajo rápidamente durante
su construcción. Como descubrimos en la introducción, Future mezcla la
definición del programa con su interpretación (es decir, con su ejecución).
Future también es malo desde una perspectiva de rendimiento: cada vez que
.flatMap es llamado, una cerradura se manda al Ejecutor, resultando en una
programación de hilos y en cambios de contexto innecesarios. No es inusual ver
50% del poder de nuestro CPU lidiando con planeación de hilos. Tanto es así que
la paralelización de trabajo con Futuro puede volverlo más lento.
En combinación, la evaluación estricta y el despacho de ejecutores significa que
es imposible de saber cuándo inició un trabajo, cuando terminó, o las sub-tareas
que se despacharon para calcular el resultado final. No debería sorprendernos
que las “soluciones” para el monitoreo de rendimiento para frameworks basados en
Future sean buenas opciones para las ventas.
Además, Future.flatMap requiere de un ExecutionContext en el ámbito
implícito: los usuarios están forzados a pensar sobre la lógica de negocios y la
semántica de ejecución a la vez.
7.2 Efectos y efectos laterales
Si no podemos llamar métodos que realicen efectos laterales en nuestra lógica de
negocios, o en Future (or Id, or Either, o Const, etc), entonces,
¿cuándo podemos escribirlos? La respuesta es: en una Monad que retrasa la
ejecución hasta que es interpretada por el punto de entrada de la aplicación. En
este punto, podemos referirnos a I/O y a la mutación como un efecto en el
mundo, capturado por el sistema de tipos, en oposición a tener efectos laterales
ocultos.
La implementación simple de tal Monad es IO, formalizando la versión que
escribimos en la introducción:
final class IO[A](val interpret: () => A)
object IO {
def apply[A](a: =>A): IO[A] = new IO(() => a)
implicit val monad: Monad[IO] = new Monad[IO] {
def point[A](a: =>A): IO[A] = IO(a)
def bind[A, B](fa: IO[A])(f: A => IO[B]): IO[B] = IO(f(fa.interpret()).interpret())
}
}
El método .interpret se llama únicamente una vez, en el punto de entrada de
una aplicación.
def main(args: Array[String]): Unit = program.interpret()
Sin embargo, hay dos grandes problemas con este simple IO:
- Puede ocasionar un sobreflujo de la pila
- No soporta cómputos paralelos
Ambos problemas serán abordados en este capítulo. Sin embargo, sin importar lo
complicado de una implementación interna de una Monad, los principios aquí
descritos seguirán siendo ciertos: estamos modularizando la definición de un
programa y su ejecución, tales que podemos capturar los efectos en la signatura
de los tipos, permitiéndonos razonar sobre ellos, y reusar más código.
7.3 Seguridad de la pila
En la JVM, toda invocación a un método, agrega una entrada a la pila de llamadas
del hilo (Thread), como agregar al frente de una List. Cuando el método
completa, el método en la cabeza (head) es descartado/eliminado. La longitud
máxima de la pila de llamadas es determinada por la bandera -Xss cuando se
llama a java. Los métodos que realizan una recursión de cola, son detectados
por el compilador de Scala y no agregan una entrada. Si alcanzamos el límite, al
invocar demasiados métodos encadenados, obtenemos una excepción de
StackOverflowError.
Desgraciadamente, toda invocación anidada de .flatMap (sobre una instancia de
IO) agrega otro método de invocación a la pila. La forma más sencilla de ver
esto es repetir esta acción por siempre, y ver si logra sobrevivir más de unos
cuántos segundos. Podemos usar .forever, que viene de Apply (un “padre” de
Monad):
scala> val hello = IO { println("hello") }
scala> Apply[IO].forever(hello).interpret()
hello
hello
hello
...
hello
java.lang.StackOverflowError
at java.io.FileOutputStream.write(FileOutputStream.java:326)
at ...
at monadio.IO$$anon$1.$anonfun$bind$1(monadio.scala:18)
at monadio.IO$$anon$1.$anonfun$bind$1(monadio.scala:18)
at ...
Scalaz tiene una typeclass que las instancias de Monad pueden implementar si
son seguras: BindRec requiere de un espacio de pila constante para bind
recursivo:
@typeclass trait BindRec[F[_]] extends Bind[F] {
def tailrecM[A, B](f: A => F[A \/ B])(a: A): F[B]
override def forever[A, B](fa: F[A]): F[B] = ...
}
No necesitamos BindRec para todos los programas, pero es esencial para una
implementación de propósito general de Monad.
La manera de conseguir seguridad en la pila es la conversión de invocaciones de
métodos en referencias a una ADT, la mónada Free:
sealed abstract class Free[S[_], A]
object Free {
private final case class Return[S[_], A](a: A) extends Free[S, A]
private final case class Suspend[S[_], A](a: S[A]) extends Free[S, A]
private final case class Gosub[S[_], A0, B](
a: Free[S, A0],
f: A0 => Free[S, B]
) extends Free[S, B] { type A = A0 }
...
}
La ADT Free es una es una representación del tipo de datos natural de la
interfaz Monad:
-
Returnrepresenta.point -
Gosubrepresenta.bind/.flatMap
Cuando una ADT es un reflejo de los argumentos de las funciones relacionadas, recibe el nombre de una codificación Church.
Free recibe este nombre debido a que puede ser generada de manera gratuita
para cualquier S[_]. Por ejemplo, podríamos hacer que S sea una de las
álgebras Drone o Machines del Capítulo 3 y generar una representación de la
estructura de datos de nuestro programa. Regresaremos más tarde a este punto
para explicar por qué razón esto es de utilidad.
7.3.1 Trampoline
Free es más general de lo necesario. Haciendo que el álgebra S[_] sea
() => ?, un cálculo diferido o un thunk, obtenemos Trampoline que puede
implementar una Monad de manera que se conserva el uso seguro de la pila.
object Free {
type Trampoline[A] = Free[() => ?, A]
implicit val trampoline: Monad[Trampoline] with BindRec[Trampoline] =
new Monad[Trampoline] with BindRec[Trampoline] {
def point[A](a: =>A): Trampoline[A] = Return(a)
def bind[A, B](fa: Trampoline[A])(f: A => Trampoline[B]): Trampoline[B] =
Gosub(fa, f)
def tailrecM[A, B](f: A => Trampoline[A \/ B])(a: A): Trampoline[B] =
bind(f(a)) {
case -\/(a) => tailrecM(f)(a)
case \/-(b) => point(b)
}
}
...
}
La implementación de BindRec, .tailrecM, ejecuta .bind hasta que obtenemos
una B. Aunque no se trata, técnicamente, de una implementación @tailrec, usa
un espacio de pila constante debido a que cada llamada devuelve un objeto en el
heap, con recursión postergada.
Se proporcionan funciones convenientes para la creación estricta de un
Trampoline (.done) o por nombre (.delay). También podemos crear un
Trampoline a partir de un Trampoline por nombre (.suspend):
object Trampoline {
def done[A](a: A): Trampoline[A] = Return(a)
def delay[A](a: =>A): Trampoline[A] = suspend(done(a))
def suspend[A](a: =>Trampoline[A]): Trampoline[A] = unit >> a
private val unit: Trampoline[Unit] = Suspend(() => done(()))
}
Cuando vemos un Trampoline[A] en el código, siempre es posible sustituirlo
mentalmente con una A, debido a que únicamente está añadiendo seguridad al uso
de la pila a un cómputo puro. Obtenemos la A al interpretar Free, provisto
por .run.
7.3.2 Ejemplo: DList con seguridad en el manejo de la pila
En el capítulo anterior hemos descrito el tipo de datos DList como
final case class DList[A](f: IList[A] => IList[A]) {
def toIList: IList[A] = f(IList.empty)
def ++(as: DList[A]): DList[A] = DList(xs => f(as.f(xs)))
...
}
Sin embargo, la implementación actual se ve más parecida a:
final case class DList[A](f: IList[A] => Trampoline[IList[A]]) {
def toIList: IList[A] = f(IList.empty).run
def ++(as: =>DList[A]): DList[A] = DList(xs => suspend(as.f(xs) >>= f))
...
}
En lugar de aplicar llamadas anidadas a f, usamos un Trampoline suspendido.
Interpretamos el trampolín con .run únicamente cuando es necesario, por
ejemplo, en toIList. Los cambios son mínimo, pero ahora tenemos una DList
con uso seguro de la pila que puede reordenar la concatenación de un número
largo de listas sin ocasionar un sobreflujo de la pila.
7.3.3 IO con uso seguro de la pila
De manera similar, nuestra IO puede hacerse segura (respecto al uso de la
pila), gracias a Trampoline:
final class IO[A](val tramp: Trampoline[A]) {
def unsafePerformIO(): A = tramp.run
}
object IO {
def apply[A](a: =>A): IO[A] = new IO(Trampoline.delay(a))
implicit val Monad: Monad[IO] with BindRec[IO] =
new Monad[IO] with BindRec[IO] {
def point[A](a: =>A): IO[A] = IO(a)
def bind[A, B](fa: IO[A])(f: A => IO[B]): IO[B] =
new IO(fa.tramp >>= (a => f(a).tramp))
def tailrecM[A, B](f: A => IO[A \/ B])(a: A): IO[B] = ...
}
}
El intérprete, .unsafePerformIO(), ahora tiene un nombre intencionalmente
terrorífico para desalentar su uso, con la excepción del punto de entrada de una
aplicación.
Esta vez, no obtendremos un error por sobreflujo de la pila:
scala> val hello = IO { println("hello") }
scala> Apply[IO].forever(hello).unsafePerformIO()
hello
hello
hello
...
hello
El uso de un Trampoline típicamente introduce una regresión en el desempeño
comparado a la implementación normal de referencia. Es Free en el sentido de
que se genera de manera automática, no en el sentido literal.
7.4 Librería de transformadores de mónadas
Las transformadores de mónadas son estructuras de datos que envuelven un valor subyacente y proporcionan un efecto monádico.
Por ejemplo, en el capítulo 2 usamos OptionT para que pudiéramos usar
F[Option[A]] en una comprehensión for como si se tratase de F[A]. Esto le
dio a nuestro programa el efecto de un valor opcional. De manera alternativa,
podemos conseguir el efcto de opcionalidad si tenemos una MonadPlus.
A este subconjunto de tipos de datos y extensiones a Monad con frecuencia se
le conoce como una Librería de Transformadores de Mónadas (MTL, por sus
siglas en inglés), como se resume enseguida. En esta sección, explicaremos cada
uno de los transformadores, por qué razón son útiles, y cómo funcionan.
| Efecto | Equivalencia | Transformador | Typeclass |
|---|---|---|---|
| opcionalidad | F[Maybe[A]] |
MaybeT |
MonadPlus |
| errores | F[E \/ A] |
EitherT |
MonadError |
| un valor en tiempo de ejecución | A => F[B] |
ReaderT |
MonadReader |
| journal / multi tarea | F[(W, A)] |
WriterT |
MonadTell |
| estado en cambio | S => F[(S, A)] |
StateT |
MonadState |
| mantén la calma y continúa | F[E \&/ A] |
TheseT |
|
| control de flujo | (A => F[B]) => F[B] |
ContT |
7.4.1 MonadTrans
Cada transformador tiene la forma general T[F[_], A], proporcionando al menos
una instancia de Monad y Hoist (y por lo tanto de MonadTrans):
@typeclass trait MonadTrans[T[_[_], _]] {
def liftM[F[_]: Monad, A](a: F[A]): T[F, A]
}
@typeclass trait Hoist[F[_[_], _]] extends MonadTrans[F] {
def hoist[M[_]: Monad, N[_]](f: M ~> N): F[M, ?] ~> F[N, ?]
}
.liftM nos permite crear un transformador de mónadas si tenemos un F[A]. Por
ejemplo, podemos crear un OptionT[IO, String] al invocar ` .liftM[OptionT] en
una IO[String]`.
.hoist es la misma idea, pero para transformaciones naturales.
Generalmente, hay tres maneras de crear un transformador de mónadas:
- A partir de la estructura equivalente, usando el constructor del transformador
- A partir de un único valor
A, usando.pureusando la sintaxis deMonad - A partir de
F[A], usando.liftMusando la sintaxis deMonadTrans
Debido a la forma en la que funciona la inferencia de tipos en Scala, esto con frecuencia significa que un parámetro de tipo complejo debe escribirse de manera explícita. Como una forma de lidiar con el problema, los transformadores proporcionan constructores convenientes en su objeto compañero que los hacen más fáciles de usar.
7.4.2 MaybeT
OptionT, MaybeT y LazyOption tienen implementaciones similares,
proporcionando opcionalidad a través de Option, Maybe y LazyOption,
respectivamente. Nos enfocaremos en MaybeT para evitar la repetición.
final case class MaybeT[F[_], A](run: F[Maybe[A]])
object MaybeT {
def just[F[_]: Applicative, A](v: =>A): MaybeT[F, A] =
MaybeT(Maybe.just(v).pure[F])
def empty[F[_]: Applicative, A]: MaybeT[F, A] =
MaybeT(Maybe.empty.pure[F])
...
}
proporcionando una MonadPlus
implicit def monad[F[_]: Monad] = new MonadPlus[MaybeT[F, ?]] {
def point[A](a: =>A): MaybeT[F, A] = MaybeT.just(a)
def bind[A, B](fa: MaybeT[F, A])(f: A => MaybeT[F, B]): MaybeT[F, B] =
MaybeT(fa.run >>= (_.cata(f(_).run, Maybe.empty.pure[F])))
def empty[A]: MaybeT[F, A] = MaybeT.empty
def plus[A](a: MaybeT[F, A], b: =>MaybeT[F, A]): MaybeT[F, A] = a orElse b
}
Esta mónada se ve un poco complicada, pero simplemente está delegando todo a la
Monad[F] y entonces envolviendo todo dentro de un MaybeT. Simplemente es
código para cumplir con el trabajo.
Con esta mónada podemos escribir lógica que maneja la opcionalidad en el
contexto de F[_], más bien que estar lidiando con Option o Maybe.
Por ejemplo, digamos que estamos interactuando con un sitio social para contar
el número de estrellas que tiene el usuario, y empezamos con una cadena
(String) que podría o no corresponder al usuario. Tenemos esta álgebra:
trait Twitter[F[_]] {
def getUser(name: String): F[Maybe[User]]
def getStars(user: User): F[Int]
}
def T[F[_]](implicit t: Twitter[F]): Twitter[F] = t
Necesitamos invocar getUser seguido de getStars. Si usamos Monad como
nuestro contexto, nuestra función es difícil porque tendremos que lidiar con el
caso Empty:
def stars[F[_]: Monad: Twitter](name: String): F[Maybe[Int]] = for {
maybeUser <- T.getUser(name)
maybeStars <- maybeUser.traverse(T.getStars)
} yield maybeStars
Sin embargo, si tenemos una MonadPlus como nuestro contexto, podemos poner
Maybe dentro de F[_] usando .orEmpty, y olvidarnos del asunto:
def stars[F[_]: MonadPlus: Twitter](name: String): F[Int] = for {
user <- T.getUser(name) >>= (_.orEmpty[F])
stars <- T.getStars(user)
} yield stars
Sin embargo, agregar un requerimiento de MonadPlus puede ocasionar problemas
más adelante en el proceso, si el contexto no tiene una. La solución es, o
cambiar el contexto del programa a MaybeT[F, ?] (elevando el contexto de
Monad[F] a una MonadPlus), o para usar explícitamente MaybeT en el tipo de
retorno, a costa de un poco de código adicional:
def stars[F[_]: Monad: Twitter](name: String): MaybeT[F, Int] = for {
user <- MaybeT(T.getUser(name))
stars <- T.getStars(user).liftM[MaybeT]
} yield stars
La decisión de requerir una Monad más poderosa vs devolver un transformador es
algo que cada equipo puede decidir por sí mismo basándose en los intérpretes que
planee usar en sus programas.
7.4.3 EitherT
Un valor opcional es el caso especial de un valor que puede ser un error, pero
no sabemos nada sobre el error. EitherT (y la variante perezosa LazyEitherT)
nos permite usar cualquier tipo que deseemos como el valor del error,
porporcionando información contextual sobre lo que pasó mal.
EitherT es un envoltorio sobre F[A \/ B]
final case class EitherT[F[_], A, B](run: F[A \/ B])
object EitherT {
def either[F[_]: Applicative, A, B](d: A \/ B): EitherT[F, A, B] = ...
def leftT[F[_]: Functor, A, B](fa: F[A]): EitherT[F, A, B] = ...
def rightT[F[_]: Functor, A, B](fb: F[B]): EitherT[F, A, B] = ...
def pureLeft[F[_]: Applicative, A, B](a: A): EitherT[F, A, B] = ...
def pure[F[_]: Applicative, A, B](b: B): EitherT[F, A, B] = ...
...
}
Monad es una MonadError
@typeclass trait MonadError[F[_], E] extends Monad[F] {
def raiseError[A](e: E): F[A]
def handleError[A](fa: F[A])(f: E => F[A]): F[A]
}
.raiseError y .handleError son descriptivos en sí mismos: el equivalente de
lanzar (throw) y atrapar (catch) una excepción, respectivamente.
MonadError tiene sintaxis adicional para lidiar con problemas comunes:
implicit final class MonadErrorOps[F[_], E, A](self: F[A])(implicit val F: MonadError[F, E])\
{
def attempt: F[E \/ A] = ...
def recover(f: E => A): F[A] = ...
def emap[B](f: A => E \/ B): F[B] = ...
}
.attempt trae los errores dentro del valor, lo cual es útil para exponer los
errores en los subsistemas como valores de primera clase.
.recover es para convertir un error en un valor en todos los casos, en
oposición a .handleError que toma una F[A] y por lo tanto permite una
recuperación parcial.
.emap, es para aplicar transformaciones que pueden fallar.
El MonadError para EitherT es:
implicit def monad[F[_]: Monad, E] = new MonadError[EitherT[F, E, ?], E] {
def monad[F[_]: Monad, E] = new MonadError[EitherT[F, E, ?], E] {
def bind[A, B](fa: EitherT[F, E, A])
(f: A => EitherT[F, E, B]): EitherT[F, E, B] =
EitherT(fa.run >>= (_.fold(_.left[B].pure[F], b => f(b).run)))
def point[A](a: =>A): EitherT[F, E, A] = EitherT.pure(a)
def raiseError[A](e: E): EitherT[F, E, A] = EitherT.pureLeft(e)
def handleError[A](fa: EitherT[F, E, A])
(f: E => EitherT[F, E, A]): EitherT[F, E, A] =
EitherT(fa.run >>= {
case -\/(e) => f(e).run
case right => right.pure[F]
})
}
No debería sorprender que podamos reescribir el ejemplo con MonadPlus con
MonadError, insertando mensajes informativos de error:
def stars[F[_]: Twitter](name: String)
(implicit F: MonadError[F, String]): F[Int] = for {
user <- T.getUser(name) >>= (_.orError(s"user '$name' not found")(F))
stars <- T.getStars(user)
} yield stars
donde .orError es un método conveniente sobre Maybe
sealed abstract class Maybe[A] {
...
def orError[F[_], E](e: E)(implicit F: MonadError[F, E]): F[A] =
cata(F.point(_), F.raiseError(e))
}
La versión que usa EitherT directamente es:
def stars[F[_]: Monad: Twitter](name: String): EitherT[F, String, Int] = for {
user <- EitherT(T.getUser(name).map(_ \/> s"user '$name' not found"))
stars <- EitherT.rightT(T.getStars(user))
} yield stars
La instancia más simple de MonadError es la de \/, perfecta para probar
lógica de negocios que es requerida por una MonadError. Por ejemplo,
final class MockTwitter extends Twitter[String \/ ?] {
def getUser(name: String): String \/ Maybe[User] =
if (name.contains(" ")) Maybe.empty.right
else if (name === "wobble") "connection error".left
else User(name).just.right
def getStars(user: User): String \/ Int =
if (user.name.startsWith("w")) 10.right
else "stars have been replaced by hearts".left
}
Nuestras pruebas unitarias para .stars pueden cubrir estos casos:
scala> stars("wibble")
\/-(10)
scala> stars("wobble")
-\/(connection error)
scala> stars("i'm a fish")
-\/(user 'i'm a fish' not found)
scala> stars("fommil")
-\/(stars have been replaced by hearts)
Así como hemos visto varias veces, podemos enfocarnos en probar la lógica de negocios sin distracciones.
Finalmente, si devolvemos nuestra álgebra JsonClient del capítulo 4.3
trait JsonClient[F[_]] {
def get[A: JsDecoder](
uri: String Refined Url,
headers: IList[(String, String)]
): F[A]
...
}
recuerde que únicamente hemos codificado el camino feliz en la API. Si nuestro
intérprete para esta álgebra únicamente funciona para una F que tiene una
MonadError, podemos definir los tipos de errores como una preocupación
tangencial. En verdad, podemos tener dos capas de errores si definimos el
intérprete para una EitherT[IO, JsonClient.Error, ?]
object JsonClient {
sealed abstract class Error
final case class ServerError(status: Int) extends Error
final case class DecodingError(message: String) extends Error
}
que cubre los problemas de I/O (red), problemas de estado del servidor, y asuntos con el modelado de los payloads JSON de nuestro servidor.
7.4.3.1 Escogiendo un tipo de errores
La comunidad no se ha decidido sobre la mejor estrategia para el tipo de
errores E en MonadError.
Una escuela de pensamiento dice que deberíamos escoger algo general, como una
cadena (String). La otra escuela de pensamiento dice que una aplicación
debería tener una ADT de errores, permitiendo que errores distintos sean
reportados o manejados de manera diferente. Una “pandilla” de gente prefiere
usar Throwable para tener máxima compatibilidad con la JVM.
Hay dos problemas con una ADT de errores a nivel de la aplicación:
- Es muy torpe crear un nuevo error. Una archivo se vuelve un repositorio monolítico de errores, agregando las ADTs de subsistemas individuales.
- Sin importar qué tan granular sea el reporte de errores, la solución es con frecuencia la misma, realice un log de los datos e intente de nuevo, o ríndase. No necesitamos una ADT para esto.
Una ADT de errores es útil si cada valor permite la ejecución de una estrategia distinta de recuperación.
El compromiso entre una ADT de errores y una cadena String está en un formato
intermedio. JSON es una buena elección si puede entenderse por la mayoría de los
frameworks de loggeo y monitoreo.
Un problema con la ausencia de stacktraces es que puede ser complicado ubicar
cúal porción de código fue la causa del error. Con sourcecode de Li
Haoyi, podemos incluir información
contextual como metadatos sobre nuestros errores:
final case class Meta(fqn: String, file: String, line: Int)
object Meta {
implicit def gen(implicit fqn: sourcecode.FullName,
file: sourcecode.File,
line: sourcecode.Line): Meta =
new Meta(fqn.value, file.value, line.value)
}
final case class Err(msg: String)(implicit val meta: Meta)
Aunque Err es referencialmente transparente, la construcción implícita de
Meta no parece ser referencialmente transparente desde una lectura natural:
dos invocaciones a Meta.gen (que se invoca implícitamente cuando se crea un
Err) producirán diferentes valores porque la ubicación en el código fuente
impacta el valor retornado:
scala> println(Err("hello world").meta)
Meta(com.acme,<console>,10)
scala> println(Err("hello world").meta)
Meta(com.acme,<console>,11)
Para entender esto, tenemos que apreciar que los métodos sourcecode.* son
macros que están generando código fuente para nosotros. Si tuvieramos que
escribir el código arriba de manera explícita sería claro lo que está
sucediendo:
scala> println(Err("hello world")(Meta("com.acme", "<console>", 10)).meta)
Meta(com.acme,<console>,10)
scala> println(Err("hello world")(Meta("com.acme", "<console>", 11)).meta)
Meta(com.acme,<console>,11)
Sí, hemos usado macros, pero también pudimos escribir Meta manualmente y
hubiera sido necesario hacerla obsoleta antes que nuestra documentación.
7.4.4 ReaderT
La mónada reader envuelve A => F[B] permitiendo que un programa F[B] dependa
del valor de tiempo de ejecución A. Para aquellos que etán familiarizados con
la inyección de dependencias, la mónada reader es el equivalente funcional de la
inyección @Inject de Spring o de Guice, sin el uso de XML o reflexión.
ReaderT es simplemente un alias de otro tipo de datos que con frecuencia es
más general, y que recibe su nombre en honor al matemático Heinrich Kleisli.
type ReaderT[F[_], A, B] = Kleisli[F, A, B]
final case class Kleisli[F[_], A, B](run: A => F[B]) {
def dimap[C, D](f: C => A, g: B => D)(implicit F: Functor[F]): Kleisli[F, C, D] =
Kleisli(c => run(f(c)).map(g))
def >=>[C](k: Kleisli[F, B, C])(implicit F: Bind[F]): Kleisli[F, A, C] = ...
def >==>[C](k: B => F[C])(implicit F: Bind[F]): Kleisli[F, A, C] = this >=> Kleisli(k)
...
}
object Kleisli {
implicit def kleisliFn[F[_], A, B](k: Kleisli[F, A, B]): A => F[B] = k.run
...
}
Una conversión implícita en el objeto companion nos permite usar Kleisli en
lugar de una función, de modo que puede proporcionarse como el parámetro de
.bind, o >>=.
El uso más común para ReaderT es proporcionar información del contexto a un
programa. En drone-dynamic-agents necesitamos acceso al token Oauth 2.0 del
usuario para ser capaz de contactar a Google. El proceder obvio es cargar
RefreshTokens del disco al momento de arranque, y hacer que cada método tome
un parámetro RefreshToken. De hecho, se trata de un requerimiento tan común
que Martin Odersky propuso las funciones
implícitas.
Una mejor solución para nuestro programa es tener un álgebra de configuración que proporcione la configuración cuando sea necesario, es decir
trait ConfigReader[F[_]] {
def token: F[RefreshToken]
}
Hemos reinventado MonadReader, la typeclass que está asociada a ReaderT,
donde .ask es la misma que nuestra .token y S es RefreshToken:
@typeclass trait MonadReader[F[_], S] extends Monad[F] {
def ask: F[S]
def local[A](f: S => S)(fa: F[A]): F[A]
}
con la implementación
implicit def monad[F[_]: Monad, R] = new MonadReader[Kleisli[F, R, ?], R] {
def point[A](a: =>A): Kleisli[F, R, A] = Kleisli(_ => F.point(a))
def bind[A, B](fa: Kleisli[F, R, A])(f: A => Kleisli[F, R, B]) =
Kleisli(a => Monad[F].bind(fa.run(a))(f))
def ask: Kleisli[F, R, R] = Kleisli(_.pure[F])
def local[A](f: R => R)(fa: Kleisli[F, R, A]): Kleisli[F, R, A] =
Kleisli(f andThen fa.run)
}
Una ley de MonadReader es que S no puede cambiar entre invocaciones, es
decir ask >> ask === ask. Para nuestro caso de uso, esto significa que la
configuración se lee una única vez. Si decidimos después que deseamos recargar
nuestra configuración cada vez que sea necesario, por ejemplo, al permitir el
cambio de token sin reiniciar la aplicación, podemos reintroducir ConfigReader
que no tiene tal ley.
En nuestra implementación OAuth 2.0 podemos primero mover la evidencia de
Monad a los métodos:
def bearer(refresh: RefreshToken)(implicit F: Monad[F]): F[BearerToken] =
for { ...
y entonces refactorizar el parámetro refresh para que sea parte de Monad
def bearer(implicit F: MonadReader[F, RefreshToken]): F[BearerToken] =
for {
refresh <- F.ask
Cualquier parámetro puede moverse dentro de MonadReader. Esto es del mayor
valor posible para los usuarios que simplemente desean pasar esta información.
Con ReaderT, podemos reservar bloques de parámetros implícitos, para un uso
exclusivo de typeclases, reduciendo la carga mental que viene con el uso de
Scala.
El otro método en MonadReader es .local
def local[A](f: S => S)(fa: F[A]): F[A]
Podemos cambiar S y ejecutar un programa fa cdentro del contexto local,
devolviendo la S original. Un caso de uso para .local es la generación de un
stacktrace que tenga sentido para nuestro dominio, ¡proporcionándonos un logging
anidado! A partir de lo que aprendimos en nuestra estructura de datos Meta de
la sección anterior, definimos una función para realizar un chequeo:
def traced[A](fa: F[A])(implicit F: MonadReader[F, IList[Meta]]): F[A] =
F.local(Meta.gen :: _)(fa)
y la podemos usar para envolver funciones que operan en este contexto
def foo: F[Foo] = traced(getBar) >>= barToFoo
automáticamente pasando cualquier información que no se esté rastreando de manera explícita. Un plugin para el compilador o una macro podría realizar lo opuesto, haciendo que todo se realice por default.
Si accedemos a .ask podemos ver el rastro completo de cómo se realizaron las
llamadas, sin la distracci[on de los detalles en la implementación del bytecode.
¡Un stacktrace referencialmente transparente!
Un programador a la defensiva podría truncar la IList[Meta] a cierta longitud
para evitar el equivalente de un sobreflujo de pila. En realidad, una estructura
de datos más apropiada es Dequeue.
.local puede también usarse para registrar la información contextual que es
directamente relavante a la tarea que se está realizando, como el número de
espacios que debemos indentar una línea cuando se está imprimiendo un archivo
con formato legible por humanos, haciendo que esta indentación aumente en dos
espacios cuando introducimos una estructura anidada.
Finalmente, si no podemos pedir una MonadReader porque nuestra aplicación no
proporciona una, siempre podemos devolver un ReaderT.
def bearer(implicit F: Monad[F]): ReaderT[F, RefreshToken, BearerToken] =
ReaderT( token => for {
...
Si alguien que recibe un ReaderT, y tienen el parámetro token a la mano,
entonces pueden invocar access.run(token) y tener de vuelta un
F[BearerToken].
Dado que no tenemos muchos callers, deberíamos simplemente revertir a un
parámetro de función regular. MonadReader es de mayor utilidad cuando:
- Deseamos refactorizar el código más tarde para recargar la configuración
- El valor no es necesario por usuarios (llamadas) intermedias
- o, cuando deseamos restringir el ámbito/alcance para que sea local
Dotty puede quedarse con sus funciones implícitas… nosotros ya tenemos
ReaderT y MonadReader.
7.4.5 WriterT
Lo opuesto a la lectura es la escritura. El transformador de mónadas WriterT
es usado típicamente para escribir a un journal.
final case class WriterT[F[_], W, A](run: F[(W, A)])
object WriterT {
def put[F[_]: Functor, W, A](value: F[A])(w: W): WriterT[F, W, A] = ...
def putWith[F[_]: Functor, W, A](value: F[A])(w: A => W): WriterT[F, W, A] = ...
...
}
El tipo envuelto es F[(W, A)] y el journal se acumula en W.
¡No hay únicamente una mónada asociada, sino dos! MonadTell y MonadListen
@typeclass trait MonadTell[F[_], W] extends Monad[F] {
def writer[A](w: W, v: A): F[A]
def tell(w: W): F[Unit] = ...
def :++>[A](fa: F[A])(w: =>W): F[A] = ...
def :++>>[A](fa: F[A])(f: A => W): F[A] = ...
}
@typeclass trait MonadListen[F[_], W] extends MonadTell[F, W] {
def listen[A](fa: F[A]): F[(A, W)]
def written[A](fa: F[A]): F[W] = ...
}
MonadTell es para escribir al journal y MonadListen es para recuperarlo. La
implementación de WriterT es
implicit def monad[F[_]: Monad, W: Monoid] = new MonadListen[WriterT[F, W, ?], W] {
def point[A](a: =>A) = WriterT((Monoid[W].zero, a).point)
def bind[A, B](fa: WriterT[F, W, A])(f: A => WriterT[F, W, B]) = WriterT(
fa.run >>= { case (wa, a) => f(a).run.map { case (wb, b) => (wa |+| wb, b) } })
def writer[A](w: W, v: A) = WriterT((w -> v).point)
def listen[A](fa: WriterT[F, W, A]) = WriterT(
fa.run.map { case (w, a) => (w, (a, w)) })
}
El ejemplo más obvio es usar MonadTell para loggear información, o para
reportar la auditoría. Reusar Meta para reportar errores podría servir para
crear una estructura de log como
sealed trait Log
final case class Debug(msg: String)(implicit m: Meta) extends Log
final case class Info(msg: String)(implicit m: Meta) extends Log
final case class Warning(msg: String)(implicit m: Meta) extends Log
y usar Dequeue[Log] como nuestro tipo de journal. Podríamos cambiar nuestro
método OAuth2 authenticate a
def debug(msg: String)(implicit m: Meta): Dequeue[Log] = Dequeue(Debug(msg))
def authenticate: F[CodeToken] =
for {
callback <- user.start :++> debug("started the webserver")
params = AuthRequest(callback, config.scope, config.clientId)
url = config.auth.withQuery(params.toUrlQuery)
_ <- user.open(url) :++> debug(s"user visiting $url")
code <- user.stop :++> debug("stopped the webserver")
} yield code
Incluso podríamos conbinar esto con las trazas de ReaderT y tener logs
estructurados.
El que realiza la llamada puede recuperar los logs con .written y hacer algo
con ellos.
Sin embargo, existe el argumento fuerte de que el logging merece su propia álgebra. El nivel de log es con frecuencia necesario en el momento de creación por razones de rendimiento y la escritura de logs es típicamente manejado a nivel de la aplicación más bien que algo sobre lo que cada componente necesite estar preocupado.
La W en WriterT tiene un Monoid, permitiéndonos escribir al journal
cualquier clase de cálculoo monoidal como un valor secundario junto con nuestro
programa principal. Por ejemplo, el conteo del número de veces que hacemos algo,
construyendo una explicación del cálculo, o la construcción de una
TradeTemplate para una nueva transacción mientras que asignamos un precio.
Una especialización popular de WriterT ocurre cuando la mónada es Id,
indicando que el valor subyacente run es simplemente la tupla simple (W, A).
type Writer[W, A] = WriterT[Id, W, A]
object WriterT {
def writer[W, A](v: (W, A)): Writer[W, A] = WriterT[Id, W, A](v)
def tell[W](w: W): Writer[W, Unit] = WriterT((w, ()))
...
}
final implicit class WriterOps[A](self: A) {
def set[W](w: W): Writer[W, A] = WriterT(w -> self)
def tell: Writer[A, Unit] = WriterT.tell(self)
}
que nos permite dejar que cualquier valor lleve consigo un cálculo monoidal
secundario, sin la necesidad de un contexto F[_].
En resumen, WriterT / MonadTell es la manera de conseguir multi tareas en la
programación funcional.
7.4.6 StateT
StateT nos permite ejecutar .put, .get y .modify sobre un valor que es
manejado por el contexto monádico. Es el reemplazo funcional de var.
Si fueramos a escribir un método impuro que tiene acceso a algún estado mutable,
y contenido en un var, pudiera haber tenido la firma () => F[A] y devolver
un valor diferente en cada invocación, rompiendo con la transparencia
referencial. Con la programación funcional pura, la función toma el estado como
la entrada y devuelve el estado actualizado como la salida, que es la razón por
la que el tipo subyacente de StateT es S => F[(S, A)].
La mónada asociada es MonadState
@typeclass trait MonadState[F[_], S] extends Monad[F] {
def put(s: S): F[Unit]
def get: F[S]
def modify(f: S => S): F[Unit] = get >>= (s => put(f(s)))
...
}
StateT se implementa de manera ligeramente diferente que los transformadores
de mónada que hemos estudiado hasta el momento. En lugar de usar un case class
es una ADT con dos miembros:
sealed abstract class StateT[F[_], S, A]
object StateT {
def apply[F[_], S, A](f: S => F[(S, A)]): StateT[F, S, A] = Point(f)
private final case class Point[F[_], S, A](
run: S => F[(S, A)]
) extends StateT[F, S, A]
private final case class FlatMap[F[_], S, A, B](
a: StateT[F, S, A],
f: (S, A) => StateT[F, S, B]
) extends StateT[F, S, B]
...
}
que es una forma especializada de Trampoline, proporcionandonos seguridad en
el uso de la pila cuando deseamos recuperar la estructura de datos subyacente,
.run:
sealed abstract class StateT[F[_], S, A] {
def run(initial: S)(implicit F: Monad[F]): F[(S, A)] = this match {
case Point(f) => f(initial)
case FlatMap(Point(f), g) =>
f(initial) >>= { case (s, x) => g(s, x).run(s) }
case FlatMap(FlatMap(f, g), h) =>
FlatMap(f, (s, x) => FlatMap(g(s, x), h)).run(initial)
}
...
}
StateT puede implemnentar de manera directa MonadState con su ADT:
implicit def monad[F[_]: Applicative, S] = new MonadState[StateT[F, S, ?], S] {
def point[A](a: =>A) = Point(s => (s, a).point[F])
def bind[A, B](fa: StateT[F, S, A])(f: A => StateT[F, S, B]) =
FlatMap(fa, (_, a: A) => f(a))
def get = Point(s => (s, s).point[F])
def put(s: S) = Point(_ => (s, ()).point[F])
}
con .pure reflejado en el objeto compañero como .stateT:
object StateT {
def stateT[F[_]: Applicative, S, A](a: A): StateT[F, S, A] = ...
...
}
y MonadTrans.liftM proporcionando el constructor F[A] => StateT[F, S, A]
como es usual.
Una variante común de StateT es cuando F = Id, proporcionando la signatura
de tipo subyacente S => (S, A). Scalaz proporciona un alias de tipo y
funciones convenientes para interactuar con el transformador de mónadas State
de manera directa, y reflejando MonadState:
type State[a] = StateT[Id, a]
object State {
def apply[S, A](f: S => (S, A)): State[S, A] = StateT[Id, S, A](f)
def state[S, A](a: A): State[S, A] = State((_, a))
def get[S]: State[S, S] = State(s => (s, s))
def put[S](s: S): State[S, Unit] = State(_ => (s, ()))
def modify[S](f: S => S): State[S, Unit] = ...
...
}
Para un ejemplo podemos regresar a las pruebas de lógica de negocios de
drone-dynamic-agents. Recuerde del Capítulo 3 que creamos Mutablep como
intérpretes de prueba para nuestra aplicación y que almacenamos el número de
started y los nodos stopped en una var`.
class Mutable(state: WorldView) {
var started, stopped: Int = 0
implicit val drone: Drone[Id] = new Drone[Id] { ... }
implicit val machines: Machines[Id] = new Machines[Id] { ... }
val program = new DynAgentsModule[Id]
}
Ahora sabemos que podemos escribir un mucho mejor simulador de pruebas con
State. Tomaremos la oportunidad de hacer un ajuste a la precisión de nuestra
simulación al mismo tiempo. Recuerde que un objeto clave de nuestro dominio de
aplicación es la visión del mundo:
final case class WorldView(
backlog: Int,
agents: Int,
managed: NonEmptyList[MachineNode],
alive: Map[MachineNode, Epoch],
pending: Map[MachineNode, Epoch],
time: Epoch
)
Dado que estamos escribiendo una simulación del mundo para nuestras pruebas, podemos crear un tipo de datos que capture la realidad de todo
final case class World(
backlog: Int,
agents: Int,
managed: NonEmptyList[MachineNode],
alive: Map[MachineNode, Epoch],
started: Set[MachineNode],
stopped: Set[MachineNode],
time: Epoch
)
La diferencia principal es que los nodos que están en los estados started y
stopped pueden ser separados. Nuestr intérprete puede ser implementado en
términos de State[World, a] y popdemos escribir nuestras pruebas para realizar
aserciones sobre la forma en la que se ve el World y WorldView después de
haber ejecutado la lógica de negocios.
Los intérpretes, que están simulando el contacto externo con los servicios Drone y Google, pueden implementarse como:
import State.{ get, modify }
object StateImpl {
type F[a] = State[World, a]
private val D = new Drone[F] {
def getBacklog: F[Int] = get.map(_.backlog)
def getAgents: F[Int] = get.map(_.agents)
}
private val M = new Machines[F] {
def getAlive: F[Map[MachineNode, Epoch]] = get.map(_.alive)
def getManaged: F[NonEmptyList[MachineNode]] = get.map(_.managed)
def getTime: F[Epoch] = get.map(_.time)
def start(node: MachineNode): F[Unit] =
modify(w => w.copy(started = w.started + node))
def stop(node: MachineNode): F[Unit] =
modify(w => w.copy(stopped = w.stopped + node))
}
val program = new DynAgentsModule[F](D, M)
}
y podemos reescribir nuestras pruebas para seguir la convención donde:
-
world1es el estado del mundo antes de ejecutar el programa -
view1es la creencia/visión de la aplicación sobre el mundo -
world2es el estado del mundo después de ejecutar el programa -
view2es la creencia/visión sobre la aplicación después de ejecutar el programa
Por ejemplo,
it should "request agents when needed" in {
val world1 = World(5, 0, managed, Map(), Set(), Set(), time1)
val view1 = WorldView(5, 0, managed, Map(), Map(), time1)
val (world2, view2) = StateImpl.program.act(view1).run(world1)
view2.shouldBe(view1.copy(pending = Map(node1 -> time1)))
world2.stopped.shouldBe(world1.stopped)
world2.started.shouldBe(Set(node1))
}
Esperemos que el lector nos perdone al mirar atrás a nuestro bucle anterior con implementación de lógica de negocio
state = initial()
while True:
state = update(state)
state = act(state)
y usar StateT para manejar el estado (state). Sin embargo, nuestra lógica de
negocios DynAgents requiere únicamente de Applicative y estaríamos violando
la ley del poder mínimo al requerir MonadState que es estrictamente más
poderoso. Es, por lo tanto, enteramente razonable manejar el estado manualmente
al pasarlo en un update y act, y dejar que quien realiza una llamada use un
StateT si así lo desea.
7.4.7 IndexedStateT
El código que hemos estudiado hasta el momento no es como Scalaz implementa
StateT. En lugar de esto, un type alias apunta a IndexedStateT
type StateT[F[_], S, A] = IndexedStateT[F, S, S, A]
La implementación de IndexedStateT es básicamente la que ya hemos estudiado,
con un parámetro de tipo extra que permiten que los estados de entrada S1 y
S2 difieran:
sealed abstract class IndexedStateT[F[_], -S1, S2, A] {
def run(initial: S1)(implicit F: Bind[F]): F[(S2, A)] = ...
...
}
object IndexedStateT {
def apply[F[_], S1, S2, A](
f: S1 => F[(S2, A)]
): IndexedStateT[F, S1, S2, A] = Wrap(f)
private final case class Wrap[F[_], S1, S2, A](
run: S1 => F[(S2, A)]
) extends IndexedStateT[F, S1, S2, A]
private final case class FlatMap[F[_], S1, S2, S3, A, B](
a: IndexedStateT[F, S1, S2, A],
f: (S2, A) => IndexedStateT[F, S2, S3, B]
) extends IndexedStateT[F, S1, S3, B]
...
}
IndexedStateT no tiene una MonadState cuando S1 != s2, aunque sí tiene una
Monad.
El siguiente ejemplo está adaptado de Index your State de Vincent Marquez.
Considere el escenario en el que deseamos diseñar una interfaz algebraica para
una búsqueda de un mapeo de un Inta un String. Se puede tratar de una
implementación en red y el orden de las invocaciones es esencial. Nuestro primer
intento de realizar la API es algo como lo siguiente:
trait Cache[F[_]] {
def read(k: Int): F[Maybe[String]]
def lock: F[Unit]
def update(k: Int, v: String): F[Unit]
def commit: F[Unit]
}
con errores en tiempo de ejecución si .update o .commit es llamada sin un
.lock. Un diseño más complejo puede envolver múltiples traits y una DSL a la
medida que nadie recuerde cómo usar.
En vez de esto, podemos usar IndexedStateT para requerir que la invocación se
realice en el estado correcto. Primero definimos nuestros estados posibles como
una ADT.
sealed abstract class Status
final case class Ready() extends Status
final case class Locked(on: ISet[Int]) extends Status
final case class Updated(values: Int ==>> String) extends Status
y entonces revisitar nuestra álgebra
trait Cache[M[_]] {
type F[in, out, a] = IndexedStateT[M, in, out, a]
def read(k: Int): F[Ready, Ready, Maybe[String]]
def readLocked(k: Int): F[Locked, Locked, Maybe[String]]
def readUncommitted(k: Int): F[Updated, Updated, Maybe[String]]
def lock: F[Ready, Locked, Unit]
def update(k: Int, v: String): F[Locked, Updated, Unit]
def commit: F[Updated, Ready, Unit]
}
lo que nos ocasionará un error en tiempo de compilación si intentamos realizar
un .update sin un .lock
for {
a1 <- C.read(13)
_ <- C.update(13, "wibble")
_ <- C.commit
} yield a1
[error] found : IndexedStateT[M,Locked,Ready,Maybe[String]]
[error] required: IndexedStateT[M,Ready,?,?]
[error] _ <- C.update(13, "wibble")
[error] ^
pero nos permite construir funciones que pueden componerse al incluirlas explícitamente en su estado:
def wibbleise[M[_]: Monad](C: Cache[M]): F[Ready, Ready, String] =
for {
_ <- C.lock
a1 <- C.readLocked(13)
a2 = a1.cata(_ + "'", "wibble")
_ <- C.update(13, a2)
_ <- C.commit
} yield a2
7.4.8 IndexedReaderWriterStateT
Aquellos que deseen una combinación de ReaderT, WriterT e IndexedStateT
no serán decepcionados. El transformador IndexedReaderWriterStateT envuelve
(R, S1) => F[(W, A, S2)] con R teniendo una semántica de Reader, W es
para escrituras monoidales, y los parámetros S para actualizaciones de
estado indexadas.
sealed abstract class IndexedReaderWriterStateT[F[_], -R, W, -S1, S2, A] {
def run(r: R, s: S1)(implicit F: Monad[F]): F[(W, A, S2)] = ...
...
}
object IndexedReaderWriterStateT {
def apply[F[_], R, W, S1, S2, A](f: (R, S1) => F[(W, A, S2)]) = ...
}
type ReaderWriterStateT[F[_], -R, W, S, A] = IndexedReaderWriterStateT[F, R, W, S, S, A]
object ReaderWriterStateT {
def apply[F[_], R, W, S, A](f: (R, S) => F[(W, A, S)]) = ...
}
Las abreviaturas se proporcionan porque de otra manera, con toda honestidad, estos tipos son tan largos que parecen que son parte de una API de J2EE:
type IRWST[F[_], -R, W, -S1, S2, A] = IndexedReaderWriterStateT[F, R, W, S1, S2, A]
val IRWST = IndexedReaderWriterStateT
type RWST[F[_], -R, W, S, A] = ReaderWriterStateT[F, R, W, S, A]
val RWST = ReaderWriterStateT
IRWST es una implementación más eficiente que una pila de transformadores
creadas como ReaderT[WriterT[IndexedStateT[F, ...], ...], ...].
7.4.9 TheseT
TheseT permite que los errores aborten los cálculos o que se acumulen si
existe un éxito parcial. Por esta razón recibe el nombre de mantén la calma y
continúa.
El tipo de datos subyacente es F[A \&/ B] con una A siendo el tipo del
error, requiriendo que exista un Semigroup para permitir la acumulación de
errores
final case class TheseT[F[_], A, B](run: F[A \&/ B])
object TheseT {
def `this`[F[_]: Functor, A, B](a: F[A]): TheseT[F, A, B] = ...
def that[F[_]: Functor, A, B](b: F[B]): TheseT[F, A, B] = ...
def both[F[_]: Functor, A, B](ab: F[(A, B)]): TheseT[F, A, B] = ...
implicit def monad[F[_]: Monad, A: Semigroup] = new Monad[TheseT[F, A, ?]] {
def bind[B, C](fa: TheseT[F, A, B])(f: B => TheseT[F, A, C]) =
TheseT(fa.run >>= {
case This(a) => a.wrapThis[C].point[F]
case That(b) => f(b).run
case Both(a, b) =>
f(b).run.map {
case This(a_) => (a |+| a_).wrapThis[C]
case That(c_) => Both(a, c_)
case Both(a_, c_) => Both(a |+| a_, c_)
}
})
def point[B](b: =>B) = TheseT(b.wrapThat.point[F])
}
}
No hay mónada especial asociada con TheseT, que simplemente es una Monad
regular. Si desearamos abortar el cálculo podríamos devolver un valor This,
pero estamos acumulando errores cuando devolvemos una Both que también
contiene la parte exitosa del cálculo.
TheseT también puede analizarse desde una perspectiva distinta: A no
necesita ser un error. De manera similar a WriterT, la A puede ser un
cálculo secundario que estamos realizando junto con un cálculo primario B.
TheseT permite la salida temprana cuando algo especial sobre A lo demanda.
7.4.10 ContT
El estilo de programación conocido como CPS (por sus siglas en inglés Continuation Passing Style) consiste en el uso de funciones que nunca regresan, y en lugar de esto, continúan al siguiente cómputo. CPS es popular en JavaScript y en Lisp dado que permiten el uso de callbacks cuando los datos están disponibles. Una implementación equivalente del patrón en Scala impuro se vería como
def foo[I, A](input: I)(next: A => Unit): Unit = next(doSomeStuff(input))
y podríamos hacer que el cómputo fuera puro al introducir el contexto F[_]
def foo[F[_], I, A](input: I)(next: A => F[Unit]): F[Unit]
y refactorizar para devolver una función para la entrada provista
def foo[F[_], I, A](input: I): (A => F[Unit]) => F[Unit]
ContT es simplemente un contenedor con esta signatura, con una instancia de
Monad
final case class ContT[F[_], B, A](_run: (A => F[B]) => F[B]) {
def run(f: A => F[B]): F[B] = _run(f)
}
object IndexedContT {
implicit def monad[F[_], B] = new Monad[ContT[F, B, ?]] {
def point[A](a: =>A) = ContT(_(a))
def bind[A, C](fa: ContT[F, B, A])(f: A => ContT[F, B, C]) =
ContT(c_fb => fa.run(a => f(a).run(c_fb)))
}
}
y sintaxis conveniente para crear una ContT a partir de un valor monádico:
implicit class ContTOps[F[_]: Monad, A](self: F[A]) {
def cps[B]: ContT[F, B, A] = ContT(a_fb => self >>= a_fb)
}
Sin embargo, el uso simple de callbacks en las continuaciones no trae nada a la
programación funcional pura debido a que ya conocemos cómo secuenciar cómputos
que no bloqueen, potencialmente distribuidos: es para esto que sirve Monad y
podemos hacer esto con un .bind o con una flecha Kleisli. Para observar por
qué razón las continuaciones son útiles necesitamos considerar un ejemplo más
complejo bajo una restricción de diseño más rígida.
7.4.10.1 Control de Flujo
Digamos que hemos modularizado nuestra aplicación usando componentes que pueden realizar I/O, y que cada componente es desarrollado por equipo distintos:
final case class A0()
final case class A1()
final case class A2()
final case class A3()
final case class A4()
def bar0(a4: A4): IO[A0] = ...
def bar2(a1: A1): IO[A2] = ...
def bar3(a2: A2): IO[A3] = ...
def bar4(a3: A3): IO[A4] = ...
Nuestra meta es producir una A0 dada una A1. Mientras que JavaScript y Lisp
usarían continuaciones para resolver este problema (debido a que la I/O es
bloqueante), podríamos simplemente encadenar las funciones
def simple(a: A1): IO[A0] = bar2(a) >>= bar3 >>= bar4 >>= bar0
Podríamos elevar .simple a su forma escrita con continuaciones al usar
sintaxis conveniente .cps y un poco de código extra (boilerplate) para cada
paso:
def foo1(a: A1): ContT[IO, A0, A2] = bar2(a).cps
def foo2(a: A2): ContT[IO, A0, A3] = bar3(a).cps
def foo3(a: A3): ContT[IO, A0, A4] = bar4(a).cps
def flow(a: A1): IO[A0] = (foo1(a) >>= foo2 >>= foo3).run(bar0)
De modo que, ¿qué nos brinda esto? Primeramente, es digno de mención que el flujo de control de la aplicación es de izquierda a derecha
¿Que tal si fuéramos los autores de foo2 y desearamos post-procesar la a0
que recivimos de la derecha (de más adelante en el proceso de cómputo), es
decir, deseamos dividir nuestro foo2 en foo2a y foo2b
Agregue la restricción de que no es posible cambiar la definición de flow o
bar0. Tal vez no es nuestro código y está definido por el framework que
estemos usando.
No es posible procesar la salida de a0 al modificar cualquiera de los métodos
restantes barX. Sin embargo, con ContT podemos modificar foo2 para
procesar el resultado de la continuación next:
que puede definirse con
def foo2(a: A2): ContT[IO, A0, A3] = ContT { next =>
for {
a3 <- bar3(a)
a0 <- next(a3)
} yield process(a0)
}
No estamos limitados a mapear sobre el valor devuelto, ¡también podemos realizar
un .bind en otro control de flujo devolviendo el flujo lineal en un grafo!
def elsewhere: ContT[IO, A0, A4] = ???
def foo2(a: A2): ContT[IO, A0, A3] = ContT { next =>
for {
a3 <- bar3(a)
a0 <- next(a3)
a0_ <- if (check(a0)) a0.pure[IO]
else elsewhere.run(bar0)
} yield a0_
}
O podemos mantenernos dentro del flujo original y reintentar todo lo que sigue
def foo2(a: A2): ContT[IO, A0, A3] = ContT { next =>
for {
a3 <- bar3(a)
a0 <- next(a3)
a0_ <- if (check(a0)) a0.pure[IO]
else next(a3)
} yield a0_
}
Se trata de únicamente un reintento, no de un ciclo infinito. Por ejemplo, podríamos solicitar que cómputos subsiguientes reconfirmaran una acción potencialmente peligrosa.
Finalmente, podemos realizar acciones que son específicas dentro del contexto de
ContT, en este caso IO que nos deja hacer un manejo de errores y limpieza de
recursos.
def foo2(a: A2): ContT[IO, A0, A3] = bar3(a).ensuring(cleanup).cps
7.4.10.2 Cuándo ordenar espagueti
No es un accidente que estos diagramas se vean como espagueti, y eso es
exactamente lo que ocurre cuando empezamos a manipular el control de flujo.
Todos los mecanismos que hemos discutido en esta sección son simples de
implementar directamente si podemos editar la definición de flow, y por lo
tanto no requerimos usar ContT.
Sin embargo, si estamos diseñando un framework, deberíamos podemos considerar el
sistema de plugins como callbacks de ContT para permitir a nuestros usuarios
más poder sobre el control de flujo. Algunas veces el cliente simplemente quiere
el espagueti.
Por ejemplo, si el compilador de Scala estuviera escrito usando CPS, permitiría un enfoque basado en principios para comunicar las fases del compilador. Un plugin para el compilador sería capaz de realizar algunas acciones basándose en el tipo inferido de una expresión, calculado en una etapa posterior del proceso de compilación. De manera similar, las continuaciones serían una buena API para una herramienta extensible para los builds, o para un editor de texto.
Algo que debería considerarse con ContT es que no tiene un uso seguro de la
pila, de modo que no es posible usarla para programas que se ejecutan por
siempre.
7.4.10.3 No use ContT
Una variante más compleja de ContT llamada IndexedContT envuelve (A =>
F[B]) => F[C]. El nuevo parámetro de tipo C permite al tipo de retorno del
cómputo completo sea diferente del tipo de retorno entre cada componente. Pero
si B no es igual a C entonces no hay una Monad.
Sin perder la oportunidad de generalizar tanto como sea posible, IndexedContT
es realmente implementada en términos de una estructura aún más general (note la
s estra antes de la T)
final case class IndexedContsT[W[_], F[_], C, B, A](_run: W[A => F[B]] => F[C])
type IndexedContT[f[_], c, b, a] = IndexedContsT[Id, f, c, b, a]
type ContT[f[_], b, a] = IndexedContsT[Id, f, b, b, a]
type ContsT[w[_], f[_], b, a] = IndexedContsT[w, f, b, b, a]
type Cont[b, a] = IndexedContsT[Id, Id, b, b, a]
donde W[_] tiene una Comonad, y ContT está implementada como un alias de
tipo. Los objetos compañeros existen para contener los aliases de tipo con
constructores de tipo convenientes.
La verdad es que, cinco parámetros de tipo es tal vez una generalización bastante amplia (tal vez demasiado). Pero de nuevo, la sobre-generalización es consistente con las sensibilidades de las continuaciones.
7.4.11 Las pilas de transformadores y los implícitos ambiguos
Esto concluye nuestro tour de los transformadores de mónadas en Scalaz.
Cuando se combinan múltiples transformadores, llamamos a esto una pila de
transformadores y aunque es muy verboso, es posible leer las características al
leer los transformadores. Por ejemplo, si construimos un contexto F[_] que sea
un conjunto de transformadores compuestos, tales como
type Ctx[A] = StateT[EitherT[IO, E, ?], S, A]
sabemos que estamos agregando manejadores de errores con un tipo de error E
(existe un MonadError[Ctx, E]) y estamos manejando el estado A (existe una
MonadState[Ctx, S]).
Lamentablemente, existen desventajas prácticas al uso de transformadores de
mónadas y sus compañeras de typeclases Monad:
- Múltiples parámetros implícitos de
Monadsignifican que el compilador no puede encontrar la sintaxis correcta que debe usarse para el contexto. - Las mónadas no se pueden componer en el caso general, lo que significa que el orden de anidación de los transformadores es importante.
- Todos los intérpretes deben elevarse al contexto común. Por ejemplo,
podríamos tener una implementación de alguna álgebra que use
IOy ahora es necesario envolverla dentro deStateTyEitherTincluso cuando son usados dentro del intérprete. - Existen costos de desempeño asociados a cada capa. Y algunos transformadores
de mónadas son peores que otros.
StateTes particularmente malo pero inclusoEitherTpuede ocasionar problemas de asignación de memoria para aplicaciones de alto desempeño.
Por eso es necesario considerar soluciones.
7.4.12 Sin sintaxis
Digamos que tenemos un álgebra
trait Lookup[F[_]] {
def look: F[Int]
}
y algunos tipos de datos
final case class Problem(bad: Int)
final case class Table(last: Int)
que deseemos usar en nuestra lógica de negocios
def foo[F[_]](L: Lookup[F])(
implicit
E: MonadError[F, Problem],
S: MonadState[F, Table]
): F[Int] = for {
old <- S.get
i <- L.look
_ <- if (i === old.last) E.raiseError(Problem(i))
else ().pure[F]
} yield i
El primer problema que encontramos es que esto no compila
[error] value flatMap is not a member of type parameter F[Table]
[error] old <- S.get
[error] ^
Existen algunas soluciones tácticas a este problema. La más obvia es hacer que todos los parámetros sean explícitos
def foo1[F[_]: Monad](
L: Lookup[F],
E: MonadError[F, Problem],
S: MonadState[F, Table]
): F[Int] = ...
y requerir únicamente que Monad sea pasado de manera implícita por medio de
límites de contexto. Sin embargo, esto significa que debemos alambrar
manualmente la MonadError y MonadState cuando invocamos foo1 y cuando
llamamos otro método que requiere un implícito.
Una segunda solución es dejar los parámetros como implícitos y usar el shadowing de nombres para hacer todos excepto uno de los parámetros explícitos. Esto permite que computos previos usen la resolución implícita cuando nos llaman pero todavía necesitamos pasar los parámetros de manera explícita si las llamamos.
@inline final def shadow[A, B, C](a: A, b: B)(f: (A, B) => C): C = f(a, b)
def foo2a[F[_]: Monad](L: Lookup[F])(
implicit
E: MonadError[F, Problem],
S: MonadState[F, Table]
): F[Int] = shadow(E, S) { (E, S) => ...
o podríamos hacer shadow de una sola Monad, dejando que la otra proporcione
nuestra sintaxis y que esté disponible para cuando llamamos a otros métodos.
@inline final def shadow[A, B](a: A)(f: A => B): B = f(a)
...
def foo2b[F[_]](L: Lookup[F])(
implicit
E: MonadError[F, Problem],
S: MonadState[F, Table]
): F[Int] = shadow(E) { E => ...
Una tercera opción, con un costo un poco más elevado, es la creación de una
typeclass de Monad a la medida, que contenga referencias implícitas a las dos
clases de Monad que nos interesan
trait MonadErrorState[F[_], E, S] {
implicit def E: MonadError[F, E]
implicit def S: MonadState[F, S]
}
y una derivación de la typeclass dada por una MonadError y MonadState
object MonadErrorState {
implicit def create[F[_], E, S](
implicit
E0: MonadError[F, E],
S0: MonadState[F, S]
) = new MonadErrorState[F, E, S] {
def E: MonadError[F, E] = E0
def S: MonadState[F, S] = S0
}
}
Ahora, si deseamos acceder a S o E podemos obtenerlas por medio de F.S o
F.E
def foo3a[F[_]: Monad](L: Lookup[F])(
implicit F: MonadErrorState[F, Problem, Table]
): F[Int] =
for {
old <- F.S.get
i <- L.look
_ <- if (i === old.last) F.E.raiseError(Problem(i))
else ().pure[F]
} yield i
Como segunda solución, podemos escoger una de las instancias de Monad para que
sea implícita dentro del bloque, y esto se puede conseguir al importarla
def foo3b[F[_]](L: Lookup[F])(
implicit F: MonadErrorState[F, Problem, Table]
): F[Int] = {
import F.E
...
}
7.4.12.1 Composición de transformadores
Un EitherT[StateT[...], ...] tiene un MonadError pero no tiene un
MonadState, mientras que StateT[EitherT[...], ...] puede proporcionar ambos.
La solución es estudiar las derivaciones implícitas en el objeto compañero de los transformadores y podemos asegurarnos de que los transformadores más externos proporcionan todo lo que necesitamos.
Una regla de oro es que hay que usar los transformadores más complejos en el exterior, y este capítulo presentó los transformadores en orden creciente de complejidad.
7.4.12.2 Elevando transformadores
Continuando con el mismo ejemplo, digamos que nuestra álgebra Lookup tiene un
intérprete IO
object LookupRandom extends Lookup[IO] {
def look: IO[Int] = IO { util.Random.nextInt }
}
pero deseamos que nuestro contexto sea
type Ctx[A] = StateT[EitherT[IO, Problem, ?], Table, A]
para proporcionarnos un MonadError y un MonadState. Esto significa que
necesitamos envolver LookupRandom para que opere sobre nuestro Ctx.
Primero, deseamos usar la sintaxis .liftM disponible en Monad, que usa
MonadTrans para elevar desde nuestro F[A] hacia G[F, A]
final class MonadOps[F[_]: Monad, A](fa: F[A]) {
def liftM[G[_[_], _]: MonadTrans]: G[F, A] = ...
...
}
Es importante darse cuenta de que los parámetros de tipo de .liftM tienen dos
hoyos, uno de la forma _[_] y el otro de forma _. Si creamos aliases de tipo
para estas formas
type Ctx0[F[_], A] = StateT[EitherT[F, Problem, ?], Table, A]
type Ctx1[F[_], A] = EitherT[F, Problem, A]
type Ctx2[F[_], A] = StateT[F, Table, A]
podemos abstraer sobre MonadTrans para elevar un Lookup[F] a cualquier
Lookup[G[F, ?]] donde G es un transformador de mónadas.
def liftM[F[_]: Monad, G[_[_], _]: MonadTrans](f: Lookup[F]) =
new Lookup[G[F, ?]] {
def look: G[F, Int] = f.look.liftM[G]
}
Permitiéndonos envolver una vez para EitherT, y de nuevo para StateT
val wrap1 = Lookup.liftM[IO, Ctx1](LookupRandom)
val wrap2: Lookup[Ctx] = Lookup.liftM[EitherT[IO, Problem, ?], Ctx2](wrap1)
Otra forma de lograr esto, en un único paso, es usar MonadIO que permite
elevar una IO en una pila de transformadores:
@typeclass trait MonadIO[F[_]] extends Monad[F] {
def liftIO[A](ioa: IO[A]): F[A]
}
con instancias de MonadIO para todas las combinaciones comunes de
transformadores.
El costo extra del código repetitivo para elevar un intérprete IO a cualquier
instancia de MonadIO es por lo tanto de dos líneas de código (para la
definición del intérprete), más una linea por elemento del álgebra, y una línea
final para invocarla:
def liftIO[F[_]: MonadIO](io: Lookup[IO]) = new Lookup[F] {
def look: F[Int] = io.look.liftIO[F]
}
val L: Lookup[Ctx] = Lookup.liftIO(LookupRandom)
7.4.12.3 Desempeño
El problema más grande con los transformadores de mónadas es el costo adicional
en términos de rendimiento. EitherT tenía un costo extra relativamente bajo,
donde cada invocación de .flatMap generaba un grupo de objetos, pero esto
puede afectar aplicaciones de alto rendimiento donde cada asignación de memoria
adicional importa. Otros transformadores, tales como StateT, efectivamente
agregan un trampolín, y ContT mantiene la cadena de invocaciones entera
retenida en memoria.
Si el rendimiento se vuelve un problema, la solución es no usar transformadores
de mónadas. Al menos no las estructuras de datos de los transformadores. Una
gran ventaja de las typeclases de Monad es que podemos crear una versión
optimizada F[_] para nuestra aplicación que proporcione las typeclasses
naturalmente. Aprenderemos cómo crear una F[_] en los próximos dos capítulos,
cuando estudiemos seriamente dos estructuras que ya hemos visto: Free y IO.
7.5 Una comida gratis
Nuestra industria ruega el uso de lenguajes de alto nivel que sean seguros, intercambiando la eficiencia de los desarrolladores y la confiabilidad por un tiempo reducido durante la ejecución.
El compilador JIT (por sus siglas en inglés: Just In Time) en la JVM tiene un desempeño tan alto que funciones simples tienen un desempeño comparable a sus equivalentes en C o C++, ignorando el costo del recolector de basura. Sin embargo, el JIT realiza únicamente optimizaciones de bajo nivel: predicción de bifurcaciones, el inlining (sustitución) de métodos, despliegue de ciclos, y así sucesivamente.
El JIT no realiza optimizaciones de nuestra lógica de negocios, por ejemplo realizar un agrupamiento de las llamadas de red, o la paralelización de tareas independientes. El desarrollador es responsable de escribir la lógica de negocio y las optimizaciones a la vez, reduciendo la legibilidad y haciendo el código más difícil de mantener. Sería muy bueno si las optimizaciones fueran asuntos de importancia secundaria.
Si, en vez de esto, tenemos una estructura de datos que describe nuestra lógica de negocios en términos de conceptos de alto nivel, no de instrucciones de máquina, podemos realizar optimizaciones de alto nivel. Las estructuras de datos de esta naturaleza se llaman, típicamente, estructuras Free (libres) y pueden generarse automáticamente para los miembros de interfaces algebraicas de nuestro programa. Por ejemplo, un Free Applicative puede ser generado para permitirnos ejecutar agrupamiento por lotes o la simplificación de I/O costosa a través de la red.
En esta sección aprenderemos cómo crear estructuras libres, y cómo pueden ser usadas.
7.5.1 Free (Monad)
Fundamentalmente, las mónadas describen un programa secuencial donde cada paso depende del previo. Por lo tanto estamos limitados a modificaciones que sólo saben sobre cosas que ya hemos ejecutado y la próxima que vamos a ejecutar.
Como un recordatorio, Free es la representación de la estructura de datos de
una Monad y está definido por tres miembros
sealed abstract class Free[S[_], A] {
def mapSuspension[T[_]](f: S ~> T): Free[T, A] = ...
def foldMap[M[_]: Monad](f: S ~> M): M[A] = ...
...
}
object Free {
implicit def monad[S[_], A]: Monad[Free[S, A]] = ...
private final case class Suspend[S[_], A](a: S[A]) extends Free[S, A]
private final case class Return[S[_], A](a: A) extends Free[S, A]
private final case class Gosub[S[_], A0, B](
a: Free[S, A0],
f: A0 => Free[S, B]
) extends Free[S, B] { type A = A0 }
def liftF[S[_], A](value: S[A]): Free[S, A] = Suspend(value)
...
}
-
Suspendrepresenta un programa que todavía no ha sido interpretado -
Returnes.pure -
Gosubes.bind
Un valor Free[S, A] puede ser generada de manera automática para cualquier
álgebra S. Para hacer esto explícito, considere nuesra aplicación del álgebra
de Machines
trait Machines[F[_]] {
def getTime: F[Epoch]
def getManaged: F[NonEmptyList[MachineNode]]
def getAlive: F[Map[MachineNode, Epoch]]
def start(node: MachineNode): F[Unit]
def stop(node: MachineNode): F[Unit]
}
Podemos definir una estructura Free generada para Machines al crear una ADT
con un tipo de datos para cada elemento en el álgebra. Cada tipo de datos tiene
los mismos tipos de parámetros como su elemento correspondiente, está
parametrizado sobre el tipo de retorno, y tiene el mismo nombre:
object Machines {
sealed abstract class Ast[A]
final case class GetTime() extends Ast[Epoch]
final case class GetManaged() extends Ast[NonEmptyList[MachineNode]]
final case class GetAlive() extends Ast[Map[MachineNode, Epoch]]
final case class Start(node: MachineNode) extends Ast[Unit]
final case class Stop(node: MachineNode) extends Ast[Unit]
...
La ADT define un AST (por sus siglas en inglés Abstract Syntax Tree) debido a que cada miembro está representando un cómputo en un programa.
Entonces definimos .liftF, una implementación de Machines, con Free[Ast,
?] siendo el contexto. Todo método simplemente delega a Free.liftT para crear
un Suspend
...
def liftF = new Machines[Free[Ast, ?]] {
def getTime = Free.liftF(GetTime())
def getManaged = Free.liftF(GetManaged())
def getAlive = Free.liftF(GetAlive())
def start(node: MachineNode) = Free.liftF(Start(node))
def stop(node: MachineNode) = Free.liftF(Stop(node))
}
}
cuando construimos un programa, parametrizado sobre un valor Free, lo
ejecutamos al proporcionar un intérprete (una transformación natural
Ast ~> M) al método .foldMap. Por ejemplo, si pudieramos proporcionar un
intérprete que mapee a IO podemos construir un IO[Unit] por medio del AST
libre
def program[F[_]: Monad](M: Machines[F]): F[Unit] = ...
val interpreter: Machines.Ast ~> IO = ...
val app: IO[Unit] = program[Free[Machines.Ast, ?]](Machines.liftF)
.foldMap(interpreter)
En aras de la exhaustividad, un intérprete que delega a una implementación
directa es fácil de escribir. Esto puede ser útil si el resto de la aplicación
está usando Free como el contexto y nosotros tenemos una implementación IO
que deseamos usar:
def interpreter[F[_]](f: Machines[F]): Ast ~> F = λ[Ast ~> F] {
case GetTime() => f.getTime
case GetManaged() => f.getManaged
case GetAlive() => f.getAlive
case Start(node) => f.start(node)
case Stop(node) => f.stop(node)
}
Pero nuestra lógica de negocios requiere de más que simplemente Machines,
también requerimos acceder a nuestra álgebra de Drone, y recuerde que está
definido como
trait Drone[F[_]] {
def getBacklog: F[Int]
def getAgents: F[Int]
}
object Drone {
sealed abstract class Ast[A]
...
def liftF = ...
def interpreter = ...
}
Lo que deseamos es que nuestra AST sea la combinación de ASTs para Machines y
Drone. Estudiamos Coproduct en el capítulo 6, que es una disjunción de alta
clase (una higher kinded disjunction):
final case class Coproduct[F[_], G[_], A](run: F[A] \/ G[A])
Podemos usar el contexto Free[Coproduct[Machines.Ast, Drone.Ast, ?], ?].
Podríamos crear manualmente el coproducto pero estaríamos nadando en código repetivo, y tendríamos que repetirlo todo de nuevo si desearamos agregar una tercer álgebra.
La typeclass con el nombre scalaz.Inject ayuda:
type :<:[F[_], G[_]] = Inject[F, G]
sealed abstract class Inject[F[_], G[_]] {
def inj[A](fa: F[A]): G[A]
def prj[A](ga: G[A]): Option[F[A]]
}
object Inject {
implicit def left[F[_], G[_]]: F :<: Coproduct[F, G, ?]] = ...
...
}
Las derivaciones implícitas generan instancias de Inject cuando las
requerimos, permitiéndonos reescribir nuestros liftF para que funcionen con
cualquier combinación de ASTs:
def liftF[F[_]](implicit I: Ast :<: F) = new Machines[Free[F, ?]] {
def getTime = Free.liftF(I.inj(GetTime()))
def getManaged = Free.liftF(I.inj(GetManaged()))
def getAlive = Free.liftF(I.inj(GetAlive()))
def start(node: MachineNode) = Free.liftF(I.inj(Start(node)))
def stop(node: MachineNode) = Free.liftF(I.inj(Stop(node)))
}
Sería bueno que F :<: G se leyera como si nuestro Ast fuera un miembro del
conjunto completo de instrucciones F: esta sintaxis es intencional.
Poniendo todo junto, digamos que tenemos un program que escribimos abstrayendo
sobre Monad
def program[F[_]: Monad](M: Machines[F], D: Drone[F]): F[Unit] = ...
y tenemos algunas implementaciones existentes de Machines y Drone, y podemos
crear interpretes a partir de ellos:
val MachinesIO: Machines[IO] = ...
val DroneIO: Drone[IO] = ...
val M: Machines.Ast ~> IO = Machines.interpreter(MachinesIO)
val D: Drone.Ast ~> IO = Drone.interpreter(DroneIO)
y combinarlos en un conjunto de instrucciones más grande usando un método
conveniente de nuestro objeto compañero en NaturalTransformation
object NaturalTransformation {
def or[F[_], G[_], H[_]](fg: F ~> G, hg: H ~> G): Coproduct[F, H, ?] ~> G = ...
...
}
type Ast[a] = Coproduct[Machines.Ast, Drone.Ast, a]
val interpreter: Ast ~> IO = NaturalTransformation.or(M, D)
Y entonces usarlo para produir un IO
val app: IO[Unit] = program[Free[Ast, ?]](Machines.liftF, Drone.liftF)
.foldMap(interpreter)
¡Pero hemos viajado en círculos! Podríamos haber usado IO como el contexto de
nuestro programa en primer lugar y haber evitado Free. De modo que por qué
pasamos por todos estos problemas? A continuación tenemos algunos ejemplos de
cuando Free podría ser de utilidad.
7.5.1.1 Pruebas con Mocks y Stubs
Puede parecer hipócrita proponer que Free pueda usarse para reducir el
boilerplate, dada la gran cantidad de código que hemos escrito. Sin embargo,
existe un punto de inflexión cuando el Ast paga por sí mismo cuando tenemos
muchas pruebas que requieren implementaciones stub.
Si los métodos .Ast y .liftF están definidos para un álgebra, podemos crear
intérpretes parciales
val M: Machines.Ast ~> Id = stub[Map[MachineNode, Epoch]] {
case Machines.GetAlive() => Map.empty
}
val D: Drone.Ast ~> Id = stub[Int] {
case Drone.GetBacklog() => 1
}
que puede ser usado para probar nuestro program
program[Free[Ast, ?]](Machines.liftF, Drone.liftF)
.foldMap(or(M, D))
.shouldBe(1)
Al usar funciones parciales, y no funciones totales, estamos exponiéndonos a errores en tiempo de ejecución. Muchos equipos están felices de aceptar este costo en sus pruebas unitarias dado que el test fallaría si hay un error del programador.
También es posible lograr lo mismo con implementaciones de nuestras álgebras que
implementen cada método con ???, haciendo un override de cada cosa necesaria
según sea el caso.
7.5.1.2 Monitoreo
Es típico que las aplicaciones de servidor sean monitoreadas por agentes de ejecución que manipulan el bytecode para insertar profilers y extraer información de distintas clases de uso o rendimiento.
Si el contexto de nuestra aplicación es Free, no es necesario recurrir a la
manipulación de bytecode, y en vez de esto es posible implementar un monitor de
efectos laterales como un intérprete sobre el que tenemos completo control.
Por ejemplo, considere usar este agente Ast ~> Ast:
val Monitor = λ[Demo.Ast ~> Demo.Ast](
_.run match {
case \/-(m @ Drone.GetBacklog()) =>
JmxAbstractFactoryBeanSingletonProviderUtilImpl.count("backlog")
Coproduct.rightc(m)
case other =>
Coproduct(other)
}
)
que almacena invocaciones del método: podríamos usar una rutina específica del vendedor en código real. También podríamos monitorear mensajes de interés específicos y registrarlos como una ayuda de depuración.
Podemos adjuntar Monitor a nuestra aplicación de producción Free con
.mapSuspension(Monitor).foldMap(interpreter)
o combinar las transformaciones naturales y ejecutarlas con un único
.foldMap(Monitor.andThen(interpreter))
7.5.1.3 Parches al código
Como ingenieros, estamos acostumbrados a peticiones de soluciones bizarras que es necesario añadir a la lógica central de la aplicación. Tal vez deseemos programar tales casos especiales como excepciones a la regla y manejarlos de manera tangencial a la lógica central de nuestro programa.
Por ejemplo, suponga que obtenemos un memo de contabilidad indicándonos lo siguiente
URGENTE: Bob está usando el nodo
#c0feepara ejecutar el final del año. ¡NO DETENGA ESTA MÁQUINA!
No hay posibilidad de discutir porqué Bob no debería estar usando nuestras máquinas para sus cuentas super importantes, de modo que es necesario hackear nuestra lógica de negocio y poner un release a producción tan pronto como sea posible.
Nuestro parche al código puede mapear a una estructura Free, permitiéndonos
regresar un resultado predeterminado (Free.pure) en lugar de programar la
ejecución de la instrucción. Creamos un caso especial para la instrucción en una
transformación natural personalizada con su valor de retorno:
val monkey = λ[Machines.Ast ~> Free[Machines.Ast, ?]] {
case Machines.Stop(MachineNode("#c0ffee")) => Free.pure(())
case other => Free.liftF(other)
}
después de haber revisado que funcione, subimos el cambio a producción, y ponemos una alarma para la próxima semana para recordarnos de removerla, y quitar los permisos de accesso de Bob de nuestros servidores.
Nuestra prueba unitaria podría usar State como el contexto objetivo, de modo
que podamos darle seguimiento a todos los nodos que detuvimos:
type S = Set[MachineNode]
val M: Machines.Ast ~> State[S, ?] = Mocker.stub[Unit] {
case Machines.Stop(node) => State.modify[S](_ + node)
}
Machines
.liftF[Machines.Ast]
.stop(MachineNode("#c0ffee"))
.foldMap(monkey)
.foldMap(M)
.exec(Set.empty)
.shouldBe(Set.empty)
junto con una prueba de que los nodos “normales” no son afectados.
Una ventaja de usar Free para evitar detener los nodos #c0fee es que podemos
estar seguros de atrapar todos los usos en lugar de tener que revisar la lógica
del negocio y buscar todos los usos de .stop. Si nuestro contexto de la
aplicación es simplemente IO podríamos, por supuesto, implementar esta lógica
en la implementación de Machines[IO] pero una ventaja de usar Free es que no
necesitamos tocar el código existente y podemos aislar y probar este
comportamiento (temporal) sin seguir atado a las implementaciones de IO.
7.5.2 FreeApp (Applicative)
A pesar de que este capítulo se llama Mónadas avanzadas, el punto que
debemos recordar es: no deberíamos usar mónadas a menos que realmente
tengamos que usarlas. En esta sección, veremos porqué FreeAp (un aplicativo
free), es preferible a las mónadas Free.
FreeAp se define como la representación de la estructura de datos de los
métodos ap y pure de la typeclass Applicative:
sealed abstract class FreeAp[S[_], A] {
def hoist[G[_]](f: S ~> G): FreeAp[G,A] = ...
def foldMap[G[_]: Applicative](f: S ~> G): G[A] = ...
def monadic: Free[S, A] = ...
def analyze[M:Monoid](f: F ~> λ[α => M]): M = ...
...
}
object FreeAp {
implicit def applicative[S[_], A]: Applicative[FreeAp[S, A]] = ...
private final case class Pure[S[_], A](a: A) extends FreeAp[S, A]
private final case class Ap[S[_], A, B](
value: () => S[B],
function: () => FreeAp[S, B => A]
) extends FreeAp[S, A]
def pure[S[_], A](a: A): FreeAp[S, A] = Pure(a)
def lift[S[_], A](x: =>S[A]): FreeAp[S, A] = ...
...
}
Los métodos .hoist y .foldMap son como los análogos de Free
.mapSuspension y .foldMap.
Como una conveniencia, podemos generar una Free[S, A] a partir de nuestro
FreeAp[S, A] con .monadic. Esto es de especial utilidad para optimizar
subsistemas más pequeños y sin embargo usarlos como parte de un programa Free
más grande.
Como Free, donde debemos crear una FreeAp para nuestras ASTs, más
boilerplate…
def liftA[F[_]](implicit I: Ast :<: F) = new Machines[FreeAp[F, ?]] {
def getTime = FreeAp.lift(I.inj(GetTime()))
...
}
7.5.2.1 Haciendo llamadas agrupadas a la red
Iniciamos este capítulo con afirmaciones grandes sobre rendimiento. Es tiempo de cumplir.
La versión humanizada de Philip Stark de los Números de latencia de Peter Norvig sirven de motivación para enfocarnos en la reducción de las llamadas a la red para optimisar una aplicación:
| Computadora | Escala de tiempo humana | Analogía humana |
|---|---|---|
| Referencia a cache L1 | 0.5 segs | Un latido del corazón |
| Error en la predicción de bifurcación | 5 segs | Bostezo |
| Referencia a cache L2 | 7 segs | Bostezo largo |
| Mutex lock/unlock | 25 segs | Preparar una taza de té |
| Referencia a memoria principal | 100 segs | Cepillarse los dientes |
| Comprimir 1 KB con Zippy | 50 min | Pipeline de CI de scalac |
| Enviar 2 KB por una red de 1 Gbps | 5.5 hr | Tren de Londres a Edinburgo |
| Lectura aleatoria de SSD | 1.7 días | Fin de semana |
| Lectura sequencial de 1 MB de memoria | 2.9 días | Fin de semana largo |
| Viaje redondo dentro del mismo centro de datos | 5.8 días | Vacaciones largas de EE.UU. |
| Lectura sequencial de 1 MB de SSD | 11.6 días | Fiesta corta de la Union Europea |
| Búsqueda en disco | 16.5 semanas | Periodo de la universidad |
| Lectura sequencial de 1 MB de disco | 7.8 meses | Maternidad pagada completamente en Noruega |
| Envío de un paquete de CA->Holanda->CA | 4.8 años | Periodo de gobierno |
Aunque Free y ``FreeAp` incurren en un costo extra de asignación dinámica de
memoria, el equivalente a 100 segundos en la tabla humanizada, cada vez que
convertimos dos invocaciones de red en una invocación agrupada, ahorramos casi 5
años.
Cuando estamos en un contexto Applicative, podemos optimizar con seguridad
nuestra aplicación sin incumplir ninguna de las expectativas del programa
original, y sin llenar desordenadamente nuestra lógica de negocios.
Felizmente, nuestra lógica de negocio únicamente requiere de un Applicative,
recuerde
final class DynAgentsModule[F[_]: Applicative](D: Drone[F], M: Machines[F])
extends DynAgents[F] {
def act(world: WorldView): F[WorldView] = ...
...
}
Para empezar, crearemos el código repetitivo de lift para una álgebra nueva
Batch
trait Batch[F[_]] {
def start(nodes: NonEmptyList[MachineNode]): F[Unit]
}
object Batch {
sealed abstract class Ast[A]
final case class Start(nodes: NonEmptyList[MachineNode]) extends Ast[Unit]
def liftA[F[_]](implicit I: Ast :<: F) = new Batch[FreeAp[F, ?]] {
def start(nodes: NonEmptyList[MachineNode]) = FreeAp.lift(I.inj(Start(nodes)))
}
}
y entonces crearemos una instancia de DynAgentsModule con FreeAp como el
contexto
type Orig[a] = Coproduct[Machines.Ast, Drone.Ast, a]
val world: WorldView = ...
val program = new DynAgentsModule(Drone.liftA[Orig], Machines.liftA[Orig])
val freeap = program.act(world)
En el capítulo 6, estudiamos el tipo de datos Const, que nos permite analizar
un programa. No debería sorprendernos que FreeAp.analize esté implementado en
términos de Const:
sealed abstract class FreeAp[S[_], A] {
...
def analyze[M: Monoid](f: S ~> λ[α => M]): M =
foldMap(λ[S ~> Const[M, ?]](x => Const(f(x)))).getConst
}
Proporcionamos una transformación natural para registrar todos los inicios de
nodos y analizar (.analize) nuestro programa para obtener todos los nodos que
necesitan iniciarse:
val gather = λ[Orig ~> λ[α => IList[MachineNode]]] {
case Coproduct(-\/(Machines.Start(node))) => IList.single(node)
case _ => IList.empty
}
val gathered: IList[MachineNode] = freeap.analyze(gather)
El siguiente paso es extender el conjunto de instrucciones de Orig a
Extended, lo que incluye el Batch.Ast y escribir un programa FreeApp que
inicie todos nuestros nodos reunidos (gathered) en una única invocación a la
red.
type Extended[a] = Coproduct[Batch.Ast, Orig, a]
def batch(nodes: IList[MachineNode]): FreeAp[Extended, Unit] =
nodes.toNel match {
case None => FreeAp.pure(())
case Some(nodes) => FreeAp.lift(Coproduct.leftc(Batch.Start(nodes)))
}
También necesitamos remover todas las llamadas a Machines.Start, lo cual
podemos hacer con una transformación natural
val nostart = λ[Orig ~> FreeAp[Extended, ?]] {
case Coproduct(-\/(Machines.Start(_))) => FreeAp.pure(())
case other => FreeAp.lift(Coproduct.rightc(other))
}
Ahora tenemos dos programas, y necesitamos combinarlos. Recuerde la sintaxis
*> de Apply
val patched = batch(gathered) *> freeap.foldMap(nostart)
Poniéndolo todo junto en un mismo método:
def optimise[A](orig: FreeAp[Orig, A]): FreeAp[Extended, A] =
(batch(orig.analyze(gather)) *> orig.foldMap(nostart))
¡Eso es todo! Llamamos .optimise en cada invocación de act en nuestro ciclo
principal, lo cual es simplemente una cuestión mecánica.
7.5.3 Coyoneda (Functor)
Nombrada en honor al matemático Nobuo Yoneda, podemos generar de manera gratuita
una estructura de datos Functor para cualquier álgebra S[_]
sealed abstract class Coyoneda[S[_], A] {
def run(implicit S: Functor[S]): S[A] = ...
def trans[G[_]](f: F ~> G): Coyoneda[G, A] = ...
...
}
object Coyoneda {
implicit def functor[S[_], A]: Functor[Coyoneda[S, A]] = ...
private final case class Map[F[_], A, B](fa: F[A], f: A => B) extends Coyoneda[F, A]
def apply[S[_], A, B](sa: S[A])(f: A => B) = Map[S, A, B](sa, f)
def lift[S[_], A](sa: S[A]) = Map[S, A, A](sa, identity)
...
}
y también existe una versión contravariante
sealed abstract class ContravariantCoyoneda[S[_], A] {
def run(implicit S: Contravariant[S]): S[A] = ...
def trans[G[_]](f: F ~> G): ContravariantCoyoneda[G, A] = ...
...
}
object ContravariantCoyoneda {
implicit def contravariant[S[_], A]: Contravariant[ContravariantCoyoneda[S, A]] = ...
private final case class Contramap[F[_], A, B](fa: F[A], f: B => A)
extends ContravariantCoyoneda[F, A]
def apply[S[_], A, B](sa: S[A])(f: B => A) = Contramap[S, A, B](sa, f)
def lift[S[_], A](sa: S[A]) = Contramap[S, A, A](sa, identity)
...
}
La API es algo más simple que Free y FreeAp, permitiendo una transformación
natural con .trans y un .run (tomando un Functor o Contravariant,
respectivamente) para escapar de la estructura libre.
Coyo y cocoyo pueden ser de utilidad si deseamos realizar un .map o
.contramap sobre un tipo, y sabemos que podemos convertir en un tipo de datos
que tiene un Functor pero no deseamos comprometernos demasiado temprano con la
estructura de datos final. Por ejemplo, podemos crear un Coyoneda[ISet, ?]
(recuerde que ISet no tiene un Functor) para usar métodos que requieren un
Functor, y después convertir en una IList.
Si deseamos optimizar un programa con coyo o cocoyo tenemos que proporcionar el boilerplate esperada para cada álgebra:
def liftCoyo[F[_]](implicit I: Ast :<: F) = new Machines[Coyoneda[F, ?]] {
def getTime = Coyoneda.lift(I.inj(GetTime()))
...
}
def liftCocoyo[F[_]](implicit I: Ast :<: F) = new Machines[ContravariantCoyoneda[F, ?]] {
def getTime = ContravariantCoyoneda.lift(I.inj(GetTime()))
...
}
Una optimización que obtenemos al usar Coyoneda es la fusión de mapeos (y
fusión de contramapeos), lo que nos permite reescribir
xs.map(a).map(b).map(c)
en
xs.map(x => c(b(a(x))))
evitando representaciones intermedias. Por ejemplo, si xs es una List de mil
elementos, evitamos la creación de dos mil objetos porque sólo realizamos un
mapeo sobre la estructura de datos una sola vez.
Sin embargo, es mucho más fácil realizar este cambio en la función de manera
manual, o esperar a que el proyecto
scalaz-plugin sea liberado y que
realice automáticamente este tipo de optimizaciones.
7.5.4 Efectos extensibles
Los programas son simplemente datos: las estructuras libres hacen esto explícito y nos dan la habilidad de reordenar y optimizar estos datos.
Free es más especial de lo que aparente: puede secuenciar álgebras y
typeclasses arbitrarias.
Por ejemplo, está disponible una estructura libre para MonadState. Ast y
.liftF son más complicadas de lo usual debido a que tenemos que tomar en
cuenta el parámetro de tipo en MonadState, y la herencia a partir de Monad:
object MonadState {
sealed abstract class Ast[S, A]
final case class Get[S]() extends Ast[S, S]
final case class Put[S](s: S) extends Ast[S, Unit]
def liftF[F[_], S](implicit I: Ast[S, ?] :<: F) =
new MonadState[Free[F, ?], S] with BindRec[Free[F, ?]] {
def get = Free.liftF(I.inj(Get[S]()))
def put(s: S) = Free.liftF(I.inj(Put[S](s)))
val delegate = Free.freeMonad[F]
def point[A](a: =>A) = delegate.point(a)
...
}
...
}
Esto nos brinda la oportunidad de usar intérpretes optimizados. Por ejemplo,
podríamos almacenar la S en un campo atómico en lugar de construir un
trampolin StateT anidado.
¡Podríamos crear una Ast y un .liftF para casi cualquier álgebra o
typeclass! La única restricción es que la F[_] no aparezca como un parámetro
de alguna de las instrucciones, es decir, debe ser posible que el álgebra tenga
una instancia de Functor. Tristemente, esto descarta a MonadError y
Monoid.
A medida que el AST de un programa libre crece, el rendimiento se degrada debido
a que el intérprete debe realizar un match sobre el conjunto de instrucciones
con un costo O(n). Una alternativa a scalaz.Coproduct es la codificación de
iotaz, que usa una estructura de datos
optimizada para realizar un despacho dinámico de costo O(1) (usando enteros
que son asignados a cada coproducto en tiempo de compilación).
Por razones históricas una AST libre para un álgebra o typeclass se conoce como
Codificación Inicial, y una implementación directa (por ejemplo, con IO) se
conoce como Sin Etiqueta Final. Aunque hemos explorado ideas interesantes con
Free, generalmente se acepta que Sin Etiqueta Final es superior. Pero para
poder usar el estilo de Sin Etiqueta Final, requerimos de un tipo para efectos
de alto rendimiento que proporcione todas las typeclasses que hemos cubierto en
este capítulo. También necesitaríamos ser capaces de ejecutar nuestro código
Applicative en paralelo. Esto es exactamente lo que estudiaremos a
continuación.
7.6 Parallel
Existen dos operaciones con efectos que casi siempre querremos ejecutar en paralelo:
- Realizar un
.mapsobre una colección de efectos, devolviendo un único efecto. Esto se consigue por medio de.traverse, que delega al método.apply2del efecto. - Ejecutar un número fijo de efectos con el operador de grito
|@|, y combinar sus salidas, de nuevo delegando al método.apply2.
Sin embargo, en la práctica, ninguna de estas operaciones se ejecutan en
paralelo por default. La razón es que si nuestra F[_] se implementa por una
Monad, entonces las leyes derivadas para los combinadores .apply2 deben
satisfacerse, las cuales dicen:
@typeclass trait Bind[F[_]] extends Apply[F] {
...
override def apply2[A, B, C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C): F[C] =
bind(fa)(a => map(fb)(b => f(a, b)))
...
}
En otras palabras, Monad está explícitamente prohibido para ejecutar efectos
en paralelo.
Sin embargo, si tenemos una F[_] que no sea monádica, entonces puede
implementar .apply2 en paralelo. Podemos usar el mecanismo de (etiqueta) @@
para crear una instancia de Applicative para F[_] @@ Parallel, que de manera
conveniente se le asigna el alias de tipo Applicative.Par
object Applicative {
type Par[F[_]] = Applicative[λ[α => F[α] @@ Tags.Parallel]]
...
}
Los programas monádicos pueden entonces demandar un Par implícito además de su
Monad
def foo[F[_]: Monad: Applicative.Par]: F[Unit] = ...
La sintaxis de Traverse de Scalaz soporta paralelismo:
implicit class TraverseSyntax[F[_], A](self: F[A]) {
...
def parTraverse[G[_], B](f: A => G[B])(
implicit F: Traverse[F], G: Applicative.Par[G]
): G[F[B]] = Tag.unwrap(F.traverse(self)(a => Tag(f(a))))
}
Si el implícito Applicative.Par[IO] está dentro del alcance, podemos escoger
entre recorrer de manera secuencial o paralela:
val input: IList[String] = ...
def network(in: String): IO[Int] = ...
input.traverse(network): IO[IList[Int]] // one at a time
input.parTraverse(network): IO[IList[Int]] // all in parallel
De manera similar, podemos invocar .parApply o .parTupled después de usar
los operadores de grito
val fa: IO[String] = ...
val fb: IO[String] = ...
val fc: IO[String] = ...
(fa |@| fb).parTupled: IO[(String, String)]
(fa |@| fb |@| fc).parApply { case (a, b, c) => a + b + c }: IO[String]
Es digno de mención que cuando tenemos programas Applicative, tales como
def foo[F[_]: Applicative]: F[Unit] = ...
podemos usar F[A] @@ Parallel como nuestro contexto del programa y obtener
paralelismo como el default en .traverse y |@|. La conversión entre las
versiones normal y @@ Parallel de F[_] debe hacerse manualmente en el
código, lo que puede resultar doloroso. Por lo tanto, con frecuencia es más
simple demandar ambas formas de Applicative
def foo[F[_]: Applicative: Applicative.Par]: F[Unit] = ...
7.6.1 Rompiendo la ley
Podríamos tomar un enfoque aún más audaz para el paralelismo: salirnos de la ley
que demanda que .apply2 sea secuencial para Monad. Esto es altamente
controversial, pero funciona bien para la mayoría de las aplicaciones del mundo
real. Es necesario auditar nuestro código (incluyendo las dependencias de
terceros) para asegurarnos de que nada está haciendo uso de la ley implicada de
.apply2.
Envolvemos IO
final class MyIO[A](val io: IO[A]) extends AnyVal
y proporcionamos nuestra propia implementación de Monad que ejecute .apply2
en paralelo al delegar a la instancia @@ Parallel
object MyIO {
implicit val monad: Monad[MyIO] = new Monad[MyIO] {
override def apply2[A, B, C](fa: MyIO[A], fb: MyIO[B])(f: (A, B) => C): MyIO[C] =
Applicative[IO.Par].apply2(fa.io, fb.io)(f)
...
}
}
Ahora es posible usar MyIO como nuestro contexto de la aplicación en lugar de
IO, y obtener paralelismo por default.
Para ser exhaustivos: una implementación ingenua e ineficiente de
Applicative.Par para nuestro IO de juguete podría usar Future:
object IO {
...
type Par[a] = IO[a] @@ Parallel
implicit val ParApplicative = new Applicative[Par] {
override def apply2[A, B, C](fa: =>Par[A], fb: =>Par[B])(f: (A, B) => C): Par[C] =
Tag(
IO {
val forked = Future { Tag.unwrap(fa).interpret() }
val b = Tag.unwrap(fb).interpret()
val a = Await.result(forked, Duration.Inf)
f(a, b)
}
)
}
y debido a un error en el compilador de
Scala que trata todas las instancias
de @@ como huérfanas, debemos importar de manera explícita el implícito:
import IO.ParApplicative
En la sección final de este capítulo veremos cómo está implementado IO en
realidad.
7.7 IO
IO de Scalaz es la construcción de programación asíncrona más rápida del
ecosistema de Scala: hasta 50 veces más rápida que Future. IO es una
estructura de datos libre especializada para usarse como una mónada de efectos
generalizados.
sealed abstract class IO[E, A] { ... }
object IO {
private final class FlatMap ... extends IO[E, A]
private final class Point ... extends IO[E, A]
private final class Strict ... extends IO[E, A]
private final class SyncEffect ... extends IO[E, A]
private final class Fail ... extends IO[E, A]
private final class AsyncEffect ... extends IO[E, A]
...
}
IO tiene dos parámetros de tipo: tiene un Bifunctor que permite que el
tipo de error sea un ADT específico de la aplicación. Pero debido a que estamos
trabajando sobre la JVM, y debemos interactuar con librerías antiguas, se
proporciona un type alias conveniente que usa excepciones para el tipo de error:
type Task[A] = IO[Throwable, A]
7.7.1 Creación
Existen múltiples formas de crear una IO que cubra una variedad de bloques de
código que cubran situaciones para bloques de código estrictos, perezosos
(lazy), seguros e inseguros:
object IO {
// eager evaluation of an existing value
def now[E, A](a: A): IO[E, A] = ...
// lazy evaluation of a pure calculation
def point[E, A](a: =>A): IO[E, A] = ...
// lazy evaluation of a side-effecting, yet Total, code block
def sync[E, A](effect: =>A): IO[E, A] = ...
// lazy evaluation of a side-effecting code block that may fail
def syncThrowable[A](effect: =>A): IO[Throwable, A] = ...
// create a failed IO
def fail[E, A](error: E): IO[E, A] = ...
// asynchronously sleeps for a specific period of time
def sleep[E](duration: Duration): IO[E, Unit] = ...
...
}
con constructores convenientes para Task:
object Task {
def apply[A](effect: =>A): Task[A] = IO.syncThrowable(effect)
def now[A](effect: A): Task[A] = IO.now(effect)
def fail[A](error: Throwable): Task[A] = IO.fail(error)
def fromFuture[E, A](io: Task[Future[A]])(ec: ExecutionContext): Task[A] = ...
}
Los constructores más comunes, por mucho, cuando es necesario lidiar con código
antiguo, son Task.apply y Task.fromFuture:
val fa: Task[Future[String]] = Task { ... impure code here ... }
Task.fromFuture(fa)(ExecutionContext.global): Task[String]
No podemos pasar Future (sin envolverlos), porque se evalúan de manera
estricta, de modo que siempre es necesario construirlos dentro de un bloque
seguro.
Noten que el ExecutionContext no es implícito, contrario a la
convención. Recuerde que en Scalaz reservamos la palabra implicit para la
derivación de typeclasses, para simplificar el lenguaje: ExecutionContext es
una configuración que debe proporcionarse de manera explícita.
7.7.2 Ejecución
El intérprete IO se llama RTS, para el sistema de ejecución. Su
implementación está más allá del alcance de este libro. Nos enfocaremos en las
características que proporciona IO.
IO es simplemente una estructura de datos, y es interpretada al final del
mundo al extender SafeApp e implementando .run
trait SafeApp extends RTS {
sealed trait ExitStatus
object ExitStatus {
case class ExitNow(code: Int) extends ExitStatus
case class ExitWhenDone(code: Int, timeout: Duration) extends ExitStatus
case object DoNotExit extends ExitStatus
}
def run(args: List[String]): IO[Void, ExitStatus]
final def main(args0: Array[String]): Unit = ... calls run ...
}
Si estamos integrando con un sistema antiguo y no estamos en control del punto
de entrada de nuestra aplicación, podemos extender el RTS y ganar acceso a
métodos inseguros para evaluar el IO en el punto de entrada de nuestro código
con principios que usa PF.
7.7.3 Características
IO proporciona instancias de typeclasses para Bifunctor, MonadError[E, ?],
BindRec, Plus, MonadPlus (si E forma un Monoid), y un
Applicative[IO.Par[E, ?]].
En adición a la funcionalidad que viene a partir de las typeclasses, hay métodos de implementación específicos:
sealed abstract class IO[E, A] {
// retries an action N times, until success
def retryN(n: Int): IO[E, A] = ...
// ... with exponential backoff
def retryBackoff(n: Int, factor: Double, duration: Duration): IO[E, A] = ...
// repeats an action with a pause between invocations, until it fails
def repeat[B](interval: Duration): IO[E, B] = ...
// cancel the action if it does not complete within the timeframe
def timeout(duration: Duration): IO[E, Maybe[A]] = ...
// runs `release` on success or failure.
// Note that IO[Void, Unit] cannot fail.
def bracket[B](release: A => IO[Void, Unit])(use: A => IO[E, B]): IO[E, B] = ...
// alternative syntax for bracket
def ensuring(finalizer: IO[Void, Unit]): IO[E, A] =
// ignore failure and success, e.g. to ignore the result of a cleanup action
def ignore: IO[Void, Unit] = ...
// runs two effects in parallel
def par[B](that: IO[E, B]): IO[E, (A, B)] = ...
...
Es posible que un IO tenga un estado terminado, que representa trabajo que
al final será descartado (no es un error ni un éxito). Las utilidades
relacionadas a la terminación son:
...
// terminate whatever actions are running with the given throwable.
// bracket / ensuring is honoured.
def terminate[E, A](t: Throwable): IO[E, A] = ...
// runs two effects in parallel, return the winner and terminate the loser
def race(that: IO[E, A]): IO[E, A] = ...
// ignores terminations
def uninterruptibly: IO[E, A] = ...
...
7.7.4 Fiber
Una IO puede engendrar fibers, una abstracción liviana sobre un Thread de
la JVM. Podemos invocar .fork sobre una instancia de IO, y .supervise
sobre cualquier fibra incompleta para asegurar que son terminadas cuando se
completa la acción IO.
...
def fork[E2]: IO[E2, Fiber[E, A]] = ...
def supervised(error: Throwable): IO[E, A] = ...
...
Cuando tenemos una Fiber podemos invocar .join para regresar a la IO, o
interrumpt el trabajo subyacente.
trait Fiber[E, A] {
def join: IO[E, A]
def interrupt[E2](t: Throwable): IO[E2, Unit]
}
Podemos usar fibras para lograr una forma de control de concurrencia optimista.
Considere el caso donde tenemos data que requerimos analizar, pero también
tenemos que validarlo. Podemos iniciar el análisis de manera optimista y
cancelar el trabajo si la validación falla, lo que se realiza en paralelo.
final class BadData(data: Data) extends Throwable with NoStackTrace
for {
fiber1 <- analysis(data).fork
fiber2 <- validate(data).fork
valid <- fiber2.join
_ <- if (!valid) fiber1.interrupt(BadData(data))
else IO.unit
result <- fiber1.join
} yield result
Otro caso de uso para las fibras es cuando necesitamos realizar una acción de dispara y olvida. Por ejemplo, un logging de baja prioridad sobre la red.
final class Promise[E, A] private (ref: AtomicReference[State[E, A]]) {
def complete[E2](a: A): IO[E2, Boolean] = ...
def error[E2](e: E): IO[E2, Boolean] = ...
def get: IO[E, A] = ...
// interrupts all listeners
def interrupt[E2](t: Throwable): IO[E2, Boolean] = ...
}
object Promise {
def make[E, A]: IO[E, Promise[E, A]] = ...
}
Promise no es algo que típicamente se use en código de aplicación. Es un
bloque de construcción para frameworks de concurrencia de alto nivel.
7.7.5 IORef
IORef es el equivalente de IO para una variable mutable atómica.
Podemos leer la variable y tenemos una variedad de maneras de escribirla o actualizarla.
final class IORef[A] private (ref: AtomicReference[A]) {
def read[E]: IO[E, A] = ...
// write with immediate consistency guarantees
def write[E](a: A): IO[E, Unit] = ...
// write with eventual consistency guarantees
def writeLater[E](a: A): IO[E, Unit] = ...
// return true if an immediate write succeeded, false if not (and abort)
def tryWrite[E](a: A): IO[E, Boolean] = ...
// atomic primitives for updating the value
def compareAndSet[E](prev: A, next: A): IO[E, Boolean] = ...
def modify[E](f: A => A): IO[E, A] = ...
def modifyFold[E, B](f: A => (B, A)): IO[E, B] = ...
}
object IORef {
def apply[E, A](a: A): IO[E, IORef[A]] = ...
}
IORef es otro bloque de construcción y puede ser usado para proporcionar un
MonadState de alto rendimiento. Por ejemplo, para crear un newtype
especializado a Task
final class StateTask[A](val io: Task[A]) extends AnyVal
object StateTask {
def create[S](initial: S): Task[MonadState[StateTask, S]] =
for {
ref <- IORef(initial)
} yield
new MonadState[StateTask, S] {
override def get = new StateTask(ref.read)
override def put(s: S) = new StateTask(ref.write(s))
...
}
}
Podemos hacer uso de esta implementación optimizada de MonadState en un
SafeApp, donde nuestro .program depende de typeclasses optimizadas MTL:
object FastState extends SafeApp {
def program[F[_]](implicit F: MonadState[F, Int]): F[ExitStatus] = ...
def run(@unused args: List[String]): IO[Void, ExitStatus] =
for {
stateMonad <- StateTask.create(10)
output <- program(stateMonad).io
} yield output
}
Una aplicación más realista tomaría una variedad de álgebras y de typeclasses como entrada.
7.7.5.1 MonadIO
El MonadIO que estudiamos previamente estaba simplificado para esconder el
parámetro de tipo E. La typeclass real es
trait MonadIO[M[_], E] {
def liftIO[A](io: IO[E, A])(implicit M: Monad[M]): M[A]
}
con un cambio menor en el código repetitivo del objeto compañero de nuestra
álgebra, tomando en cuenta la E extra:
trait Lookup[F[_]] {
def look: F[Int]
}
object Lookup {
def liftIO[F[_]: Monad, E](io: Lookup[IO[E, ?]])(implicit M: MonadIO[F, E]) =
new Lookup[F] {
def look: F[Int] = M.liftIO(io.look)
}
...
}
7.8 Resumen
- El
Futureestá descompuesto, no vaya allá. - Administre la seguridad de la pila con un
Trampoline. - La Librería de Transformadores de Mónadas (MTL) abstrae sobre efectos comunes de typeclasses.
- Los Transformadores de Mónadas proporcionan implementaciones por defecto de la MTL.
- Las estructuras de datos
Freenos permiten analizar, optimizar y probar fácilmente nuestros programas. -
IOnos da la habilidad de implementar álgebras como efectos sobre el mundo. -
IOpuede ejecutar efectos en paralelo y es una columna vertebral para cualquier aplicación.
8. Derivación de typeclasses
Las typeclasses proporcionan funcionalidad polimórfica a nuestras aplicaciones. Pero para usar una typeclass necesitamos instancias para nuestros objetos del dominio de negocios.
La creación de una instancia de typeclass a partir de instancias existentes se conoce como derivación de typeclasses y es el tema de este capítulo.
Existen cuatro enfoques para la derivación de typeclasses:
- Instancias manuales para cada objeto del dominio. Esto no es factible para
aplicaciones del mundo real porque requiere cientos de lineas de código par
cada línea de una
case class. Es útil sólo para propósitos educacionales y optimización de rendimiento a la medida. - Abstraer sobre la typeclass por una typeclass de Scalaz existente. Este es el
enfoque usado por
scalaz-deriving, produciendo pruebas automáticas y derivaciones para productos y coproductos. - Macros. Sin embargo, la escritura de macros para cada typeclass requiere de un desarrollador avanzado y experimentado. Felizmente, la librería Magnolia de Jon Pretty abstrae sobre macros escritas a mano con una API simple, centralizando la interacción compleja con el compilador.
- Escribir un programa genérico usando la librería
Shapeless. El mecanismo
implicites un lenguaje dentro del lenguaje Scala y puede ser usado para escribir programas a nivel de tipo.
En este capítulo estudiaremos typeclasses con complejidad creciente y sus
derivaciones. Empezaremos con scalaz-deriving como el mecanismo con más
principios, repitiendo algunas lecciones del Capítulo 5 “Typeclasses de Scalaz”,
y después con Magnolia (el más fácil de usar), terminando con Shapeless (el más
poderoso) para las typeclasses con lógica de derivación compleja.
8.1 Ejemplos
Este capítulo mostrará cómo definir derivaciones para cinco typeclasses específicas. Cada ejemplo exhibe una característica que puede ser generalizada:
@typeclass trait Equal[A] {
// type parameter is in contravariant (parameter) position
@op("===") def equal(a1: A, a2: A): Boolean
}
// for requesting default values of a type when testing
@typeclass trait Default[A] {
// type parameter is in covariant (return) position
def default: String \/ A
}
@typeclass trait Semigroup[A] {
// type parameter is in both covariant and contravariant position (invariant)
@op("|+|") def append(x: A, y: =>A): A
}
@typeclass trait JsEncoder[T] {
// type parameter is in contravariant position and needs access to field names
def toJson(t: T): JsValue
}
@typeclass trait JsDecoder[T] {
// type parameter is in covariant position and needs access to field names
def fromJson(j: JsValue): String \/ T
}
8.2 scalaz-deriving
La librería scalaz-deriving es una extensión de Scalaz y puede agregarse al
build.sbt con
val derivingVersion = "1.0.0"
libraryDependencies += "org.scalaz" %% "scalaz-deriving" % derivingVersion
proporcionando nuevas typeclasses, mostradas abajo con relación a las typeclasses de Scalaz:
Antes de proceder, aquí tenemos una rápida recapitulación de las typeclasses centrales de Scalaz:
@typeclass trait InvariantFunctor[F[_]] {
def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B]
}
@typeclass trait Contravariant[F[_]] extends InvariantFunctor[F] {
def contramap[A, B](fa: F[A])(f: B => A): F[B]
def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B] = contramap(fa)(g)
}
@typeclass trait Divisible[F[_]] extends Contravariant[F] {
def conquer[A]: F[A]
def divide2[A, B, C](fa: F[A], fb: F[B])(f: C => (A, B)): F[C]
...
def divide22[...] = ...
}
@typeclass trait Functor[F[_]] extends InvariantFunctor[F] {
def map[A, B](fa: F[A])(f: A => B): F[B]
def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B] = map(fa)(f)
}
@typeclass trait Applicative[F[_]] extends Functor[F] {
def point[A](a: =>A): F[A]
def apply2[A,B,C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C): F[C] = ...
...
def apply12[...]
}
@typeclass trait Monad[F[_]] extends Functor[F] {
@op(">>=") def bind[A, B](fa: F[A])(f: A => F[B]): F[B]
}
@typeclass trait MonadError[F[_], E] extends Monad[F] {
def raiseError[A](e: E): F[A]
def emap[A, B](fa: F[A])(f: A => E \/ B): F[B] = ...
...
}
8.2.1 No repita (DRY)
La forma más simple de derivar una typeclass es reutilizar una que ya exista:
La typeclass Equal tiene una instancia de Contravariant[Equal],
proporcionando .contramap:
object Equal {
implicit val contravariant = new Contravariant[Equal] {
def contramap[A, B](fa: Equal[A])(f: B => A): Equal[B] =
(b1, b2) => fa.equal(f(b1), f(b2))
}
...
}
Como usuarios de Equal, podemos usar .contramap para nuestros parámetros de
tipo únicos. Recuerde que las instancias de typeclasses van en los compañeros de
tipos de datos para que estén en el alcance implícito:
final case class Foo(s: String)
object Foo {
implicit val equal: Equal[Foo] = Equal[String].contramap(_.s)
}
scala> Foo("hello") === Foo("world")
false
Sin embargo, no todas las typeclasses tienen una instancia de Contravariant.
En particular, las typeclasses con parámetros de tipo en posición covariante
podrían más bien tener un Functor.
object Default {
def instance[A](d: =>String \/ A) = new Default[A] { def default = d }
implicit val string: Default[String] = instance("".right)
implicit val functor: Functor[Default] = new Functor[Default] {
def map[A, B](fa: Default[A])(f: A => B): Default[B] = instance(fa.default.map(f))
}
...
}
Ahora podríamos derivar un Default[Foo]
object Foo {
implicit val default: Default[Foo] = Default[String].map(Foo(_))
...
}
Si una typeclass tiene parámetros en posiciones tanto covariantes como
contravariantes, como es el caso con Semigroup, podría proporcionar un
InvariantFunctor
object Semigroup {
implicit val invariant = new InvariantFunctor[Semigroup] {
def xmap[A, B](ma: Semigroup[A], f: A => B, g: B => A) = new Semigroup[B] {
def append(x: B, y: =>B): B = f(ma.append(g(x), g(y)))
}
}
...
}
y podemos invocar .xmap
object Foo {
implicit val semigroup: Semigroup[Foo] = Semigroup[String].xmap(Foo(_), _.s)
...
}
Generalmente, es más simple usar .xmap en lugar de .map o .contramap:
final case class Foo(s: String)
object Foo {
implicit val equal: Equal[Foo] = Equal[String].xmap(Foo(_), _.s)
implicit val default: Default[Foo] = Default[String].xmap(Foo(_), _.s)
implicit val semigroup: Semigroup[Foo] = Semigroup[String].xmap(Foo(_), _.s)
}
8.2.2 MonadError
Típicamente, las cosas que escriben a partir de un valor polimórfico tienen
una instancia de Contravariant, y las cosas que leen a un valor polimórfico
tienen un Functor. Sin embargo, es bastante posible que la lectura pueda
fallar. Por ejemplo, si tenemos una String por defecto no significa que
podamos derivar una String Refined NonEmpty a partir de esta
import eu.timepit.refined.refineV
import eu.timepit.refined.api._
import eu.timepit.refined.collection._
implicit val nes: Default[String Refined NonEmpty] =
Default[String].map(refineV[NonEmpty](_))
falla al compilar con
[error] default.scala:41:32: polymorphic expression cannot be instantiated to expected type;
[error] found : Either[String, String Refined NonEmpty]
[error] required: String Refined NonEmpty
[error] Default[String].map(refineV[NonEmpty](_))
[error] ^
Recuerde del Capítulo 4.1 que refineV devuelve un Either, como nos ha
recordado el compilador.
Como el autor de la typeclass de Default, podríamos lograr algo mejor que
Functor y proporcionar un MonadError[Default, String]:
implicit val monad = new MonadError[Default, String] {
def point[A](a: =>A): Default[A] =
instance(a.right)
def bind[A, B](fa: Default[A])(f: A => Default[B]): Default[B] =
instance((fa >>= f).default)
def handleError[A](fa: Default[A])(f: String => Default[A]): Default[A] =
instance(fa.default.handleError(e => f(e).default))
def raiseError[A](e: String): Default[A] =
instance(e.left)
}
Ahora tenemos acceso a la sintaxis .emap y podemos derivar nuestro tipo
refinado
implicit val nes: Default[String Refined NonEmpty] =
Default[String].emap(refineV[NonEmpty](_).disjunction)
De hecho, podemos proporcionar una regla de derivación para todos los tipos refinados
implicit def refined[A: Default, P](
implicit V: Validate[A, P]
): Default[A Refined P] = Default[A].emap(refineV[P](_).disjunction)
donde Validate es de la librería refined y es requerido por refineV.
De manera similar podemos usar .emap para derivar un decodificador Int a
partir de un Long, con protección alrededor del método no total .toInt de la
librería estándar.
implicit val long: Default[Long] = instance(0L.right)
implicit val int: Default[Int] = Default[Long].emap {
case n if (Int.MinValue <= n && n <= Int.MaxValue) => n.toInt.right
case big => s"$big does not fit into 32 bits".left
}
Como autores de la typeclass Default, podríamos reconsiderar nuestro diseño de
la API de modo que nunca pueda fallar, por ejemplo con la siguiente firma de
tipo
@typeclass trait Default[A] {
def default: A
}
No seríamos capaces de definir un MonadError, forzándonos a proporcionar
instancias que nunca fallen. Esto resultará en más código repetitivo pero
ganaremos en seguridad en tiempo de compilación. Sin embargo, continuaremos con
String \/ A como el retorno de tipo dado que es un ejemplo más general.
8.2.3 .fromIso
Todas las typeclasses en Scalaz tienen un método en su objeto compañero con una firma similar a la siguiente:
object Equal {
def fromIso[F, G: Equal](D: F <=> G): Equal[F] = ...
...
}
object Monad {
def fromIso[F[_], G[_]: Monad](D: F <~> G): Monad[F] = ...
...
}
Estas significan que si tenemos un tipo F, y una forma de convertirlo en una
G que tenga una instancia, entonces podemos llamar Equal.fromIso para tener
una instancia para F.
Por ejemplo, como usuarios de la typeclass, si tenemos un tipo de datos Bar
podemos definir un isomorfismo a (String, Int)
import Isomorphism._
final case class Bar(s: String, i: Int)
object Bar {
val iso: Bar <=> (String, Int) = IsoSet(b => (b.s, b.i), t => Bar(t._1, t._2))
}
y entonces derivar Equal[Bar] porque ya existe un Equal para todas las
tuplas:
object Bar {
...
implicit val equal: Equal[Bar] = Equal.fromIso(iso)
}
El mecanismo .fromIso también puede ayudarnos como autores de typeclasses.
Considere Default que tiene una firma de tipo Unit => F[A]. Nuestro método
default es de hecho isomórfico a Kleisli[F, Unit, A], el transformador de
mónadas ReaderT.
Dado que Kleisli ya proporciona un MonadError (si F tiene una), podemos
derivar MonadError[Default, String] al crear un isomorfismo entre Default y
Kleisli:
private type Sig[a] = Unit => String \/ a
private val iso = Kleisli.iso(
λ[Sig ~> Default](s => instance(s(()))),
λ[Default ~> Sig](d => _ => d.default)
)
implicit val monad: MonadError[Default, String] = MonadError.fromIso(iso)
proporcionándonos .map, .xmap y .emap que hemos estado usando hasta el
momento, efectivamente de manera gratuita.
8.2.4 Divisible y Applicative
Para derivar Equal para nuestra case class con dos parámetros, reutilizamos la
instancia que Scalaz proporcionó para las tuplas. Pero, ¿de dónde vino la
instancia para las tuplas?
Una typeclass más específica que Contravariant es Divisible. Equal tiene
una instancia:
implicit val divisible = new Divisible[Equal] {
...
def divide[A1, A2, Z](a1: =>Equal[A1], a2: =>Equal[A2])(
f: Z => (A1, A2)
): Equal[Z] = { (z1, z2) =>
val (s1, s2) = f(z1)
val (t1, t2) = f(z2)
a1.equal(s1, t1) && a2.equal(s2, t2)
}
def conquer[A]: Equal[A] = (_, _) => true
}
Y a partir de divide2, Divisible es capaz de construir derivaciones hasta
divide22. Podemos llamar a estos métodos directamente para nuestros tipos de
datos:
final case class Bar(s: String, i: Int)
object Bar {
implicit val equal: Equal[Bar] =
Divisible[Equal].divide2(Equal[String], Equal[Int])(b => (b.s, b.i))
}
El equivalente para los parámetros de tipo en posición covariante es
Applicative:
object Bar {
...
implicit val default: Default[Bar] =
Applicative[Default].apply2(Default[String], Default[Int])(Bar(_, _))
}
Pero debemos ser cuidadosos para no violar las leyes de las typeclasses cuando
implementemos Divisible o Applicative. En particular, es fácil violar la
ley de composición que dice que los siguientes dos caminos de código deben
resultar exactamente en la misma salida:
divide2(divide2(a1, a2)(dupe), a3)(dupe)divide2(a1, divide2(a2, a3)(dupe))(dupe)- para cualquier
dupe: A => (A, A)
con leyes similares para Applicative.
Considere JsEncoder y la instancia propuesta para Divisible
new Divisible[JsEncoder] {
...
def divide[A, B, C](fa: JsEncoder[A], fb: JsEncoder[B])(
f: C => (A, B)
): JsEncoder[C] = { c =>
val (a, b) = f(c)
JsArray(IList(fa.toJson(a), fb.toJson(b)))
}
def conquer[A]: JsEncoder[A] = _ => JsNull
}
De un lado de las leyes de composición, para una entrada de tipo String,
tenemos
JsArray([JsArray([JsString(hello),JsString(hello)]),JsString(hello)])
y por el otro
JsArray([JsString(hello),JsArray([JsString(hello),JsString(hello)])])
que son diferentes. Podríamos experimentar con variaciones de la implementación
divide, pero nunca satisfará las leyes para todas las entradas.
Por lo tanto no podemos proporcionar un Divisible[JsEncoder] porque violaría
las leyes matemáticas e invalidaría todas las suposiciones de las que dependen
los usuarios de Divisible.
Para ayudar en la prueba de leyes, las typeclasses de Scalaz contienen versiones codificadas de sus leyes en la typeclass misma. Podemos escribir una prueba automática, verificando que la ley falla, para recordarnos de este hecho:
val D: Divisible[JsEncoder] = ...
val S: JsEncoder[String] = JsEncoder[String]
val E: Equal[JsEncoder[String]] = (p1, p2) => p1.toJson("hello") === p2.toJson("hello")
assert(!D.divideLaw.composition(S, S, S)(E))
Por otro lado, una prueba similar de JsDecoder cumple las leyes de composición
de Applicative
final case class Comp(a: String, b: Int)
object Comp {
implicit val equal: Equal[Comp] = ...
implicit val decoder: JsDecoder[Comp] = ...
}
def composeTest(j: JsValue) = {
val A: Applicative[JsDecoder] = Applicative[JsDecoder]
val fa: JsDecoder[Comp] = JsDecoder[Comp]
val fab: JsDecoder[Comp => (String, Int)] = A.point(c => (c.a, c.b))
val fbc: JsDecoder[((String, Int)) => (Int, String)] = A.point(_.swap)
val E: Equal[JsDecoder[(Int, String)]] = (p1, p2) => p1.fromJson(j) === p2.fromJson(j)
assert(A.applyLaw.composition(fbc, fab, fa)(E))
}
para un poco de valores de prueba
composeTest(JsObject(IList("a" -> JsString("hello"), "b" -> JsInteger(1))))
composeTest(JsNull)
composeTest(JsObject(IList("a" -> JsString("hello"))))
composeTest(JsObject(IList("b" -> JsInteger(1))))
Ahora estamos razonablemente seguros de que nuestra MonadError cumple con las
leyes.
Sin embargo, simplemente porque tenemos una prueba que pasa para un conjunto pequeño de datos no prueba que las leyes son satisfechas. También debemos razonar durante la implementación para convencernos a nosotros mismos que de debería satisfacer las leyes, e intentar proponer casos especiales donde podría fallar.
Una forma de generar una amplia variedad de datos de prueba es usar la librería
scalacheck, que proporciona una
typeclass Arbitrary que se integra con la mayoría de los frameworks de prueba
para repetir una prueba con datos generados aleatoriamente.
La librería jsonformat proporciona una Arbitrary[JsValue] (¡todos deberían
proporcionar una instancia de Arbitrary para sus ADTs!) permitiéndonos hacer
uso de la característica forAll de Scalatest:
forAll(SizeRange(10))((j: JsValue) => composeTest(j))
Esta prueba nos proporciona aún más confianza de que nuestra typeclass cumple
con las leyes de composición de Applicative. Al verificar todas las leyes en
Divisible y MonadError también tenemos muchas pruebas de humo de manera
gratuita.
8.2.5 Decidable y Alt
Donde Divisible y Applicative nos proporcionan derivaciones de typeclasses
para productos (construidos a partir de tuplas), Decidable y Alt nos dan
coproductos (construidos a partir de disyunciones anidadas):
@typeclass trait Alt[F[_]] extends Applicative[F] with InvariantAlt[F] {
def alt[A](a1: =>F[A], a2: =>F[A]): F[A]
def altly1[Z, A1](a1: =>F[A1])(f: A1 => Z): F[Z] = ...
def altly2[Z, A1, A2](a1: =>F[A1], a2: =>F[A2])(f: A1 \/ A2 => Z): F[Z] = ...
def altly3 ...
def altly4 ...
...
}
@typeclass trait Decidable[F[_]] extends Divisible[F] with InvariantAlt[F] {
def choose1[Z, A1](a1: =>F[A1])(f: Z => A1): F[Z] = ...
def choose2[Z, A1, A2](a1: =>F[A1], a2: =>F[A2])(f: Z => A1 \/ A2): F[Z] = ...
def choose3 ...
def choose4 ...
...
}
Las cuatro typeclasses centrales tienen firmas simétricas:
| Typeclass | método | dado | firma | devuelve |
|---|---|---|---|---|
Applicative |
apply2 |
F[A1], F[A2] |
(A1, A2) => Z |
F[Z] |
Alt |
altly2 |
F[A1], F[A2] |
(A1 \/ A2) => Z |
F[Z] |
Divisible |
divide2 |
F[A1], F[A2] |
Z => (A1, A2) |
F[Z] |
Decidable |
choose2 |
F[A1], F[A2] |
Z => (A1 \/ A2) |
F[Z] |
soportando productos covariantes; coproductos covariantes; productos contravariantes; coproductos contravariantes.
Podemos escribir un Decidable[Equal], ¡permitiéndonos derivar Equal para
cualquier ADT!
implicit val decidable = new Decidable[Equal] {
...
def choose2[Z, A1, A2](a1: =>Equal[A1], a2: =>Equal[A2])(
f: Z => A1 \/ A2
): Equal[Z] = { (z1, z2) =>
(f(z1), f(z2)) match {
case (-\/(s), -\/(t)) => a1.equal(s, t)
case (\/-(s), \/-(t)) => a2.equal(s, t)
case _ => false
}
}
}
Para un ADT
sealed abstract class Darth { def widen: Darth = this }
final case class Vader(s: String, i: Int) extends Darth
final case class JarJar(i: Int, s: String) extends Darth
donde los productos (Vader y JarJar) tienen un Equal
object Vader {
private val g: Vader => (String, Int) = d => (d.s, d.i)
implicit val equal: Equal[Vader] = Divisible[Equal].divide2(Equal[String], Equal[Int])(g)
}
object JarJar {
private val g: JarJar => (Int, String) = d => (d.i, d.s)
implicit val equal: Equal[JarJar] = Divisible[Equal].divide2(Equal[Int], Equal[String])(g)
}
podemos derivar la instancia de Equal para la ADT completa
object Darth {
private def g(t: Darth): Vader \/ JarJar = t match {
case p @ Vader(_, _) => -\/(p)
case p @ JarJar(_, _) => \/-(p)
}
implicit val equal: Equal[Darth] = Decidable[Equal].choose2(Equal[Vader], Equal[JarJar])(g)
}
scala> Vader("hello", 1).widen === JarJar(1, "hello").widen
false
Las typeclasses que tienen un Applicative pueden ser elegibles para Alt. Si
deseamos usar nuestro truco con Klick.iso, tenemos que extender
IsomorphismMonadError y hacer un mixin en Alt. Actualice nuestro
MonadError[Default, String] para tener un Alt[Default]:
private type K[a] = Kleisli[String \/ ?, Unit, a]
implicit val monad = new IsomorphismMonadError[Default, K, String] with Alt[Default] {
override val G = MonadError[K, String]
override val iso = ...
def alt[A](a1: =>Default[A], a2: =>Default[A]): Default[A] = instance(a1.default)
}
Permitiéndonos derivar nuestro Default[Darth]
object Darth {
...
private def f(e: Vader \/ JarJar): Darth = e.merge
implicit val default: Default[Darth] =
Alt[Default].altly2(Default[Vader], Default[JarJar])(f)
}
object Vader {
...
private val f: (String, Int) => Vader = Vader(_, _)
implicit val default: Default[Vader] =
Alt[Default].apply2(Default[String], Default[Int])(f)
}
object JarJar {
...
private val f: (Int, String) => JarJar = JarJar(_, _)
implicit val default: Default[JarJar] =
Alt[Default].apply2(Default[Int], Default[String])(f)
}
scala> Default[Darth].default
\/-(Vader())
Regresando a las typeclasses de scalaz-deriving, los padres invariantes de
Alt y Decidable son:
@typeclass trait InvariantApplicative[F[_]] extends InvariantFunctor[F] {
def xproduct0[Z](f: =>Z): F[Z]
def xproduct1[Z, A1](a1: =>F[A1])(f: A1 => Z, g: Z => A1): F[Z] = ...
def xproduct2 ...
def xproduct3 ...
def xproduct4 ...
}
@typeclass trait InvariantAlt[F[_]] extends InvariantApplicative[F] {
def xcoproduct1[Z, A1](a1: =>F[A1])(f: A1 => Z, g: Z => A1): F[Z] = ...
def xcoproduct2 ...
def xcoproduct3 ...
def xcoproduct4 ...
}
soportando typeclasses con un InvariantFunctor como Monoid y Semigroup.
8.2.6 Aridad arbitraria y @deriving
Existen dos problemas con InvariantApplicative e InvariantAlt:
- Sólo soportan productos de cuatro campos y coproductos de cuatro entradas.
- Existe mucho código repetitivo en el objeto compañero del tipo de datos.
En esta sección resolveremos ambos problemas con clases adicionales introducidas
por scalaz-deriving
Efectivamente, nuestras typeclasses centrales Applicative, Divisible, Alt
y Decidable todas se pueden extender a aridades arbitrarias usando la librería
iotaz, y por lo tanto el postfijo z.
La librería iotaz tiene tres tipos principales:
-
TListque describe cadenas de tipos con longitudes arbitrarias -
Prod[A <: TList]para productos -
Cop[A<: TList]para coproductos
A modo de ejemplo, una representación TList de Darth de la sección previa es
import iotaz._, TList._
type DarthT = Vader :: JarJar :: TNil
type VaderT = String :: Int :: TNil
type JarJarT = Int :: String :: TNil
que puede ser instanciada como:
val vader: Prod[VaderT] = Prod("hello", 1)
val jarjar: Prod[JarJarT] = Prod(1, "hello")
val VaderI = Cop.Inject[Vader, Cop[DarthT]]
val darth: Cop[DarthT] = VaderI.inj(Vader("hello", 1))
Para ser capaces de usar la API de scalaz-deriving, necesitamos un
Isomorfismo entre nuestras ADTs y la representación genérica de iotaz. Es
mucho código repetitivo, y volveremos a esto en un momento:
object Darth {
private type Repr = Vader :: JarJar :: TNil
private val VaderI = Cop.Inject[Vader, Cop[Repr]]
private val JarJarI = Cop.Inject[JarJar, Cop[Repr]]
private val iso = IsoSet(
{
case d: Vader => VaderI.inj(d)
case d: JarJar => JarJarI.inj(d)
}, {
case VaderI(d) => d
case JarJarI(d) => d
}
)
...
}
object Vader {
private type Repr = String :: Int :: TNil
private val iso = IsoSet(
d => Prod(d.s, d.i),
p => Vader(p.head, p.tail.head)
)
...
}
object JarJar {
private type Repr = Int :: String :: TNil
private val iso = IsoSet(
d => Prod(d.i, d.s),
p => JarJar(p.head, p.tail.head)
)
...
}
Con esto fuera del camino podemos llamar la API Deriving para Equal, posible
porque scalaz-deriving proporciona una instancia optimizada de
Deriving[Equal]
object Darth {
...
implicit val equal: Equal[Darth] = Deriving[Equal].xcoproductz(
Prod(Need(Equal[Vader]), Need(Equal[JarJar])))(iso.to, iso.from)
}
object Vader {
...
implicit val equal: Equal[Vader] = Deriving[Equal].xproductz(
Prod(Need(Equal[String]), Need(Equal[Int])))(iso.to, iso.from)
}
object JarJar {
...
implicit val equal: Equal[JarJar] = Deriving[Equal].xproductz(
Prod(Need(Equal[Int]), Need(Equal[String])))(iso.to, iso.from)
}
Para ser capaces de hacer lo mismo para nuestra typeclass Default, necesitamos
proporcionar una instancia de Deriving[Default]. Esto es solo un caso de
envolver nuestro Alt con un auxiliar:
object Default {
...
implicit val deriving: Deriving[Default] = ExtendedInvariantAlt(monad)
}
y entonces invocarlo desde los objetos compañeros
object Darth {
...
implicit val default: Default[Darth] = Deriving[Default].xcoproductz(
Prod(Need(Default[Vader]), Need(Default[JarJar])))(iso.to, iso.from)
}
object Vader {
...
implicit val default: Default[Vader] = Deriving[Default].xproductz(
Prod(Need(Default[String]), Need(Default[Int])))(iso.to, iso.from)
}
object JarJar {
...
implicit val default: Default[JarJar] = Deriving[Default].xproductz(
Prod(Need(Default[Int]), Need(Default[String])))(iso.to, iso.from)
}
Hemos resuelto el problema de aridad arbitraria, pero hemos introducido aún más código repetitivo.
El punto clave es que la anotación @deriving, que viene del deriving-plugin,
genera todo este código repetitivo automáticamente y únicamente requiere ser
aplicado en el punto más alto de la jerarquía de una ADT:
@deriving(Equal, Default)
sealed abstract class Darth { def widen: Darth = this }
final case class Vader(s: String, i: Int) extends Darth
final case class JarJar(i: Int, s: String) extends Darth
También están incluidos en scalaz-deriving instancias para Order,
Semigroup y Monoid. Instancias para Show y Arbitrary están disponibles
al instalar las librerías extras scalaz-deriving-magnolia y
scalaz-deriving-scalacheck.
8.2.7 Ejemplos
Finalizamos nuestro estudio de scalaz-deriving con implementaciones
completamente revisadas de todas las typeclasses de ejemplo. Antes de hacer esto
necesitamos conocer sobre un nuevo tipo de datos: /~\, es decir, la serpiente
en el camino, para contener dos estructuras higher kinded que comparten el
mismo parámetro de tipo:
sealed abstract class /~\[A[_], B[_]] {
type T
def a: A[T]
def b: B[T]
}
object /~\ {
type APair[A[_], B[_]] = A /~\ B
def unapply[A[_], B[_]](p: A /~\ B): Some[(A[p.T], B[p.T])] = ...
def apply[A[_], B[_], Z](az: =>A[Z], bz: =>B[Z]): A /~\ B = ...
}
Típicamente usamos esto en el contexto de Id /~\ TC donde TC es nuestra
typeclass, significando que tenemos un valor, y una instancia de una typeclass
para ese valor, sin saber nada más sobre el valor.
Además, todos los métodos en la API Deriving API tienen evidencia implícita de
la forma A PairedWith FA, permitiendo que la librería iotaz sea capaz de
realizar .zip, .traverse, y otras operaciones sobre Prod y Cop. Podemos
ignorar estos parámetros, dado que no los usamos directamente.
8.2.7.1 Equal
Como con Default podríamos definir un Decidable de aridad fija y envolverlo
con ExtendedInvariantAlt (la forma más simple), pero escogemos implementar
Decidablez directamente por el beneficio de obtener más performance. Hacemos
dos optimizaciones adicionales:
- Realizar igualdad de instancias
.eqantes de aplicarEqual.equal, ejecutando un atajo al verificar la igualdad entre valores idénticos. -
Foldable.allpermitiendo una salida temprana cuando cualquier comparación esfalse, por ejemplo si los primeros campos no empatan, ni siquiera pedimosEqualpara los valores restantes.
new Decidablez[Equal] {
@inline private final def quick(a: Any, b: Any): Boolean =
a.asInstanceOf[AnyRef].eq(b.asInstanceOf[AnyRef])
def dividez[Z, A <: TList, FA <: TList](tcs: Prod[FA])(g: Z => Prod[A])(
implicit ev: A PairedWith FA
): Equal[Z] = (z1, z2) => (g(z1), g(z2)).zip(tcs).all {
case (a1, a2) /~\ fa => quick(a1, a2) || fa.value.equal(a1, a2)
}
def choosez[Z, A <: TList, FA <: TList](tcs: Prod[FA])(g: Z => Cop[A])(
implicit ev: A PairedWith FA
): Equal[Z] = (z1, z2) => (g(z1), g(z2)).zip(tcs) match {
case -\/(_) => false
case \/-((a1, a2) /~\ fa) => quick(a1, a2) || fa.value.equal(a1, a2)
}
}
8.2.7.2 Default
Tristemente, la API de iotaz para .traverse (y su análogo, .coptraverse)
requiere que definamos transformaciones naturales, que tienen una sintaxis
torpe, incluso con el plugin kind-projector.
private type K[a] = Kleisli[String \/ ?, Unit, a]
new IsomorphismMonadError[Default, K, String] with Altz[Default] {
type Sig[a] = Unit => String \/ a
override val G = MonadError[K, String]
override val iso = Kleisli.iso(
λ[Sig ~> Default](s => instance(s(()))),
λ[Default ~> Sig](d => _ => d.default)
)
val extract = λ[NameF ~> (String \/ ?)](a => a.value.default)
def applyz[Z, A <: TList, FA <: TList](tcs: Prod[FA])(f: Prod[A] => Z)(
implicit ev: A PairedWith FA
): Default[Z] = instance(tcs.traverse(extract).map(f))
val always = λ[NameF ~> Maybe](a => a.value.default.toMaybe)
def altlyz[Z, A <: TList, FA <: TList](tcs: Prod[FA])(f: Cop[A] => Z)(
implicit ev: A PairedWith FA
): Default[Z] = instance {
tcs.coptraverse[A, NameF, Id](always).map(f).headMaybe \/> "not found"
}
}
8.2.7.3 Semigroup
No es posible definir un Semigroup para coproductos generales, sin embargo es
posible definir uno para productos generales. Podemos usar la aridad arbitraria
InvariantApplicative:
new InvariantApplicativez[Semigroup] {
type L[a] = ((a, a), NameF[a])
val appender = λ[L ~> Id] { case ((a1, a2), fa) => fa.value.append(a1, a2) }
def xproductz[Z, A <: TList, FA <: TList](tcs: Prod[FA])
(f: Prod[A] => Z, g: Z => Prod[A])
(implicit ev: A PairedWith FA) =
new Semigroup[Z] {
def append(z1: Z, z2: =>Z): Z = f(tcs.ziptraverse2(g(z1), g(z2), appender))
}
}
8.2.7.4 JsEncoder y JsDecoder
scalaz-deriving no proporciona acceso a nombres de los campos de modo que no
es posible escribir un codificador/decodificador JSON.
8.3 Magnolia
La librería de macros Magnolia proporciona una API limpia para escribir
derivaciones de typeclasses. Se instala con la siguiente entrada en build.sbt
libraryDependencies += "com.propensive" %% "magnolia" % "0.10.1"
Un autor de typeclasses implementaría los siguientes miembros:
import magnolia._
object MyDerivation {
type Typeclass[A]
def combine[A](ctx: CaseClass[Typeclass, A]): Typeclass[A]
def dispatch[A](ctx: SealedTrait[Typeclass, A]): Typeclass[A]
def gen[A]: Typeclass[A] = macro Magnolia.gen[A]
}
La API de Magnolia es:
class CaseClass[TC[_], A] {
def typeName: TypeName
def construct[B](f: Param[TC, A] => B): A
def constructMonadic[F[_]: Monadic, B](f: Param[TC, A] => F[B]): F[A]
def parameters: Seq[Param[TC, A]]
def annotations: Seq[Any]
}
class SealedTrait[TC[_], A] {
def typeName: TypeName
def subtypes: Seq[Subtype[TC, A]]
def dispatch[B](value: A)(handle: Subtype[TC, A] => B): B
def annotations: Seq[Any]
}
con auxiliares
class CaseClass[TC[_], A] {
def typeName: TypeName
def construct[B](f: Param[TC, A] => B): A
def constructMonadic[F[_]: Monadic, B](f: Param[TC, A] => F[B]): F[A]
def parameters: Seq[Param[TC, A]]
def annotations: Seq[Any]
}
class SealedTrait[TC[_], A] {
def typeName: TypeName
def subtypes: Seq[Subtype[TC, A]]
def dispatch[B](value: A)(handle: Subtype[TC, A] => B): B
def annotations: Seq[Any]
}
La typeclass Monadic, usada en constructMonadic, es generada automáticamente
si nuestro tipo de datos tiene un .map y un método .flatMap cuando
escribimos import mercator._
No tiene sentido usar Magnolia para typeclasses que pueden abstraerse con
Divisible, Decidable, Applicative o Alt, dado que estas abstracciones
proporcionan mucha estructura extra y pruebas de manera gratuita. Sin embargo,
Magnolia ofrece funcionalidad que scalaz-deriving no puede proporcionar:
acceso a los nombres de campos, nombres de tipos, anotaciones y valores por
defecto.
8.3.1 Ejemplo: JSON
Tenemos ciertas decisiones de diseño que hacer con respecto a la serialización JSON:
- ¿Deberíamos incluir campos con valores
null? - ¿Deberíamos decodificar de manera distinta valores faltantes vs
null? - ¿Cómo codificamos el nombre de un coproducto?
- Cómo lidiamos con coproductos que no son
JsObject?
Escogemos alternativas por defecto razonables
- No incluya campos si el valor es un
JsNull. - Maneje campos faltantes de la misma manera que los valores
null. - Use un campo especial
"type"para desambiguar coproductos usando el nombre del tipo. - Ponga valores primitivos en un campo especial
"xvalue".
y deje que los usuarios adjunten una anotación a los campos coproductos y productos para personalizar sus formatos:
sealed class json extends Annotation
object json {
final case class nulls() extends json
final case class field(f: String) extends json
final case class hint(f: String) extends json
}
@json.field("TYPE")
sealed abstract class Cost
final case class Time(s: String) extends Cost
final case class Money(@json.field("integer") i: Int) extends Cost
Empiece con un JsEncoder que maneja únicamente nuestras elecciones razonables:
object JsMagnoliaEncoder {
type Typeclass[A] = JsEncoder[A]
def combine[A](ctx: CaseClass[JsEncoder, A]): JsEncoder[A] = { a =>
val empty = IList.empty[(String, JsValue)]
val fields = ctx.parameters.foldRight(right) { (p, acc) =>
p.typeclass.toJson(p.dereference(a)) match {
case JsNull => acc
case value => (p.label -> value) :: acc
}
}
JsObject(fields)
}
def dispatch[A](ctx: SealedTrait[JsEncoder, A]): JsEncoder[A] = a =>
ctx.dispatch(a) { sub =>
val hint = "type" -> JsString(sub.typeName.short)
sub.typeclass.toJson(sub.cast(a)) match {
case JsObject(fields) => JsObject(hint :: fields)
case other => JsObject(IList(hint, "xvalue" -> other))
}
}
def gen[A]: JsEncoder[A] = macro Magnolia.gen[A]
}
Podemos ver cómo la API de Magnolia hace que sea sencillo acceder a nombres de campos y typeclasses para cada parámetro.
Ahora agregue soporte para anotaciones que manejan preferencias del usuario. Para evitar la búsqueda de anotaciones en cada codificación, los guardaremos en un arreglo. Aunque el acceso a los campos en un arreglo no es total, tenemos la garantía de que los índices siempre serán adecuados. El rendimiento es con frecuencia la víctima en la negociación entre especialización y generalización.
object JsMagnoliaEncoder {
type Typeclass[A] = JsEncoder[A]
def combine[A](ctx: CaseClass[JsEncoder, A]): JsEncoder[A] =
new JsEncoder[A] {
private val anns = ctx.parameters.map { p =>
val nulls = p.annotations.collectFirst {
case json.nulls() => true
}.getOrElse(false)
val field = p.annotations.collectFirst {
case json.field(name) => name
}.getOrElse(p.label)
(nulls, field)
}.toArray
def toJson(a: A): JsValue = {
val empty = IList.empty[(String, JsValue)]
val fields = ctx.parameters.foldRight(empty) { (p, acc) =>
val (nulls, field) = anns(p.index)
p.typeclass.toJson(p.dereference(a)) match {
case JsNull if !nulls => acc
case value => (field -> value) :: acc
}
}
JsObject(fields)
}
}
def dispatch[A](ctx: SealedTrait[JsEncoder, A]): JsEncoder[A] =
new JsEncoder[A] {
private val field = ctx.annotations.collectFirst {
case json.field(name) => name
}.getOrElse("type")
private val anns = ctx.subtypes.map { s =>
val hint = s.annotations.collectFirst {
case json.hint(name) => field -> JsString(name)
}.getOrElse(field -> JsString(s.typeName.short))
val xvalue = s.annotations.collectFirst {
case json.field(name) => name
}.getOrElse("xvalue")
(hint, xvalue)
}.toArray
def toJson(a: A): JsValue = ctx.dispatch(a) { sub =>
val (hint, xvalue) = anns(sub.index)
sub.typeclass.toJson(sub.cast(a)) match {
case JsObject(fields) => JsObject(hint :: fields)
case other => JsObject(hint :: (xvalue -> other) :: IList.empty)
}
}
}
def gen[A]: JsEncoder[A] = macro Magnolia.gen[A]
}
Para el decodificador usamos .constructMonadic que tiene una firma de tipo
similar a .traverse
object JsMagnoliaDecoder {
type Typeclass[A] = JsDecoder[A]
def combine[A](ctx: CaseClass[JsDecoder, A]): JsDecoder[A] = {
case obj @ JsObject(_) =>
ctx.constructMonadic(
p => p.typeclass.fromJson(obj.get(p.label).getOrElse(JsNull))
)
case other => fail("JsObject", other)
}
def dispatch[A](ctx: SealedTrait[JsDecoder, A]): JsDecoder[A] = {
case obj @ JsObject(_) =>
obj.get("type") match {
case \/-(JsString(hint)) =>
ctx.subtypes.find(_.typeName.short == hint) match {
case None => fail(s"a valid '$hint'", obj)
case Some(sub) =>
val value = obj.get("xvalue").getOrElse(obj)
sub.typeclass.fromJson(value)
}
case _ => fail("JsObject with type", obj)
}
case other => fail("JsObject", other)
}
def gen[A]: JsDecoder[A] = macro Magnolia.gen[A]
}
De nuevo, agregando soporte para las preferencias del usuario y valores por defecto para los campos, junto con algunas optimizaciones:
object JsMagnoliaDecoder {
type Typeclass[A] = JsDecoder[A]
def combine[A](ctx: CaseClass[JsDecoder, A]): JsDecoder[A] =
new JsDecoder[A] {
private val nulls = ctx.parameters.map { p =>
p.annotations.collectFirst {
case json.nulls() => true
}.getOrElse(false)
}.toArray
private val fieldnames = ctx.parameters.map { p =>
p.annotations.collectFirst {
case json.field(name) => name
}.getOrElse(p.label)
}.toArray
def fromJson(j: JsValue): String \/ A = j match {
case obj @ JsObject(_) =>
import mercator._
val lookup = obj.fields.toMap
ctx.constructMonadic { p =>
val field = fieldnames(p.index)
lookup
.get(field)
.into {
case Maybe.Just(value) => p.typeclass.fromJson(value)
case _ =>
p.default match {
case Some(default) => \/-(default)
case None if nulls(p.index) =>
s"missing field '$field'".left
case None => p.typeclass.fromJson(JsNull)
}
}
}
case other => fail("JsObject", other)
}
}
def dispatch[A](ctx: SealedTrait[JsDecoder, A]): JsDecoder[A] =
new JsDecoder[A] {
private val subtype = ctx.subtypes.map { s =>
s.annotations.collectFirst {
case json.hint(name) => name
}.getOrElse(s.typeName.short) -> s
}.toMap
private val typehint = ctx.annotations.collectFirst {
case json.field(name) => name
}.getOrElse("type")
private val xvalues = ctx.subtypes.map { sub =>
sub.annotations.collectFirst {
case json.field(name) => name
}.getOrElse("xvalue")
}.toArray
def fromJson(j: JsValue): String \/ A = j match {
case obj @ JsObject(_) =>
obj.get(typehint) match {
case \/-(JsString(h)) =>
subtype.get(h) match {
case None => fail(s"a valid '$h'", obj)
case Some(sub) =>
val xvalue = xvalues(sub.index)
val value = obj.get(xvalue).getOrElse(obj)
sub.typeclass.fromJson(value)
}
case _ => fail(s"JsObject with '$typehint' field", obj)
}
case other => fail("JsObject", other)
}
}
def gen[A]: JsDecoder[A] = macro Magnolia.gen[A]
}
Llamamos al método JsMagnoliaEncoder.gen o a JsMagnoliaDecoder.gen desde el
objeto compañero de nuestro tipo de datos. Por ejemplo, la API de Google Maps
final case class Value(text: String, value: Int)
final case class Elements(distance: Value, duration: Value, status: String)
final case class Rows(elements: List[Elements])
final case class DistanceMatrix(
destination_addresses: List[String],
origin_addresses: List[String],
rows: List[Rows],
status: String
)
object Value {
implicit val encoder: JsEncoder[Value] = JsMagnoliaEncoder.gen
implicit val decoder: JsDecoder[Value] = JsMagnoliaDecoder.gen
}
object Elements {
implicit val encoder: JsEncoder[Elements] = JsMagnoliaEncoder.gen
implicit val decoder: JsDecoder[Elements] = JsMagnoliaDecoder.gen
}
object Rows {
implicit val encoder: JsEncoder[Rows] = JsMagnoliaEncoder.gen
implicit val decoder: JsDecoder[Rows] = JsMagnoliaDecoder.gen
}
object DistanceMatrix {
implicit val encoder: JsEncoder[DistanceMatrix] = JsMagnoliaEncoder.gen
implicit val decoder: JsDecoder[DistanceMatrix] = JsMagnoliaDecoder.gen
}
Felizmente, ¡la anotación @deriving soporta Magnolia! Si el autor de la
typeclass proporciona un archivo deriving.conf junto con su jar, que contenga
el siguiente texto
jsonformat.JsEncoder=jsonformat.JsMagnoliaEncoder.gen
jsonformat.JsDecoder=jsonformat.JsMagnoliaDecoder.gen
la deriving-macro llamará al método provisto por el usuario:
@deriving(JsEncoder, JsDecoder)
final case class Value(text: String, value: Int)
@deriving(JsEncoder, JsDecoder)
final case class Elements(distance: Value, duration: Value, status: String)
@deriving(JsEncoder, JsDecoder)
final case class Rows(elements: List[Elements])
@deriving(JsEncoder, JsDecoder)
final case class DistanceMatrix(
destination_addresses: List[String],
origin_addresses: List[String],
rows: List[Rows],
status: String
)
8.3.2 Derivación completamente automática
La generación de instancias implicit en el objeto compañero del tipo de datos
se conoce (históricamente) como derivación semiautomática, en contraste con la
completamente automática que ocurre cuando el .gen se hace implicit
object JsMagnoliaEncoder {
...
implicit def gen[A]: JsEncoder[A] = macro Magnolia.gen[A]
}
object JsMagnoliaDecoder {
...
implicit def gen[A]: JsDecoder[A] = macro Magnolia.gen[A]
}
Los usuarios pueden importar estos métodos en su alcance y obtener derivación mágica en el punto de uso
scala> final case class Value(text: String, value: Int)
scala> import JsMagnoliaEncoder.gen
scala> Value("hello", 1).toJson
res = JsObject([("text","hello"),("value",1)])
Esto puede parecer tentador, dado que envuelve la cantidad mínima de escritura, pero hay dos advertencias a tomar en cuenta:
- La macro se llama en cada sitio de uso, es decir cada vez que llamamos
.toJson. Esto hace que la compilación sea más lenta y también produce más objetos en tiempo de ejecución, lo que impactará el rendimiento en tiempo de ejecución. - Podrían derivarse cosas inesperadas
La primera advertencia es evidente, pero las derivaciones inesperadas se manifiestan como errores sutiles. Considere lo que podría ocurrir con
@deriving(JsEncoder)
final case class Foo(s: Option[String])
si olvidamos proporcionar una derivación implícita para Option. Esperaríamos
que Foo(Some("hello") se viera como
{
"s":"hello"
}
Pero en realidad sería la siguiente
{
"s": {
"type":"Some",
"get":"hello"
}
}
porque Magnolia derivó un codificador Option por nosotros.
Esto es confuso, y preferiríamos que el compilador nos indicara que olvidamos algo. Es por esta razón que no se recomienda la derivación completamente automática.
8.4 Shapeless
La librería Shapeless es notablemente
la librería más complicada en Scala. La razón de esto es porque toma la
característica implicit del lenguaje hasta el extremo: la creación de un
lenguaje de programación genérica a nivel de los tipos.
No se trata de un concepto extraño: en Scalaz intentamos limitar nuestro uso de
la característica implicit a las typeclasses, pero algunas veces solicitamos
que el compilador nos provea evidencia que relacione los tipos. Por ejemplo
las relaciones Liskov o Leibniz (<~< y ===), y el inyectar (Inject) una
álgebra libre de scalaz.Coproduct de álgebras.
Para instalar Shapeless, agregue lo siguiente a build.sbt
libraryDependencies += "com.chuusai" %% "shapeless" % "2.3.3"
En el centro de Shapeless están los tipos de datos HList y Coproduct
package shapeless
sealed trait HList
final case class ::[+H, +T <: HList](head: H, tail: T) extends HList
sealed trait NNil extends HList
case object HNil extends HNil {
def ::[H](h: H): H :: HNil = ::(h, this)
}
sealed trait Coproduct
sealed trait :+:[+H, +T <: Coproduct] extends Coproduct
final case class Inl[+H, +T <: Coproduct](head: H) extends :+:[H, T]
final case class Inr[+H, +T <: Coproduct](tail: T) extends :+:[H, T]
sealed trait CNil extends Coproduct // no implementations
que son representaciones genéricas de productos y coproductos. El trait
sealed trait HNil se usa por motivos de conveniencia de modo que nunca sea
necesario teclear HNil.type.
Shapeless tiene un clon del tipo de datos IsoSet, llamado Generic, que nos
permite viajar entre una ADT y su representación genérica:
trait Generic[T] {
type Repr
def to(t: T): Repr
def from(r: Repr): T
}
object Generic {
type Aux[T, R] = Generic[T] { type Repr = R }
def apply[T](implicit G: Generic[T]): Aux[T, G.Repr] = G
implicit def materialize[T, R]: Aux[T, R] = macro ...
}
Muchos de los tipos en Shapeless tiene un tipo miembro (Repr) y un tipo .Aux
alias en su objeto compañero que hace que el segundo tipo sea visible. Esto
permite solicitar Generic[Foo] para un tipo Foo sin tener que proporcionar
la representación genérica, que se genera por una macro.
scala> import shapeless._
scala> final case class Foo(a: String, b: Long)
Generic[Foo].to(Foo("hello", 13L))
res: String :: Long :: HNil = hello :: 13 :: HNil
scala> Generic[Foo].from("hello" :: 13L :: HNil)
res: Foo = Foo(hello,13)
scala> sealed abstract class Bar
case object Irish extends Bar
case object English extends Bar
scala> Generic[Bar].to(Irish)
res: English.type :+: Irish.type :+: CNil.type = Inl(Irish)
scala> Generic[Bar].from(Inl(Irish))
res: Bar = Irish
Existe un LabelledGeneric complementario que incluye los nombres de los campos
scala> import shapeless._, labelled._
scala> final case class Foo(a: String, b: Long)
scala> LabelledGeneric[Foo].to(Foo("hello", 13L))
res: String with KeyTag[Symbol with Tagged[String("a")], String] ::
Long with KeyTag[Symbol with Tagged[String("b")], Long] ::
HNil =
hello :: 13 :: HNil
scala> sealed abstract class Bar
case object Irish extends Bar
case object English extends Bar
scala> LabelledGeneric[Bar].to(Irish)
res: Irish.type with KeyTag[Symbol with Tagged[String("Irish")], Irish.type] :+:
English.type with KeyTag[Symbol with Tagged[String("English")], English.type] :+:
CNil.type =
Inl(Irish)
Note que el valor de una representación LabelledGeneric es la misma que la
representación Generic: los nombres de los campos existen únicamente en el
tipo y son borrados en tiempo de ejecución.
Nunca necesitamos teclear KeyTag manualmente, y usamos el alias de tipo:
type FieldType[K, +V] = V with KeyTag[K, V]
Si deseamos acceder al campo de nombre desde un FielType[K, A], pedimos
evidencia implícita Witness.Aux[K], que nos permite acceder al valor de K en
tiempo de ejecución.
Superficialmente, esto es todo lo que tenemos que saber de Shapeless para ser capaces de derivar un typeclass. Sin embargo, las cosas se ponen cada vez más complejas, de modo que procederemos con ejemplos cada vez más complejos.
8.4.1 Ejemplo: Equal
Un patrón típico es el de extender la typeclass que deseamos derivar, y poner el
código de Shapeless en su objeto compañero. Esto nos da un alcance implícito que
el compilador puede buscar sin requerir imports complejos.
trait DerivedEqual[A] extends Equal[A]
object DerivedEqual {
...
}
El punto de entrada a una derivación de Shapeless es un método, gen, que
requiere dos parámetros de tipo, la A que estamos derivando y la R para su
representación genérica. Entonces pedimos por la Generic.Aux[A, R], que
relaciona A con R, y una instancia de la typeclass Derived para la R.
Empezamos con esta firma y una implementación simple:
import shapeless._
object DerivedEqual {
def gen[A, R: DerivedEqual](implicit G: Generic.Aux[A, R]): Equal[A] =
(a1, a2) => Equal[R].equal(G.to(a1), G.to(a2))
}
Así hemos reducido el problema a proporcionar un implícito Equal[R] para una
R que es la representación Generic de A. Primero considere los productos,
donde R <: HList. Esta es la firma que deseamos implementar:
implicit def hcons[H: Equal, T <: HList: DerivedEqual]: DerivedEqual[H :: T]
porque si podemos implementarlo para una cabeza y una cola, el compilador será
capaz de realizar una recursión en este método hasta que alcance el final de la
lista. Donde necesitemos proporcionar una instancia para el HNil vacío
implicit def hnil: DerivedEqual[HNil]
Implementamos estos métodos
implicit def hcons[H: Equal, T <: HList: DerivedEqual]: DerivedEqual[H :: T] =
(h1, h2) => Equal[H].equal(h1.head, h2.head) && Equal[T].equal(h1.tail, h2.tail)
implicit val hnil: DerivedEqual[HNil] = (_, _) => true
y para los coproductos deseamos implementar estas firmas
implicit def ccons[H: Equal, T <: Coproduct: DerivedEqual]: DerivedEqual[H :+: T]
implicit def cnil: DerivedEqual[CNil]
.cnil nunca será llamada por una typeclass como Equal con parámetros de tipo
únicamente en posición contravariante. Pero el compilador no sabe esto, de modo
que tenemos que proporcionar un stub:
implicit val cnil: DerivedEqual[CNil] = (_, _) => sys.error("impossible")
Para el caso del coproducto sólo podemos comparar dos cosas si están alineadas,
que ocurre cuando son tanto In1 o Inr
implicit def ccons[H: Equal, T <: Coproduct: DerivedEqual]: DerivedEqual[H :+: T] = {
case (Inl(c1), Inl(c2)) => Equal[H].equal(c1, c2)
case (Inr(c1), Inr(c2)) => Equal[T].equal(c1, c2)
case _ => false
}
¡Es digno de mención que nuestros métodos se alinean con el concepto de
conquer (hnil), divide2 (hlist) y alt2 (coproduct)! Sin embargo, no
tenemos ninguna de las ventajas de implementar Decidable, debido a que ahora
debemos empezar desde ceros cuando escribimos pruebas para este código.
De modo que probemos esto con una ADT simple
sealed abstract class Foo
final case class Bar(s: String) extends Foo
final case class Faz(b: Boolean, i: Int) extends Foo
final case object Baz extends Foo
Tenemos que proporcionar instancias en los objetos compañeros:
object Foo {
implicit val equal: Equal[Foo] = DerivedEqual.gen
}
object Bar {
implicit val equal: Equal[Bar] = DerivedEqual.gen
}
object Faz {
implicit val equal: Equal[Faz] = DerivedEqual.gen
}
final case object Baz extends Foo {
implicit val equal: Equal[Baz.type] = DerivedEqual.gen
}
Pero no siempre compila
[error] shapeless.scala:41:38: ambiguous implicit values:
[error] both value hnil in object DerivedEqual of type => DerivedEqual[HNil]
[error] and value cnil in object DerivedEqual of type => DerivedEqual[CNil]
[error] match expected type DerivedEqual[R]
[error] : Equal[Baz.type] = DerivedEqual.gen
[error] ^
¡Bienvenido a los mensajes de error de compilación de Shapeless!
El problema, que no es evidente en lo absoluto a partir del mensaje de error, es
que el compilador es incapaz de averiguar qué es R, y “piensa” que es otra
cosa. Requerimos proporcionar los parámetros de tipo explícitamente cuando
llamamos a gen, es decir
implicit val equal: Equal[Baz.type] = DerivedEqual.gen[Baz.type, HNil]
o podemos usar la macro Generic para ayudarnos y dejar que el compilador
infiera la representación genérica
final case object Baz extends Foo {
implicit val generic = Generic[Baz.type]
implicit val equal: Equal[Baz.type] = DerivedEqual.gen[Baz.type, generic.Repr]
}
...
La razón por la que esto soluciona el problema es porque la firma de tipo, después de quitar algunas conveniencias sintácticas (syntactic sugar)
def gen[A, R: DerivedEqual](implicit G: Generic.Aux[A, R]): Equal[A]
se convierte en
def gen[A, R](implicit R: DerivedEqual[R], G: Generic.Aux[A, R]): Equal[A]
El compilador de Scala resuelve estas restricciones de tipo de izquierda a
derecha, de modo que encuentra muchas soluciones distintas a DerivedEqual[R]
antes de aplicar la restricción Generic.Aux[A, R]. Otra forma de resolver esto
es no usar límites de contexto.
Con esto en mente, ya no requerimos el implicit val generic o los parámetros
de tipo explícitos en la invocación a .gen. Podemos alambrar @deriving al
agregar una entrada en deriving.conf (asumiendo que deseamos hacer un override
de la implementación scalaz-deriving).
scalaz.Equal=fommil.DerivedEqual.gen
y escribir
@deriving(Equal) sealed abstract class Foo
@deriving(Equal) final case class Bar(s: String) extends Foo
@deriving(Equal) final case class Faz(b: Boolean, i: Int) extends Foo
@deriving(Equal) final case object Baz
Pero, reemplazar scalaz-deriving significa que los tiempos de compilación se
vuelven más lentos. Esto se debe a que el compilador está resolviendo N
búsquedas implícitas para cada producto de N campos o coproductos de N
productos, mientras que scalaz-deriving y Magnolia no.
Note que cuando usamos scalaz-deriving o Magnolia podemos poner la anotación
@deriving únicamente en el miembro más alto de una ADT, pero para Shapeless
debemos agregarlo a todas sus entradas.
Sin embargo, esta implementación todavía tiene un error: falla para los tipos recursivos en tiempo de ejecución, es decir
@deriving(Equal) sealed trait ATree
@deriving(Equal) final case class Leaf(value: String) extends ATree
@deriving(Equal) final case class Branch(left: ATree, right: ATree) extends ATree
scala> val leaf1: Leaf = Leaf("hello")
val leaf2: Leaf = Leaf("goodbye")
val branch: Branch = Branch(leaf1, leaf2)
val tree1: ATree = Branch(leaf1, branch)
val tree2: ATree = Branch(leaf2, branch)
scala> assert(tree1 /== tree2)
[error] java.lang.NullPointerException
[error] at DerivedEqual$.shapes$DerivedEqual$$$anonfun$hcons$1(shapeless.scala:16)
...
La razón por la que esto pasa es porque Equal[Tree] depende de
Equal[Branch], que a su vez depende de Equal[Tree]. ¡Recursión y BANG! Debe
cargarse de manera perezosa, y no de manera estricta.
Tanto scalaz-deriving como Magnolia lidian automáticamente con la pereza, pero
en Shapeless es la responsabilidad del autor de la typeclass.
Los tipos de macro Cached, Strict y Lazy modifican el comportamiento de la
inferencia de tipos del compilador, permitiéndonos alcanzar el nivel de pereza
que necesitemos. El patrón que hay que seguir es usar Cached[Strict[_]] en el
punto de entrada y Lazy[_] alrededor de las instancias H.
Es mejor apartarse de los límites de contexto y los tipos SAM a partir de este punto:
sealed trait DerivedEqual[A] extends Equal[A]
object DerivedEqual {
def gen[A, R](
implicit G: Generic.Aux[A, R],
R: Cached[Strict[DerivedEqual[R]]]
): Equal[A] = new Equal[A] {
def equal(a1: A, a2: A) =
quick(a1, a2) || R.value.value.equal(G.to(a1), G.to(a2))
}
implicit def hcons[H, T <: HList](
implicit H: Lazy[Equal[H]],
T: DerivedEqual[T]
): DerivedEqual[H :: T] = new DerivedEqual[H :: T] {
def equal(ht1: H :: T, ht2: H :: T) =
(quick(ht1.head, ht2.head) || H.value.equal(ht1.head, ht2.head)) &&
T.equal(ht1.tail, ht2.tail)
}
implicit val hnil: DerivedEqual[HNil] = new DerivedEqual[HNil] {
def equal(@unused h1: HNil, @unused h2: HNil) = true
}
implicit def ccons[H, T <: Coproduct](
implicit H: Lazy[Equal[H]],
T: DerivedEqual[T]
): DerivedEqual[H :+: T] = new DerivedEqual[H :+: T] {
def equal(ht1: H :+: T, ht2: H :+: T) = (ht1, ht2) match {
case (Inl(c1), Inl(c2)) => quick(c1, c2) || H.value.equal(c1, c2)
case (Inr(c1), Inr(c2)) => T.equal(c1, c2)
case _ => false
}
}
implicit val cnil: DerivedEqual[CNil] = new DerivedEqual[CNil] {
def equal(@unused c1: CNil, @unused c2: CNil) = sys.error("impossible")
}
@inline private final def quick(a: Any, b: Any): Boolean =
a.asInstanceOf[AnyRef].eq(b.asInstanceOf[AnyRef])
}
Mientras estábamos haciendo esto, optimizamos usando el atajo quick de
scalaz-deriving.
Ahora podemos llamar
assert(tree1 /== tree2)
sin tener una excepción en tiempo de ejecución.
8.4.2 Ejemplo: Default
Hay dos trampas en la implementación de una typeclass con un parámetro de tipo
en posición covariante. Aquí creamos valores HList y Coproducto, y debemos
proporcionar un valor para el caso CNil dado que corresponde al caso donde
ningún coproducto es capaz de proporcionar un valor.
sealed trait DerivedDefault[A] extends Default[A]
object DerivedDefault {
def gen[A, R](
implicit G: Generic.Aux[A, R],
R: Cached[Strict[DerivedDefault[R]]]
): Default[A] = new Default[A] {
def default = R.value.value.default.map(G.from)
}
implicit def hcons[H, T <: HList](
implicit H: Lazy[Default[H]],
T: DerivedDefault[T]
): DerivedDefault[H :: T] = new DerivedDefault[H :: T] {
def default =
for {
head <- H.value.default
tail <- T.default
} yield head :: tail
}
implicit val hnil: DerivedDefault[HNil] = new DerivedDefault[HNil] {
def default = HNil.right
}
implicit def ccons[H, T <: Coproduct](
implicit H: Lazy[Default[H]],
T: DerivedDefault[T]
): DerivedDefault[H :+: T] = new DerivedDefault[H :+: T] {
def default = H.value.default.map(Inl(_)).orElse(T.default.map(Inr(_)))
}
implicit val cnil: DerivedDefault[CNil] = new DerivedDefault[CNil] {
def default = "not a valid coproduct".left
}
}
Así como pudimos establecer una analogía entre Equal y Decidable, podemos
ver la relación con Alt en .point (hnil), .apply2 (.hcons) y .altly2
(.ccons).
Hay poco que aprender de un ejemplo como Semigroup, de modo que iremos
directamente a estudiar los codificadores y decodificadores.
8.4.3 Ejemplo: JsEncoder
Para ser capaces de reproducir nuestro codificador JSON de Magnolia, debemos ser capaces de acceder a
- los nombres de los campos y de las clases
- las anotaciones para las preferencias del usuario
- los valores por defecto en una
case class
Empezaremos al crear un codificador que maneje únicamente los valores por defecto.
Para obtener los nombres de los campos, usamos LabelledGeneric en lugar de
Generic, y cuando definimos el tipo del elemento en la cabeza, usamos
FieldType[K, H] en lugar de simplemente H. Un Witness.Aux[K] proporciona
el valor del nombre del campo en tiempo de ejecución.
Todos nuestros métodos van a devolver JsObject, de modo que en lugar de
devolver un JsValue podemos especializar y crear DerivedJsEncoder que tiene
una firma de tipo distinta a la de JsEncoder
import shapeless._, labelled._
sealed trait DerivedJsEncoder[R] {
def toJsFields(r: R): IList[(String, JsValue)]
}
object DerivedJsEncoder {
def gen[A, R](
implicit G: LabelledGeneric.Aux[A, R],
R: Cached[Strict[DerivedJsEncoder[R]]]
): JsEncoder[A] = new JsEncoder[A] {
def toJson(a: A) = JsObject(R.value.value.toJsFields(G.to(a)))
}
implicit def hcons[K <: Symbol, H, T <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[T]
): DerivedJsEncoder[FieldType[K, H] :: T] =
new DerivedJsEncoder[A, FieldType[K, H] :: T] {
private val field = K.value.name
def toJsFields(ht: FieldType[K, H] :: T) =
ht match {
case head :: tail =>
val rest = T.toJsFields(tail)
H.value.toJson(head) match {
case JsNull => rest
case value => (field -> value) :: rest
}
}
}
implicit val hnil: DerivedJsEncoder[HNil] =
new DerivedJsEncoder[HNil] {
def toJsFields(h: HNil) = IList.empty
}
implicit def ccons[K <: Symbol, H, T <: Coproduct](
implicit
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[T]
): DerivedJsEncoder[FieldType[K, H] :+: T] =
new DerivedJsEncoder[FieldType[K, H] :+: T] {
private val hint = ("type" -> JsString(K.value.name))
def toJsFields(ht: FieldType[K, H] :+: T) = ht match {
case Inl(head) =>
H.value.toJson(head) match {
case JsObject(fields) => hint :: fields
case v => IList.single("xvalue" -> v)
}
case Inr(tail) => T.toJsFields(tail)
}
}
implicit val cnil: DerivedJsEncoder[CNil] =
new DerivedJsEncoder[CNil] {
def toJsFields(c: CNil) = sys.error("impossible")
}
}
Shapeless selecciona los caminos de código en tiempo de compilación basándose en la presencia de anotaciones, lo que puede llevarnos a código más optimizado, a expensas de repetición de código. Esto significa que el número de anotaciones con las que estamos lidiando, y sus subtipos debe ser manejable o nos encontraremos escribiendo una cantidad de código unas diez veces más grande. Podemos poner nuestras tres anotaciones en una sola que contenga todos los parámetros de optimización:
case class json(
nulls: Boolean,
field: Option[String],
hint: Option[String]
) extends Annotation
Todos los usuarios de nuestra anotación deben proporcionar los tres valores dado que los valores por defecto y los métodos de conveniencia no están disponibles en los constructores de anotaciones. Podemos escribir extractores personalizados de modo que no tengamos que cambiar nuestro código de Magnolia
object json {
object nulls {
def unapply(j: json): Boolean = j.nulls
}
object field {
def unapply(j: json): Option[String] = j.field
}
object hint {
def unapply(j: json): Option[String] = j.hint
}
}
Podemos solicitar Annotation[json, A] para una case class o un sealed
trait para obtener acceso a la anotación, pero entonces debemos escribir un
hcons y un ccons que lidien con ambos casos debido a que la evidencia no
será generada si no está presente la anotación. Por lo tanto tenemos que
introducir un alcance implícito de prioridad más baja y poner la evidencia de
que no hay anotaciones ahí.
También podríamos solicitar evidencia Annotations.Aux[json, A, J] para obtener
un HList de la anotación json para el tipo A. De nuevo, debemos
proporcionar hcons y ccons que lidian con los casos donde hay y no hay una
anotación.
Para soportar esta anotación, ¡debemos escribir cuatro veces la cantidad de código que antes!
Empezaremos por reescribir el JsEncoder, únicamente manejando código del
usuario que no tenga ninguna anotación. Ahora cualquier código que use la
anotación @json fallará en compilar, y esta es una buena garantía de
seguridad.
Debemos agregar tipos A y J al DerivedJsEncoder y pasarlos a través de las
anotaciones en su método .toJsObject. Nuestra evidencia .hcons y .ccons
ahora proporciona instancias para DerivedJsEEncoder con una anotación
None.type y las movemos a una prioridad más baja de modo que podamos lidiar
con Annotation[json, A] en una prioridad más alta.
Note que la evidencia para J está listada antes que la correspondiente a R.
Esto es importante, dado que el compilador debe primero fijar el tipo de J
antes de que pueda resolver el tipo para R.
sealed trait DerivedJsEncoder[A, R, J <: HList] {
def toJsFields(r: R, anns: J): IList[(String, JsValue)]
}
object DerivedJsEncoder extends DerivedJsEncoder1 {
def gen[A, R, J <: HList](
implicit
G: LabelledGeneric.Aux[A, R],
J: Annotations.Aux[json, A, J],
R: Cached[Strict[DerivedJsEncoder[A, R, J]]]
): JsEncoder[A] = new JsEncoder[A] {
def toJson(a: A) = JsObject(R.value.value.toJsFields(G.to(a), J()))
}
implicit def hnil[A]: DerivedJsEncoder[A, HNil, HNil] =
new DerivedJsEncoder[A, HNil, HNil] {
def toJsFields(h: HNil, a: HNil) = IList.empty
}
implicit def cnil[A]: DerivedJsEncoder[A, CNil, HNil] =
new DerivedJsEncoder[A, CNil, HNil] {
def toJsFields(c: CNil, a: HNil) = sys.error("impossible")
}
}
private[jsonformat] trait DerivedJsEncoder1 {
implicit def hcons[A, K <: Symbol, H, T <: HList, J <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H] :: T, None.type :: J] =
new DerivedJsEncoder[A, FieldType[K, H] :: T, None.type :: J] {
private val field = K.value.name
def toJsFields(ht: FieldType[K, H] :: T, anns: None.type :: J) =
ht match {
case head :: tail =>
val rest = T.toJsFields(tail, anns.tail)
H.value.toJson(head) match {
case JsNull => rest
case value => (field -> value) :: rest
}
}
}
implicit def ccons[A, K <: Symbol, H, T <: Coproduct, J <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H] :+: T, None.type :: J] =
new DerivedJsEncoder[A, FieldType[K, H] :+: T, None.type :: J] {
private val hint = ("type" -> JsString(K.value.name))
def toJsFields(ht: FieldType[K, H] :+: T, anns: None.type :: J) =
ht match {
case Inl(head) =>
H.value.toJson(head) match {
case JsObject(fields) => hint :: fields
case v => IList.single("xvalue" -> v)
}
case Inr(tail) => T.toJsFields(tail, anns.tail)
}
}
}
Ahora podemos agregar las firmas de tipo para los seis métodos nuevos, cubriendo todas las posibilidades para los lugares posibles donde puede ocurrir la anotación. Note que únicamente soportamos una anotación en cada posición. Si el usuario proporciona múltiples anotaciones, cualquiera después de la primera será ignorada silenciosamente.
Ya nos estamos quedando sin nombres para las cosas, de modo que llamaremos
arbitrariamente Annotated cuando exista una anotación para la A, y Custom
cuando exista una anotación sobre el campo.
object DerivedJsEncoder extends DerivedJsEncoder1 {
...
implicit def hconsAnnotated[A, K <: Symbol, H, T <: HList, J <: HList](
implicit
A: Annotation[json, A],
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H] :: T, None.type :: J]
implicit def cconsAnnotated[A, K <: Symbol, H, T <: Coproduct, J <: HList](
implicit
A: Annotation[json, A],
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H] :+: T, None.type :: J]
implicit def hconsAnnotatedCustom[A, K <: Symbol, H, T <: HList, J <: HList](
implicit
A: Annotation[json, A],
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H] :: T, Some[json] :: J]
implicit def cconsAnnotatedCustom[A, K <: Symbol, H, T <: Coproduct, J <: HList](
implicit
A: Annotation[json, A],
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H] :+: T, Some[json] :: J]
}
private[jsonformat] trait DerivedJsEncoder1 {
...
implicit def hconsCustom[A, K <: Symbol, H, T <: HList, J <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H] :: T, Some[json] :: J] = ???
implicit def cconsCustom[A, K <: Symbol, H, T <: Coproduct, J <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsEncoder[H]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H] :+: T, Some[json] :: J]
}
En realidad no necesitamos .hconsAnnotated o .hconsAnnotatedCustom para
nada, dado que una anotación en una case class no significa nada para la
codificación de dicho producto, y sólo se usa en .cconsAnnotated*. Por lo
tanto, podemos borrar dos métodos.
.cconsAnnotated y .cconsAnnotatedCustom pueden definirse como
new DerivedJsEncoder[A, FieldType[K, H] :+: T, None.type :: J] {
private val hint = A().field.getOrElse("type") -> JsString(K.value.name)
def toJsFields(ht: FieldType[K, H] :+: T, anns: None.type :: J) = ht match {
case Inl(head) =>
H.value.toJson(head) match {
case JsObject(fields) => hint :: fields
case v => IList.single("xvalue" -> v)
}
case Inr(tail) => T.toJsFields(tail, anns.tail)
}
}
y
new DerivedJsEncoder[A, FieldType[K, H] :+: T, Some[json] :: J] {
private val hintfield = A().field.getOrElse("type")
def toJsFields(ht: FieldType[K, H] :+: T, anns: Some[json] :: J) = ht match {
case Inl(head) =>
val ann = anns.head.get
H.value.toJson(head) match {
case JsObject(fields) =>
val hint = (hintfield -> JsString(ann.hint.getOrElse(K.value.name)))
hint :: fields
case v =>
val xvalue = ann.field.getOrElse("xvalue")
IList.single(xvalue -> v)
}
case Inr(tail) => T.toJsFields(tail, anns.tail)
}
}
Podría preocupar un poco el uso de .head y .get pero recuerde que los tipos
aquí son :: y Some, y por lo tanto estos métodos son totales y seguros de
usarse.
.hconsCustom y .cconsCustom se escriben
new DerivedJsEncoder[A, FieldType[K, H] :: T, Some[json] :: J] {
def toJsFields(ht: FieldType[K, H] :: T, anns: Some[json] :: J) = ht match {
case head :: tail =>
val ann = anns.head.get
val next = T.toJsFields(tail, anns.tail)
H.value.toJson(head) match {
case JsNull if !ann.nulls => next
case value =>
val field = ann.field.getOrElse(K.value.name)
(field -> value) :: next
}
}
}
y
new DerivedJsEncoder[A, FieldType[K, H] :+: T, Some[json] :: J] {
def toJsFields(ht: FieldType[K, H] :+: T, anns: Some[json] :: J) = ht match {
case Inl(head) =>
val ann = anns.head.get
H.value.toJson(head) match {
case JsObject(fields) =>
val hint = ("type" -> JsString(ann.hint.getOrElse(K.value.name)))
hint :: fields
case v =>
val xvalue = ann.field.getOrElse("xvalue")
IList.single(xvalue -> v)
}
case Inr(tail) => T.toJsFields(tail, anns.tail)
}
}
Obviamente, hay mucho código repetitivo y verboso, pero observando de cerca podemos ver que cada método está implementado tan eficientemente como sea posible con la información que tiene: los caminos del código se seleccionan en tiempo de compilación más bien que en tiempo de ejecución.
Los que sean obsesivos con el rendimiento tal vez deseen refactorizar este
código de modo que toda la información de anotaciones esté disponible de
antemano, más bien que inyectada por medio del método .toJsFields, con otra
capa de indirección. Para un rendimiento máximo, también podríamos tratar cada
personalización como una anotación separada, pero eso multiplicaría la cantidad
de código que hemos escrito todavía más, con un coste adicional para el tiempo
de compilación requerido a nuestros usuarios. Tales optimizaciones están más
allá del alcance de este libro, pero es posible y las personas las hacen: la
habilidad de mover trabajo de tiempo de ejecución a tiempo de compilación es una
de las cosas más atractivas de la programación genérica.
Una advertencia más de la que tenemos que estar conscientes: LabelledGeneric
no es compatible con
scalaz.@@, pero existe
una solución. Digamos que efectivamente deseamos ignorar las etiquetas de modo
que agregamos las siguientes reglas de derivación a los objetos compañeros de
nuestro codificador y decodificador
object JsEncoder {
...
implicit def tagged[A: JsEncoder, Z]: JsEncoder[A @@ Z] =
JsEncoder[A].contramap(Tag.unwrap)
}
object JsDecoder {
...
implicit def tagged[A: JsDecoder, Z]: JsDecoder[A @@ Z] =
JsDecoder[A].map(Tag(_))
}
Esperaríamos ser capaces de derivar un JsDecoder para algo como nuestra
TradeTemplate del Capítulo 5
final case class TradeTemplate(
otc: Option[Boolean] @@ Tags.Last
)
object TradeTemplate {
implicit val encoder: JsEncoder[TradeTemplate] = DerivedJsEncoder.gen
}
Pero obtenemos el siguiente error de compilación
[error] could not find implicit value for parameter G: LabelledGeneric.Aux[A,R]
[error] implicit val encoder: JsEncoder[TradeTemplate] = DerivedJsEncoder.gen
[error] ^
El mensaje de error es tan útil como siempre. La solución es introducir
evidencia para H @@ Z en el alcance implícito de menor prioridad, y entonces
simplemente invocar el código que el compilador habría encontrado en primer
lugar:
object DerivedJsEncoder extends DerivedJsEncoder1 with DerivedJsEncoder2 {
...
}
private[jsonformat] trait DerivedJsEncoder2 {
this: DerivedJsEncoder.type =>
// WORKAROUND https://github.com/milessabin/shapeless/issues/309
implicit def hconsTagged[A, K <: Symbol, H, Z, T <: HList, J <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsEncoder[H @@ Z]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H @@ Z] :: T, None.type :: J] = hcons(K, H, T)
implicit def hconsCustomTagged[A, K <: Symbol, H, Z, T <: HList, J <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsEncoder[H @@ Z]],
T: DerivedJsEncoder[A, T, J]
): DerivedJsEncoder[A, FieldType[K, H @@ Z] :: T, Some[json] :: J] = hconsCustom(K, H, T)
}
Felizmente, sólo es necesario considerar los productos, dado que no es posible agregar etiquetas a los coproductos.
8.4.4 JsDecoder
El lado de la decodificación es tal como lo esperaríamos basándonos en los
ejemplos previos. Podemos construir una instancia de un FieldType[K, H con el
auxiliar field[K](h: H). Soportando únicamente los razonables valores por
defecto significa que escribimos:
sealed trait DerivedJsDecoder[A] {
def fromJsObject(j: JsObject): String \/ A
}
object DerivedJsDecoder {
def gen[A, R](
implicit G: LabelledGeneric.Aux[A, R],
R: Cached[Strict[DerivedJsDecoder[R]]]
): JsDecoder[A] = new JsDecoder[A] {
def fromJson(j: JsValue) = j match {
case o @ JsObject(_) => R.value.value.fromJsObject(o).map(G.from)
case other => fail("JsObject", other)
}
}
implicit def hcons[K <: Symbol, H, T <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsDecoder[H]],
T: DerivedJsDecoder[T]
): DerivedJsDecoder[FieldType[K, H] :: T] =
new DerivedJsDecoder[FieldType[K, H] :: T] {
private val fieldname = K.value.name
def fromJsObject(j: JsObject) = {
val value = j.get(fieldname).getOrElse(JsNull)
for {
head <- H.value.fromJson(value)
tail <- T.fromJsObject(j)
} yield field[K](head) :: tail
}
}
implicit val hnil: DerivedJsDecoder[HNil] = new DerivedJsDecoder[HNil] {
private val nil = HNil.right[String]
def fromJsObject(j: JsObject) = nil
}
implicit def ccons[K <: Symbol, H, T <: Coproduct](
implicit
K: Witness.Aux[K],
H: Lazy[JsDecoder[H]],
T: DerivedJsDecoder[T]
): DerivedJsDecoder[FieldType[K, H] :+: T] =
new DerivedJsDecoder[FieldType[K, H] :+: T] {
private val hint = ("type" -> JsString(K.value.name))
def fromJsObject(j: JsObject) =
if (j.fields.element(hint)) {
j.get("xvalue")
.into {
case \/-(xvalue) => H.value.fromJson(xvalue)
case -\/(_) => H.value.fromJson(j)
}
.map(h => Inl(field[K](h)))
} else
T.fromJsObject(j).map(Inr(_))
}
implicit val cnil: DerivedJsDecoder[CNil] = new DerivedJsDecoder[CNil] {
def fromJsObject(j: JsObject) = fail(s"JsObject with 'type' field", j)
}
}
Agregar las preferencias del usuario usando anotaciones sigue la misma ruta que
en el caso de DerivedJsEncoder y es mecánico, de modo que se deja como un
ejercicio al lector.
Algo más falta: valores por defecto para las case class. Podríamos solicitar
evidencia, pero el gran problema es que ya no podemos usar el mismo mecanismo de
derivación para los productos y coproductos: la evidencia nunca se crea para
coproductos.
La solución es bastante drástica. Debemos separar nuestro DerivedJsDecoder en
DerivedCoproductJsDecoder y DerivedProductJsDecoder. Nos enfocaremos en el
DerivedProductJsDecoder, y mientras es que estamoos en esto usaremos un Map
para encontrar más rápidamente los campos:
sealed trait DerivedProductJsDecoder[A, R, J <: HList, D <: HList] {
private[jsonformat] def fromJsObject(
j: Map[String, JsValue],
anns: J,
defaults: D
): String \/ R
}
Podemos solicitar evidencia de que existen valores por defecto con
Default.Aux[A, D] y duplicar todos los métodos para lidiar con el caso donde
se tiene y no se tiene un valor por defecto. Sin embargo, aquí Shapeless tiene
piedad y proporciona Default.AsOptions.Aux[A, D] dejándonos manejar los
valores por defecto en tiempo de ejecución.
object DerivedProductJsDecoder {
def gen[A, R, J <: HList, D <: HList](
implicit G: LabelledGeneric.Aux[A, R],
J: Annotations.Aux[json, A, J],
D: Default.AsOptions.Aux[A, D],
R: Cached[Strict[DerivedProductJsDecoder[A, R, J, D]]]
): JsDecoder[A] = new JsDecoder[A] {
def fromJson(j: JsValue) = j match {
case o @ JsObject(_) =>
R.value.value.fromJsObject(o.fields.toMap, J(), D()).map(G.from)
case other => fail("JsObject", other)
}
}
...
}
Debemos mover los métodos .hcons y .hnil al objeto compañero de la nueva
typeclass sellada, que puede manejar valores por defecto
object DerivedProductJsDecoder {
...
implicit def hnil[A]: DerivedProductJsDecoder[A, HNil, HNil, HNil] =
new DerivedProductJsDecoder[A, HNil, HNil, HNil] {
private val nil = HNil.right[String]
def fromJsObject(j: StringyMap[JsValue], a: HNil, defaults: HNil) = nil
}
implicit def hcons[A, K <: Symbol, H, T <: HList, J <: HList, D <: HList](
implicit
K: Witness.Aux[K],
H: Lazy[JsDecoder[H]],
T: DerivedProductJsDecoder[A, T, J, D]
): DerivedProductJsDecoder[A, FieldType[K, H] :: T, None.type :: J, Option[H] :: D] =
new DerivedProductJsDecoder[A, FieldType[K, H] :: T, None.type :: J, Option[H] :: D] {
private val fieldname = K.value.name
def fromJsObject(
j: StringyMap[JsValue],
anns: None.type :: J,
defaults: Option[H] :: D
) =
for {
head <- j.get(fieldname) match {
case Maybe.Just(v) => H.value.fromJson(v)
case _ =>
defaults.head match {
case Some(default) => \/-(default)
case None => H.value.fromJson(JsNull)
}
}
tail <- T.fromJsObject(j, anns.tail, defaults.tail)
} yield field[K](head) :: tail
}
...
}
Ahora ya no podemos usar @deriving para productos y coproductos: sólo puede
haber una entrada en el archivo deriving.conf.
¡Oh! Y no olvide agregar soporte para @@
object DerivedProductJsDecoder extends DerivedProductJsDecoder1 {
...
}
private[jsonformat] trait DerivedProductJsDecoder2 {
this: DerivedProductJsDecoder.type =>
implicit def hconsTagged[
A, K <: Symbol, H, Z, T <: HList, J <: HList, D <: HList
](
implicit
K: Witness.Aux[K],
H: Lazy[JsDecoder[H @@ Z]],
T: DerivedProductJsDecoder[A, T, J, D]
): DerivedProductJsDecoder[
A,
FieldType[K, H @@ Z] :: T,
None.type :: J,
Option[H @@ Z] :: D
] = hcons(K, H, T)
implicit def hconsCustomTagged[
A, K <: Symbol, H, Z, T <: HList, J <: HList, D <: HList
](
implicit
K: Witness.Aux[K],
H: Lazy[JsDecoder[H @@ Z]],
T: DerivedProductJsDecoder[A, T, J, D]
): DerivedProductJsDecoder[
A,
FieldType[K, H @@ Z] :: T,
Some[json] :: J,
Option[H @@ Z] :: D
] = hconsCustomTagged(K, H, T)
}
8.4.5 Derivaciones complicadas
Shapeless nos permite muchos más tipos de derivaciones de las que son posibles
con scalaz-deriving o Magnolia. Como un ejemplo de un
codificador/decodificador que es posible con Magnolia, considere este modelo XML
de
xmlformat
@deriving(Equal, Show, Arbitrary)
sealed abstract class XNode
@deriving(Equal, Show, Arbitrary)
final case class XTag(
name: String,
attrs: IList[XAttr],
children: IList[XTag],
body: Maybe[XString]
)
@deriving(Equal, Show, Arbitrary)
final case class XAttr(name: String, value: XString)
@deriving(Show)
@xderiving(Equal, Monoid, Arbitrary)
final case class XChildren(tree: IList[XTag]) extends XNode
@deriving(Show)
@xderiving(Equal, Semigroup, Arbitrary)
final case class XString(text: String) extends XNode
Dada la naturaleza de XML tiene sentido que se tengan pares de
codificador/decodificador separados para XChildren y contendido XString.
Podríamos proporcionar una derivación para el XChildren con Shapeless pero
queremos campos con uso de mayúsculas y minúsculas basado en la clase de
typeclass que tengan, así como campos con Option. Podríamos incluso requerir
que los campos estén anotados con su nombre codificado. Además, cuando se
decodifique desearíamos tener diferentes estrategias para manejar los cuerpos de
los elementos XML, que pueden tener múltiples partes, dependiendo de si su tipo
tiene un Semigroup, Monoid o ninguno.
8.4.6 Ejemplo: UrlQueryWriter
De manera similar a xmlformat, nuestra aplicación drone-dynamic-agents
podría beneficiarse de una derivación de typeclasses de la typeclass
UrlQueryWriter, que está construida a partir de instancias de
UrlEncodedWriter para cada campo de entrada. No soporta coproductos:
@typeclass trait UrlQueryWriter[A] {
def toUrlQuery(a: A): UrlQuery
}
trait DerivedUrlQueryWriter[T] extends UrlQueryWriter[T]
object DerivedUrlQueryWriter {
def gen[T, Repr](
implicit
G: LabelledGeneric.Aux[T, Repr],
CR: Cached[Strict[DerivedUrlQueryWriter[Repr]]]
): UrlQueryWriter[T] = { t =>
CR.value.value.toUrlQuery(G.to(t))
}
implicit val hnil: DerivedUrlQueryWriter[HNil] = { _ =>
UrlQuery(IList.empty)
}
implicit def hcons[Key <: Symbol, A, Remaining <: HList](
implicit Key: Witness.Aux[Key],
LV: Lazy[UrlEncodedWriter[A]],
DR: DerivedUrlQueryWriter[Remaining]
): DerivedUrlQueryWriter[FieldType[Key, A] :: Remaining] = {
case head :: tail =>
val first =
Key.value.name -> URLDecoder.decode(LV.value.toUrlEncoded(head).value, "UTF-8")
val rest = DR.toUrlQuery(tail)
UrlQuery(first :: rest.params)
}
}
Es razonable preguntarse si estas 30 lineas en realidad son una mejora sobre las 8 lineas para las 2 instancias manuales que nuestra aplicación necesita: una decisión que debe tomarse caso por caso.
En aras de la exhaustividad, la derivación UrlEncodedWriter puede escribirse
con Magnolia
object UrlEncodedWriterMagnolia {
type Typeclass[a] = UrlEncodedWriter[a]
def combine[A](ctx: CaseClass[UrlEncodedWriter, A]) = a =>
Refined.unsafeApply(ctx.parameters.map { p =>
p.label + "=" + p.typeclass.toUrlEncoded(p.dereference(a))
}.toList.intercalate("&"))
def gen[A]: UrlEncodedWriter[A] = macro Magnolia.gen[A]
}
8.4.7 El lado oscuro de la derivación
“Cuidado hay que tener de la derivación automática. Furia, miedo, agresión; el lado oscuro de la derivación son. Fluirán ellos fácilmente, rápidos serán para unirse a ti en pelea. Si alguna vez comienzas el camino oscuro, por siempre dominarán tu compilador, y consumirte logrará.”
- un antiguo maestro de Shapeless
Además de todas las advertencias sobre la derivación automática que fueron mencionadas para Magnolia, Shapeless es mucho peor. La derivación de Shapeless completamente automática no es únicamente la causa más común de compilación lenta, también es una fuente dolorosa de errores de coherencia de typeclasses.
La derivación completamente automática ocurre cuando el def gen es implícito
de modo que una invocación realizará una recursión por todas las entradas de la
ADT. Debido a la forma en la que funcionan los alcances implícitos, un implicit
def importado tendrá una prioridad más alta que las instancias personalizadas
en los objetos compañeros, creando una fuente de incoherencia de typeclasses.
Por ejemplo, considere este código si nuestro .gen fuera implícito
import DerivedJsEncoder._
@xderiving(JsEncoder)
final case class Foo(s: String)
final case class Bar(foo: Foo)
Esperaríamos que la forma codificada derivada de manera completamente automática se viera como
{
"foo":"hello"
}
debido a que hemos usado xderiving para Foo. Pero podría más bien ser
{
"foo": {
"s":"hello"
}
}
Es peor aún cuando se agregan métodos al objeto compañero de la typeclass, resultando en que la typeclass siempre se deriva en el punto de uso y los usuarios no pueden optar por no realizarla.
Fundamentalmente, cuando escriba programas genéricos, los implícitos pueden ignorarse por el compilador dependiendo del alcance, ¡resultando en que perdemos la seguridad en tiempo de compilación que era nuestra motivación para programar a nivel de tipos desde el principio!
Todo es mucho más simple en el lado claro, donde implicit se usa para
typeclasses coherentes, globalmente únicas. El miedo al código repetitivo es el
camino al lado oscuro. El miedo lleva a la furia. La furia lleva al odio. El
odio lleva al sufrimiento.
8.5 Rendimiento
No hay bala plateada en lo que respecta a la derivación de typeclasses. Un eje que hay que considerar es el del rendimiento: tanto en tiempo de compilación como en tiempo de ejecución.
8.5.1 Tiempo de compilación
Cuando se trata de tiempos de compilación, Shapeless es un caso extremo. No es
poco común ver que un proyecto pequeño expanda su tiempo de compilación de un
segundo a un minuto. Para investigar temas de compilación, podemos realizar un
perfilado de código con el plugin scalac-profiling
addCompilerPlugin("ch.epfl.scala" %% "scalac-profiling" % "1.0.0")
scalacOptions ++= Seq("-Ystatistics:typer", "-P:scalac-profiling:no-profiledb")
Este produce una salida que puede generar un gráfico de llamas.
Para el caso de una derivación de Shapeless, obtenemos una gráfica vívida
casi todo el tiempo de compilación se usa en la resolución implícita. Note que
esto también incluye la compilación de las instancias de scalaz-deriving,
Magnolia y manuales, pero los cómputos de Shapeless dominan.
Este es el caso cuando la derivación funciona. Si hay un problema con la derivación de Shapeless, el compilador puede quedarse en un ciclo infinito y debe ser terminado.
8.5.2 Rendimiento en tiempo de ejecución
Si ahora consideramos el rendimiento en tiempo de ejecución, la respuesta siempre es: depende.
Suponiendo que la lógica de derivación ha sido escrita de una manera eficiente, sólo es posible saber cuál es más rápida a través de la experimentación.
La librería jsonformat usa el Java Microbenchmark Harness
(JMH) sobre modelos que
mapean a GeoJSON, Google Maps, y Twitter, contribuidos por Andriy Plokhotnyuk.
Hay tres pruebas por modelo:
- codificar el
ADTa unJsValue - una decodificación exitosa del mismo valor
JsValueal ADT - una decodificación fallida de un
JsValuecon un error de datos
aplicada a las siguientes implementaciones:
- Magnolia
- Shapeless
- manualmente escrita
con las optimizaciones equivalentes en cada una. Los resultados son en operaciones por segundo (más alto es mejor), en una poderosa computadora de escritorio, usando un único hilo:
> jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*encode*
Benchmark Mode Cnt Score Error Units
GeoJSONBenchmarks.encodeMagnolia thrpt 5 70527.223 ± 546.991 ops/s
GeoJSONBenchmarks.encodeShapeless thrpt 5 65925.215 ± 309.623 ops/s
GeoJSONBenchmarks.encodeManual thrpt 5 96435.691 ± 334.652 ops/s
GoogleMapsAPIBenchmarks.encodeMagnolia thrpt 5 73107.747 ± 439.803 ops/s
GoogleMapsAPIBenchmarks.encodeShapeless thrpt 5 53867.845 ± 510.888 ops/s
GoogleMapsAPIBenchmarks.encodeManual thrpt 5 127608.402 ± 1584.038 ops/s
TwitterAPIBenchmarks.encodeMagnolia thrpt 5 133425.164 ± 1281.331 ops/s
TwitterAPIBenchmarks.encodeShapeless thrpt 5 84233.065 ± 352.611 ops/s
TwitterAPIBenchmarks.encodeManual thrpt 5 281606.574 ± 1975.873 ops/s
Vemos que las implementaciones manuales son las que liderean la tabla, seguidas por Magnolia, y con Shapeless de un 30% a un 70% de rendimiento con respecto a las instancias manuales. Ahora para la decodificación
> jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*decode.*Success
Benchmark Mode Cnt Score Error Units
GeoJSONBenchmarks.decodeMagnoliaSuccess thrpt 5 40850.270 ± 201.457 ops/s
GeoJSONBenchmarks.decodeShapelessSuccess thrpt 5 41173.199 ± 373.048 ops/s
GeoJSONBenchmarks.decodeManualSuccess thrpt 5 110961.246 ± 468.384 ops/s
GoogleMapsAPIBenchmarks.decodeMagnoliaSuccess thrpt 5 44577.796 ± 457.861 ops/s
GoogleMapsAPIBenchmarks.decodeShapelessSuccess thrpt 5 31649.792 ± 861.169 ops/s
GoogleMapsAPIBenchmarks.decodeManualSuccess thrpt 5 56250.913 ± 394.105 ops/s
TwitterAPIBenchmarks.decodeMagnoliaSuccess thrpt 5 55868.832 ± 1106.543 ops/s
TwitterAPIBenchmarks.decodeShapelessSuccess thrpt 5 47711.161 ± 356.911 ops/s
TwitterAPIBenchmarks.decodeManualSuccess thrpt 5 71962.394 ± 465.752 ops/s
Esta es una carrera más cerrada por el segundo lugar, con Shapeless y Magnolia
manteniendo el ritmo. Finalmente, la decodificación a partir de un JsValue que
contiene datos inválidos (en una posición intencionalmente torpe)
> jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*decode.*Error
Benchmark Mode Cnt Score Error Units
GeoJSONBenchmarks.decodeMagnoliaError thrpt 5 981094.831 ± 11051.370 ops/s
GeoJSONBenchmarks.decodeShapelessError thrpt 5 816704.635 ± 9781.467 ops/s
GeoJSONBenchmarks.decodeManualError thrpt 5 586733.762 ± 6389.296 ops/s
GoogleMapsAPIBenchmarks.decodeMagnoliaError thrpt 5 1288888.446 ± 11091.080 ops/s
GoogleMapsAPIBenchmarks.decodeShapelessError thrpt 5 1010145.363 ± 9448.110 ops/s
GoogleMapsAPIBenchmarks.decodeManualError thrpt 5 1417662.720 ± 1197.283 ops/s
TwitterAPIBenchmarks.decodeMagnoliaError thrpt 5 128704.299 ± 832.122 ops/s
TwitterAPIBenchmarks.decodeShapelessError thrpt 5 109715.865 ± 826.488 ops/s
TwitterAPIBenchmarks.decodeManualError thrpt 5 148814.730 ± 1105.316 ops/s
Justo cuando pensamos que habíamos visto un patrón, en donde Magnolia y Shapeless ganaban la carrera cuando decodificaban datos inválidos de GeoJSON, entonces vemos que las instancias manuales ganan los retos de Google Maps y Twitter.
Deseamos incluir a scalaz-deriving en la comparación, de modo que comparamos
una implementación equivalente de Equal, probada con dos valores que tienen el
mismo contenido (True) y dos valores que tienen contenidos ligeramente
distintos (False)
> jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*equal*
Benchmark Mode Cnt Score Error Units
GeoJSONBenchmarks.equalScalazTrue thrpt 5 276851.493 ± 1776.428 ops/s
GeoJSONBenchmarks.equalMagnoliaTrue thrpt 5 93106.945 ± 1051.062 ops/s
GeoJSONBenchmarks.equalShapelessTrue thrpt 5 266633.522 ± 4972.167 ops/s
GeoJSONBenchmarks.equalManualTrue thrpt 5 599219.169 ± 8331.308 ops/s
GoogleMapsAPIBenchmarks.equalScalazTrue thrpt 5 35442.577 ± 281.597 ops/s
GoogleMapsAPIBenchmarks.equalMagnoliaTrue thrpt 5 91016.557 ± 688.308 ops/s
GoogleMapsAPIBenchmarks.equalShapelessTrue thrpt 5 107245.505 ± 468.427 ops/s
GoogleMapsAPIBenchmarks.equalManualTrue thrpt 5 302247.760 ± 1927.858 ops/s
TwitterAPIBenchmarks.equalScalazTrue thrpt 5 99066.013 ± 1125.422 ops/s
TwitterAPIBenchmarks.equalMagnoliaTrue thrpt 5 236289.706 ± 3182.664 ops/s
TwitterAPIBenchmarks.equalShapelessTrue thrpt 5 251578.931 ± 2430.738 ops/s
TwitterAPIBenchmarks.equalManualTrue thrpt 5 865845.158 ± 6339.379 ops/s
Como es de esperarse, las instancias manuales están bastante lejos del resto,
con Shapeless liderando las derivaciones automáticas, scalaz-deriving hace un
gran esfuerzo para GeoJSON, pero se queda bastante atrás tanto en la prueba de
Google Maps como en la de Twitter. Las pruebas False son más de lo mismo:
> jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*equal*
Benchmark Mode Cnt Score Error Units
GeoJSONBenchmarks.equalScalazFalse thrpt 5 89552.875 ± 821.791 ops/s
GeoJSONBenchmarks.equalMagnoliaFalse thrpt 5 86044.021 ± 7790.350 ops/s
GeoJSONBenchmarks.equalShapelessFalse thrpt 5 262979.062 ± 3310.750 ops/s
GeoJSONBenchmarks.equalManualFalse thrpt 5 599989.203 ± 23727.672 ops/s
GoogleMapsAPIBenchmarks.equalScalazFalse thrpt 5 35970.818 ± 288.609 ops/s
GoogleMapsAPIBenchmarks.equalMagnoliaFalse thrpt 5 82381.975 ± 625.407 ops/s
GoogleMapsAPIBenchmarks.equalShapelessFalse thrpt 5 110721.122 ± 579.331 ops/s
GoogleMapsAPIBenchmarks.equalManualFalse thrpt 5 303588.815 ± 2562.747 ops/s
TwitterAPIBenchmarks.equalScalazFalse thrpt 5 193930.568 ± 1176.421 ops/s
TwitterAPIBenchmarks.equalMagnoliaFalse thrpt 5 429764.654 ± 11944.057 ops/s
TwitterAPIBenchmarks.equalShapelessFalse thrpt 5 494510.588 ± 1455.647 ops/s
TwitterAPIBenchmarks.equalManualFalse thrpt 5 1631964.531 ± 13110.291 ops/s
El rendimiento en tiempo de ejecución de scalaz-deriving, Magnolia y Shapeless
es normalmente lo suficientemente bueno. Debemos ser realistas: no estamos
escribiendo aplicaciones que deban ser capaces de codificar más de 130,000
valores a JSON, por segundo, en un único núcleo, en la JVM. Si esto es un
problema, probablemente deba usar C++.
Es poco probable que las instancias derivadas sean el cuello de botella de una aplicación. Incluso si lo fueran, existe la posibilidad de usar las soluciones manuales, que son más poderosas y por lo tanto más peligrosas: es fácil introducir errores de dedo, de código, o incluso regresiones de rendimiento por accidente cuando se escriben instancias manuales.
En conclusión: las derivaciones y las macros no son rivales para una instancia manual bien escrita.
8.6 Resumen
Cuando esté decidiendo en una tecnología para usar con derivación de typeclasses, la siguiente gráfica puede ayudar
| Feature | Scalaz | Magnolia | Shapeless | Manual |
|---|---|---|---|---|
@deriving |
sí | sí | sí | |
| Leyes | sí | |||
| Compilación rápida | sí | sí | sí | |
| Nombres de campos | sí | sí | ||
| Anotaciones | sí | parcialmente | ||
| Valores por defecto | sí | con advertencias | ||
| Complicada | dolorosamente | |||
| Rendimiento | no tengo rival |
Prefiera scalaz-deriving de ser posible, usando Magnolia para
codificadores/decodificadores o si el rendimiento es algo que deba considerar,
usando Shapeless únicamente si los tiempos de compilación no son una
preocupación.
Las instancias manuales siempre son una salida de emergencia para casos especiales y para conseguir el mayor rendimiento. Evite introducir errores de dedo al usar una herramienta de generación de código.
9. Alambrando la aplicación
Para finalizar, aplicaremos lo que hemos aprendido para alambrar la aplicación de ejemplo, e implementaremos un cliente y servidor HTTP usando la librería de PF pura http4s.
El código fuente de la aplicación drone-dynamic-agents está disponible junto
con el código fuente del libro en https://github.com/fommil/fpmortals/ bajo el
folder examples. No es necesario estar en una computadora para leer este
capítulo, pero muchos lectores preferirán explorar el código fuente además de
leer este texto.
Algunas partes de la aplicación se han dejado sin implementar, como ejercicios
para el lector. Vea el README para más instrucciones.
9.1 Visión general
Nuestra aplicación principal únicamente requiere de una implementación del
álgebra DynAgents.
trait DynAgents[F[_]] {
def initial: F[WorldView]
def update(old: WorldView): F[WorldView]
def act(world: WorldView): F[WorldView]
}
Ya tenemos una implementación, DynAgentsModule, que requiere implementaciones
de las álgebras Drone y Machines, que requieren álgebras JsonClient,
LocalClock y OAuth2, etc., etc.
Es valioso tener una visión completa de todas las álgebras, módulos e intérpretes de una aplicación. Esta es la estructura del código fuente:
├── dda
│ ├── algebra.scala
│ ├── DynAgents.scala
│ ├── main.scala
│ └── interpreters
│ ├── DroneModule.scala
│ └── GoogleMachinesModule.scala
├── http
│ ├── JsonClient.scala
│ ├── OAuth2JsonClient.scala
│ ├── encoding
│ │ ├── UrlEncoded.scala
│ │ ├── UrlEncodedWriter.scala
│ │ ├── UrlQuery.scala
│ │ └── UrlQueryWriter.scala
│ ├── oauth2
│ │ ├── Access.scala
│ │ ├── Auth.scala
│ │ ├── Refresh.scala
│ │ └── interpreters
│ │ └── BlazeUserInteraction.scala
│ └── interpreters
│ └── BlazeJsonClient.scala
├── os
│ └── Browser.scala
└── time
├── Epoch.scala
├── LocalClock.scala
└── Sleep.scala
Las firmas de todas las álgebras pueden resumirse como
trait Sleep[F[_]] {
def sleep(time: FiniteDuration): F[Unit]
}
trait LocalClock[F[_]] {
def now: F[Epoch]
}
trait JsonClient[F[_]] {
def get[A: JsDecoder](
uri: String Refined Url,
headers: IList[(String, String)]
): F[A]
def post[P: UrlEncodedWriter, A: JsDecoder](
uri: String Refined Url,
payload: P,
headers: IList[(String, String)]
): F[A]
}
trait Auth[F[_]] {
def authenticate: F[CodeToken]
}
trait Access[F[_]] {
def access(code: CodeToken): F[(RefreshToken, BearerToken)]
}
trait Refresh[F[_]] {
def bearer(refresh: RefreshToken): F[BearerToken]
}
trait OAuth2JsonClient[F[_]] {
// same methods as JsonClient, but doing OAuth2 transparently
}
trait UserInteraction[F[_]] {
def start: F[String Refined Url]
def open(uri: String Refined Url): F[Unit]
def stop: F[CodeToken]
}
trait Drone[F[_]] {
def getBacklog: F[Int]
def getAgents: F[Int]
}
trait Machines[F[_]] {
def getTime: F[Epoch]
def getManaged: F[NonEmptyList[MachineNode]]
def getAlive: F[MachineNode ==>> Epoch]
def start(node: MachineNode): F[Unit]
def stop(node: MachineNode): F[Unit]
}
Note que algunas firmas de capítulos previos han sido refactorizadas para usar los tipos de datos de Scalaz, ahora que sabemos porqué son superiores a las alternativas en la librería estándar.
Los tipos de datos son:
@xderiving(Order, Arbitrary)
final case class Epoch(millis: Long) extends AnyVal
@deriving(Order, Show)
final case class MachineNode(id: String)
@deriving(Equal, Show)
final case class CodeToken(token: String, redirect_uri: String Refined Url)
@xderiving(Equal, Show, ConfigReader)
final case class RefreshToken(token: String) extends AnyVal
@deriving(Equal, Show, ConfigReader)
final case class BearerToken(token: String, expires: Epoch)
@deriving(ConfigReader)
final case class OAuth2Config(token: RefreshToken, server: ServerConfig)
@deriving(ConfigReader)
final case class AppConfig(drone: BearerToken, machines: OAuth2Config)
@xderiving(UrlEncodedWriter)
final case class UrlQuery(params: IList[(String, String)]) extends AnyVal
y las typeclasses son
@typeclass trait UrlEncodedWriter[A] {
def toUrlEncoded(a: A): String Refined UrlEncoded
}
@typeclass trait UrlQueryWriter[A] {
def toUrlQuery(a: A): UrlQuery
}
Derivamos typeclasses útiles usando scalaz-deriving y Magnolia. La typeclass
ConfigReader es de la librería pureconfig y se usa para leer la
configuración en tiempo de ejecución a partir de archivos de propiedades HOCON.
Y sin entrar en detalles sobre cómo implementar las álgebras, necesitamos
conocer el grafo de dependencias de nuestro DynAgentsModule.
final class DynAgentsModule[F[_]: Applicative](
D: Drone[F],
M: Machines[F]
) extends DynAgents[F] { ... }
final class DroneModule[F[_]](
H: OAuth2JsonClient[F]
) extends Drone[F] { ... }
final class GoogleMachinesModule[F[_]](
H: OAuth2JsonClient[F]
) extends Machines[F] { ... }
Existen dos módulos que implementan OAuth2JsonClient, uno que usará el álgebra
OAuth2 Refresh (para Google) y otra que reutilice un BearerToken que no
expira (para Drone).
final class OAuth2JsonClientModule[F[_]](
token: RefreshToken
)(
H: JsonClient[F],
T: LocalClock[F],
A: Refresh[F]
)(
implicit F: MonadState[F, BearerToken]
) extends OAuth2JsonClient[F] { ... }
final class BearerJsonClientModule[F[_]: Monad](
bearer: BearerToken
)(
H: JsonClient[F]
) extends OAuth2JsonClient[F] { ... }
Hasta el momento hemos visto requerimientos para F que tienen un
Applicative[F], Monad[F] y MonadState[F, BearerToken]. Todos estos
requerimientos pueden satisfacerse usando StateT[Task, BearerToken, ?] como
nuestro contexto de la aplicación.
Sin embargo, algunas de nuestras álgebras tienen solo un intérprete, usando
Task
final class LocalClockTask extends LocalClock[Task] { ... }
final class SleepTask extends Sleep[Task] { ... }
Pero recuerde que nuestras álgebras pueden proporcionar un liftM en su objeto
compañero, vea el Capítulo 7.4 sobre la Librería de Transformadores de Mónadas,
permitiéndonos elevar un LocalClock[Task] en nuestro contexto deseado
StateT[Task, BearerToken, ?], y todo es consistente.
Tristemente, ese no es el final de la historia. Las cosas se vuelven más
complicadas cuando vamos a la siguiente capa. Nuestro JsonClient tiene un
intérprete que usa un contexto distinto
final class BlazeJsonClient[F[_]](H: Client[Task])(
implicit
F: MonadError[F, JsonClient.Error],
I: MonadIO[F, Throwable]
) extends JsonClient[F] { ... }
object BlazeJsonClient {
def apply[F[_]](
implicit
F: MonadError[F, JsonClient.Error],
I: MonadIO[F, Throwable]
): Task[JsonClient[F]] = ...
}
Note que el constructor BlazeJsonClient devuelve un Task[JsonClient[F]], no
un JsonClient[F]. Esto es porque el acto de creación del cliente es un efecto:
se crean pools de conexiones mutables y se manejan internamente por https.
No debemos olvidar que tenemos que proporcionar un RefreshToken para
GoogleMachinesModule. Podríamos solicitar al usuario hacer todo el trabajo
mecánico, pero somos buenas personas y proveemos una aplicación que usa las
álgebras Auth y Access. LAs implementaciones AuthModule y AccessModule
traen consigo dependencias adicionales, pero felizmente no cambian al contexto
de la aplicación F[_].
final class AuthModule[F[_]: Monad](
config: ServerConfig
)(
I: UserInteraction[F]
) extends Auth[F] { ... }
final class AccessModule[F[_]: Monad](
config: ServerConfig
)(
H: JsonClient[F],
T: LocalClock[F]
) extends Access[F] { ... }
final class BlazeUserInteraction private (
pserver: Promise[Void, Server[Task]],
ptoken: Promise[Void, String]
) extends UserInteraction[Task] { ... }
object BlazeUserInteraction {
def apply(): Task[BlazeUserInteraction] = ...
}
El intérprete para UserInteraction es la parte más compleja de nuestro código:
inicia un servidor HTTP, envía al usuario a visitar una página web en su
navegador, captura un callback en el servidor, y entonces devuelve el resultado
mientras que apaga el servidor web de manera segura.
Más bien que usar un StateT para administrar este estado, usaremos la
primitiva Promise (de ioeffect). Siempre usaremos Promise (o IORef) en
lugar de un StateT cuando estemos escribiendo un intérprete IO dado que nos
permite contener la abstracción. Si fuéramos a usar un StateT, no sólo tendría
un impacto en el rendimiento de la aplicación completa, sino que también habría
una fuga de manejo de stado interno en la aplicación principal, que sería
responsable de proporcionar el valor inicial. Tampoco podríamos usar StateT en
este escenario debido a que requerimos semántica de “espera” que sólo es
proporcionada por Promise.
9.2 Main
La parte más fea de la PF es asegurarse de que las mónadas estén alineadas y eso
tiende a pasar en el punto de entrada Main.
Nuestro ciclo principal es
state = initial()
while True:
state = update(state)
state = act(state)
y las buenas noticias es que el código real se verá como
for {
old <- F.get
updated <- A.update(old)
changed <- A.act(updated)
_ <- F.put(changed)
_ <- S.sleep(10.seconds)
} yield ()
donde F mantiene el estado del mundo en una MonadState[F, WorldView].
Podemos poner esto en un método que se llame .step y repetirlo por siempre al
invocar .step[F].forever[Unit].
Hay dos enfoques que podemos tomar, y exploraremos ambos. El primero, y el más
simple, es construir una pila de mónadas con la que todas las mónadas sean
compatibles. A todo se le agregaría un .liftM para elevarlo a la pila más
general.
El código que deseamos escribir para el modo de autenticación única es
def auth(name: String): Task[Unit] = {
for {
config <- readConfig[ServerConfig](name + ".server")
ui <- BlazeUserInteraction()
auth = new AuthModule(config)(ui)
codetoken <- auth.authenticate
client <- BlazeJsonClient
clock = new LocalClockTask
access = new AccessModule(config)(client, clock)
token <- access.access(codetoken)
_ <- putStrLn(s"got token: $token")
} yield ()
}.run
donde .readConfig y .putStrLn son invocaciones a librerías. Podemos pensar
de ellas como intérpretes Task de álgebras que leen la configuración de tiempo
de ejecución de la aplicación e imprimen una cadena a la pantalla.
Pero este código no compila, por dos razones. Primero, necesitamos considerar lo
que será nuestra pila de mónadas. El constructor BlazeJsonClient devuelve un
Task pero los métodos JsonClient requieren un MonadError[...,
JsonClient.Error]. Este puede ser provisto por EitherT. Por lo tanto podemos
construir la pila de mónadas co,unes para la entera comprensión for`como
type H[a] = EitherT[Task, JsonClient.Error, a]
Tristemente esto significa que debemos llamar .liftM sobre todo lo que
devuelva un Task, lo que agrega mucho código repetitivo y verboso. Además, el
método .liftM no toma un tipo de la forma H[_], sino un tipo de la forma
H[_[_], _], de modo que tenemos que crear un alias de tipo para ayudar al
compilador:
type HT[f[_], a] = EitherT[f, JsonClient.Error, a]
type H[a] = HT[Task, a]
ahora podemos llamar .liftM[HT] cuando recibimos un Task
for {
config <- readConfig[ServerConfig](name + ".server").liftM[HT]
ui <- BlazeUserInteraction().liftM[HT]
auth = new AuthModule(config)(ui)
codetoken <- auth.authenticate.liftM[HT]
client <- BlazeJsonClient[H].liftM[HT]
clock = new LocalClockTask
access = new AccessModule(config)(client, clock)
token <- access.access(codetoken)
_ <- putStrLn(s"got token: $token").liftM[HT]
} yield ()
Pero esto todavía no compila, debido a que clock es un LocalClock[Task] y
AccessModule requiere un LocalClock[H]. Entonces agregamos el código verboso
.liftM necesario al objeto compañero de LocalClock y entonces podemos elevar
el álgebra completa
clock = LocalClock.liftM[Task, HT](new LocalClockTask)
¡y ahora todo compila!
El segundo enfoque para alambrar la aplicación completa es más compleja, pero es necesaria cuando existen conflictos en la pila de la mónada, tales como necesitamos en nuestro ciclo principal. Si realizamos un análisis encontramos que son necesarias las siguientes:
-
MonadError[F, JsonClient.Error]para usos delJsonClient -
MonadState[F, BearerToken]para usos delOAuth2JsonClient -
MonadState[F, WorldView]para nuestro ciclo principal
Tristemente, los dos requerimientos de MonadState están en conflicto.
Podríamos construir un tipo de datos que capture todo el estado del programa,
pero entonces tendríamos una abstracción defectuosa. En lugar de esto, anidamos
nuestras comprensiones for y proporcionamos estado donde sea necesario.
Ahora tenemos que pensar sobre tres capas, que llamaremos F, G, H
type HT[f[_], a] = EitherT[f, JsonClient.Error, a]
type GT[f[_], a] = StateT[f, BearerToken, a]
type FT[f[_], a] = StateT[f, WorldView, a]
type H[a] = HT[Task, a]
type G[a] = GT[H, a]
type F[a] = FT[G, a]
Ahora tenemos algunas malas noticias sobre .liftM… funciona solamente una
capa a la vez. Si tenemos un Task[A] y deseamos un F[A], debemos ir a través
de cada paso y teclear ta.liftM[HT].liftM[GT].liftM[FT]. De manera similar,
cuando elevamos álgebras tenemos que invocar liftM múltiples veces. Para tener
un Sleep[F], tenemos que teclear
val S: Sleep[F] = {
import Sleep.liftM
liftM(liftM(liftM(new SleepTask)))
}
y para obtener un LocalClock[G] tenemos que hacer dos elevaciones
val T: LocalClock[G] = {
import LocalClock.liftM
liftM(liftM(new LocalClockTask))
}
La aplicación principal se vuelve”
def agents(bearer: BearerToken): Task[Unit] = {
...
for {
config <- readConfig[AppConfig]
blaze <- BlazeJsonClient[G]
_ <- {
val bearerClient = new BearerJsonClientModule(bearer)(blaze)
val drone = new DroneModule(bearerClient)
val refresh = new RefreshModule(config.machines.server)(blaze, T)
val oauthClient =
new OAuth2JsonClientModule(config.machines.token)(blaze, T, refresh)
val machines = new GoogleMachinesModule(oauthClient)
val agents = new DynAgentsModule(drone, machines)
for {
start <- agents.initial
_ <- {
val fagents = DynAgents.liftM[G, FT](agents)
step(fagents, S).forever[Unit]
}.run(start)
} yield ()
}.eval(bearer).run
} yield ()
}
donde el ciclo exterior está usando Task, el ciclo de enmedio está usando G,
y el ciclo interno está usando F.
Las llamadas a .run(start) y .eval(bearer) son donde proporcionamos el
estado para las partes StateT de nuestra aplicación. El .run es para revelar
el error EitherT.
Podemos invocar estos dos puntos de entrada para nuestra SafeApp
object Main extends SafeApp {
def run(args: List[String]): IO[Void, ExitStatus] = {
if (args.contains("--machines")) auth("machines")
else agents(BearerToken("<invalid>", Epoch(0)))
}.attempt[Void].map {
case \/-(_) => ExitStatus.ExitNow(0)
case -\/(err) => ExitStatus.ExitNow(1)
}
}
¡y entonces ejecutarla!
> runMain fommil.dda.Main --machines
[info] Running (fork) fommil.dda.Main --machines
...
[info] Service bound to address /127.0.0.1:46687
...
[info] Created new window in existing browser session.
...
[info] Headers(Host: localhost:46687, Connection: keep-alive, User-Agent: Mozilla/5.0 ...)
...
[info] POST https://www.googleapis.com/oauth2/v4/token
...
[info] got token: "<elided>"
¡Yay!
9.3 Blaze
Implementaremos el cliente y el servidor HTTP con la librería de terceros
http4s. Los intérpretes para sus álgebras de cliente y servidor se llaman
Blaze.
Necesitamos las siguientes dependencias
val http4sVersion = "0.18.16"
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-dsl" % http4sVersion,
"org.http4s" %% "http4s-blaze-server" % http4sVersion,
"org.http4s" %% "http4s-blaze-client" % http4sVersion
)
9.3.1 BlazeJsonClient
Necesitaremos algunos imports
import org.http4s
import org.http4s.{ EntityEncoder, MediaType }
import org.http4s.headers.`Content-Type`
import org.http4s.client.Client
import org.http4s.client.blaze.{ BlazeClientConfig, Http1Client }
El módulo Client puede resumirse como
final class Client[F[_]](
val shutdown: F[Unit]
)(implicit F: MonadError[F, Throwable]) {
def fetch[A](req: Request[F])(f: Response[F] => F[A]): F[A] = ...
...
}
donde Request y Responseson los tipos de datos:
final case class Request[F[_]](
method: Method
uri: Uri,
headers: Headers,
body: EntityBody[F]
) {
def withBody[A](a: A)
(implicit F: Monad[F], A: EntityEncoder[F, A]): F[Request[F]] = ...
...
}
final case class Response[F[_]](
status: Status,
headers: Headers,
body: EntityBody[F]
)
construidos a partir de
final case class Headers(headers: List[Header])
final case class Header(name: String, value: String)
final case class Uri( ... )
object Uri {
// not total, only use if `s` is guaranteed to be a URL
def unsafeFromString(s: String): Uri = ...
...
}
final case class Status(code: Int) {
def isSuccess: Boolean = ...
...
}
type EntityBody[F[_]] = fs2.Stream[F, Byte]
El tipo EntityBody es un alias para Stream de la librería
fs2(https://github.com/functional-streams-for-scala/fs2). El tipo de datos
Stream puede pensarse como un torrente de datos con efectos, perezoso, y
basado en tracción (pull-based). Está implementado como una mónada Free con
capacidades para atrapar excepciones e interrupciones. Stream toma dos
parámetros de tipo: un tipo de efecto y un tipo de contenido, y tiene una
representación interna eficiente para hacer un procesamiento por lotes. Por
ejemplo, aunque estemos usando Stream[F, Byte], en realidad está envolviendo
un Array[Byte] desnudo que llega sobre la red.
Tenemos que convertir nuestras representaciones para el encabezado y URL en las versiones requeridas por https4s:
def convert(headers: IList[(String, String)]): http4s.Headers =
http4s.Headers(
headers.foldRight(List[http4s.Header]()) {
case ((key, value), acc) => http4s.Header(key, value) :: acc
}
)
def convert(uri: String Refined Url): http4s.Uri =
http4s.Uri.unsafeFromString(uri.value) // we already validated our String
Tanto nuestro método .get como .post requieren una conversión del tipo
Response de http4s a una A. Podemos refactorizar esto en una única función,
.handler
import JsonClient.Error
final class BlazeJsonClient[F[_]] private (H: Client[Task])(
implicit
F: MonadError[F, Error],
I: MonadIO[F, Throwable]
) extends JsonClient[F] {
...
def handler[A: JsDecoder](resp: http4s.Response[Task]): Task[Error \/ A] = {
if (!resp.status.isSuccess)
Task.now(JsonClient.ServerError(resp.status.code).left)
else
for {
text <- resp.body.through(fs2.text.utf8Decode).compile.foldMonoid
res = JsParser(text)
.flatMap(_.as[A])
.leftMap(JsonClient.DecodingError(_))
} yield res
}
}
El .through(fs2.text.utf8decode) es para convertir un Stream[Task, Byte] en
un Stream[Task, String], con .compile.foldMonoid interpretándolo con nuestra
Task y combinando todas las partes usando el Monoid[String], dándonos un
Task[String].
Entonces analizamos gramaticalmente la cadena como JSON y usamos el
JsDecoder[A] para crear la salida requerida.
Esta es nuestra implementación de .get
def get[A: JsDecoder](
uri: String Refined Url,
headers: IList[(String, String)]
): F[A] =
I.liftIO(
H.fetch(
http4s.Request[Task](
uri = convert(uri),
headers = convert(headers)
)
)(handler[A])
)
.emap(identity)
.get es totalmente código para conectar partes: convertimos nuestros tipos de
entrada en http4s.Request, entonces invocamos .fetch sobre el Client con
nuestro handler. Esto nos da un Task[Error \/ A], pero tenemos que devolver
un F[A]. Por lo tanto usamos el método MonadIO.liftIO para crear un
F[Error \/ A] y entonces usamos .emap para poner el error dentro de la F.
Tristemente, la compilación de este código fallaría si lo intentamos. El error se vería como
[error] BlazeJsonClient.scala:95:64: could not find implicit value for parameter
[error] F: cats.effect.Sync[scalaz.ioeffect.Task]
Básicamente, es porque faltan dependencias del ecosistema de cats.
La razón de esta falla es que la librería http4s está usando una librería de
programación FP diferente, no Scalaz. Felizmente, scalaz-ioeffect proporciona
una capa de compatibilidad y el proyecto
shims proporciona conversiones implícitas
transparentes (hasta que no lo son). Podemos lograr que nuestro código compile
con las siguientes dependencias:
libraryDependencies ++= Seq(
"com.codecommit" %% "shims" % "1.4.0",
"org.scalaz" %% "scalaz-ioeffect-cats" % "2.10.1"
)
y estos imports
import shims._
import scalaz.ioeffect.catz._
La implementación de .post es similar pero también debemos proporcionar una
instancia de
EntityEncoder[Task, String Refined UrlEncoded]
Felizmente, la typeclass EntityEncoder proporciona conveniencias que nos
permiten derivar una a partir de un codificador existente String
implicit val encoder: EntityEncoder[Task, String Refined UrlEncoded] =
EntityEncoder[Task, String]
.contramap[String Refined UrlEncoded](_.value)
.withContentType(
`Content-Type`(MediaType.`application/x-www-form-urlencoded`)
)
La única diferencia entre .get y .post es la forma en que construimos
http4s.Request
http4s.Request[Task](
method = http4s.Method.POST,
uri = convert(uri),
headers = convert(headers)
)
.withBody(payload.toUrlEncoded)
y la pieza final es el constructor, que es un caso de invocar HttpClient con
un objeto de configuración
object BlazeJsonClient {
def apply[F[_]](
implicit
F: MonadError[F, JsonClient.Error],
I: MonadIO[F, Throwable]
): Task[JsonClient[F]] =
Http1Client(BlazeClientConfig.defaultConfig).map(new BlazeJsonClient(_))
}
9.3.2 BlazeUserInteraction
Necesitamos iniciar un servidor HTTP, que es mucho más sencillo de lo que suena. Primero, los imports
import org.http4s._
import org.http4s.dsl._
import org.http4s.server.Server
import org.http4s.server.blaze._
Necesitamos crear una dsl para nuestro tipo de efecto, que entonces importaremos
private val dsl = new Http4sDsl[Task] {}
import dsl._
Ahora podemos usar la dsl de http4s para crear endpoints HTTP. Más bien que describir todo lo que puede hacerse, simplemente implementaremos el endpoint que sea similar a cualquier otro DLS de HTTP
private object Code extends QueryParamDecoderMatcher[String]("code")
private val service: HttpService[Task] = HttpService[Task] {
case GET -> Root :? Code(code) => ...
}
El tipo de retorno de cada empate de patrones es un Task[Response[Task]. En
nuestra implementación deseamos tomar el code y ponerlo en una promesa
ptoken:
final class BlazeUserInteraction private (
pserver: Promise[Throwable, Server[Task]],
ptoken: Promise[Throwable, String]
) extends UserInteraction[Task] {
...
private val service: HttpService[Task] = HttpService[Task] {
case GET -> Root :? Code(code) =>
ptoken.complete(code) >> Ok(
"That seems to have worked, go back to the console."
)
}
...
}
pero la definición de nuestras rutas de servicios no es suficiente, requerimos
arrancar un servidor, lo que hacemos con BlazeBuilder
private val launch: Task[Server[Task]] =
BlazeBuilder[Task].bindHttp(0, "localhost").mountService(service, "/").start
Haciendo un enlace con el puerto 0 hace que el sistema operativo asigne un
puerto efímero. Podemos descubrir en qué puerto está ejecutándose en realidad al
consultar el campo server.address.
Nuestra implementación de los métodos .start y .stop ahora es directa
def start: Task[String Refined Url] =
for {
server <- launch
updated <- pserver.complete(server)
_ <- if (updated) Task.unit
else server.shutdown *> fail("server was already running")
} yield mkUrl(server)
def stop: Task[CodeToken] =
for {
server <- pserver.get
token <- ptoken.get
_ <- IO.sleep(1.second) *> server.shutdown
} yield CodeToken(token, mkUrl(server))
private def mkUrl(s: Server[Task]): String Refined Url = {
val port = s.address.getPort
Refined.unsafeApply(s"http://localhost:${port}/")
}
private def fail[A](s: String): String =
Task.fail(new IOException(s) with NoStackTrace)
El tiempo de 1.second ocupado en dormir es necesario para evitar apagar el
servidor antes de que la respuesta se envío de regreso al navegador. ¡La IO no
pierde el tiempo cuando se trata de rendimiento concurrente!
Finalmente, para crear un BlazeUserInteraction, únicamente requerimos las dos
promesas sin inicializar
object BlazeUserInteraction {
def apply(): Task[BlazeUserInteraction] = {
for {
p1 <- Promise.make[Void, Server[Task]].widenError[Throwable]
p2 <- Promise.make[Void, String].widenError[Throwable]
} yield new BlazeUserInteraction(p1, p2)
}
}
Podríamos haber usado IO[Void, ?] en vez de esto, pero dado que el resto de
nuestra aplicación está usando Task (es decir, IO[Throwable, ?]), invocamos
.widenErrorpara evitar introducir cualquier cantidad de código verboso que nos
podría distraer.
9.4 Gracias
¡Y eso es todo! Felicidades por llegar al final.
Si usted aprendió algo de este libro, por favor diga a sus amigos. Este libro no tiene un departamento de marketing, de modo que sus palabras son necesarias para dar a conocerlo.
Involúcrese en Scalaz al unirse al cuarto de charla de gitter. Ahí puede solicitar consejo, ayudar a los nuevos (ahora usted es un experto), y contribuir al siguiente release.
Tabla de typeclasses
| Typeclass | Método | Desde | Dado | Hacia |
|---|---|---|---|---|
InvariantFunctor |
xmap |
F[A] |
A => B, B => A |
F[B] |
Contravariant |
contramap |
F[A] |
B => A |
F[B] |
Functor |
map |
F[A] |
A => B |
F[B] |
Apply |
ap / <*>
|
F[A] |
F[A => B] |
F[B] |
apply2 |
F[A], F[B] |
(A, B) => C |
F[C] |
|
Alt |
altly2 |
F[A], F[B] |
(A \/ B) => C |
F[C] |
Divide |
divide2 |
F[A], F[B] |
C => (A, B) |
F[C] |
Decidable |
choose2 |
F[A], F[B] |
C => (A \/ B) |
F[C] |
Bind |
bind / >>=
|
F[A] |
A => F[B] |
F[B] |
join |
F[F[A]] |
F[A] |
||
Cobind |
cobind |
F[A] |
F[A] => B |
F[B] |
cojoin |
F[A] |
F[F[A]] |
||
Applicative |
point |
A |
F[A] |
|
Divisible |
conquer |
F[A] |
||
Comonad |
copoint |
F[A] |
A |
|
Semigroup |
append |
A, A |
A |
|
Plus |
plus / <+>
|
F[A], F[A] |
F[A] |
|
MonadPlus |
withFilter |
F[A] |
A => Boolean |
F[A] |
Align |
align |
F[A], F[B] |
F[A \&/ B] |
|
merge |
F[A], F[A] |
F[A] |
||
Zip |
zip |
F[A], F[B] |
F[(A, B)] |
|
Unzip |
unzip |
F[(A, B)] |
(F[A], F[B]) |
|
Cozip |
cozip |
F[A \/ B] |
F[A] \/ F[B] |
|
Foldable |
foldMap |
F[A] |
A => B |
B |
foldMapM |
F[A] |
A => G[B] |
G[B] |
|
Traverse |
traverse |
F[A] |
A => G[B] |
G[F[B]] |
sequence |
F[G[A]] |
G[F[A]] |
||
Equal |
equal / ===
|
A, A |
Boolean |
|
Show |
shows |
A |
String |
|
Bifunctor |
bimap |
F[A, B] |
A => C, B => D |
F[C, D] |
leftMap |
F[A, B] |
A => C |
F[C, B] |
|
rightMap |
F[A, B] |
B => C |
F[A, C] |
|
Bifoldable |
bifoldMap |
F[A, B] |
A => C, B => C |
C |
(con MonadPlus) |
separate |
F[G[A, B]] |
(F[A], F[B]) |
|
Bitraverse |
bitraverse |
F[A, B] |
A => G[C], B => G[D] |
G[F[C, D]] |
bisequence |
F[G[A], G[B]] |
G[F[A, B]] |
Haskell
La documentación de Scalaz con frecuencia cita de librerías o artículos escritos para el lenguaje de programación Haskell. En este breve capítulo, aprenderemos suficiente Haskell para ser capaces de entender el material original, y para asistir a charlas de Haskell en conferencias de programación funcional.
Data
Haskell tiene una sintaxis muy limpia para las ADTs. Esta es una estructura de lista ligada.
data List a = Nil | Cons a (List a)
List es un constructor de tipos, a es el parámetro de tipo, | separa
los constructores de datos, que son: Nil la lista vacía y una celda Cons.
Cons toma dos parámetros, que están separados por espacios en blanco: sin
comas ni paréntesis para los parámetros.
No existen las subclases en Haskell, de modo que no hay tal cosa como el tipo
Nil o el tipo Cons: ambos construyen una List.
Una (posible) traducción a Scala sería:
sealed abstract class List[A]
object Nil {
def apply[A]: List[A] = ...
def unapply[A](as: List[A]): Option[Unit] = ...
}
object Cons {
def apply[A](head: A, tail: List[A]): List[A] = ...
def unapply[A](as: List[A]): Option[(A, List[A])] = ...
}
Es decir, el constructor de tipo es algo como sealed abstract class, y cada
constructor de datos es .apply / .unapply. Note que Scala no realiza un
emparejamiento de patrones exhaustivo sobre esta codificación, por lo que Scalaz
no lo usa.
Podemos usar notación infija: una definición más agradable podría usar el
símbolo :. en lugar de Cons
data List t = Nil | t :. List t
infixr 5 :.
donde especificamos una fijeza (fixity), que puede ser
-
infix: sin asociatividad -
infixl: asociatividad a la izquierda -
infixr: asociatividad a la derecha
Un número de 0 (baja) a 9 (alta) especifica la precedencia. Ahora podemos crear una lista de enteros al teclear
1 :. 2 :. Nil
Haskell ya tiene una estructura de lista ligada, que tan fundamental en la
programación funcional que se le ha asignado una sintaxis con corchetes
cuadrados [a]
data [] a = [] | a : [a]
infixr 5 :
y un constructor conveniente de valores multi-argumentos: [1, 2, 3] en lugar
de 1 : 2 : 3 : []
Al final nuestras ADTs tendrán que almacenar valores primitivos. Los tipos de datos primitivos más comunes son:
-
Char: un caracter unicode -
Text: para bloques de texto unicode -
Int: un entero con signo de precisión fija, dependiente de la máquina -
Word: unIntsin signo, y de tamaños fijosWord8/Word16/Word32/Word64 -
Float/Double: Números de precisión IEEE sencilla y doble -
Integer/Natural: enteros de precisión arbitraria con y sin signo, respectivamente -
(,): tuplas, desde 0 (también conocido como unit) hasta 62 campos -
IOinspiración para laIOde Scalaz, implementada para el entorno de ejecución
con menciones honoríficas para
data Bool = True | False
data Maybe a = Nothing | Just a
data Either a b = Left a | Right b
data Ordering = LT | EQ | GT
Como Scala, Haskell tiene aliases de tipo: un alias o su forma expandida pueden
ser usados de forma intercambiable. Por razones históricas, String está
definido como una lista ligada de Char
type String = [Char]
que es muy ineficiente y siempre desearemos usar Text más bien.
Finalmente, es posible definir nombres de campos en las ADTs usando sintaxis de registros, lo que significa que podemos crear los constructores de datos con llaves y usar anotaciones de tipo con dos puntos dobles para indicar los tipos
-- ADT desnuda
data Resource = Human Int String
data Company = Company String [Resource]
-- Usando la sintaxis de registros
data Resource = Human
{ serial :: Int
, humanName :: String
}
data Company = Company
{ companyName :: String
, employees :: [Resource]
}
Note que el constructor de datos Human y el tipo Resource no tienen el mismo
nombre. La sintaxis de registro genera el equivalente a un método para acceder a
los campos y un método para hacer copias
-- construct
adam = Human 0 Adam
-- field access
serial adam
-- copy
eve = adam { humanName = "Eve" }
Una alternativa más eficiente que las definiciones data de un único campo es
usar un newtype, que no tiene costo adicional en tiempo de ejecución:
newtype Alpha = Alpha { underlying :: Double }
equivalente a extends AnyVal pero sin sus problemas.
Funciones
Aunque no es necesario, es una buena práctica escribir explícitamente la firma
de una función: su nombre seguido de su tipo. Por ejemplo fold especializada
para una lista ligada
foldl :: (b -> a -> b) -> b -> [a] -> b
Todas las funciones usan currying en Haskell, cada parámetro está separado por
una -> y el tipo final es el tipo de retorno. La firma anterior es equivalente
a la siguiente firma de Scala:
def foldLeft[A, B](f: (B, A) => B)(b: B)(as: List[A]): B
Algunas observaciones:
- no se ocupan palabras reservadas
- no hay necesidad de declarar los tipos que se introducen
- no hay necesidad de nombrar los parámetros
lo que resulta en código conciso.
Las funciones infijas están definidas en paréntesis y requieren de una definición de fijeza:
(++) :: [a] -> [a] -> [a]
infixr 5 ++
Las funciones normales pueden invocarse usando notación infija al rodear su nombre con comillas. Las dos formas siguientes son equivalentes:
a `foo` b
foo a b
Una función infija puede invocarse como una función normal si ponemos su nombre entre paréntesis, y puede aplicarse currying ya sea por la izquierda o por la derecha, con frecuencia resultando en semántica distinta:
invert = (1.0 /)
half = (/ 2.0)
Las funciones con frecuencia se escriben con el parámetro más general al principio, para habilitar un máximo reutilización de las formas que usan currying.
La definición de una función puede usar emparejamiento de patrones, con una
linea por caso. Ahí es posible nombrar los parámetros usando los constructores
de datos para extraer los parámetros, de manera muy similar a una cláusula
case de Scala:
fmap :: (a -> b) -> Maybe a -> Maybe b
fmap f (Just a) = Just (f a)
fmap _ Nothing = Nothing
Los guiones bajos sirven para indicar parámetros que son ignorados y los nombres de las funciones pueden estar en posición infija:
(<+>) :: Maybe a -> Maybe a -> Maybe a
Just a <+> _ = Just a
Empty <+> Just a = Just a
Empty <+> Empty = Empty
Podemos definir funciones lambda anónimas con una diagonal invertida, que se parece a la letra griega λ. Las siguientes expresiones son equivalentes:
(*)
(\a1 -> \a2 -> a1 * a2)
(\a1 a2 -> a1 * a2)
Las funciones de Haskell sobre las que se hace un emparejamiento de patrones son conveniencias sintácticas para funciones lambda anidadas. Considere una función simple que crea una tupla dadas tres entradas:
tuple :: a -> b -> c -> (a, b, c)
La implementación
tuple a b c = (a, b, c)
es equivalente a
tuple = \a -> \b -> \c -> (a, b, c)
En el cuerpo de una función podemos crear asignaciones locales de valores con
cláusulas let o where. Las siguientes son definiciones equivalentes de map
para una lista ligada (un apóstrofe es un caracter válido en los identificadores
de nombre):
map :: (a -> b) -> [a] -> [b]
-- explicit
map f as = foldr map' [] as
where map' a bs = f a : bs
-- terser, making use of currying
map f = foldr map' []
where map' a = (f a :)
-- let binding
map f = let map' a = (f a :)
in foldr map' []
-- actual implementation
map _ [] = []
map f (x : xs) = f x : map f xs
if / then / else son palabras reservadas para sentencias condicionales:
filter :: (a -> Bool) -> [a] -> [a]
filter _ [] = []
filter f (head : tail) = if f head
then head : filter f tail
else filter f tail
Un estilo alternativo es el uso de guardas de casos
filter f (head : tail) | f head = head : filter f tail
| otherwise = filter f tail
El emparejamiento de patrones sobre cualquier término se hace con case ... of
unfoldr :: (a -> Maybe (b, a)) -> a -> [b]
unfoldr f b = case f b of
Just (b', a') -> b' : unfoldr f a'
Nothing -> []
Las guardas pueden usarse dentro de los emparejamientos. Por ejemplo, digamos que deseamos tratar a los ceros como un caso especial:
unfoldrInt :: (a -> Maybe (Int, a)) -> a -> [Int]
unfoldrInt f b = case f b of
Just (i, a') | i == 0 -> unfoldrInt f a'
| otherwise -> i : unfoldrInt f a'
Nothing -> []
Finalmente, dos funciones que vale la pena mencionar son ($) y (.)
-- operador de aplicación
($) :: (a -> b) -> a -> b
infixr 0
-- composición de funciones
(.) :: (b -> c) -> (a -> b) -> a -> c
infixr 9
Ambas funciones son alternativas de estilo a los paréntesis anidados.
Las siguientes funciones son equivalentes:
Just (f a)
Just $ f a
así como lo son
putStrLn (show (1 + 1))
putStrLn $ show $ 1 + 1
Existe una tendencia a preferir la composición de funciones con . en lugar de
múltiples $
(putStrLn . show) $ 1 + 1
Typeclasses
Para definir una typeclass usamos la palabra reservada class, seguida del
nombre de la typeclass, su parámetro de tipo, y entonces los miembros requeridos
un una cláusula where.
Si existen dependencias entre las typeclasses, por ejemplo, el hecho de que un
Applicative requiere la existencia de un Functor, llamamos a esta una
restricción y usamos la notación =>:
class Functor f where
(<$>) :: (a -> b) -> f a -> f b
infixl 4 <$>
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
infixl 4 <*>
class Applicative f => Monad f where
(=<<) :: (a -> f b) -> f a -> f b
infixr 1 =<<
Proporcionamos una implementación de una typeclass con la palabra reservada
instance. Si deseamos repetir la firma en las funciones instancias, útiles por
claridad, debemos habilitar la extensión del lenguaje InstanceSigs.
{-# LANGUAGE InstanceSigs #-}
data List a = Nil | a :. List a
-- definidos en otra parte
(++) :: List a -> List a -> List a
map :: (a -> b) -> List a -> List b
flatMap :: (a -> List b) -> List a -> List b
foldLeft :: (b -> a -> b) -> b -> List a -> b
instance Functor List where
(<$>) :: (a -> b) -> List a -> List b
f <$> as = map f as
instance Applicative List where
pure a = a :. Nil
Nil <*> _ = Nil
fs <*> as = foldLeft (++) Nil $ (<$> as) <$> fs
instance Monad List where
f =<< list = flatMap f list
Si tenemos una restricción de typeclass en una función, usamos la misma notación
=>. Por ejemplo podemos definir algo similar al Apply.apply2 de Scalaz
apply2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
apply2 f fa fb = f <$> fa <*> fb
Dado que hemos introducido Monad, es un punto para introducir la notación
do, que fue la inspiración de las comprensiones for de Scala:
do
a <- f
b <- g
c <- h
pure (a, b, c)
que es equivalente a
f >>= \a ->
g >>= \b ->
h >>= \c ->
pure (a, b, c)
donde >>= es =<< con los parámetros en orden contrario
(>>=) :: Monad f => f a -> (a -> f b) -> f b
(>>=) = flip (=<<)
infixl 1 >>=
-- from the stdlib
flip :: (a -> b -> c) -> b -> a -> c
A diferencia de Scala, no es necesario crear variables para los valores unit, o
proporcionar un yield si estamos devolviendo (). Por ejemplo
for {
_ <- putStr("hello")
_ <- putStr(" world")
} yield ()
se traduce como
do putStr "hello"
putStr " world"
Los valores que no son monádicos pueden crearse con la palabra reservada let:
nameReturn :: IO String
nameReturn = do putStr "What is your first name? "
first <- getLine
putStr "And your last name? "
last <- getLine
let full = first ++ " " ++ last
putStrLn ("Pleased to meet you, " ++ full ++ "!")
pure full
Finalmente, Haskell tiene derivación de typeclasses con la palabra reservada
deriving, la inspiración para @scalaz.deriving. Definir las reglas de
derivación es un tema avanzado, pero es fácil derivar una typeclass para una
ADT:
data List a = Nil | a :. List a
deriving (Eq, Ord)
Algebras
En Scala, las typeclasses y las álgebras se definen como una interfaz trait,
Las typeclasses son inyectadas con la característica implicit y las álgebras
se pasan como parámetros explícitos. No hay soporte a nivel de Haskell para las
álgebras: ¡son simplemente datos!
Considere el álgebra simple Console de la introducción. Podemos reescribirla
en Haskell como un registro de funciones:
data Console m = Console
{ println :: Text -> m ()
, readln :: m Text
}
con la lógica de negocios usando una restricción monádica
echo :: (Monad m) => Console m -> m ()
echo c = do line <- readln c
println c line
Una implementación de producción para Console probablemente tendría tipo
Console IO. La función liftIO de Scalaz está inspirada en una función de
Haskell del mismo nombre y puede elevar Console IO a cualquier pila de Mónadas
Avanzadas.
Dos extensiones adicionales del lenguaje hacen que la lógica de negocio sea aún
más limpia. Por ejemplo, RecordWildCards permite importar todos los campos de
un tipo mediante el uso de {..}:
echo :: (Monad m) => Console m -> m ()
echo Console{..} = do line <- readln
println line
NamedFieldPuns requiere que cada campo importado sea listado explícitamente,
lo que es más código verboso pero hace que el código sea más fácil de leer:
echo :: (Monad m) => Console m -> m ()
echo Console{readln, println} = do line <- readln
println line
Mientras en Scala esta codificación podría llamarse Finalmente sin etiquetas, en Haskell es conocida como estilo MTL. Sin entrar en detalles, algunos desarrolladores de Scala no comprendieron un artículo de investigación sobre los beneficios de rendimiento de las ADTs generalizadas en Haskell.
Una alternativa al estilo MTL son los Efectos extensibles, también conocido como estilo de Mónada libre.
Módulos
El código fuente de Haskell está organizado en módulos jerárquicos con la
restricción de que todo el contenido de un module debe vivir en un único
archivo. En la parte superior de un archivo se declara el nombre del module
module Silly.Tree where
Una convención es usar directorios en el disco para organizar el código, de modo
que este archivo iría en Silly/Tree.hs.
Por defecto todos los símbolos en el archivo son exportados pero podemos escoger
exportar miembros específicos, por ejemplo el tipo Tree y los constructores de
datos, y una función fringe, omitiendo sapling:
module Silly.Tree (Tree(..), fringe) where
data Tree a = Leaf a | Branch (Tree a) (Tree a)
fringe :: Tree a -> [a]
fringe (Leaf x) = [x]
fringe (Branch left right) = fringe left ++ fringe right
sapling :: Tree String
sapling = Leaf ""
De manera interesante, podemos exportar símbolos que son importados al módulo, permitiendo que los autores de librerías empaquen su API completa en un único módulo, sin importar cómo fue implementada.
En un archivo distinto podemos importar todos los miembros exportados desde
Silly.Tree
import Silly.Tree
que es aproximadamente equivalente a la sintaxis de Scala import silly.tree._.
Si deseamos restringir los símbolos que importamos podemos proveer una lista
explícita entre paréntesis después del import
import Silly.Tree (Tree, fringe)
Aquí únicamente importamos el constructor de tipo Tree (no los constructores
de datos) y la función fringe. Si desamos importar todos los constructores de
datos (y los emparejadores de patrones) podemos usar Tree(...). Si únicamente
deseamos importar el constructor Branch podemos listarlo explícitamente:
import Silly.Tree (Tree(Branch), fringe)
Si tenemos colisión de nombres sobre un símbolo podemos usar un import
qualified, con una lista opcional de símbolos a importar
import qualified Silly.Tree as T
Ahora podemos acceder a la función fringe con T.fringe.
De manera alternativa, más bien que seleccionar, podemos escoger que no importar
import Silly.Tree hiding (fringe)
Por defecto el módulo Prelude es importado implícitamente pero si agregamos un
import explícito del módulo Prelude, únicamente nuestra versión es usada.
Podemos usar esta técnica para esconder funciones antiguas inseguras
import Prelude hiding ((!!), head)
o usar un preludio personalizado y deshabilitar el preludio por defecto con la
extensión del lenguaje NoImplicitPrelude.
Evaluación
Haskell se compila a código nativo, no hay una máquina virtual, pero existe un recolector de basura. Un aspecto fundamental del ambiente de ejecución es que todos los parámetros se evalúan de manera perezosa por default. Haskell trata todos los términos como una promesa de proporcionar un valor cuando sea necesario, llamado un thunk. Los thunks se reducen tanto como sea necesario para proceder, y no más.
¡Una enorme ventaja de la evaluación perezosa es que es mucho más complicado ocasionar un sobreflujo de la pila! Una desventaja es que existe un costo adicional comparado con la evaluación estricta, por lo que Haskell nos permite optar por la evaluación estricta parámetro por parámetro.
Haskell también está matizado por lo que significa evaluación estricta: se dice que un término está en su forma normal de cabeza débil (WHNF, por sus siglas en inglés) si los bloques de código más externos no pueden reducirse más, y en su forma normal si el término está completamente evaluado. La estrategia de evaluación por defecto de Scala corresponde aproximadamente a la forma normal.
Por ejemplo, estos términos están en forma normal:
42
(2, "foo")
\x -> x + 1
mientras que los siguientes no están en forma normal (todavía pueden reducirse más):
1 + 2 -- reduces to 3
(\x -> x + 1) 2 -- reduces to 3
"foo" ++ "bar" -- reduces to "foobar"
(1 + 1, "foo") -- reduces to (2, "foo")
Los siguientes términos están en WHNF debido a que el código más externo no puede reducirse más (aunque las partes internas puedan):
(1 + 1, "foo")
\x -> 2 + 2
'f' : ("oo" ++ "bar")
La estrategia de evaluación por defecto es no realizar reducción alguna cuando
se pasa un término como parámetro. El soporte a nivel de lenguaje nos permite
solicitar WHNF para cualquier término con ($!)
-- evalúa `a` a WHNF, entonces invoca la función con dicho valor
($!) :: (a -> b) -> a -> b
infixr 0
Podemos usar un signo de admiración ! en los parámetros data:
data StrictList t = StrictNil | !t :. !(StrictList t)
data Employee = Employee
{ name :: !Text
, age :: !Int
}
La extensión StrictData permite parámetros estrictos para todos los datos en
un módulo.
Otra extensión, BangPatterns, permite que ! sea usado en los argumentos de
las funciones. La extensión Strict hace todas las funciones y los parámetros
de datos en el módulo estrictos por defecto.
Yendo al extremo podemos usar ($!!) y la typeclass NFData para evaluación en
su forma normal.
class NFData a where
rnf :: a -> ()
($!!) :: (NFData a) => (a -> b) -> a -> b
que está sujeto a la disponibilidad de una instancia de NFData.
El costo de optar por ser estricto es que Haskell se comporta como cualquier otro lenguaje estricto y puede realizar trabajo innecesario. Optar por la evaluación estricta debe hacerse con mucho cuidado, y únicamente para mejoras de rendimiento medibles. Si está en duda, sea perezoso y acepte las opciones por defecto.
Siguientes pasos
Haskell es un lenguaje más rápido, seguro y simple que Scala y ha sido probado
en la industria. Considere tomar el curso de data61 sobre programación
funcional, y pregunte en el cuarto de
charla #qfpl en freenode.net.
Algunos materiales de aprendizaje adicionales son:
- Programming in Haskell para aprender Haskell a partir de principios primarios.
- Parallel and Concurrent Programming in Haskell y What I Wish I Knew When Learning Haskell para sabiduría intermedia.
- Glasgow Haskell Compiler User Guide y HaskellWiki para los hechos duros.
- Eta, es decir Haskell para la JVM.
Si usted disfruta usar Haskell y entiende el valor que traería a su negocio, ¡dígale a sus managers! De esa manera, el pequeño porcentaje de managers que lideran proyectos de Haskell serán capaces de atraer talento de programación funcional de los muchos equipos que no, y todos serán felices.
Licencias de terceros
Algo del código fuente de este libro ha sido copiado de proyectos de software libre. La licencia de estos proyectos requieren que los siguientes textos se distribuyan con el código que se presenta en este libro.
Scala License
Copyright (c) 2002-2017 EPFL
Copyright (c) 2011-2017 Lightbend, Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the EPFL nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Scalaz License
Copyright (c) 2009-2014 Tony Morris, Runar Bjarnason, Tom Adams,
Kristian Domagala, Brad Clow, Ricky Clarkson,
Paul Chiusano, Trygve Laugstøl, Nick Partridge,
Jason Zaugg
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of
its contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.