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
):
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:
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:
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.
¿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:
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:
que nos permite escribir:
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
.
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.
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
:
Estaríamos violando la pureza y no estaríamos escribiendo código puramente
funcional: futureEcho
es 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[_]
:
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]
:
e invocar echo[IO]
para obtener de vuelta el valor
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
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.
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.
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
).
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
Podemos proporcionar una solución alternativa al definir un val
fuera del
for
o crear un Option
a partir de la asignación inicial
2.1.2 Filter
Es posible poner sentencias if
después de un generador para filtrar los
valores usando un predicado
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.
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.
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í:
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.
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:
Y por último, veamos que sucede con un Future
que falla:
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:
Si tenemos que hacer esto para una versión asíncrona de la misma API
entonces tenemos que ser cuidadosos de no hacer trabajo extra porque
ejecutará ambas consultas. Podemos hacer empate de patrones en el primer resultado pero el tipo es incorrecto
Necesitamos crear un Future
a partir del cache
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
que puede reescribirse de manera asíncrona
Pero si deseamos terminar temprano con un valor de retorno exitoso, el código síncrono simple:
se traduce a for
comprehension anidados cuando nuestras dependencias son
asíncronas:
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.
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.
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, _]
.
.run
nos devuelve el contexto original.
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):
y así podemos mezclar métodos que devuelven un Option
simple al envolverlos en
un Future.successful
(.pure[Future]
) seguido de un OptionT
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
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
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:
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.
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.
Ahora estamos listos para escribir nuestra lógica de negocio, pero necesitamos
indicar que dependemos de Drone
y de Machines
.
Podemos escribir la interfaz para nuestra lógica de negocio
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.
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)
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.
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.
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:
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.
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.
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
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:
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:
Entonces podemos crear pruebas más avanzadas de los métodos update
y act
,
ayudándonos a eliminar bugs y refinar los requerimientos:
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:
y también puede usar notación infija:
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:
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:
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 class
también conocidos como productos -
sealed abstract class
también conocidos como coproductos -
case object
eInt
,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
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
.:
4.1.2 Funciones sobre ADTs
Las ADTs pueden tener funciones puras
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,
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
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
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:
Esto es útil al crear coproductos anónimos cuando no podemos poner todas las implementaciones en el mismo archivo de código fuente.
Otra forma de coproducto alternativa es creat un sealed abstract class
especial con definiciones final case class
que simplemente envuelvan a los
tipos deseados:
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,
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:
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
):
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
y los siguientes imports
Refined
permite definir Person
usando tipos ad hoc refinados para capturar
los requerimientos de manera exacta, escrito A Refined B
.
El valor subyacente puede obtenerse con .value
. Podemos construir un valor en
tiempo de ejecución usando .refineV
, que devuelve un Either
Si agregamos el siguiente import
podemos construir valores válidos en tiempo de compilación y obtener errores si el valor provisto no cumple con los requerimientos
Es posible codificar requerimientos más completos, por ejemplo podríamos usar la
regla MaxSize
que con los siguientes imports
que captura los requerimientos de que String
debe no ser vacía y además tener
un máximo de 10 caracteres.
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:
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:
-
Unit
tien un solo valor (razón por la cual se llama “unit”) -
Boolean
tiene dos valores -
Int
tiene 4,294,967,295 valores -
String
tiene 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 => Boolean
tiene complejidad 2 -
Boolean => Boolean
tiene complejidad 4 -
Option[Boolean] => Option[Boolean]
tiene complejidad 27 -
Boolean => Int
es como un quintillón, aproximándose a un sextillón. -
Int => Boolean
es 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 => B
conb ^ 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:
Podríamos convertir y reordenar
y convertir de vuelta a tipos y obtener
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
es equivalente a
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
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
.
Sin embargo, el uso de métodos en object
s 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:
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:
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:
Podemos ver las características clave de una typeclass en acción:
- no hay estado
-
Ordering
yNumeric
tienen un parámetro de tipoT
-
Ordering
tiene uncompare
abstracto, yNumeric
tiene unplus
,times
,negate
yzero
abstractos. -
Ordering
define unlt
generalizado, ygt
basados encompare
,Numeric
defineabs
en términos delt
,negate
yzero
. -
Numeric
extiendeOrdering
.
Ahora podemos escribir funciones para tipos que “tengan un(a)” typeclass
Numeric
:
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
”
pero ahora tenemos que usar implicitly[Numeric[T]]
en todos lados. Al definir
el código repetitivo en el companion object de la typeclass
podemos obtener el implícito con menos ruido
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:
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:
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:
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:
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)
Podríamos crear nuestra propia estructura de datos para números complejos:
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
.
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
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 A
s.
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
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:
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:
y entonces se tiene una respuesta JSON en el payload
Todas las peticiones al servidor, provenientes del usuario, deben incluir el encabezado
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
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:
Necesitamos instancias de JsDecoder[AccessResponse]
y
JsDecoder[RefreshResponse]
. Podemos hacer esto mediante el uso de una función
auxiliar:
Ponemos las instancias de los companions en nuestros tipos de datos, de modo que siempre estén en el alcance/ámbito implícito:
Podemos entonces parsear una cadena en un AccessResponse
o una
RefreshResponse
Es necesario escribir nuestra propia typeclass para codificación de URL y POST. El siguiente fragmento de código es un diseño razonable:
Es necesario proporcionar instancias de una typeclass para los tipos básicos:
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:
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
:
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
Casi suena fácil cuando lo escribimos de esta manera.
También requerimos de un álgebra para abstraer el sistema local de tiempo
E introducimos los tipos de datos que usaremos en la lógica de refresco
y ahora podemos escribir un módulo cliente para OAuth2:
4.4 Resumen
- Los ADTs (tipos de datos algebraicos) están definidos como productos
(
final case class
) y coproductos (sealed abstract class
). - Los tipos
refined
hacen 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.typeclass
genera.ops
en 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
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
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
.
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.
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.
Si escribimos un método que tome templates: List[TradeTemplate]
, entonces
necesitaremos llamar únicamente
¡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:
Sin embargo, esto no hace lo que queremos porque Monoid[Option[A]]
realizará
una agregación de su contenido, por ejemplo,
mientras que deseamos implementar la regla del “último gana”. Podríamos hacer un
override del valor default Monoid[Option[A]]
con el nuestro propio:
Ahora todo compila, de modo que si lo intentamos…
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.
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 === f2
implicaf2 === f1
-
reflexivo
f === f
-
transitivo
f1 === f2 && f2 === f3
implica 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
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:
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
:
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
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
:
El map
también debe realizar una operación nula si la función provista es la
identidad (es decir, x => x
)
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:
-
void
toma una instancia deF[A]
y siempre devuelve unF[Unit]
, y se olvida de todos los valores a la vez que preserva la estructura. -
fproduct
toma la misma entrada quemap
pero 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. -
fpair
repite todos los elementos deA
en una tuplaF[(A, A)]
-
strengthL
empareja el contenido de unaF[B]
con una constanteA
a la izquierda. -
strenghtR
empareja el contenido de unaF[A]
con una constanteB
a la derecha. -
lift
toma una funciónA => B
y 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. -
mapply
nos obliga a pensar un poco. Digamos que tenemos unaF[_]
de funcionesA => B
y el valorA
, entonces podemos obtener unF[B]
. Tiene una firma/signatura similar a la depure
pero 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:
.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:
Esto nos permitió escribir lógica breve de negocios como
y
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
:
y
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:
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:
Recuerde que cuando aprendimos sobre Monoid
, escribimos lo siguiente:
Sabemos que esto es tonto y que pudimos escribir:
.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
que es una versión generalizada del método de la librería estándar mkString
:
El foldLeft
proporciona el medio para obtener cualquier elemento mediante un
índice para recorrer la estructura, incluyendo un grupo grande de métodos
relacionados:
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
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
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
por ejemplo
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.
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.
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.
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
:
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
:
5.4.3 Traverse
Traverse
es lo que sucede cuando hacemos el cruce de un Functor
con un
Foldable
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:
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!).
es decir, se trata de una codificación con datos de un OR
lógico inclusivo.
A
o B
o ambos A
y B
.
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:
y un Map[K, Int]
simplemente totaliza sus contenidos cuando hace la unión
.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
Existen variantes convenientes de align
que se aprovechan de la estructura de
\&/
las cuáles deberían tener sentido a partir de las firmas/signaturas de tipo. Ejemplos:
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
:
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:
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
JsDecoder
para unDouble
, y una manera de ir de unDouble
a unAlpha
, entonces yo puedo darte unJsDecoder
para unAlpha
”. - “si me das un
JsEncoder
par unDouble
, y una manera de ir de unAlpha
a unDouble
, entonces yo puedo darte unJsEncoder
para unAlpha
”.
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.
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
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:
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
C
a partir de sus constituyentesA
yB
- 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:
que es exactamente lo que se usó en el Capítulo 3:
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:
y se usa así:
o haga una invocación directa de `applyX
A pesar de ser más común su uso con efectos, Apply
funciona también con
estructuras de datos. Considere reescribir
como
Si nosotros deseamos únicamente la salida combinada como una tupla, existen métodos para hacer sólo eso:
Existen también versiones generalizadas de ap
para más de dos parámetros:
junto con métodos .lift
que toman funciones normales y las alzan al contexto
F[_]
, la generalización de Functor.lift
y .apF
, una sintáxis parcialmente aplicada para ap
Finalmente .forever
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.
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:
ifM
y ap
están optimizados para crear un cache y reusar las ramas de código,
compare a la forma más larga
que produce una nueva List(0)
o List(1, 1)
cada vez que se invoca una
alternativa.
Bind
también tiene sintaxis especial
>>
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
.
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
, (dondefa
está enF[A]
) es decir, aplicarpure(identity)
no realiza ninguna operación. -
Homomorfismo:
pure(a) <*> pure(ab) === pure(ab(a))
(dondeab
es unaA => B
), es decir aplicar una funciónpure
a un valorpure
es lo mismo que aplicar la función al valor y entonces usarpure
sobre el resultado. -
Intercambio:
pure(a) <*> fab === fab <*> pure (f => f(a))
, (dondefab
es unaF[A => B]
), es decirpure
es 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))
dondefa
es unaF[A]
,f
es unaA => F[B]
yg
es 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
como
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
como
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
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
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
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:
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
.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:
Aunque superficialmente pueda parecer como si <+>
se comportara como |+|
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:
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
:
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.
Applicative
y Monad
tienen versiones especializadas de PlusEmpty
.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:
withFilter
nos 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:
msuml
realiza un fold
utilizando el Monoid
de PlusEmpty[G]
y collapse
realiza un foldRight
usando PlusEmpty
del tipo target:
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
El método esencial zip
que es una versión menos poderosa que Divide.tuple2
,
y si un Functor[F]
se proporciona entonces zipWith
puede 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
.
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:
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
.
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
por ejemplo
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
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
.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
).
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
.
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
)
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
Ahora podemos implementar una Comonad[Hood]
cojoin
nos da proporciona una Hood[Hood[IList]]
que contiene todas las
posibles vecindades en nuestra IList
.
En verdad, ¡cojoin
es simplemente positions
! Podríamos hacer un override
con una implementación más directa y eficiente
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
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.
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
.
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
.
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
.
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
N
cosas:
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.
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:
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:
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:
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:
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:
The A
introducida en .flatten
está ocultando la A
introducida por la
clase. Es equivalente a escribir
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).
=:=
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
Scalaz mejora sobre <:<
y =:=
con Liskov (que tiene el alias <~<
) y
Leibniz (====
).
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
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
Monad
Comonad
Traverse1
Align
-
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:
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:
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.
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:
Algunas etiquetas/tags útiles se proporcionan en el objeto Tags
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
.
y esto nos permite escribir una implementación mucho más limpia de
Monoid[TradeTemplate]
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[_]
.
Un ejemplo de una transformación natural es la función que convierte una IList
en una List
.
O, de manera más concisa, usando las conveniencias sintácticas proporcionadas
por el plugin kind-projector
:
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
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:
Si introducimos un isomorfismo, con frecuencia podemos generar muchas de las typeclases estándar. Por ejemplo,
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.
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
.
Maybe
tiene una instancia de typeclass para todas las cosas
Align
Traverse
-
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.
.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
.
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
.
con la sintaxis correspondiente
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
.
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
Plus
Optional
Cozip
y dependiendo del contenido
-
Equal
/Order
-
Semigroup
/Monoid
/Band
Además, hay métodos especiales
.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.
6.7.3 Validation
A primera vista, Validation
(con alias simbólico \?/
, Elvis feliz) aparece
ser un clon de Disjunction
:
Con sintáxis conveniente
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
Cozip
Plus
Optional
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
:
Si usamos la sintáxis |@|
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:
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:
.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
y con una sintáxis para una construcción conveniente
These
tiene instancias de una typeclass para
Monad
Bitraverse
Traverse
Cobind
y dependiendo del contenido
-
Semigroup
/Monoid
/Band
-
Equal
/Order
Show
These
(\&/
) tiene muchos de los métodos que que esperamos de ``Disjunction
(
\/) y
Validation (
?/`)
.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
.
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:
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:
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
.
Const
proporciona una instancia de Applicative[Const[A, ?]]
si hay un
Monoid[A]
disponible:
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:
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:
Con nuestra interpretación de nuestro programa, podemos realizar aserciones sobre los métodos que son invocados:
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
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í:
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:
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:
La creación de una instancia de List
requiere de la creación cuidadosa, y
lenta, de sincronización de Thread
s 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
.
.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:
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:
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:
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.
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
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:
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]
El cálculo equivalente es (los símbolos son creados a partir de
DList.fromIList
)
que reparte el trabajo en appends asociativos a la derecha (es decir, rápidos)
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
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.
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.
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
.
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 | right
deBin
- 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
.
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:
El tercer caso es cuando esto empieza a ponerse interesante: left
es un Bin
conteniendo únicamente un Bin
a su right
.
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.
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.
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:
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
.
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.
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
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
¡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.
Tree
es una versión by need (por necesidad) de StrictTree
con
constructores convenientes
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:
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
.
Por ejemplo, Reducer[A, IList[A]]
puede proporcionar un .cons
eficiente
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:
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
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.
Por ejemplo, la instancia Cord[String]
devuelve una Three
con la cadena a la
mitad y comillas en ambos lados
Por lo tanto una String
se muestra tal y como se escribe en el código fuente
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
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
):
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:
Cuando insertamos una nueva entrada, comparamos el valor mínimo actual y lo reemplazamos si la nueva entrada es más pequeña:
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:
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.
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.
Un gran caso de uso para Diev
es para almacenar periodos de tiempo. Por
ejemplo, en nuestro TradeTemplate
del capítulo anterior
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
.
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:
El método .interpret
se llama únicamente una vez, en el punto de entrada de
una aplicación.
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
):
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:
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
:
La ADT Free
es una es una representación del tipo de datos natural de la
interfaz Monad
:
-
Return
representa.point
-
Gosub
representa.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.
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
):
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
Sin embargo, la implementación actual se ve más parecida a:
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
:
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:
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
):
.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.pure
usando la sintaxis deMonad
- A partir de
F[A]
, usando.liftM
usando 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.
proporcionando una MonadPlus
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:
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
:
Sin embargo, si tenemos una MonadPlus
como nuestro contexto, podemos poner
Maybe
dentro de F[_]
usando .orEmpty
, y olvidarnos del asunto:
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:
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]
Monad
es una MonadError
.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:
.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:
No debería sorprender que podamos reescribir el ejemplo con MonadPlus
con
MonadError
, insertando mensajes informativos de error:
donde .orError
es un método conveniente sobre Maybe
La versión que usa EitherT
directamente es:
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,
Nuestras pruebas unitarias para .stars
pueden cubrir estos casos:
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
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, ?]
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:
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:
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:
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.
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
Hemos reinventado MonadReader
, la typeclass que está asociada a ReaderT
,
donde .ask
es la misma que nuestra .token
y S
es RefreshToken
:
con la implementación
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:
y entonces refactorizar el parámetro refresh
para que sea parte de Monad
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
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:
y la podemos usar para envolver funciones que operan en este contexto
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
.
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.
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
MonadTell
es para escribir al journal y MonadListen
es para recuperarlo. La
implementación de WriterT
es
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
y usar Dequeue[Log]
como nuestro tipo de journal. Podríamos cambiar nuestro
método OAuth2 authenticate
a
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)
.
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
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:
que es una forma especializada de Trampoline
, proporcionandonos seguridad en
el uso de la pila cuando deseamos recuperar la estructura de datos subyacente,
.run
:
StateT
puede implemnentar de manera directa MonadState
con su ADT:
con .pure
reflejado en el objeto compañero como .stateT
:
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
:
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`.
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:
Dado que estamos escribiendo una simulación del mundo para nuestras pruebas, podemos crear un tipo de datos que capture la realidad de todo
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:
y podemos reescribir nuestras pruebas para seguir la convención donde:
-
world1
es el estado del mundo antes de ejecutar el programa -
view1
es la creencia/visión de la aplicación sobre el mundo -
world2
es el estado del mundo después de ejecutar el programa -
view2
es la creencia/visión sobre la aplicación después de ejecutar el programa
Por ejemplo,
Esperemos que el lector nos perdone al mirar atrás a nuestro bucle anterior con implementación de lógica de negocio
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
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:
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 Int
a 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:
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.
y entonces revisitar nuestra álgebra
lo que nos ocasionará un error en tiempo de compilación si intentamos realizar
un .update
sin un .lock
pero nos permite construir funciones que pueden componerse al incluirlas explícitamente en su estado:
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.
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:
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
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
y podríamos hacer que el cómputo fuera puro al introducir el contexto F[_]
y refactorizar para devolver una función para la entrada provista
ContT
es simplemente un contenedor con esta signatura, con una instancia de
Monad
y sintaxis conveniente para crear una ContT
a partir de un valor monádico:
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:
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
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:
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
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!
O podemos mantenernos dentro del flujo original y reintentar todo lo que sigue
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.
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
)
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
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
Monad
significan 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
IO
y ahora es necesario envolverla dentro deStateT
yEitherT
incluso 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.
StateT
es particularmente malo pero inclusoEitherT
puede 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
y algunos tipos de datos
que deseemos usar en nuestra lógica de negocios
El primer problema que encontramos es que esto no compila
Existen algunas soluciones tácticas a este problema. La más obvia es hacer que todos los parámetros sean explícitos
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.
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.
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
y una derivación de la typeclass dada por una MonadError
y MonadState
Ahora, si deseamos acceder a S
o E
podemos obtenerlas por medio de F.S
o
F.E
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
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
pero deseamos que nuestro contexto sea
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]
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
podemos abstraer sobre MonadTrans
para elevar un Lookup[F]
a cualquier
Lookup[G[F, ?]]
donde G
es un transformador de mónadas.
Permitiéndonos envolver una vez para EitherT
, y de nuevo para StateT
Otra forma de lograr esto, en un único paso, es usar MonadIO
que permite
elevar una IO
en una pila de transformadores:
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:
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
-
Suspend
representa un programa que todavía no ha sido interpretado -
Return
es.pure
-
Gosub
es.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
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:
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
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
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:
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
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):
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:
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:
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
y tenemos algunas implementaciones existentes de Machines
y Drone
, y podemos
crear interpretes a partir de ellos:
y combinarlos en un conjunto de instrucciones más grande usando un método
conveniente de nuestro objeto compañero en NaturalTransformation
Y entonces usarlo para produir un IO
¡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
que puede ser usado para probar nuestro program
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
:
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
o combinar las transformaciones naturales y ejecutarlas con un único
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
#c0fee
para 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:
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:
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
:
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…
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
Para empezar, crearemos el código repetitivo de lift
para una álgebra nueva
Batch
y entonces crearemos una instancia de DynAgentsModule
con FreeAp
como el
contexto
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
:
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:
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.
También necesitamos remover todas las llamadas a Machines.Start
, lo cual
podemos hacer con una transformación natural
Ahora tenemos dos programas, y necesitamos combinarlos. Recuerde la sintaxis
*>
de Apply
Poniéndolo todo junto en un mismo método:
¡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[_]
y también existe una versión contravariante
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:
Una optimización que obtenemos al usar Coyoneda
es la fusión de mapeos (y
fusión de contramapeos), lo que nos permite reescribir
en
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
:
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
.map
sobre una colección de efectos, devolviendo un único efecto. Esto se consigue por medio de.traverse
, que delega al método.apply2
del 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:
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
Los programas monádicos pueden entonces demandar un Par
implícito además de su
Monad
La sintaxis de Traverse
de Scalaz soporta paralelismo:
Si el implícito Applicative.Par[IO]
está dentro del alcance, podemos escoger
entre recorrer de manera secuencial o paralela:
De manera similar, podemos invocar .parApply
o .parTupled
después de usar
los operadores de grito
Es digno de mención que cuando tenemos programas Applicative
, tales como
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
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
y proporcionamos nuestra propia implementación de Monad
que ejecute .apply2
en paralelo al delegar a la instancia @@ Parallel
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
:
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:
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.
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:
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:
con constructores convenientes para Task
:
Los constructores más comunes, por mucho, cuando es necesario lidiar con código
antiguo, son Task.apply
y Task.fromFuture
:
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
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:
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:
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
.
Cuando tenemos una Fiber
podemos invocar .join
para regresar a la IO
, o
interrumpt
el trabajo subyacente.
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.
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.
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.
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
Podemos hacer uso de esta implementación optimizada de MonadState
en un
SafeApp
, donde nuestro .program
depende de typeclasses optimizadas MTL:
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
con un cambio menor en el código repetitivo del objeto compañero de nuestra
álgebra, tomando en cuenta la E
extra:
7.8 Resumen
- El
Future
está 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
Free
nos permiten analizar, optimizar y probar fácilmente nuestros programas. -
IO
nos da la habilidad de implementar álgebras como efectos sobre el mundo. -
IO
puede 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
implicit
es 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:
8.2 scalaz-deriving
La librería scalaz-deriving
es una extensión de Scalaz y puede agregarse al
build.sbt
con
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:
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
:
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:
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
.
Ahora podríamos derivar un Default[Foo]
Si una typeclass tiene parámetros en posiciones tanto covariantes como
contravariantes, como es el caso con Semigroup
, podría proporcionar un
InvariantFunctor
y podemos invocar .xmap
Generalmente, es más simple usar .xmap
en lugar de .map
o .contramap
:
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
falla al compilar con
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]
:
Ahora tenemos acceso a la sintaxis .emap
y podemos derivar nuestro tipo
refinado
De hecho, podemos proporcionar una regla de derivación para todos los tipos refinados
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.
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
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:
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)
y entonces derivar Equal[Bar]
porque ya existe un Equal
para todas las
tuplas:
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
:
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:
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:
El equivalente para los parámetros de tipo en posición covariante es
Applicative
:
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
De un lado de las leyes de composición, para una entrada de tipo String
,
tenemos
y por el otro
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:
Por otro lado, una prueba similar de JsDecoder
cumple las leyes de composición
de Applicative
para un poco de valores de prueba
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:
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):
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!
Para un ADT
donde los productos (Vader
y JarJar
) tienen un Equal
podemos derivar la instancia de Equal
para la ADT completa
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]
:
Permitiéndonos derivar nuestro Default[Darth]
Regresando a las typeclasses de scalaz-deriving
, los padres invariantes de
Alt
y Decidable
son:
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:
-
TList
que 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
que puede ser instanciada como:
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:
Con esto fuera del camino podemos llamar la API Deriving
para Equal
, posible
porque scalaz-deriving
proporciona una instancia optimizada de
Deriving[Equal]
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:
y entonces invocarlo desde los objetos compañeros
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:
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:
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
.eq
antes de aplicarEqual.equal
, ejecutando un atajo al verificar la igualdad entre valores idénticos. -
Foldable.all
permitiendo una salida temprana cuando cualquier comparación esfalse
, por ejemplo si los primeros campos no empatan, ni siquiera pedimosEqual
para los valores restantes.
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
.
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
:
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
Un autor de typeclasses implementaría los siguientes miembros:
La API de Magnolia es:
con auxiliares
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:
Empiece con un JsEncoder
que maneja únicamente nuestras elecciones razonables:
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.
Para el decodificador usamos .constructMonadic
que tiene una firma de tipo
similar a .traverse
De nuevo, agregando soporte para las preferencias del usuario y valores por defecto para los campos, junto con algunas optimizaciones:
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
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
la deriving-macro
llamará al método provisto por el usuario:
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
Los usuarios pueden importar estos métodos en su alcance y obtener derivación mágica en el punto de uso
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
si olvidamos proporcionar una derivación implícita para Option
. Esperaríamos
que Foo(Some("hello")
se viera como
Pero en realidad sería la siguiente
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
En el centro de Shapeless están los tipos de datos HList
y Coproduct
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:
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.
Existe un LabelledGeneric
complementario que incluye los nombres de los campos
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:
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.
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:
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:
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
Implementamos estos métodos
y para los coproductos deseamos implementar estas firmas
.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:
Para el caso del coproducto sólo podemos comparar dos cosas si están alineadas,
que ocurre cuando son tanto In1
o Inr
¡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
Tenemos que proporcionar instancias en los objetos compañeros:
Pero no siempre compila
¡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
o podemos usar la macro Generic
para ayudarnos y dejar que el compilador
infiera la representación genérica
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)
se convierte en
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
).
y escribir
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
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:
Mientras estábamos haciendo esto, optimizamos usando el atajo quick
de
scalaz-deriving
.
Ahora podemos llamar
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.
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
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:
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
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
.
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.
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
y
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
y
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
Esperaríamos ser capaces de derivar un JsDecoder
para algo como nuestra
TradeTemplate
del Capítulo 5
Pero obtenemos el siguiente error de compilación
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:
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:
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:
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.
Debemos mover los métodos .hcons
y .hnil
al objeto compañero de la nueva
typeclass sellada, que puede manejar valores por defecto
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 @@
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
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:
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
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
Esperaríamos que la forma codificada derivada de manera completamente automática se viera como
debido a que hemos usado xderiving
para Foo
. Pero podría más bien ser
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
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
ADT
a unJsValue
- una decodificación exitosa del mismo valor
JsValue
al ADT - una decodificación fallida de un
JsValue
con 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:
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
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)
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
)
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:
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
.
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:
Las firmas de todas las álgebras pueden resumirse como
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:
y las typeclasses son
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
.
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).
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
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
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[_]
.
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
y las buenas noticias es que el código real se verá como
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
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
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:
ahora podemos llamar .liftM[HT]
cuando recibimos un Task
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
¡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
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
y para obtener un LocalClock[G]
tenemos que hacer dos elevaciones
La aplicación principal se vuelve”
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
¡y entonces ejecutarla!
¡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
9.3.1 BlazeJsonClient
Necesitaremos algunos imports
El módulo Client
puede resumirse como
donde Request
y Response
son los tipos de datos:
construidos a partir de
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:
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
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
.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
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:
y estos imports
La implementación de .post
es similar pero también debemos proporcionar una
instancia de
Felizmente, la typeclass EntityEncoder
proporciona conveniencias que nos
permiten derivar una a partir de un codificador existente String
La única diferencia entre .get
y .post
es la forma en que construimos
http4s.Request
y la pieza final es el constructor, que es un caso de invocar HttpClient
con
un objeto de configuración
9.3.2 BlazeUserInteraction
Necesitamos iniciar un servidor HTTP, que es mucho más sencillo de lo que suena. Primero, los imports
Necesitamos crear una dsl
para nuestro tipo de efecto, que entonces importaremos
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
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
:
pero la definición de nuestras rutas de servicios no es suficiente, requerimos
arrancar un servidor, lo que hacemos con BlazeBuilder
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
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
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
.widenError
para 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.
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:
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
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
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]
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
: unInt
sin 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 -
IO
inspiración para laIO
de Scalaz, implementada para el entorno de ejecución
con menciones honoríficas para
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
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
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
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:
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
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:
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:
Las funciones normales pueden invocarse usando notación infija al rodear su nombre con comillas. Las dos formas siguientes son equivalentes:
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:
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:
Los guiones bajos sirven para indicar parámetros que son ignorados y los nombres de las funciones pueden estar en posición infija:
Podemos definir funciones lambda anónimas con una diagonal invertida, que se parece a la letra griega λ. Las siguientes expresiones son equivalentes:
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:
La implementación
es equivalente a
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):
if
/ then
/ else
son palabras reservadas para sentencias condicionales:
Un estilo alternativo es el uso de guardas de casos
El emparejamiento de patrones sobre cualquier término se hace con case ... of
Las guardas pueden usarse dentro de los emparejamientos. Por ejemplo, digamos que deseamos tratar a los ceros como un caso especial:
Finalmente, dos funciones que vale la pena mencionar son ($)
y (.)
Ambas funciones son alternativas de estilo a los paréntesis anidados.
Las siguientes funciones son equivalentes:
así como lo son
Existe una tendencia a preferir la composición de funciones con .
en lugar de
múltiples $
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 =>
:
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
.
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
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:
que es equivalente a
donde >>=
es =<<
con los parámetros en orden contrario
A diferencia de Scala, no es necesario crear variables para los valores unit, o
proporcionar un yield
si estamos devolviendo ()
. Por ejemplo
se traduce como
Los valores que no son monádicos pueden crearse con la palabra reservada let
:
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:
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:
con la lógica de negocios usando una restricción monádica
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 {..}
:
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:
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
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
:
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
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
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:
Si tenemos colisión de nombres sobre un símbolo podemos usar un import
qualified
, con una lista opcional de símbolos a importar
Ahora podemos acceder a la función fringe
con T.fringe
.
De manera alternativa, más bien que seleccionar, podemos escoger que no importar
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
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:
mientras que los siguientes no están en forma normal (todavía pueden reducirse más):
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):
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 ($!)
Podemos usar un signo de admiración !
en los parámetros data
:
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.
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.