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: