Tabla de contenidos
- Introducción
-
Empezando
- Objetivos del capítulo
- Introducción
- Instalando PureScript
- Instalando las herramientas
- ¡Hola, PureScript!
- Compilando para el navegador
- Quitando código no usado
- Compilando módulos CommonJS
- Seguimiento de dependencias con Bower
- Calculando diagonales
- Probando el código usando el modo interactivo
- Conclusión
-
Funciones y registros (records)
- Objetivos del capítulo
- Preparación del proyecto
- Tipos simples
- Tipos cuantificados
- Notas sobre la sangría (indentation)
- Definiendo nuestros tipos
- Constructores de tipo (type constructors) y familias (kinds)
- Mostrando entradas de la agenda
- Prueba temprano, prueba a menudo
- Creando agendas
- Funciones currificadas (curried functions)
- Consultando la agenda
- Aplicación de funciones infija
- Composición de funciones
- Prueba, prueba, prueba…
- Conclusión
-
Recursividad, asociaciones (maps) y pliegues (folds)
- Objetivos del capítulo
- Preparación del proyecto
- Introducción
- Recursividad sobre arrays
- Asociaciones (maps)
- Operadores infijos
- Filtrando arrays
- Aplanando arrays
- Arrays por comprensión (array comprehensions)
- Notación ‘do’ (do notation)
- Guardas (guards)
- Pliegues (folds)
- Recursividad final (tail recursion)
- Acumuladores (accumulators)
- Prefiere pliegues a recursividad explícita
- Un sistema de ficheros virtual
- Listando todos los ficheros
- Conclusión
-
Ajuste de patrones (pattern matching)
- Objetivos del capítulo
- Preparación del proyecto
- Ajuste de patrones simple
- Patrones simples
- Guardas
- Patrones de array (array patterns)
- Patrones de registro (record patterns) y polimorfismo de fila (row polymorphism)
- Patrones anidados (nested patterns)
- Patrones nombrados (named patterns)
- Expresiones “case” (case expressions)
- Fallos de ajuste de patrones (pattern match failures) y funciones parciales (partial functions)
- Tipos de datos algebraicos (algebraic data types)
- Usando ADTs
- Doble sentido en registros (record puns)
- Newtypes
- Una biblioteca para gráficos vectoriales
- Calculando rectángulos de delimitación
- Conclusión
-
Clases de tipos (type classes)
- Objetivos del capítulo
- Preparación del proyecto
- Clases de tipos comunes
- Anotaciones de tipo (type annotations)
- Instancias superpuestas (overlapping instances)
- Dependencias de instancia (instance dependencies)
- Clases de tipos de varios parámetros (multi parameter type classes)
- Dependencias funcionales (functional dependencies)
- Clases de tipos nularias (nullary type classes)
- Superclases (superclasses)
- Una clase de tipos para funciones resumen
- Conclusión
-
Validación aplicativa (applicative validation)
- Objetivos del capítulo
- Preparación del proyecto
- Generalizando la aplicación de funciones
- Elevando funciones arbitrarias
- La clase de tipos Applicative
- Intuición para Applicative
- Más efectos
- Combinando efectos
- Validación aplicativa
- Validadores con expresiones regulares (regular expression validators)
- Funtores transitables (traversable functors)
- Funtores aplicativos para paralelismo
- Conclusión
-
La mónada Eff
- Objetivos del capítulo
- Preparación del proyecto
- La clase de tipos mónada
- Leyes de la mónada
- Plegando con mónadas
- Mónadas y aplicativos
- Efectos nativos (native effects)
- Efectos secundarios y pureza
- La mónada Eff
- Efectos extensibles (extensible effects)
- Intercalando efectos
- La familia de Eff
- Objetos y filas
- Efectos de grano fino (fine-grained effects)
- Gestores (handlers) y acciones (actions)
- Estado mutable
- Efectos DOM
- Una interfaz de usuario para la agenda
- Conclusión
- Gráficos con Canvas
-
Interfaz para funciones externas (foreign function interface)
- Objetivos del capítulo
- Preparación del proyecto
- Una advertencia
- Llamando a PureScript desde JavaScript
- Entendiendo la generación de nombres
- Representación de datos en tiempo de ejecución (runtime data representation)
- Representando ADTs
- Representando tipos cuantificados
- Representando tipos restringidos
- Usando código JavaScript desde PureScript
- Envolviendo valores JavaScript
- Definiendo tipos externos
- Funciones de múltiples argumentos
- Representando efectos secundarios
- Definiendo nuevos efectos
- Trabajando con datos no tipados
- Gestionando valores nulos e indefinidos
- Serialización genérica de JSON
- Conclusión
-
Aventuras monádicas
- Objetivos del capítulo
- Preparación del proyecto
- Cómo jugar
- La mónada State
- La mónada Reader
- La mónada Writer
- Transformadores de mónada (monad transformers)
- El transformador de mónada ExceptT
- Pilas de transformadores de mónada (monad transformer stacks)
- ¡Clases de tipos al rescate!
- Alternativas
- Mónadas por comprensión
- Retroceso (backtracking)
- La mónada RWS
- Implementando la lógica del juego
- Ejecutando el cálculo
- Gestionando opciones de línea de comandos
- Conclusión
- El infierno de retrollamadas (callback hell)
- Verificación generativa (generative testing)
- Lenguajes específicos del dominio (domain-specific languages)
Introducción
JavaScript funcional
Hace ya algún tiempo que han empezado a aparecer las técnicas de programación funcional en Javascript:
- Bibliotecas como UnderscoreJS permiten al desarrollador aprovechar funciones probadas como
map
,filter
yreduce
para crear programas más grandes a partir de programas más pequeños mediante composición: - La programación asíncrona en NodeJS se apoya firmemente en las funciones como valores de primera clase para definir retrollamadas (callbacks).
- Bibliotecas como React y virtual-dom modelan las vistas como funciones puras sobre el estado de la aplicación.
Las funciones permiten una forma simple de abstracción que puede resultar en grandes ganancias en productividad. Sin embargo, la programación funcional en JavaScript tiene sus propias desventajas. JavaScript es verboso, no tipado, y carece de formas potentes de abstracción. El JavaScript no restringido hace también el razonamiento ecuacional muy difícil.
PureScript es un lenguaje de programación cuyo objetivo es abarcar estos problemas. Proporciona sintaxis ligera, que nos permite escribir código muy expresivo que sigue siendo claro y legible. Usa un rico sistema de tipos para soportar abstracciones potentes. También genera código rápido e inteligible, cosa importante cuando hay que interoperar con JavaScript u otros lenguajes que compilan a JavaScript. Total, espero convencerte de que PureScript consigue un equilibrio muy práctico entre el poder teórico de la programación funcional pura y el estilo de programación rápida y flexible de JavaScript.
Tipos e inferencia de tipos
El debate sobre los lenguajes de tipado estático contra los de tipado dinámico está bien documentado. PuseScript es un lenguaje de tipado estático, lo que significa que el compilador puede dar a un programa correcto un tipo que indica su comportamiento. A la inversa, los programas a los que no se les puede dar un tipo son programas incorrectos, y serán rechazados por el compilador. En PureScript, al contrario que en los lenguajes de tipado dinámico, los tipos existen únicamente en tiempo de compilación, y no tienen representación en tiempo de ejecución.
Es importante notar que, de varias maneras, los tipos en Purescript no son como los tipos que has visto en otros lenguajes como Java o C#. Aunque sirven el mismo propósito a un nivel alto, los tipos en PureScript están inspirados por lenguajes como ML y Haskell. Los tipos de PureScript son expresivos, permitiendo al desarrollador hacer afirmaciones sólidas sobre sus programas. Más importante, el sistema de tipos de PureScript soporta inferencia de tipos requiriendo muchas menos anotaciones de tipo explícitas que otros lenguajes, convirtiendo el sistema de tipos en una herramienta en lugar de en un estorbo. Un simple ejemplo, el siguiente código define un número, pero no hay mención del tipo Number
en ningún sitio del código:
Un ejemplo más elaborado muestra que la corrección de los tipos puede ser confirmada sin anotación de tipos, incluso cuando existen tipos que son desconocidos para el compilador:
Aquí, el tipo de x
es desconocido, pero el compilador sigue pudiendo verificar que iterate
obedece las reglas del sistema de tipos, sin importar qué tipo pueda tener x
.
En este libro, intentaré convencerte (o reafirmar tu creencia) de que los tipos estáticos no sólo son medios para ganar confianza en la corrección de tus programas, sino que también ayudan al desarrollo por derecho propio. Refactorizar una base de código extensa en JavaScript puede ser difícil para cualquier cosa que no sean las abstracciones más simples, pero un sistema de tipos expresivo junto a un comprobador de tipos puede incluso convertir la refactorización en una experiencia divertida e interactiva.
Además, la red de seguridad proporcionada por un sistema de tipos permite formas de abstracción más avanzadas. De hecho, PureScript proporciona una poderosa forma de abstracción que es fundamentalmente guiada por tipos: las clases de tipos (type classes) populares en el lenguaje de programación funcional Haskell.
Programación web políglota
La programación funcional tiene sus historias de éxito, aplicaciones donde ha sido particularmente exitosa: análisis de datos, análisis sintáctico, implementación de compiladores, programación genérica o paralelismo por nombrar unas cuantas.
Sería posible practicar desarrollo de aplicaciones de extremo a extremo en un lenguaje funcional como PureScript. PureScript proporciona la habilidad de importar código JavaScript existente, proporcionando tipos para sus valores y funciones, y usar entonces esas funciones en código PureScript normal. Veremos este enfoque más tarde en el libro.
Sin embargo, una de las fortalezas de PureScript es su interoperabilidad con otros lenguajes que compilan a JavaScript. Otro enfoque sería usar PureScript como un subconjunto del desarrollo de tu aplicación y usar otro(s) lenguajes para escribir el resto del código JavaScript.
Aquí hay algunos ejemplos:
- Lógica central escrita en PureScript, con la interfaz de usuario escrita en JavaScript.
- Aplicación escrita en JavaScript u otro lenguaje que compile a JavaScript, con pruebas escritas en PureScript.
- PureScript usado para automatizar las pruebas de interfaz de usuario para una aplicación ya existente.
En este libro, nos vamos a enfocar en resolver pequeños problemas con PureScript. Las soluciones se podrían integrar en una aplicación más grande, pero también veremos como llamar a código PureScript desde JavaScript y viceversa.
Prerrequisitos
Los requerimientos de software para este libro son mínimos: El primer capítulo te guiará para preparar un entorno de desarrollo desde cero, y las herramientas que vamos a usar están disponibles en los repositorios estándar de la mayoría de sistemas operativos modernos.
El compilador de PureScript se puede descargar como una distribución binaria o se puede construir a partir de los fuentes en cualquier sistema que tenga una instalación reciente del compilador de Haskell GHC, el siguiente capítulo dará los pasos.
El código de esta versión es compatible con las versiones 0.11.*
del compilador de PureScript.
Sobre ti
Voy a asumir que estas familiarizado con JavaScript. Cualquier familiaridad previa con herramientas comunes del ecosistema JavaScript como NPM y Gulp serán beneficiosas si deseas adaptar la configuración estándar para tus necesidades, pero dicho conocimiento no es necesario.
No se necesita ningún conocimiento previo de programación funcional, pero ciertamente no hace daño. Las ideas nuevas estarán acompañadas de ejemplos prácticos, de manera que seas capaz de formar una intuición para los conceptos de programación funcional que usaremos.
Los lectores que estén familiarizados con el lenguaje de programación Haskell reconocerán un montón de las ideas y sintaxis presentadas en este libro, ya que PureScript está fuertemente influenciado por Haskell. Sin embargo, dichos lectores deben entender que hay unas cuantas diferencias importantes entre PureScript y Haskell. No siempre va a ser apropiado intentar aplicar ideas de un lenguaje en el otro, aunque muchos de los conceptos presentados aquí tendrán una interpretación en Haskell.
Cómo leer este libro
Los capítulos de este libro son bastante autocontenidos. Sin embargo, un principiante con poca experiencia en programación funcional debería estudiar los capítulos en orden. Los primeros capítulos sientan las bases requeridas para entender el material que aparece más tarde en el libro. Un lector que se sienta cómodo con las ideas de la programación funcional (especialmente uno con experiencia en un lenguaje fuértemente tipado como ML o Haskell) probablemente será capaz de adquirir una comprensión general del código en los capítulos posteriores del libro sin leer los capítulos iniciales.
Cada capítulo se va a enfocar en un único ejemplo práctico, proporcionando la motivación para cualquier idea nueva introducida. El código de cada capítulo está disponible en este repositorio GitHub. Algunos capítulos incluirán fragmentos de código tomados del código fuente del capítulo, pero para adquirir un entendimiento completo debes leer el código del repositorio junto al material del libro. Las secciones más largas contendrán fragmentos más cortos que puedes ejecutar en el modo interactivo (PSCi) para ayudarte a entender.
Los ejemplos de código aparecerán en una fuente monoespaciada, como sigue:
Los comandos que deben escribirse en la línea de comandos estarán precedidos por un símbolo de dólar:
Normalmente, estos comandos estarán orientados a usuarios de Unix, de manera que los usuarios de Windows tendrán que hacer pequeños cambios como modificar el separador de ficheros o reemplazar los comandos empotrados de shell con sus equivalentes de Windows.
Los comandos que se deben escribir en el indicador de comandos de modo interactivo de PSCi estarán precedidos por un signo ‘mayor que’.
Cada capítulo contendrá ejercicios etiquetados con su nivel de dificultad. Es muy recomendable que intentes hacer los ejercicios de cada capítulo para entender el material completamente.
Este libro pretende proporcionar una introducción al lenguaje PureScript para principiantes, pero no es la clase de libro que proporciona una lista de soluciones ‘plantilla’ para problemas. Para los principiantes, este libro debe ser un reto divertido, y lograrás sacarle el mayor partido si lees el material, intentas hacer los ejercicios y, lo más importante, intentas escribir algún código por tu cuenta.
Consiguiendo ayuda
Si te atascas en algún punto, hay un número de recursos disponibles online para aprender PureScript:
- El canal IRC de PureScript es un sitio estupendo para hablar sobre los problemas que puedas estar teniendo. Entra con tu cliente IRC en irc.freenode.net y conéctate al canal #purescript.
- El sitio web de PureScript contiene enlaces a varios recursos de aprendizaje, incluyendo ejemplos de código, videos y otros recursos para principiantes.
- El repositorio de documentación de PureScript contiene artículos y ejemplos de una amplia variedad de tópicos, escritos por desarrolladores y usuarios de PureScript.
- Try PureScript! es un sitio web que permite a los usuarios compilar código PureScript en el navegador web y contiene varios ejemplos de código simples.
- Pursuit es una base de datos donde puedes buscar tipos y funciones de PureScript.
Si prefieres aprender leyendo ejemplos, las organizaciones de Github purescript
, purescript-node
y purescript-contrib
contienen un montón de ejemplos de código PureScript.
Acerca del autor
Soy el desarrollador original del compilador de PureScript. Vivo en Los Angeles, California, y empecé a programar a una edad temprana en BASIC sobre un ordenador personal de 8 bits, el Amstrad CPC. Desde entonces he trabajado profesionalmente en una variedad de lenguajes de programación (incluyendo Java, Scala, C#, F#, Haskell y PureScript).
No muy tarde en mi carrera profesional, empecé a apreciar la programación funcional y sus conexiones con las matemáticas, y disfrutaba aprendiendo conceptos de programación funcional usando el lenguaje de programación Haskell.
Empecé a trabajar en el compilador de PureScript como respuesta a mi experiencia con JavaScript. Me encontré usando técnicas de programación funcional que había adquirido en lenguajes como Haskell, pero quería un entorno más escrupuloso en el que aplicarlas. Las soluciones del momento incluían varios intentos de compilar Haskell a JavaScript conservando su semántica (Fay, Haste, GHCJS), pero estaba interesado en ver si tendría éxito afrontando el problema desde el otro lado - intentando mantener la semántica de JavaScript, disfrutando la sintaxis y el sistema de tipos de un lenguaje como Haskell.
Mantengo un blog, y se me puede encontrar en Twitter.
Acerca de la traducción
Este libro es una traducción de PureScript By Example de Phil Freeman
Dado que muchos de los términos usados en el libro son de uso común en su forma original inglesa y sus correspondientes traducciones (buenas o malas) no lo son tanto, he incluido en el texto (donde he considerado útil) el término original en itálica y entre paréntesis. La traducción al castellano está en este repositorio GitHub.
Agradecimientos
Me gustaría dar las gracias a los muchos contribuyentes que ayudaron a que PureScript alcanzara su estado actual. Sin el enorme esfuerzo colectivo que se ha hecho en el compilador, herramientas, bibliotecas, documentación y pruebas, el proyecto habría fracasado sin duda.
El logo de PureScript que aparece en la portada de este libro fue creado por Gareth Hughes, y es utilizado bajo los términos de la licencia Creative Commons Attribution 4.0.
Finalmente, me gustaría dar las gracias a todos los que me han aportado comentarios y correcciones sobre el contenido de este libro.
Empezando
Objetivos del capítulo
En este capítulo, el objetivo será preparar un entorno de desarrollo para PureScript y escribir nuestro primer programa en PureScript.
Nuestro primer proyecto será una biblioteca PureScript muy simple, que proporcionará una única función para calcular la longitud de la diagonal de un triángulo rectángulo.
Introducción
Estas son las herramientas que vamos a usar para preparar nuestro entorno de desarrollo para PureScript:
-
purs
- El propio compilador de PureScript. -
npm
- El gestor de paquetes de Node, que nos ayudará a instalar el resto de herramientas de desarrollo. - Pulp - Una herramienta de línea de comandos que automatiza muchas de las tareas asociadas a la gestión de proyectos PureScript.
El resto del capítulo te guiará en la instalación y configuración de estas herramientas.
Instalando PureScript
La manera recomendada de instalar el compilador de PureScript es descargar una distribución binaria para tu plataforma en el sitio web de PureScript.
Debes verificar que los ejecutables del compilador de PureScript están disponibles en tu ruta de ejecutables. Intenta ejecutar el compilador de PureScript en la línea de comandos para comprobarlo:
Otras opciones para instalar el compilador de PureScript:
- Mediante NPM:
npm install -g purescript
. - Construir el compilador a partir del código fuente. Las instrucciones están disponibles en el sitio web de PureScript.
Instalando las herramientas
Si no tienes una instalación funcional de NodeJS, debes instalarlo. Esto instalará también el gestor de paquetes npm
en tu sistema. Asegúrate de que tienes npm
instalado y disponible en tu ruta de ejecutables.
También necesitarás instalar la herramienta de línea de comandos Pulp y el gestor de paquetes Bower usando npm
como sigue:
Esto dejará las herramientas de línea de comandos pulp
y bower
en tu ruta de ejecutables. En este punto tienes todas las herramientas necesarias para crear tu primer proyecto PureScript.
¡Hola, PureScript!
Empecemos simple. Usaremos Pulp para compilar y ejecutar un simple programa “Hello World!”.
Comienza creando un proyecto en un directorio vacío usando el comando pulp init
:
Pulp ha creado por nosotros dos directorios, src
y test
, y un fichero de configuración bower.json
. El directorio src
contendrá nuestros ficheros fuente y el directorio test
nuestras pruebas. Usaremos el directorio test
más adelante.
Modifica el fichero src/Main.purs
para que contenga lo siguiente:
Este pequeño ejemplo ilustra unas cuantas ideas clave:
- Todos los ficheros comienzan con una cabecera de módulo. Un nombre de módulo consiste en una o más palabras comenzando por mayúsculas y separadas por puntos. En este caso hemos usado una única palabra, pero
Mi.Primer.Modulo
seria un nombre de módulo igualmente válido. - Los módulos se importan usando su nombre completo, incluyendo los puntos que separan las partes del nombre de módulo. Aquí, importamos el módulo
Control.Monad.Eff.Console
que proporciona la funciónlog
. - El programa
main
está definido como una aplicación de función. En PureScript, la aplicación de función se indica con espacio en blanco separando el nombre de la función de sus argumentos.
Ejecutemos este código usando el siguiente comando:
¡Enhorabuena! Acabas de compilar y ejecutar tu primer programa PureScript.
Compilando para el navegador
Pulp puede usarse para convertir nuestro código PureScript en JavaScript susceptible de ser usado un un navegador web mediante el uso del comando pulp browserify
:
A continuación de esto, debes ver un montón de código JavaScript impreso en la consola. Esto es la salida de la herramienta Browserify aplicada a una biblioteca estándar de PureScript llamada Prelude, así como el código del directorio src
. Este código JavaScript puede redirigirse a un fichero e incluirse en un documento HTML. Si lo intentas, debes ver las palabras “Hello, World!” impresas en la consola de tu navegador.
Quitando código no usado
Pulp proporciona un comando alternativo, pulp build
, que puede usarse con la opción -O
para aplicar la fase de eliminación de código muerto responsable de quitar JavaScript innecesario de la salida. El resultado es mucho más pequeño:
De nuevo, el código generado se puede usar en un documento HTML. Si abres output.js
, debes ver unos cuantos módulos compilados con esta pinta:
Esto ilustra unos cuantos puntos sobre el modo en que el compilador de PureScript genera el código JavaScript:
- Todo módulo se convierte en un objeto, creado por una función envoltorio, que contiene los miembros exportados por el módulo.
- PureScript intenta preservar los nombres de las variables cuando sea posible.
- La aplicación de funciones en PureScript se convierte en aplicación de funciones de JavaScript.
- El método principal se ejecuta después de que todos los módulos hayan sido definidos, y es generado como una simple llamada a método sin argumentos.
- El código PureScript no depende de ninguna biblioteca de tiempo de ejecución (runtime library). Todo el código que genera el compilador tiene su origen en un módulo PureScript del que tu código depende.
Estos puntos son importantes, ya que significan que PureScript genera código simple e inteligible. De hecho, el proceso de generación de código es una transformación bastante superficial. No es necesario un conocimiento avanzado del lenguaje para predecir qué código JavaScript será generado para cierto código de entrada.
Compilando módulos CommonJS
Pulp también puede usarse para generar módulos CommonJS a partir de código PureScript. Esto puede ser útil cuando usemos NodeJS o cuando estemos desarrollando un proyecto grande que usa módulos CommonJS para partir el código en componentes más pequeñas.
Para construir módulos CommonJS, usa el comando pulp build
(sin la opción -O
):
Los módulos generados serán colocados en el directorio output
por defecto. Cada módulo PureScript se compilará a su propio módulo CommonJS en su propio subdirectorio.
Seguimiento de dependencias con Bower
Para escribir la función diagonal
(el objetivo de este capítulo), necesitaremos poder calcular raíces cuadradas. El paquete purescript-math
contiene definiciones de tipos para las funciones definidas en el objeto JavaScript Math
, así que instalémoslo:
La opción --save
hace que la dependencia se añada al fichero de configuración bower.json
.
Los fuentes de la biblioteca purescript-math
deben estar ahora disponibles en el subdirectorio bower_components
y serán incluidos cuando compiles tu proyecto.
Calculando diagonales
Escribamos la función diagonal
, que será un ejemplo de uso de una función de una biblioteca externa.
Primero importa el módulo Math
añadiendo la siguiente línea al principio del fichero src/Main.purs
:
También es necesario importar el módulo Prelude
que define operaciones muy básicas como la suma y multiplicación de números:
Ahora define la función diagonal
como sigue:
Date cuenta de que no es necesario definir un tipo para nuestra función. El compilador es capaz de inferir que diagonal
es una función que toma dos números y devuelve un número. Sin embargo, en general es una buena práctica proporcionar anotaciones de tipo como una forma de documentación.
Modifiquemos la función main
para que use la nueva función diagonal
:
Ahora compila y ejecuta el proyecto de nuevo usando pulp run
:
Probando el código usando el modo interactivo
El compilador PureScript viene con un REPL (Read Eval Print Loop) interactivo llamado PSCi. Puede ser muy util para probar tu código y experimentar con nuevas ideas. Usemos PSCi para probar la función diagonal
.
Pulp puede cargar módulos fuente en PSCi automáticamente mediante el comando pulp repl
.
Puedes escribir :?
para ver una lista de comandos:
Pulsando la tecla Tab puedes ver una lista de todas las funciones disponibles en tu propio código, así como las disponibles en las dependencias Bower y el módulo Prelude.
Empieza importando el módulo Prelude
:
Intenta evaluar unas cuantas expresiones ahora:
Probemos ahora nuestra nueva función diagonal
en PSCi:
También puedes usar PSCi para definir funciones:
No te preocupes si la sintaxis de estos ejemplos no te resulta muy clara. Tendrá más sentido según vayas leyendo el libro.
Finalmente, puedes comprobar el tipo de una expresión usando el comando :type
:
Prueba el modo interactivo ahora. Si te atascas en algún punto, usa el comando de Reset :reset
para descargar cualquier módulo que pueda estar compilado en memoria.
Conclusión
En este capítulo hemos preparado un proyecto simple en PureScript usando la herramienta Pulp.
También hemos escrito nuestra primera función en PureScript y un programa JavaScript que puede ser ejecutado tanto en el navegador como en NodeJS.
Usaremos este entorno en los siguientes capítulos para compilar, depurar y probar nuestro código, así que debes asegurarte de que estás cómodo con las herramientas y técnicas involucradas.
Funciones y registros (records)
Objetivos del capítulo
Este capítulo presenta dos elementos esenciales de los programas PureScript: funciones y registros. Además veremos cómo estructurar programas PureScript y cómo usar los tipos como una ayuda en el desarrollo de programas.
Construiremos una aplicación de agenda simple para manejar una lista de contactos. Este código presentará algunas nuevas ideas de la sintaxis de PureScript.
La interfaz de nuestra aplicación será el modo interactivo PSCi, pero sería posible tomar como base este código para construir una interfaz en JavaScript. De hecho, haremos exactamente eso en capítulos posteriores, añadiendo validación de formulario y funcionalidad de salvado/carga.
Preparación del proyecto
El código fuente de este capítulo estará contenido en el fichero src/Data/AddressBook.purs
. Este fichero comienza con una declaración de módulo y su lista de importación:
Aquí importamos varios módulos:
- El módulo
Control.Plus
que define el valorempty
. - El módulo
Data.List
proporcionado por el paquetepurescript-lists
que puede instalarse usando Bower. Contiene unas cuantas funciones que necesitaremos para trabajar con listas enlazadas. - El módulo
Data.Maybe
que define tipos de datos y funciones para trabajar con valores opcionales.
Date cuenta de que lo que importamos de estos módulos está listado explícitamente entre paréntesis. Esto es generalmente una buena práctica, ya que ayuda a evitar conflictos entre los símbolos importados.
Asumiendo que has clonado el repositorio con el código fuente del libro, el proyecto para este capítulo puede construirse usando Pulp mediante los siguientes comandos:
Tipos simples
PureScript define tres tipos integrados que se corresponden con los tipos primitivos de JavaScript: números, cadenas y booleanos. Están definidos en el módulo Prim
que se importa de manera implícita por todos los módulos. Se llaman Number
, String
y Boolean
respectivamente y puedes verlos en PSCi usando el comando :type
para imprimir los tipos de algunos valores simples:
PureScript define otros tipos integrados: enteros, caracteres, formaciones (arrays en adelante), registros (records) y funciones.
Los enteros se diferencian de los tipos de coma flotante de tipo Number
en que carecen de coma decimal.
Los caracteres literales van rodeados por comillas simples, a diferencia de las cadenas literales que usan dobles comillas:
Los arrays se corresponden a arrays de JavaScript, pero al contrario que en JavaScript, todos los elementos de un array PureScript deben tener el mismo tipo:
El error del último ejemplo es un error del comprobador de tipos, que intenta unificar sin éxito (es decir, igualar) los tipos de los dos elementos.
Los registros se corresponden con los objetos de JavaScript y los registros literales tienen la misma sintaxis que los objetos literales de JavaScript:
Este tipo indica que el objeto especificado tiene dos campos, un campo name
de tipo String
y un campo interests
que tiene tipo Array String
, es decir, un array de cadenas.
Los campos de los registros se pueden acceder usando un punto, seguido por la etiqueta del campo a acceder:
Las funciones de PureScript se corresponden con las funciones de JavaScript. Las bibliotecas estándar de PureScript proporcionan un montón de ejemplos de funciones, y veremos más en este capítulo:
Las funciones pueden ser definidas en el nivel superior de un fichero especificando los argumentos antes del signo igual:
De manera alternativa, las funciones se pueden definir en línea usando una barra diagonal inversa seguida de una lista de nombres de argumento delimitada por espacios. Para introducir una declaración de varias líneas en PSCi, podemos entrar en “modo paste” usando el comando :paste
. En este modo, las declaraciones se finalizan usando la secuencia de teclas Control-D:
Habiendo definido esta función en PSCi, podemos aplicarla a sus argumentos separando los dos argumentos del nombre de la función mediante espacio en blanco:
Tipos cuantificados
En la sección anterior, vimos los tipos de algunas funciones definidas en el Prelude. Por ejemplo, la función flip
tenía el siguiente tipo:
La palabra clave forall
indica aquí que flip
tiene un tipo universalmente cuantificado. Significa que podemos sustituir cualquier tipo por a
, b
y c
, y flip
funcionará con esos tipos.
Por ejemplo, podemos elegir que el tipo de a
sea Int
, b
sea String
y c
sea String
. En ese caso, podríamos especializar el tipo de flip
a:
No tenemos que indicar en el código que queremos especializar un tipo cuantificado, sucede automáticamente. Por ejemplo, podemos simplemente usar flip
como si ya tuviese este tipo:
Aunque podemos elegir cualquier tipo para a
, b
y c
, tenemos que ser consistentes. El tipo de la función que hemos pasado a flip
tenía que ser consistente con los tipos de los otros argumentos. Es por eso que pasamos la cadena “Ten” como segundo argumento, y el número 10
como el tercero. No funcionaría si invirtiésemos los argumentos:
Notas sobre la sangría (indentation)
El código PureScript es sensible a la sangría, al igual que Haskell y al contrario que JavaScript. Esto significa que el espacio en blanco de tu código no carece de significado. Se usa para agrupar regiones de código, de la misma manera que se usan las llaves en los lenguajes tipo C.
Si una declaración abarca múltiples líneas, entonces cualquier línea excepto la primera debe tener sangría más allá del nivel de la primera línea.
Así, lo siguiente es código PureScript válido:
Pero esto no es código válido:
En el segundo caso, el compilador de PureScript intentará analizar dos declaraciones, una por cada línea.
Generalmente, las declaraciones definidas en el mismo bloque deben tener sangría al mismo nivel. Por ejemplo, en PSCi, las declaraciones en una sentencia let
deben deben tener la misma sangría. Esto es válido:
Pero esto no lo es:
Algunas palabras clave de PureScript (como where
, of
y let
) introducen un nuevo bloque de código, en el cual las declaraciones deben tener mayor nivel de sangría:
Date cuenta de que las declaraciones de foo
y bar
tienen mayor nivel de sangría que la declaración de example
.
La única excepción a esta regla es la palabra clave where
en la declaración de module
inicial al comienzo del fichero fuente.
Definiendo nuestros tipos
Un buen primer paso cuando se aborda un nuevo problema en PureScript es escribir las definiciones de tipos para cualquier valor con el que vayas a trabajar. Primero, definamos un tipo para los registros de nuestra agenda:
Esto define un sinónimo de tipo llamado Entry
. El tipo Entry
es equivalente al tipo a la derecha del símbolo igual: un registro con tres campos: firstName
, lastName
y address
. Los dos campos de nombre tendrán tipo String
, y el campo address
tendrá tipo Address
definido como sigue:
Date cuenta de que los registros pueden contener otros registros.
Ahora definamos un tercer sinónimo de tipo para nuestra estructura de datos de agenda, que será representada simplemente como una lista enlazada de entradas:
Date cuenta de que List Entry
no es lo mismo que Array Entry
, que representa un array de entradas.
Constructores de tipo (type constructors) y familias (kinds)
List
es un ejemplo de un constructor de tipo. Los valores no tienen el tipo List
directamente, sino List a
para algún tipo a
. Esto es, List
toma un argumento de tipo a
y construye un nuevo tipo List a
.
Date cuenta de que al igual que la aplicación de función, los constructores de tipo se aplican a otros tipos simplemente por yuxtaposición: el tipo List Entry
es de hecho el constructor de tipo List
aplicado al tipo Entry
, representa una lista de entradas.
Si tratamos de definir incorrectamente un valor de tipo List
(usando el operador de anotación de tipo ::
), veremos un nuevo tipo de error:
Esto es un error de familia. Al igual que los valores se distinguen por su tipo, los tipos se distinguen por su familia, y al igual que los valores erróneamente tipados acaban en errores de tipo, los tipos mal expresados resultan en errores de familia.
Hay una familia especial llamada Type
que representa la familia de todos los tipos que tienen valores, como Number
y String
.
Hay también familias para constructores de tipo. Por ejemplo, la familia Type -> Type
representa una función de tipos a tipos, como List
. Así, el error ha ocurrido aquí porque se espera que los valores tengan tipos de familia Type
, pero List
tiene familia Type -> Type
.
Para averiguar la familia de un tipo, usa el comando :kind
en PSCi. Por ejemplo:
El sistema de familias de PureScript soporta otras familias interesantes que veremos más adelante en el libro.
Mostrando entradas de la agenda
Escribamos nuestra primera función, que representará una entrada de la agenda como una cadena. Empezamos dando a la función un tipo. Esto es opcional, pero es una buena práctica, ya que actúa como una forma de documentación. De hecho, el compilador de PureScript emitirá un aviso si una declaración del nivel superior no contiene una anotación de tipo. Una declaración de tipo separa con el símbolo ::
el nombre de la función de su tipo:
La firma de tipo dice que showEntry
es una función que toma Entry
como argumento y devuelve una cadena. Aquí está el código para showEntry
:
Esta función concatena los tres campos del registro Entry
en una única cadena, usando la función showAddress
para convertir el registro contenido en el campo address
a una cadena. showAddress
se define de forma similar:
Una definición de función comienza con el nombre de la función, seguida por una lista de nombres de argumento. El resultado de la función se especifica tras el signo igual. Los campos se acceden con un punto seguido del nombre de campo. En PureScript, la concatenación usa el operador diamante (<>
), en lugar del operador de suma que usa JavaScript.
Prueba temprano, prueba a menudo
El modo interactivo PSCi permite prototipado rápido con retroalimentación inmediata, así que usémoslo para verificar que nuestras primeras funciones se comportan como esperamos:
Primero, construye el código que has escrito:
A continuación, carga PSCi y usa el comando import
para importar tu nuevo módulo:
Podemos crear una entrada usando un registro literal, que tiene el mismo aspecto que un objeto anónimo en JavaScript. Vamos a ligarlo a un nombre con una expresión let
:
Ahora intenta aplicar nuestra función al ejemplo:
Probemos también showEntry
creando una entrada de la agenda conteniendo nuestra dirección de ejemplo:
Creando agendas
Ahora escribamos algunas funciones útiles para trabajar con agendas. Necesitaremos un valor que representa una agenda vacía: una lista vacía.
Necesitaremos también una función para insertar un valor en una agenda existente. Llamaremos a esta función insertEntry
. Comienza dándole su tipo:
La firma de tipo dice que insertEntry
toma Entry
como primer argumento y AddressBook
como segundo argumento, y devuelve un nuevo AddressBook
.
No modificamos el AddressBook
directamente. En su lugar, devolvemos un nuevo AddressBook
que contiene la nueva entrada. Así, AddressBook
es un ejemplo de una estructura de datos inmutable. Esta es una idea importante en PureScript: la mutación es un efecto secundario del código e inhibe nuestra habilidad para razonar de manera efectiva sobre su comportamiento, de manera que preferimos funciones puras y datos inmutables donde sea posible.
Para implementar insertEntry
, usamos la función Cons
de Data.List
. Para ver su tipo, abre PSCi y usa el comando :type
:
La firma de tipo dice que Cons
toma un valor de cierto tipo a
y una lista de elementos de tipo a
, y devuelve una nueva lista con entradas del mismo tipo. Especialicemos esto con nuestro tipo Entry
en el papel de a
:
Pero List Entry
es lo mismo que AddressBook
, de manera que esto es equivalente a:
En nuestro caso, ya tenemos las entradas apropiadas: un Entry
y un AddressBook
, de manera que podemos aplicar Cons
y obtener un nuevo AddressBook
, ¡que es exactamente lo que queremos!
Aquí está nuestra implementación de insertEntry
:
Esto usa los dos argumentos entry
y book
declarados a la izquierda del símbolo igual y les aplica la función Cons
para crear el resultado.
Funciones currificadas (curried functions)
Las funciones en PureScript toman exactamente un argumento. Aunque parece que la función insertEntry
toma dos argumentos, es de hecho un ejemplo de una función currificada.
El operador ->
en el tipo de insertEntry
se asocia a la derecha, lo que significa que el compilador analiza el tipo como:
Esto es, insertEntry
es una función que devuelve una función. Toma un único argumento, un Entry
, y devuelve una nueva función que a su vez toma un único argumento AddressBook
y devuelve un nuevo AddressBook
.
Esto significa que podemos aplicar parcialmente insertEntry
especificando únicamente su primer argumento, por ejemplo. En PSCi podemos ver el tipo resultante:
Como esperábamos, el tipo de retorno es una función. Podemos aplicar la función resultante a un segundo argumento:
Date cuenta de que los paréntesis son innecesarios. Lo que sigue es equivalente:
Esto es porque la aplicación de función asocia a la izquierda, y esto explica por qué podemos simplemente especificar argumentos de función uno tras otro, separados por espacio en blanco.
En el resto del libro hablaremos de cosas como “funciones de dos argumentos”. Sin embargo, hay que entender que esto significa una función currificada que toma un primer argumento y devuelve otra función.
Ahora considera la definición de insertEntry
:
Si ponemos entre paréntesis de manera explícita la parte derecha, tenemos (Cons entry) book
. Esto es, insertEntry entry
es una función cuyo argumento se pasa sin más a la función (Cons entry)
. Pero si dos funciones tienen el mismo resultado para cualquier entrada entonces son la misma función. Así que podemos quitar el argumento book
de ambos lados:
Pero ahora, con el mismo razonamiento, podemos quitar entry
de ambos lados:
Este proceso se llama conversión eta, y se puede usar (junto a otras técnicas) para reescribir funciones en forma libre de puntos (point-free form), que significa que las funciones están definidas sin referencia a sus argumentos.
En el caso de insertEntry
, la conversión eta ha resultado en una definición muy clara de nuestra función: “insertEntry
es simplemente cons sobre listas”. Sin embargo, es discutible si la forma libre de puntos es mejor en general.
Consultando la agenda
La última función que necesitamos implementar para nuestra aplicación de agenda mínima buscará una persona por nombre y devolverá la Entry
correcta. Esto será una buena aplicación de la idea de construir programas componiendo pequeñas funciones, una idea clave en la programación funcional.
Podemos primero filtrar la agenda, manteniendo sólo las entradas con los nombres y apellidos correctos. Entonces podemos simplemente devolver la cabeza (es decir, el primer elemento) de la lista resultante.
Con esta especificación de alto nivel de nuestra estrategia, podemos calcular el tipo de nuestra función. Primero abre PSCi y busca los tipos de las funciones filter
y head
:
Vamos a desmontar estos dos tipos para entender su significado.
filter
es una función currificada de dos argumentos. Su primer argumento es una función que toma un elemento de una lista y devuelve un valor Boolean
como resultado. Su segundo argumento es una lista de elementos, y el valor de retorno es otra lista.
head
toma una lista como argumento y devuelve un tipo que no hemos visto antes: Maybe a
. Maybe a
representa un valor opcional de tipo a
, y proporciona una alternativa de tipo seguro al uso de null
para indicar un valor inexistente en lenguajes como JavaScript. La veremos de nuevo en más detalle en capítulos posteriores.
Los tipos universalmente cuantificados de filter
y head
pueden ser especializados por el compilador de PureScript a los siguientes tipos:
Sabemos que necesitaremos pasar el nombre y apellidos que queremos buscar como argumentos a nuestra función.
También sabemos que necesitaremos una función para pasar a filter
. Llamemos a esta función filterEntry
. filterEntry
tendrá tipo Entry -> Boolean
. La aplicación filter filterEntry
tendrá entonces tipo AddressBook -> AddressBook
. Si pasamos el resultado de esta función a la función head
, obtenemos nuestro resultado de tipo Maybe Entry
.
Juntando esto, una firma de tipo razonable para nuestra función, que llamaremos findEntry
, es:
Esta firma de tipo dice que findEntry
toma dos cadenas, nombre y apellido, un AddressBook
, y retorna un Entry
opcional. El resultado opcional contendrá un valor sólo si el nombre se encuentra en la agenda.
Y aquí está la definición de findEntry
:
Vamos a repasar el código paso a paso.
findEntry
pone en contexto tres nombres: firstName
y lastName
, ambos representando cadenas, y book
, un AddressBook
.
La parte derecha de la definición combina las funciones filter
y head
: primero, la lista de entradas es filtrada y luego, la función head
se aplica al resultado.
La función predicado filterEntry
se define como una declaración auxiliar dentro de una cláusula where
. De esta manera, la función filterEntry
está disponible dentro de la definición de nuestra función pero no fuera de ella. También, puede depender de los argumentos de la función contenedora, lo que es esencial aquí porque filterEntry
usa los argumentos firstName
y lastName
para filtrar la Entry
especificada.
Date cuenta de que al igual que en el caso de las declaraciones de nivel superior, no es necesario especificar una firma de tipo para filterEntry
. Sin embargo, se recomienda hacerlo como una forma de documentación.
Aplicación de funciones infija
En el código de findEntry
de arriba, usamos una forma diferente de aplicación de función: la función head
ha sido aplicada a la expresión filter filterEntry book
usando el símbolo infijo $
.
Esto es equivalente a la aplicación usual head (filter filterEntry book)
.
($)
es simplemente una función normal llamada apply
, definida en el Prelude como sigue:
Así, apply
toma una función y un valor, y aplica la función al valor. La palabra reservada infixr
se usa para definir ($)
como un alias de apply
.
Pero ¿por qué podríamos querer usar $
en lugar de aplicación de función normal? La razón es que $
es un operador de baja precedencia asociativo por la derecha. Esto significa que $
nos permite quitar pares de paréntesis para aplicaciones anidadas profundamente.
Por ejemplo, la siguiente aplicación de función anidada que encuentra la calle en la dirección del jefe de un empleado:
Es probablemente más legible cuando se expresa usando $
:
Composición de funciones
Al igual que hemos sido capaces de simplificar la función insertEntry
usando conversión eta, podemos simplificar la definición de findEntry
razonando sobre sus argumentos.
Date cuenta de que el argumento book
se pasa a la función filter filterEntry
, y el resultado de esta aplicación se pasa a head
. En otras palabras, book
se pasa a la composición de las funciones filter filterEntry
y head
.
En PureScript, los operadores de composición de función son <<<
y >>>
. El primero es “composición hacia atrás” (backwards composition) y el segundo es “composición hacia delante” (forwards composition).
Podemos reescribir la parte derecha de findEntry
usando cualquier operador. Usando composición hacia atrás, la parte derecha sería:
En esta forma, podemos aplicar el truco anterior de conversión eta para llegar a la forma final de findEntry
:
Una parte derecha igualmente válida sería:
De cualquier modo, esto nos da una definición clara de la función findEntry
: “findEntry
es la composición de una función de filtrado y la función head
”.
Voy a dejar que tomes tu propia decisión sobre qué definición es más fácil de entender, pero a menudo es útil pensar en las funciones como bloques de construcción de esta manera. Cada función ejecutando una única tarea y soluciones ensambladas usando composición de funciones.
Prueba, prueba, prueba…
Ahora que tenemos el núcleo de una aplicación, probémosla usando PSCi:
Vamos primero a intentar buscar una entrada en una agenda vacía (obviamente esperamos que esto devuelva un resultado vacío):
¡Un error! No hay que preocuparse, este error simplemente significa que PSCi no sabe cómo imprimir un valor de tipo Entry
como String.
El tipo de retorno de findEntry
es Maybe Entry
, que podemos convertir a String
a mano.
Nuestra función showEntry
espera un argumento de tipo Entry
, pero tenemos un valor de tipo Maybe Entry
. Recuerda que esto significa que la función devuelve un valor opcional de tipo Entry
. Lo que necesitamos es aplicar la función showEntry
si el valor opcional está presente y propagar el valor ausente si no lo está.
Afortunadamente, el módulo Prelude proporciona una manera de hacer esto. El operador map
se puede usar para elevar (lift) una función sobre un constructor de tipo apropiado como Maybe
(veremos más sobre esta función y otras como ella más tarde cuando hablemos de funtores):
Eso está mejor. El valor de retorno Nothing
indica que el valor de retorno opcional no contiene un valor, como esperábamos.
Para facilitar el uso, podemos crear una función que imprime Entry
como una String, de manera que no tengamos que usar showEntry
cada vez:
Ahora creemos una agenda no vacía e intentemos de nuevo. Reutilizaremos nuestra entrada de ejemplo anterior:
Esta vez, el resultado contenía el valor correcto. Intenta definir una agenda book2
con dos nombres insertando otro nombre en book1
y busca cada entrada por nombre.
Conclusión
En este capítulo hemos cubierto varios conceptos de programación funcional:
- Cómo usar el modo interactivo PSCi para experimentar con funciones y probar ideas.
- El papel de los tipos como herramienta de corrección e implementación.
- El uso de funciones currificadas para representar funciones de múltiples argumentos.
- Crear programas a partir de componentes más pequeñas mediante composición.
- Estructurar el código de manera limpia usando expresiones
where
. - Como evitar valores nulos usando el tipo
Maybe
. - El uso de técnicas como la conversión eta y la composición de funciones para refactorizar código.
En los siguientes capítulos nos basaremos en estas ideas.
Recursividad, asociaciones (maps) y pliegues (folds)
Objetivos del capítulo
En este capítulo, vamos a ver cómo las funciones recursivas pueden ser usadas para estructurar algoritmos. La recursividad es una técnica básica usada en la programación funcional que vamos a usar por todo el libro.
También cubriremos algunas funciones estándar de las bibliotecas estándar de PureScript. Veremos las funciones map
y fold
, así como algunos casos especiales útiles, como filter
y concatMap
.
El ejemplo motivador para este capítulo es una biblioteca de funciones para trabajar con un sistema de ficheros virtual. Aplicaremos técnicas aprendidas en este capítulo para escribir funciones que calculan propiedades de los ficheros representados por un modelo de un sistema de ficheros.
Preparación del proyecto
El código fuente para este capítulo está contenido en los dos ficheros src/Data/Path.purs
y src/FileOperations.purs
.
El módulo Data.Path
contiene un modelo de un sistema de ficheros virtual. No necesitas modificar el contenido de este módulo.
El módulo FileOperations
contiene funciones que usan la API Data.Path
. Las soluciones a los ejercicios se deben implementar en este fichero.
El proyecto tiene las siguientes dependencias de Bower:
-
purescript-maybe
, que define el constructor de tipoMaybe
. -
purescript-arrays
, que define funciones para trabajar con arrays. -
purescript-strings
, que define funciones para trabajar con cadenas JavaScript. -
purescript-foldable-traversable
, que define funciones para plegar arrays y otras estructuras de datos. -
purescript-console
, que define funciones para imprimir en la consola.
Introducción
La recursividad es una técnica importante en la programación en general, pero es particularmente común en la programación funcional porque, como veremos en este capítulo, la recursividad ayuda a reducir el estado mutable en nuestros programas.
La recursividad está estrechamente vinculada a la estrategia divide y vencerás: para resolver un problema para ciertas entradas, podemos descomponer las entradas en partes más pequeñas, resolver el problema sobre esas partes, y ensamblar una solución a partir de las soluciones parciales.
Veamos algunos ejemplos simples de recursividad en PureScript.
Aquí tenemos el habitual ejemplo de función factorial:
Aquí, podemos ver cómo la función factorial se calcula reduciendo el problema a un subproblema: el de calcular el factorial de un entero más pequeño. Cuando llegamos a cero, la respuesta es inmediata.
Aquí tenemos otro ejemplo común que calcula la función de Fibonnacci:
De nuevo, este problema se resuelve considerando las soluciones a subproblemas. En este caso, hay dos subproblemas, que corresponden a las expresiones fib (n - 1)
y fib (n - 2)
. Cuando estos dos subproblemas están resueltos, ensamblamos el resultado sumando los resultados parciales.
Recursividad sobre arrays
¡No estamos limitados a definir funciones recursivas sobre el tipo Int
! Veremos funciones recursivas definidas sobre una amplia variedad de tipos de datos cuando abarquemos el ajuste de patrones (pattern matching) más tarde en el libro, pero por ahora, nos ceñiremos a arrays y números.
Igual que podemos ramificar dependiendo de si la entrada es distinta de cero, en el caso de arrays ramificaremos dependiendo de si la entrada es no-vacía. Considera esta función que calcula la longitud de un array usando recursividad:
En esta función, usamos una expresión if .. then .. else
para ramificar basándonos en si el array está vacío. La función null
devuelve true
para un array vacío. Los arrays vacíos tienen una longitud de cero, y cualquier array no vacío tiene una longitud que es uno más que la longitud de su cola.
Este ejemplo es obviamente una manera muy poco práctica de encontrar la longitud de un array en JavaScript, pero debe proporcionar suficiente ayuda para permitirte completar los siguientes ejercicios:
Asociaciones (maps)
La función map
es un ejemplo de una función recursiva sobre arrays. Se usa para transformar los elementos de un array aplicando una función a cada uno de sus elementos. Así, cambian el contenido del array, pero preserva su forma (es decir, su longitud).
Cuando veamos las clases de tipo más adelante en el libro, veremos que la función map
es un ejemplo de un patron más general de funciones que preservan la forma y que transforman una clase de constructores de tipo llamados funtores (functors).
Probemos la función map
en PSCi:
Date cuenta de cómo se usa map
: proporcionamos como primer argumento una función que debe ser “mapeada sobre” el array, y proporcionamos el array como segundo argumento.
Operadores infijos
La función map
también se puede escribir entre la función de mapeo y el array rodeándola de comillas inversas:
Esta sintaxis se llama aplicación de función infija, y cualquier función se puede hacer infija de esta manera. Normalmente es más apropiada para funciones de dos argumentos.
Hay un operador que es equivalente a la función map
cuando se usa con arrays, llamado <$>
. Este operador se puede usar de manera infija como cualquier otro operador binario:
Veamos el tipo de map
:
El tipo de map
es de hecho más general de lo que necesitamos en este capítulo. Para nuestros propósitos, podemos tratar map
como si tuviese el siguiente tipo menos general:
Este tipo dice que podemos elegir dos tipos cualesquiera, a
y b
, con los que aplicar la función map
. a
es el tipo de elementos del array original y b
es el tipo de elementos del array resultante. En particular, no hay ninguna razón por la que map
tenga que preservar el tipo de los elementos del array. Podemos usar map
o <$>
para transformar enteros a cadenas, por ejemplo:
Aunque el operador infijo <$>
parece una sintaxis especial, es de hecho un simple apodo (alias) para una función PureScript normal. La función es simplemente aplicada usando notación infija. De hecho, la función se puede usar como una función normal poniendo el nombre entre paréntesis. Esto significa que podemos usar el nombre entre paréntesis (<$>)
en lugar de map
sobre arrays:
Los nombres de función infijos se definen como apodos para nombres de función existentes. Por ejemplo, el módulo Data.Array
define un operador infijo (..)
como sinónimo de la funcion range
como sigue:
Podemos usar este operador como sigue:
Nota: Los operadores infijos pueden ser una gran herramienta para definir lenguajes específicos del dominio con una sintaxis natural. Sin embargo, si se usan excesivamente, pueden volver el código ilegible para principiantes, de manera que es una buena cosa tener precaución al definir cualquier operador nuevo.
En el ejemplo anterior, hemos puesto la expresión 1 .. 5
entre paréntesis, pero de hecho no era necesario, porque el módulo Data.Array
asigna un nivel de precedencia mayor al operador ..
que el asignado al operador <$>
. En el ejemplo anterior, la precedencia del operador ..
se definía como 8
, el número tras la palabra clave infix
. Este valor es más alto que el nivel de precedencia de <$>
, lo que significa que no necesitamos añadir paréntesis:
Si quisiésemos asignar una asociatividad (a izquierda o derecha) a un operador infijo, podríamos hacerlo con las palabras clave infixl
o infixr
.
Filtrando arrays
El módulo Data.Array
proporciona otra función filter
que se usa a menudo junto a map
. Proporciona la capacidad de crear un nuevo array, a partir de uno existente, manteniendo únicamente los elementos que coinciden con una función predicado.
Por ejemplo, supongamos que queremos calcular un array de todos los números pares entre 1 y 10. Lo podríamos hacer como sigue:
Aplanando arrays
Otra función estándar sobre arrays es la función concat
, definida en Data.Array
. concat
aplana un array de arrays en un único array:
Hay una función relacionada llamada concatMap
que es como una combinación de las funciones concat
y map
. Si map
toma una función de valores a valores (posiblemente de un tipo diferente), concatMap
toma una función de valores a arrays de valores.
Veámosla en acción:
Aquí llamamos concatMap
con la función \n -> [n, n * n]
que envía un entero al array de dos elementos consistente en el propio entero y su cuadrado. El resultado es un array de diez enteros: los enteros de 1 a 5 junto a sus cuadrados.
Date cuenta que concatMap
concatena sus resultados. Llama a la función proporcionada una vez para cada elemento del array original, generando un array para cada uno. Finalmente, colapsa todos estos arrays en un único array que será su resultado.
map
, filter
y concatMap
forman la base de todo un rango de funciones sobre arrays llamadas arrays por comprensión
.
Arrays por comprensión (array comprehensions)
Supongamos que queremos encontrar los factores de un número n
. Una forma simple de hacerlo sería por fuerza bruta: podemos generar todos los pares de números entre 1 y n
, y tratar de multiplicarlos. Si el producto fuese n
, habríamos encontrado un par de factores de n
.
Podemos realizar este cálculo usando un array por comprensión. Lo haremos por pasos, usando PSCi como nuestro entorno de desarrollo interactivo.
El primer paso es generar un array de pares de números inferiores a n
, cosa que podemos hacer usando concatMap
.
Empecemos mapeando cada número al array 1 .. n
:
Podemos probar nuestra función:
Esto no es exactamente lo que queremos. En lugar de simplemente devolver el segundo elemento de cada par, necesitamos mapear una función sobre la copia interna de 1 .. n
que nos permitirá mantener el par completo:
Esto tiene mejor pinta. Sin embargo, estamos generando demasiados pares: tenemos [1, 2] y [2, 1] por ejemplo. Podemos excluir el segundo caso asegurándonos de que j
sólo va de i
a n
:
¡Estupendo! Ahora que tenemos todos los pares de factores potenciales, podemos usar filter
para elegir los pares cuya multiplicación da n
:
Este código usa la función product
del módulo Data.Foldable
en la biblioteca purescript-foldable-traversable
.
¡Excelente! Hemos conseguido encontrar el conjunto correcto de pares de factores sin duplicados.
Notación ‘do’ (do notation)
Sin embargo, podemos mejorar la legibilidad de nuestro código considerablemente. map
y concatMap
son tan fundamentales que forman la base (o más bien, sus generalizaciones map
y bind
forman la base) de una sintaxis especial llamada notación do.
Nota: Igual que map
y concatMap
nos permitían escribir arrays por comprensión, los operadores más generales map
y bind
nos permiten escribir las llamadas mónadas por comprensión (monad comprehensions). Veremos muchos mas ejemplos de mónadas mas adelante, pero en este capítulo vamos a considerar únicamente arrays.
Podemos reescribir nuestra función factors
usando notación do como sigue:
La palabra clave do
comienza un bloque de código que usa notación do. El bloque consiste de expresiones de estos tipos:
- Expresiones que ligan elementos de un array a un nombre. Estas se indican con la flecha apuntando hacia atrás
<-
, con un nombre a la izquierda y una expresión a la derecha de tipo array. - Expresiones que no ligan elementos del array a nombres. La última línea
pure [i, j]
es un ejemplo de este tipo de expresión. - Expresiones que dan nombre a expresiones, usando la palabra clave
let
.
Con suerte, esta nueva notación hará la estructura del algoritmo más clara. Si reemplazas mentalmente la flecha <-
con la palabra “elige”, puedes leerlo como sigue: “elige un elemento i
entre 1 y n
, luego elige un elemento j
entre i
y n
, y finalmente devuelve [i, j]
”.
En la última línea usamos la función pure
. Esta función puede ser evaluada en PSCi, pero tenemos que proporcionar un tipo:
En el caso de arrays, pure
simplemente construye un array de un único elemento. De hecho, podemos modificar nuestra función factors
para que use esta forma en lugar de usar pure
:
El resultado será el mismo.
Guardas (guards)
Otra mejora que podemos hacer a la función factors
es poner el filtro dentro del array por comprensión. Esto es posible usando la función guard
del módulo Control.MonadZero
(del paquete purescript-control
):
Al igual que pure
, podemos aplicar la función guard
en PSCi para entender cómo funciona. El tipo de la función guard
es más general de lo que necesitamos aquí:
En nuestro caso, podemos asumir que PSCi reportó el siguiente tipo:
Para nuestros propósitos, los siguientes cálculos nos dicen todo lo que necesitamos saber de la función guard
sobre arrays:
Esto es, si a guard
se le pasa una expresión que evalúa a true
, devuelve un array con un único elemento. Si la expresión devuelve false
, su resultado está vacío.
Esto significa que si la guarda falla, la rama actual del array por comprensión terminará de manera temprana sin resultados. Lo que significa que una llamada a guard
es equivalente a usar filter
en el array intermedio. Dependiendo de la aplicación, puede que prefieras usar guard
en lugar de filter
. Prueba las dos definiciones de factors
para verificar que dan el mismo resultado.
Pliegues (folds)
Los pliegues por la izquierda y por la derecha sobre arrays proporcionan otra clase de funciones interesantes que se pueden implementar usando recursividad.
Comienza importando el módulo Data.Foldable
e inspecciona los tipos de las funciones foldl
y foldr
usando PSCi
Estos tipos son más necesarios de lo que nos interesa por ahora. Para los propósitos de este capítulo, podemos asumir que PSCi nos da la siguiente respuesta (más específica):
En ambos casos, el tipo a
corresponde a los tipos de los elementos de nuestro array. El tipo b
se puede considerar como el tipo de un “acumulador”, que acumulará un resultado según recorremos el array.
La diferencia entre las funciones foldl
y foldr
es la dirección del recorrido. foldl
pliega el array “desde la izquierda”, mientras que foldr
lo pliega “desde la derecha”.
Veamos estas funciones en acción. Usemos foldl
para sumar un array de enteros. El tipo a
será Int
, y podemos también elegir que el tipo resultante b
sea Int
. Necesitamos proporcionar tres argumentos: una función Int -> Int -> Int
que sumará el siguiente elemento al acumulador, un valor inicial para el acumulador de tipo Int
, y un array de Int
a sumar. Para el primer argumento, podemos simplemente usar el operador de suma, y el valor inicial del acumulador será cero:
En este caso, no importa si usamos foldl
o foldr
ya que el resultado es el mismo, no importa el orden en que sucedan las sumas:
Escribamos un ejemplo donde la elección de la función de pliegue importa para ilustrar la diferencia. En lugar de la función de suma, usemos la concatenación de cadenas para construir una cadena:
Esto ilustra la diferencia entre ambas funciones. La expresión de pliegue por la izquierda es equivalente a la siguiente aplicación:
Mientras que el pliegue por la derecha es equivalente a esto:
Recursividad final (tail recursion)
La recursividad es una técnica potente para especificar algoritmos, pero tiene un problema: evaluar funciones recursivas en JavaScript puede llevar a errores de desbordamiento de pila si las entradas son demasiado grandes.
Es fácil verificar el problema con el siguiente código en PSCi:
Esto es un problema. Si vamos a adoptar la recursividad como una técnica estándar de la programación funcional, necesitamos una forma de tratar con la recursividad posiblemente infinita.
PureScript proporciona una solución parcial a este problema en la forma de optimización de recursividad final (tail recursion optimization).
Nota: se pueden implementar soluciones al problema más completas en bibliotecas usando el llamado trampolining, pero eso está fuera del ámbito de este capítulo. El lector interesado puede consultar la documentación de los paquetes purescript-free
y purescript-tailrec
.
La observación clave que permite la optimización de la recursividad final es la siguiente: una llamada recursiva en una posición de cola (tail position) a una función se puede reemplazar por un salto, que no reserva una trama de pila. Una llamada está en posición de cola cuando es la última llamada hecha antes de que la función retorne. Esta es la razón por la que hemos observado un desbordamiento de pila en el ejemplo - la llamada recursiva a f
no estaba en posición de cola.
En la práctica, el compilador de PureScript no sustituye la llamada recursiva por un salto, en su lugar sustituye la función recursiva por un bucle while.
Aquí hay un ejemplo de una función recursiva con todas las llamadas recursivas en posición de cola:
Date cuenta de que la llamada recursiva a fact
es lo último que sucede en esta función - está en posición de cola.
Acumuladores (accumulators)
Una forma común de convertir una función que no es recursiva final en una función recursiva final es usar un parámetro acumulador. Un parámetro acumulador es un parámetro adicional que se añade a una función para acumular un valor de retorno, en contraposición a usar el valor de retorno para acumular el resultado.
Por ejemplo, considera esta recursividad de array que invierte el array de entrada añadiendo elementos de la cabeza del array de entrada al final del resultado:
Esta implementación no es recursiva final, de manera que el JavaScript generado provocará un desbordamiento de pila cuando se ejecute sobre un array de entrada grande. Sin embargo, podemos hacerla recursiva final introduciendo un segundo argumento para acumular el resultado:
En este caso, delegamos a la función auxiliar reverse'
, que realiza la tarea pesada de invertir el array. Date cuenta de que la función reverse'
es recursiva final. Su única llamada recursiva está en el último caso y está en posición de cola. Esto significa que el código generado será un bucle while y no desbordará la pila para entradas grandes.
Para entender la segunda implementación de reverse
, date cuenta de que la función auxiliar reverse'
usa esencialmente el parámetro acumulador para mantener un trozo adicional de estado, el resultado parcialmente construido. El resultado comienza vacío y crece en un elemento para cada elemento del array de entrada. Sin embargo, dado que los elementos posteriores se añaden al principio del array, el resultado es el array original invertido.
Date cuenta también de que mientras que podemos considerar el acumulador como “estado”, no sucede una mutación directa. El acumulador es un array inmutable y simplemente usamos argumentos de función para hilar el estado durante el cálculo.
Prefiere pliegues a recursividad explícita
Si podemos escribir nuestras funciones recursivas usando recursividad final podemos beneficiarnos de las optimizaciones de recursividad final, de manera que parece tentador intentar escribir todas nuestras funciones de esta forma. Sin embargo, es fácil olvidar que muchas funciones se puedes escribir directamente como un pliegue sobre un array o una estructura de datos similar. Escribir algoritmos directamente en términos de combinadores como map
y fold
tiene la ventaja de la simplicidad del código. Estos combinadores se comprenden bien y, de esta manera, comunican la intención del algoritmo mucho mejor que la recursividad explícita.
Por ejemplo, el ejemplo reverse
se puede escribir como un pliegue al menos de dos maneras. Aquí hay una versión que usa foldr
:
Escribir reverse
en términos de foldl
se deja como ejercicio para el lector.
Un sistema de ficheros virtual
En esta sección, vamos a aplicar lo que hemos aprendido escribiendo funciones para trabajar sobre un modelo de un sistema de ficheros. Usaremos asociaciones, pliegues y filtros para trabajar con un API predefinido.
El módulo Data.Path
define un API para un sistema de ficheros virtual como sigue:
- Hay un tipo
Path
que representa rutas en el sistema de ficheros. - Hay una ruta
root
que representa el directorio raíz. - La función
ls
enumera los ficheros de un directorio. - La función
filename
devuelve el nombre de fichero para unPath
. - La función
size
devuelve el tamaño de fichero para unPath
que representa un fichero. - La función
isDirectory
comprueba si unPath
es un fichero o un directorio.
En términos de tipos, tenemos las siguientes definiciones de tipos:
Podemos probar la API en PSCi:
El módulo FileOperations
define funciones que usan la API de Data.Path
. No necesitas modificar el módulo Data.Path
o entender su implementación. Trabajaremos en el módulo FileOperations
.
Listando todos los ficheros
Escribamos una función que realiza una enumeración profunda de todos los ficheros dentro de un directorio. La función tendrá el siguiente tipo:
Podemos definir esta función mediante recursividad. Primero, podemos usar ls
para enumerar los hijos inmediatos de un directorio. Para cada hijo, podemos aplicar recursivamente allFiles
, que devolverá un array de rutas. concatMap
nos permitirá aplicar allFiles
y aplanar los resultados al mismo tiempo.
Finalmente, podemos usar el operador cons :
para incluir el fichero actual:
Nota: El operador cons :
tiene de hecho un rendimiento pobre en arrays inmutables, de manera que en general no se recomienda. El rendimiento se puede mejorar usando otras estructuras de datos como listas enlazadas y secuencias.
Probemos esta función en PSCi:
¡Estupendo! Ahora veamos si podemos escribir esta función usando un array por comprensión usando notación do.
Recuerda que una flecha hacia atrás corresponde a elegir un elemento de un array. El primer paso es elegir el elemento de los hijos inmediatos del argumento. A continuación, llamamos a la función recursivamente para ese fichero. Como estamos usando notación do, hay una llamada implícita a concatMap
que concatena todos los resultados recursivos.
Aquí está la nueva versión:
Prueba la nueva versión en PSCi. Debes obtener el mismo resultado. Te dejo decidir qué versión ves más clara.
Conclusión
En este capítulo hemos cubierto las bases de la recursividad en PureScript como una manera de expresar algoritmos de manera concisa. También hemos introducido los operadores infijos definidos por el usuario, funciones estándar sobre arrays como asociaciones, filtros y pliegues, y los arrays por comprensión que combinan estas ideas. Finalmente, hemos mostrado la importancia de la recursividad final para evitar errores de desbordamiento de pila, y como usar parámetros acumuladores para convertir funciones a forma recursiva final.
Ajuste de patrones (pattern matching)
Objetivos del capítulo
Este capítulo presentará dos nuevos conceptos: tipos de datos algebraicos (algebraic data types) y ajuste de patrones. También cubriremos brevemente una interesante característica del sistema de tipos de PureScript: polimorfismo de fila (row polymorphism).
El ajuste de patrones es una técnica común en la programación funcional y permite al desarrollador escribir funciones compactas que expresan ideas potencialmente complejas partiendo su implementación en múltiples casos.
Los tipos de datos algebraicos son una característica del sistema de tipos de PureScript que permiten un nivel similar de expresividad en el lenguaje de los tipos; están estrechamente relacionados con el ajuste de patrones.
El objetivo del capítulo será escribir una biblioteca para describir y manipular gráficos vectoriales simples usando tipos de datos algebraicos y ajuste de patrones.
Preparación del proyecto
El código fuente de este capítulo está definido en el fichero src/Data/Picture.purs
.
El proyecto usa algunos paquetes de Bower que ya hemos visto y añade las siguientes dependencias nuevas:
-
purescript-globals
, que proporciona acceso a algunos valores y funciones comunes en JavaScript. -
purescript-math
, que proporciona acceso al moduloMath
de JavaScript.
El módulo Data.Picture
define un tipo de datos Shape
para formas simples y un tipo Picture
para colecciones de formas, junto a funciones para trabajar con esos tipos.
El módulo importa el módulo Data.Foldable
, que proporciona funciones para plegar estructuras de datos:
El módulo Data.Picture
también importa los módulos Global
y Math
, pero esta vez usando la palabra reservada as
:
Esto deja disponibles los tipos y funciones de esos módulos, pero sólo usando nombres cualificados (qualified names), como Global.infinity
y Math.max
. Esto puede ser útil para evitar importaciones superpuestas o simplemente para dejar claro de qué modulo se importan ciertas cosas.
Nota: no es necesario usar el mismo nombre de módulo que el original en un import cualificado. Nombres cualificados más cortos como import Math as M
son posibles y bastante comunes.
Ajuste de patrones simple
Comencemos viendo un ejemplo. Aquí hay una función que calcula el máximo común divisor de dos enteros usando ajuste de patrones:
Este algoritmo se llama Algoritmo de Euclides. Si buscas su definición, probablemente encontrarás un conjunto que ecuaciones matemáticas que se parecen bastante al código de arriba. Este es uno de los beneficios del ajuste de patrones: te permite definir el código por casos, escribiendo código simple y declarativo que parece una especificación de una función matemática.
Una función escrita usando ajuste de patrones funciona emparejando conjuntos de condiciones con sus resultados. Cada línea se llama alternativa o caso. Las expresiones a la izquierda del signo igual se llaman patrones (patterns), y cada caso consiste en uno o más patrones separados por espacios. Los casos describen qué condiciones deben satisfacer los argumentos antes de que se evalúe y devuelva la expresión a la derecha del signo igual. Cada caso se intenta por orden y el primer caso cuyos patrones se ajusten a sus entradas determina el valor de retorno.
Por ejemplo, la función gcd
se evalúa usando los siguientes pasos:
- Se prueba con el primer caso: si el segundo argumento es cero, la función devuelve
n
(el primer argumento). - Si no, se prueba con el segundo caso: si el primer argumento es cero, la función devuelve
m
(el segundo argumento). - De otra manera, la función se evalúa y devuelve la expresión de la última línea.
Date cuenta de que los patrones pueden ligar valores a nombres; cada línea en el ejemplo liga uno o dos de los nombres n
y m
a sus valores de entrada. Según vayamos aprendiendo distintos tipos de patrones veremos que diferentes tipos de patrones corresponden a diferentes formas de elegir nombres para los argumentos de entrada.
Patrones simples
El ejemplo de código anterior demuestra dos tipos de patrones:
- Patrones enteros literales, que se ajustan a algo de tipo
Int
sólo si el valor coincide exactamente. - Patrones variables, que ligan su argumento a un nombre.
Hay otros tipos de patrones simples:
- Literales
Number
,String
,Char
yBoolean
. - Patrones comodín (wildcard patterns), indicados mediante un subrayado (
_
), que van a ajustarse a cualquier argumento y que no ligan ningún nombre.
Aquí tenemos dos ejemplos más que demuestran el uso de estos patrones simples:
Prueba estas funciones en PSCi.
Guardas
En el ejemplo del Algoritmo de Euclides, hemos usado una expresión if .. then .. else
para decidir entre las dos alternativas m > n
y m <= n
. Otra opción en este caso sería usar una guarda.
Una guarda es una expresión de valor booleano que debe ser satisfecha junto a las restricciones impuestas por los patrones. Aquí esta el Algoritmo de Euclides reescrito para usar una guarda:
En este caso, la tercera línea usa una guarda para imponer la condición extra de que el primer argumento es estrictamente mayor que el segundo.
Como este ejemplo muestra, las guardas aparecen a la izquierda del símbolo igual, separadas de la lista de patrones por un carácter barra (|
).
Patrones de array (array patterns)
Los patrones de array literales proporcionan una forma de ajustarse a arrays de una longitud fija. Por ejemplo, supongamos que queremos escribir una función isEmpty
que identifica arrays vacíos. Podríamos hacer esto usando un patrón de array vacío ([]
) en la primera alternativa:
Aquí tenemos otra función que se ajusta a arrays de longitud 5, ligando cada uno de sus cinco argumentos de distinta manera:
El primer patrón sólo se ajusta a arrays de cinco elementos, cuyo primer y segundo elemento son 0 y 1 respectivamente. En ese caso, la función devuelve el producto del tercer y cuarto elemento. En cualquier otro caso, la función devuelve cero. Por ejemplo, en PSCi:
Los patrones de array literales nos permiten ajustar arrays de una longitud fija, pero PureScript no proporciona ningún medio para ajustar arrays de una longitud no especificada, ya que descomponer arrays inmutables de esta manera resulta en rendimiento pobre. Si necesitas una estructura de datos que soporte este tipo de ajuste, la manera recomendada es usar Data.List
. Existen otras estructuras de datos que proporcionan rendimiento asintótico mejorado para distintas operaciones.
Patrones de registro (record patterns) y polimorfismo de fila (row polymorphism)
Los patrones de registro se usan para ajustar (lo has adivinado) registros.
Los patrones de registro tienen el mismo aspecto que los registros literales, pero en lugar de especificar valores a la derecha de los dos puntos, especificamos un símbolo a ligar para cada campo.
Por ejemplo: este patrón se ajusta a cualquier registro que contenga campos llamados first
y last
, y liga sus valores a los nombres x
e y
respectivamente:
Los patrones de registro proporcionan un buen ejemplo de una característica interesante del sistema de tipos de PureScript: polimorfismo de fila. Supongamos que hemos definido showPerson
sin la firma de tipos de arriba. ¿Cuál sería su tipo inferido? Curiosamente, no es el mismo tipo que le dimos:
¿Qué es la variable de tipo r
aquí? Bien, si probamos showPerson
en PSCi vemos algo interesante:
Podemos añadir campos adicionales al registro y la función showPerson
sigue funcionando. Siempre y cuando el registro contenga los campos first
y last
de tipo String
, la aplicación de función está bien tipada. Sin embargo, no es válido llamar a showPerson
con menos campos:
Podemos leer la nueva firma de tipos de showPerson
como “toma cualquier registro con campos first
y last
que son de tipo String
y cualquier otro campo, y devuelve un String
”.
Esta función es polimórfica en la fila r
de los campos del registro, de ahí el nombre polimorfismo de fila.
Date cuenta de que también podríamos haber escrito:
y PSCi habría inferido el mismo tipo.
Veremos el polimorfismo de fila de nuevo más tarde cuando veamos los efectos extensibles.
Patrones anidados (nested patterns)
Tanto los patrones de array como los patrones de registro combinan patrones más pequeños para construir patrones más grandes. Mayormente, los ejemplos anteriores sólo han usado patrones simples dentro de patrones array y registro, pero es importante notar que los patrones se pueden anidar arbitrariamente, lo que permite definir funciones usando condiciones en tipos de datos potencialmente complejos.
Por ejemplo, este código combina dos patrones de registro:
Patrones nombrados (named patterns)
Los patrones pueden ser nombrados para traer nombres adicionales al contexto cuando se usan patrones anidados. Cualquier patrón puede nombrarse usando el símbolo @
.
Por ejemplo, esta función ordena arrays de dos elementos, nombrando los dos elementos, pero también nombrando el propio array:
De esta manera, nos ahorramos reservar un nuevo array si el par está ya ordenado.
Expresiones “case” (case expressions)
Los patrones no sólo aparecen en las declaraciones de función de nivel superior. Es posible usar patrones para ajustarse a un valor intermedio de un cálculo usando una expresión case
. Las expresiones case proporcionan una utilidad similar a las funciones anónimas: no siempre es deseable dar un nombre a una función, y una expresión case
nos permite evitar nombrar una función sólo porque queremos usar un patrón.
Aquí tenemos un ejemplo. Esta función calcula el “sufijo cero más largo” de un array (el sufijo más largo que suma cero):
Por ejemplo:
Esta función trabaja por análisis de casos. Si el array está vacío, nuestra única opción es devolver un array vacío. Si el array no está vacío, usamos una expresión case
para partir en dos casos. Si la suma del array es cero, devolvemos el array completo. Si no, recurrimos sobre la cola del array.
Fallos de ajuste de patrones (pattern match failures) y funciones parciales (partial functions)
Si los patrones de una expresión case se prueban por orden, ¿qué pasa en el caso en que ninguno de los patrones de las alternativas se ajustan a sus entradas? En este caso, la expresión case fallará en tiempo de ejecución con un fallo de ajuste de patrones.
Podemos ver este comportamiento con un ejemplo simple:
Esta función contiene un único caso, que sólo se ajusta a una única entrada, true
. Si compilamos este fichero y probamos en PSCi con cualquier otro argumento, veremos un error en tiempo de ejecución:
Las funciones que devuelven un valor para cualquier combinación de entradas se llaman funciones totales, y las funciones que no se llaman parciales.
Generalmente se considera mejor definir funciones totales donde sea posible. Si se sabe que una función no devuelve un resultado para algún conjunto válido de entradas, normalmente es mejor devolver un valor de tipo Maybe a
para algún a
, usando Nothing
para indicar fallo. De esta manera, la presencia o ausencia de un valor se puede indicar de una forma segura a nivel de tipos.
El compilador de PureScript generará un error si puede detectar que tu función no es total debido a un ajuste de patrones incompleto. La función unsafePartial
se puede usar para silenciar estos errores (¡si estas seguro de que tu función parcial es segura!). Si quitamos la llamada a la función unsafePartial
en la función de antes, el compilador generará el siguiente error:
Esto nos dice que el valor false
no coincide con ningún patrón. En general, estos avisos pueden incluir múltiples casos sin ajuste.
Si también omitimos la firma de tipos de antes:
PSCi infiere un tipo curioso:
Veremos más tipos que involucran el símbolo =>
más tarde en el libro (están relacionados con las clases de tipos), pero por ahora, basta observar que PureScript lleva el registro de las funciones parciales usando el sistema de tipos, y que debemos decir explícitamente al comprobador de tipos cuándo son seguras.
El compilador generará también un aviso en ciertos casos cuando puede detectar casos redundantes (esto es, un caso sólo se ajusta a valores que habrían sido ajustados por un caso anterior):
En este caso, el último caso se identifica correctamente como redundante:
Nota: PSCi no muestra avisos, de manera que para reproducir este ejemplo tendrás que salvar esta función a un fichero y compilarla usando pulp build
.
Tipos de datos algebraicos (algebraic data types)
Esta sección introducirá una característica del sistema de tipos de PureScript llamada tipos de datos algebraicos (o ADTs), que se relacionan fundamentalmente con el ajuste de patrones.
Sin embargo, vamos primero a considerar un ejemplo motivador que proporcionará la base para una solución al problema de este capítulo de implementar una biblioteca de gráficos vectoriales simple.
Supongamos que queremos definir un tipo para representar algunos tipos simples de formas: líneas, rectángulos, círculos, texto, etc. En un lenguaje orientado a objetos, probablemente definiríamos una interfaz o clase abstracta Shape
, y una subclase concreta para cada tipo de forma con la que queramos trabajar.
Sin embargo, esta aproximación tiene un inconveniente importante: para trabajar con Shape
s de manera abstracta, es necesario identificar todas las operaciones que uno quiera realizar y definirlas en la interfaz Shape
. Se vuelve difícil añadir nuevas operaciones sin romper la modularidad.
Los tipos de datos algebraicos proporcionan una forma segura a nivel de tipos de resolver este tipo de problemas si el conjunto de formas se conoce por anticipado. Es posible definir nuevas operaciones sobre Shape
de una forma modular manteniendo la seguridad a nivel de tipos.
Así es como Shape
se puede representar como un tipo de datos algebraico:
El tipo Point
se puede definir también como un tipo de datos algebraico como sigue:
El tipo de datos Point
ilustra algunos puntos interesantes:
- Los datos portados por un constructor de ADT no están restringidos a tipos primitivos: los constructores pueden incluir registros, arrays, o incluso otros ADTs.
- Aunque los ADTs son útiles para describir datos con múltiples constructores, también pueden ser útiles cuando hay un único constructor.
- Los constructores de un tipo de datos algebraico pueden tener el mismo nombre que el propio ADT. Esto es bastante común y es importante no confundir el constructor de tipo
Point
con el constructor de datosPoint
; viven en espacios de nombres distintos.
Esta declaración define Shape
como una suma de diferentes constructores, y para cada constructor identifica los datos que se incluyen. Una Shape
es o bien un Circle
que contiene un Point
para el centro y un radio (un número), o un Rectangle
, o una Line
, o Text
. No hay otras formas de construir un valor de tipo Shape
.
Los tipos de datos algebraicos se presentan usando la palabra reservada data
, seguida del nombre del tipo nuevo y cualquier argumento de tipo. Los constructores de tipo se definen tras el signo igual y se separan con barras (|
).
Veamos otro ejemplo de las bibliotecas estándar de PureScript. Vimos antes el tipo Maybe
que se usa para definir valores opcionales. Aquí está su definición del paquete purescript-maybe
:
Este ejemplo muestra el uso del parámetro de tipo a
. Leyendo la barra como la palabra “o”, su definición es bastante legible: “un valor de tipo Maybe a
es o bien Nothing
o Just
un valor de tipo a
”.
Los constructores de datos se pueden usar también para definir estructuras de datos recursivas. Aquí hay otro ejemplo definiendo un tipo de datos para listas enlazadas de elementos de tipo a
:
Este ejemplo está sacado del paquete purescript-lists
. Aquí, el constructor Nil
representa una lista vacía, y Cons
se usa para crear listas no vacías a partir de un elemento de cabeza y una cola. Date cuenta como la cola se define usando el tipo de datos List a
, convirtiéndose en un tipo de datos recursivo.
Usando ADTs
Es bastante simple usar los constructores de un tipo de datos algebraico para construir un valor: simplemente los aplicamos como funciones, proporcionando argumentos correspondientes a los datos incluidos en el constructor apropiado.
Por ejemplo, el constructor Line
definido arriba requería dos Point
s, así que para construir un Shape
usando el constructor Line
tenemos que proporcionar dos argumentos de tipo Point
:
Para construir los puntos p1
y p2
, aplicamos el constructor Point
a su único argumento, que es un registro.
Así, construir valores de tipos de datos algebraicos es simple, pero ¿cómo los usamos? Aquí es donde aparece la conexión importante con el ajuste de patrones: la única forma de consumir valores de un tipo algebraico de datos es usar ajuste de patrones para ajustarse a su constructor.
Veamos un ejemplo. Supongamos que queremos convertir una Shape
en String
. Tenemos que usar ajuste de patrones para descubrir qué constructor se usó para construir la Shape
. Lo podemos hacer como sigue:
Cada constructor se puede usar como un patrón, y los argumentos al constructor se pueden ligar usando sus propios patrones. Considera el primer caso de showShape
: si la Shape
coincide con el constructor Circle
, metemos en contexto los argumentos de Circle
(centro y radio) usando dos patrones variables c
y r
. Los otros casos son similares.
showPoint
es otro ejemplo de ajuste de patrones. En este caso, sólo hay un único caso, pero usamos un patrón anidado para ajustarnos a los nombres del registro contenido dentro del constructor Point
.
Doble sentido en registros (record puns)
La función showPoint
se ajusta a un registro dentro de su argumento, ligando las propiedades x
e y
a valores con los mismos nombres. En PureScript podemos simplificar este tipo de ajuste de patrones como sigue:
Aquí, únicamente especificamos los nombres de las propiedades y no necesitamos especificar los nombres de los valores que queremos ligar. Es lo que se llama un doble sentido en registro.
También es posible usar doble sentido en registro para construir registros. Por ejemplo, si tenemos valores llamados x
e y
en el ámbito, podemos construir un Point
usando Point{ x, y}`:
Esto puede ser útil para mejorar la legibilidad del código en algunas circunstancias.
Newtypes
Hay un caso especial importante de tipos de datos algebraicos llamados newtypes. Los newtypes se presentan usando la palabra reservada newtype
en lugar de data
.
Los newtypes deben definir exactamente un constructor, y ese constructor debe tomar exactamente un argumento. Esto es, un newtype da un nuevo nombre a un tipo existente. De hecho, los valores de un newtype tienen la misma representación en tiempo de ejecución que el tipo subyacente. Son, sin embargo, distintos desde el punto de vista del sistema de tipos. Esto da un nivel adicional de seguridad de tipos.
Como ejemplo, podemos querer definir newtypes como sinónimos de Number
para atribuir unidades como píxeles y pulgadas:
De esta forma, es imposible pasar un valor de tipo Pixels
a una función que espera Inches
, pero no hay sobrecoste de rendimiento en tiempo de ejecución.
Los newtypes cobrarán importancia cuando veamos las clases de tipos en el siguiente capítulo, ya que nos permiten adjuntar comportamiento diferente a un tipo sin cambiar su representación en tiempo de ejecución.
Una biblioteca para gráficos vectoriales
Usemos los tipos de datos que hemos definido antes para crear una biblioteca simple para usar gráficos vectoriales.
Definamos un sinónimo de tipo para una Picture
, un simple array de Shape
s:
Para depurar, querremos ser capaces de convertir una Picture
en algo legible. La función showPicture
nos permite hacerlo:
Probémosla. Compila tu módulo con pulp build
y abre PSCi con pulp repl
:
Calculando rectángulos de delimitación
El código de ejemplo para este módulo contiene una función bounds
que calcula el rectángulo de delimitación mínimo para una Picture
.
El tipo de datos Bounds
define un rectángulo de delimitación. También está definido como un tipo algebraico de datos con un único constructor:
bounds
usa la función foldl
de Data.Foldable
para recorrer el array de Shape
s en una Picture
, y acumular el rectángulo de delimitación mínimo:
En el caso base, necesitamos encontrar el rectángulo de delimitación mínimo de una Picture
vacía, y el rectángulo de delimitación mínimo vacío definido por emptyBounds
es suficiente.
La función de acumulación combine
se define en un bloque where
. combine
toma un rectángulo de delimitación calculado por la llamada recursiva foldl
y la siguiente Shape
del array, y usa la función union
para calcular la unión de dos rectángulos de delimitación. La función shapeBounds
calcula la delimitación de un único shape usando ajuste de patrones.
Conclusión
En este capítulo, hemos visto el ajuste de patrones, una técnica básica pero potente de la programación funcional. Hemos visto cómo usar patrones simples así como patrones de array y de registro para ajustar partes de estructuras de datos profundas.
Este capítulo también ha presentado los tipos de datos algebraicos, que están estrechamente relacionados con el ajuste de patrones. Hemos visto cómo los tipos de datos algebraicos permiten descripciones concisas de estructuras de datos y proporcionan una forma modular de extender tipos de datos con nuevas operaciones.
Finalmente, hemos visto el polimorfismo de línea, un potente tipo de abstracción que permite dar un tipo a muchas funciones idiomáticas de JavaScript. Veremos esta idea de nuevo más adelante.
En el resto del libro, usaremos ADTs y ajuste de patrones extensamente, de manera que va a resultar rentable familiarizarse con ellos ya. Intenta crear tus propios tipos de datos algebraicos y escribir funciones que los consuman usando ajuste de patrones.
Clases de tipos (type classes)
Objetivos del capítulo
Este capítulo presentará una poderosa forma de abstracción disponible en el sistema de tipos de PureScript: las clases de tipos.
El ejemplo motivador de este capítulo será una biblioteca para resumir (hashing) estructuras de datos. Veremos cómo la maquinaria de las clases de tipos nos permiten resumir estructuras de datos complejas sin tener que pensar directamente en la estructura de los propios datos.
Veremos también una colección de clases de tipos estándar del Prelude de PureScript y de las bibliotecas estándar. El código PureScript se apoya firmemente en la potencia de las clases de tipos para expresar ideas de manera concisa, así que será beneficioso que te familiarices con estas clases.
Preparación del proyecto
El código fuente de este capítulo está definido en el fichero src/Data/Hashable.purs
.
El proyecto tiene las siguientes dependencias Bower:
-
purescript-maybe
, definiendo el tipo de datosMaybe
, que representa valores opcionales. -
purescript-tuples
, definiendo el tipo de datosTuple
, que representa pares de valores. -
purescript-either
, definiendo el tipo de datosEither
, que representa uniones disjuntas. -
purescript-strings
, que define funciones que operan sobre cadenas. -
purescript-functions
, que define algunas funciones auxiliares para definir funciones PureScript.
El módulo Data.Hashable
importa varios módulos proporcionados por estos paquetes Bower.
## ¡Muéstrame!
Nuestro primer ejemplo simple de clase de tipos viene dado por una función que hemos visto varias veces: la función show
, que toma un valor y lo representa como una cadena.
show
está definido por una clase de tipos del módulo Prelude
llamada Show
, definida como sigue:
Este código declara una nueva clase de tipos llamada Show
, que está parametrizada por la variable de tipo a
.
Una instancia de clase de tipos contiene implementaciones de las funciones definidas en una clase de tipos, especializada para un tipo particular.
Por ejemplo, aquí está la definición de la instancia de clase de tipos Show
para valores Boolean
, tomada del Prelude:
Este código declara una instancia de clase de tipos llamada showBoolean
; en PureScript, las instancias de clases de tipos tienen nombre para ayudar con la legibilidad del JavaScript generado. Decimos que el tipo Boolean
pertenece a la clase de tipos Show
.
Podemos probar la clase de tipos Show
en PSCi mostrando unos cuantos valores de diferentes tipos:
Estos ejemplos muestran cómo usar show
para mostrar valores de varios tipos primitivos, pero también podemos usar show
para mostrar valores de tipos más complejos:
Si intentamos mostrar un valor del tipo Data.Either
obtenemos un mensaje de error interesante:
El problema aquí no es que no haya una instancia Show
para el tipo que tratamos de mostrar, sino que PSCi ha sido incapaz de inferir el tipo. Esto viene indicado por el tipo desconocido a
en el tipo inferido.
Podemos anotar la expresión con un tipo usando el operador ::
, de manera que PSCi pueda elegir la instancia de clase de tipos correcta:
Algunos tipos ni siquiera tienen una instancia Show
definida. Un ejemplo es el tipo función ->
. Si tratamos de mostrar una función de Int
a Int
, obtenemos un mensaje de error apropiado del comprobador de tipos:
Clases de tipos comunes
En esta sección, vamos a ver algunas clases de tipos estándar definidas en el Prelude y en las bibliotecas estándar. Estas clases de tipos forman la base de muchos patrones comunes de abstracción en el código PureScript idiomático, de manera que una comprensión básica de sus funciones es altamente recomendable.
Eq
La clase de tipos Eq
define la función eq
que comprueba la igualdad entre dos valores. El operador ==
es un sinónimo de eq
.
Date cuenta de que en cualquier caso, los dos argumentos deben tener el mismo tipo. No tiene sentido comprobar la igualdad entre dos valores de distinto tipo.
Prueba la clase de tipos Eq
en PSCi:
Ord
La clase de tipos Ord
define la función compare
, que se puede usar para comparar dos valores para tipos que soporten ordenación. Los operadores de comparación <
y >
junto a sus compañeros no estrictos <=
y >=
se pueden definir en términos de compare
.
La función compare
compara dos valores y devuelve un Ordering
con tres alternativas:
-
LT
- si el primer argumento es menor que el segundo. -
EQ
- si el primer argumento es igual al segundo. -
GT
- si el primer argumento es mayor que el segundo.
De nuevo, podemos probar la función compare
en PSCi:
Field
La clase de tipos Field
identifica los tipos que soportan operadores numéricos como suma, resta, multiplicación y división. Se proporciona para abstraer sobre esos operadores de manera que puedan ser reutilizados donde sea apropiado.
Nota: Al igual que las clases de tipos Eq
y Ord
, la clase de tipos Field
tiene soporte especial en el compilador de PureScript, de manera que expresiones simples como 1 + 2 * 3
se traduzcan a JavaScript simple, en contraposición a llamadas de función que se despachan en base a una implementación de clase de tipos.
La clase de tipos Field
está compuesta de varias superclases generales más. Esto nos permite hablar de manera abstracta de tipos que soportan algunas, no todas, de las operaciones de Field
. Por ejemplo, un tipo de números naturales sería cerrado bajo la suma y la multiplicación, pero no necesariamente bajo la resta, de manera que ese tipo puede tener una instancia de la clase Semiring
(que es una superclase de Num
), pero no una instancia de Ring
o Field
.
Las superclases se explicarán más tarde en este capítulo, pero la jerarquía completa de tipos numéricos está más allá del ámbito de este capítulo. Animamos al lector interesado a leer la documentación de las superclases de Field
en purescript-prelude
.
Semigrupos (semigroups) y Monoides (monoids)
La clase de tipos Semigroup
identifica aquellos tipos que soportan una operación append
para combinar dos valores:
Las cadenas forman un semigrupo bajo la concatenación de cadenas normal, y lo mismo es cierto para los arrays. Muchas otras instancias estándar se proporcionan en el paquete purescript-monoid
.
El operador de concatenación <>
, que ya hemos visto, se proporciona como un sinónimo de append
.
La clase de tipos Monoid
(proporcionada por el paquete purescript-monoid
) extiende el tipo Semigroup
con el concepto de un valor vacío llamado mempty
:
De nuevo, las cadenas y los arrays son ejemplos simples de monoides.
Una instancia de la clase de tipos Monoid
para un tipo describe cómo acumular un resultado con ese tipo, comenzando por un valor “vacío” y combinando nuevos resultados. Por ejemplo, podemos escribir una función que concatena un grupo de valores en algún monoide usando un pliegue. En PSCi:
El paquete purescript-monoid
proporciona muchos ejemplos de monoides y semigrupos que usaremos en el resto del libro.
Foldable
Si la clase de tipos Monoid
identifica los tipos que pueden actuar como resultado de un pliegue, la clase de tipos Foldable
identifica los constructores de tipos que pueden ser usados como la fuente de un pliegue.
La clase de tipos Foldable
se suministra en el paquete purescript-foldable-traversable
, que también contiene instancias para algunos contenedores estándar como Array
y Maybe
.
Las firmas de tipo de las funciones pertenecientes a Foldable
son un poco más complicadas que las que hemos visto hasta ahora:
Es instructivo especializar para el caso en que f
es el constructor de tipo array. En este caso, podemos reemplazar f a
con Array a
para cualquier a
, y nos damos cuenta de que los tipos de foldl
y foldr
se convierten en los tipos que vimos cuando encontramos por primera vez los pliegues sobre arrays.
¿Qué pasa con foldMap
? Bien, esa se convierte en forall a m. Monoid m => (a -> m) -> Array a -> m
. Esta firma de tipo dice que podemos elegir cualquier tipo m
para nuestro tipo resultado, siempre y cuando ese tipo sea una instancia de la clase de tipos Monoid
. Si podemos proporcionar una función que convierte nuestros elementos de array en valores en ese monoide, entonces podemos acumular sobre nuestro array usando la estructura del monoide y devolver un único valor.
Probemos foldMap
en PSCi:
Aquí, elegimos el monoide para cadenas, que concatena cadenas, y la función show
que representa un Int
como un String
. Entonces, pasando un array de enteros, vemos que los resultados de mostrar cada entero han sido concatenados en una única String
.
Pero los arrays no son los únicos tipos plegables. purescript-foldable-traversable
también define instancias de Foldable
para tipos como Maybe
y Tuple
, y otras bibliotecas como purescript-lists
definen instancias de Foldable
para sus propios tipos de datos. Foldable
captura la noción de contenedor ordenado.
Funtor (functor) y leyes de clases de tipos (type class laws)
El Prelude también define una colección de clases de tipos que permiten un estilo de programación funcional con efectos secundarios en PureScript: Functor
, Applicative
y Monad
. Veremos estas abstracciones más adelante, pero por ahora veamos la definición de la clase de tipos Functor
, que ya hemos visto en forma de la función map
:
La función map
(y su sinónimo <$>
) permite “elevar” una función a una estructura de datos. La definición precisa de la palabra “elevar” depende de la estructura de datos en cuestión, pero ya hemos visto su comportamiento para algunos tipos simples:
¿Cómo podemos entender el significado de la función map
cuando actúa sobre muchas estructuras diferentes de una manera diferente cada vez?
Podemos intuir que la función map
aplica la función que se le da a cada elemento del contenedor y construye un nuevo contenedor a partir de los resultados, con la misma forma que el original. ¿Pero cómo podemos precisar este concepto?
Se espera que las instancias de la clase de tipos Functor
obedezcan un conjunto de leyes llamadas las leyes del funtor (functor laws):
map id xs = xs
map g (map f xs) = map (g <<< f) xs
La primera ley es la ley de identidad. Dice que elevar la función identidad (la función que devuelve su argumento sin cambios) sobre una estructura devuelve la estructura original. Esto tiene sentido, ya que la función identidad no modifica su entrada.
La segunda ley es la ley de composición. Dice que mapear una función sobre una estructura y mapear una segunda función es lo mismo que mapear la composición de las dos funciones sobre la estructura.
Signifique lo que signifique “elevar” en el sentido general, debe ser cierto que cualquier definición razonable de elevar una función sobre una estructura de datos debe obedecer estas reglas.
Muchas clases de tipos estándar vienen con su propio conjunto de leyes similares. Las leyes dadas a una clase de tipos dan estructura a las funciones de esa clase de tipos y nos permiten estudiar sus instancias en general. El lector interesado puede investigar las leyes atribuidas a las clases de tipos estándar que ya hemos visto.
Anotaciones de tipo (type annotations)
Los tipos de las funciones pueden ser restringidos usando clases de tipos. Aquí tenemos un ejemplo: supongamos que queremos escribir una función que comprueba si tres valores son iguales, usando la igualdad definida por una instancia de clase de tipos Eq
.
La declaración de tipo parece un tipo polimórfico ordinario definido usando forall
. Sin embargo, a continuación hay una restricción de clase de tipos Eq a
separada del resto del tipo por una flecha doble =>
.
Este tipo dice que podemos llamar threeAreEqual
con cualquier elección de tipo a
, siempre y cuando haya una instancia Eq
disponible para a
en uno de los módulos importados.
Los tipos restringidos pueden contener varias instancias de clases de tipos, y los tipos de las instancias no están limitados a simples variables de tipo. Aquí hay otro ejemplo que usa instancias Ord
y Show
para comparar dos valores:
Date cuenta de que se pueden especificar restricciones múltiples usando el símbolo =>
múltiples veces, de la misma manera que especificamos funciones currificadas de varios argumentos. Pero recuerda no confundir ambos símbolos:
-
a -> b
denota el tipo de funciones del tipoa
al tipob
, mientras que -
a => b
aplica la restriccióna
al tipob
.
El compilador PureScript intentará inferir los tipos restringidos cuando no se proporcione una anotación de tipo. Esto puede ser util si queremos usar el tipo más general posible para una función.
Para verlo, intenta usar una de las clases de tipos estándar como Semiring
en PSCi:
Aquí, podríamos haber anotado esta función como Int -> Int
, o Number -> Number
, pero PSCi nos muestra que el tipo más general funciona para cualquier Semiring
, permitiéndonos usar nuestra función tanto con Int
s como con Number
s.
Instancias superpuestas (overlapping instances)
PureScript tiene otra regla relativa a las instancias de clases de tipos, llamada la regla de instancias superpuestas (overlapping instances rule). Cuando una instancia de clase de tipos se necesita en un punto de llamada a función, PureScript usará la información inferida por el comprobador de tipos para elegir la instancia correcta. En ese momento, debe haber exactamente una instancia apropiada para ese tipo. Si hay varias instancias válidas, el compilador emitirá un aviso.
Para mostrar esto, podemos intentar crear dos instancias de clase de tipos en conflicto para un tipo de ejemplo. En el siguiente código, creamos dos instancias superpuestas de Show
para le tipo T
:
Este módulo compilará sin avisos. Sin embargo, si usamos show
sobre el tipo T
(requiriendo al compilador que encuentre una instancia Show
), la regla de instancias superpuestas se aplicará dando un aviso:
La regla de instancias superpuestas debe cumplirse para que la selección de instancias de clases de tipos sea un proceso predecible. Si permitiésemos que existiesen dos instancias de clase de tipos para un tipo, cualquiera podría elegirse dependiendo del orden de importación de los módulos, y podría llevar a comportamiento impredecible del programa en tiempo de ejecución, algo no deseable.
Si realmente es cierto que hay dos instancias de clase de tipos válidas para un tipo que satisfacen las leyes apropiadas, una aproximación común es definir newtypes para envolver el tipo existente. Como se permite que los newtypes diferentes tengan diferentes instancias de clases de tipos, para la regla de instancias superpuestas ya no es un problema. Esta aproximación se usa en las bibliotecas estándar de PureScript, por ejemplo en purescript-maybe
, donde el tipo Maybe a
tiene múltiples instancias válidas para la clase de tipos Monoid
.
Dependencias de instancia (instance dependencies)
Al igual que la implementación de funciones puede depender de las instancias de clases de tipos usando tipos restringidos, también puede la implementación de instancias de clases de tipos depender de otras instancias de clases de tipos. Esto proporciona una forma poderosa de inferencia de programa, en la que la implementación de un programa se puede inferir usando sus tipos.
Por ejemplo, considera la clase de tipos Show
. Podemos escribir una instancia de clase de tipos para mostrar arrays de elementos, siempre y cuando tengamos una manera de mostrar los propios elementos.
Si una instancia de una clase de tipos depende de varias instancias, esas instancias deben agruparse en paréntesis y separarse por comas a la izquierda del símbolo =>
:
Estas dos instancia de clase de tipos se proporciona el la biblioteca purescript-prelude
.
Cuando se compila el programa, se elige la instancia correcta de clase de tipos para Show
basándose en el tipo inferido del argumento pasado a show
. La instancia seleccionada puede depender de muchas relaciones de instancia, pero esta complejidad no se expone al desarrollador.
Clases de tipos de varios parámetros (multi parameter type classes)
No es cierto que una clase de tipos pueda tomar un único tipo como argumento. Es el caso más común, pero de hecho, una clase de tipos se puede parametrizar por cero o más argumentos de tipo.
Veamos un ejemplo de una clase de tipos con dos argumentos de tipo.
El módulo Stream
define una clase Stream
que identifica tipos que se pueden ver como flujos de elementos, donde los elementos pueden ser extraídos del frente del flujo usando la función uncons
.
Date cuenta de que la clase de tipos Stream
está parametrizada no sólo por el tipo del flujo, sino también por sus elementos. Esto permite definir instancias de clase de tipos para el mismo tipo de flujo pero con diferentes tipos de elementos.
El módulo define dos instancias de clase de tipos: una instancia para arrays, donde uncons
quita el elemento de cabeza del array usando ajuste de patrones, y una instancia para String
que quita el primer carácter de una String
.
Podemos escribir funciones que trabajan sobre flujos arbitrarios. Por ejemplo, aquí hay una función que acumula un resultado en algún Monoid
basándose en los elementos de un flujo:
Intenta usar foldStream
en PSCi para diferentes tipos de Stream
y diferentes tipos de Monoid
.
Dependencias funcionales (functional dependencies)
Las clases de tipos multiparamétricas pueden ser muy útiles, pero pueden llevar fácilmente a tipos confusos e incluso problemas con la inferencia de tipos. Como ejemplo simple, supongamos que tenemos que escribir una función tail
genérica sobre flujos usando la clase Stream
dada arriba:
Esto nos da un mensaje de error algo confuso:
El problema es que la función genericTail
no usa el tipo element
mencionado en la definición de la clase de tipos Stream
, de manera que el tipo queda sin resolver.
Peor aún, ni siquiera podemos usar genericTail
aplicándolo a un tipo específico de flujo:
Aquí, podemos esperar que el compilador elija la instancia streamString
. Después de todo, una String
es un flujo de Char
s, y no puede ser un flujo de ningún otro tipo de elementos.
El compilador no es capaz de hacer esa deducción automáticamente, y no puede confiar en la instancia streamString
. Sin embargo, podemos ayudar al compilador añadiendo una pista en la definición de la clase de tipo:
Aquí, stream -> element
se llama dependencia funcional. Una dependencia funcional asegura una relación funcional entre los argumentos de tipo de una clase de tipo multiparamétrica. Esa dependencia funcional dice al compilador que hay una función de tipos de flujo a tipos de elemento (únicos), de manera que si el compilador sabe el tipo del flujo, puede conocer el tipo del elemento.
Esta pista es suficiente para que el compilador infiera el tipo correcto para nuestra función tail genérica de arriba:
Las dependencias funcionales pueden ser bastante útiles cuando se usan clases de tipo multiparamétricas para diseñar ciertas APIs.
Clases de tipos nularias (nullary type classes)
¡Podemos incluso definir clases de tipos sin argumentos de tipo! Se corresponden a aseveraciones (asserts) en tiempo de compilación sobre nuestras funciones, permitiéndonos seguir la pista a propiedades globales de nuestro código en el sistema de tipos.
Un ejemplo importante es la clase Partial
que vimos anteriormente cuando hablábamos de las funciones parciales. Hemos visto ya las funciones parciales head
y tail
definidas en Data.Array.Partial
:
¡Date cuenta de que no hay instancias definidas para la clase de tipos Partial
! Hacerlo anularía su propósito: intentar usar la función head
directamente resultará en un error de tipo:
En su lugar, podemos volver a publicar la restricción Partial
para cualquier función que haga uso de funciones parciales:
Ya hemos visto la función unsafePartial
que nos permite tratar una función parcial como una función normal (de manera insegura). Esta función está definida en el módulo Partial.Unsafe
:
Date cuenta de que la restricción Partial
aparece entre paréntesis a la izquierda de la flecha de función, pero no en el forall
de fuera. Esto es, unsafePartial
es una función de valores parciales a valores normales.
Superclases (superclasses)
Al igual que podemos expresar relaciones entre instancias de clases de tipos haciendo que una instancia dependa de otra, podemos expresar relaciones entre las propias clases de tipos usando las llamadas superclases.
Decimos que una clase de tipos es una superclase de otra si se requiere que toda instancia de la segunda clase sea una instancia de la primera, e indicamos una relación de superclase en la definición de clase usando una doble flecha hacia atrás.
Hemos visto ya algunos ejemplos de relaciones de superclase: La clase Eq
es una superclase de Ord
, y la clase Semigroup
es una superclase de Monoid
. Para todas las instancias de clase de tipos de la clase Ord
tiene que haber una instancia correspondiente de Eq
para el mismo tipo. Esto tiene sentido, ya que en muchos casos, cuando la función compare
informa de que dos valores son incomparables, a menudo queremos usar la clase Eq
para determinar si de hecho son iguales.
En general, tiene sentido definir una relación de superclase cuando las leyes de la subclase mencionan los miembros de la superclase. Por ejemplo, es razonable asumir, para cualquier par de instancias Ord
y Eq
, que si dos valores son iguales bajo la instancia Eq
, entonces la función compare
debe devolver EQ
. En otras palabras, a == b
debe ser ciero exactamente cuando compare a b
se evalúa a EQ
. Esta relación a nivel de leyes justifica la relación de superclase entre Eq
y Ord
.
Otra razón para definir una relación de superclase es en el caso donde hay una clara relación “es-un” entre las dos clases. Esto es, todos los miembros de la subclase son miembros de la superclase también.
Una clase de tipos para funciones resumen
En la última sección de este capítulo vamos a usar las lecciones del resto del capítulo para crear una biblioteca para resumir estructuras de datos.
Date cuenta de que esta biblioteca es para propósitos de demostración solamente y no pretende proporcionar un mecanismo de resumen robusto.
¿Qué propiedades podemos esperar de una función resumen?
- Una función resumen debe ser determinista y mapear valores iguales a códigos resumen iguales.
- Una función resumen debe distribuir sus resultados de manera aproximadamente uniforme sobre el conjunto de códigos resumen.
La primera propiedad se parece bastante a una ley para una clase de tipos, mientras que la segunda propiedad es más bien un contrato informal y ciertamente no sería realizable con el sistema de tipos de PureScript. Sin embargo, esto debe proporcionar la intuición para la siguiente clase de tipos:
Con la ley asociada de que a == b
implica hash a == hash b
.
Pasaremos el resto de esta sección construyendo una biblioteca de instancias y funciones asociadas a la clase de tipos Hashable
.
Necesitaremos una forma de combinar códigos hash de una forma determinista:
La función combineHashes
mezclará dos códigos resumen y redistribuirá el resultado sobre el intervalo 0-65535.
Escribamos una función que usa la restricción Hashable
para restringir los tipos de sus entradas. Una tarea común que requiere una función resumen es determinar si dos valores se mapean al mismo código resumen. La relación hashEqual
proporciona dicha capacidad:
Esta función usa la función on
de Data.Function
para definir la igualdad de resumen en términos de igualdad de códigos resumen, y debe leerse como una definición declarativa de la igualdad de resumen: dos valores son iguales a nivel de resumen si son iguales después de que cada valor haya pasado a través de la función hash
.
Escribamos algunas instancias de Hashable
para algunos tipos primitivos. Comencemos con una instancia para enteros. Ya que HashCode
es en realidad un entero envuelto, esto es fácil, podemos usar la función auxiliar hashCode
:
Podemos también definir una instancia simple para valores Boolean
usando ajuste de patrones:
Con una instancia para resumir enteros, podemos crear una instancia para resumir Char
s usando la función toCharCode
de Data.Char
:
Para definir una instancia para arrays, podemos mapear la función hash
sobre los elementos del array (si el tipo elemental es también una instancia de Hashable
) y entonces realizar un pliegue por la izquierda sobre los códigos resultantes usando la función combineHashes
:
Date cuenta de cómo construimos instancias usando las instancias más simples que ya hemos escrito. Usemos nuestra nueva instancia para Array
para definir una instancia para String
s, convirtiendo una String
en un array de Char
s:
¿Cómo podemos probar que estas instancias de Hashable
satisfacen las leyes de clase de tipos que hemos enunciado antes? Necesitamos asegurarnos de que valores iguales tienen códigos resumen iguales. En casos como Int
, Char
, String
y Boolean
, esto es simple porque no hay valores de esos tipos que sean iguales en el sentido de Eq
pero no sean idénticamente iguales.
¿Y en el caso de los tipos más interesantes? Para probar la ley de clase para la instancia de Array
podemos usar inducción sobre la longitud del array. El único array con longitud cero es []
. Cualquier par de arrays no vacíos son iguales sólo si tienen elementos iguales a la cabeza y sus colas son iguales, por la definición de Eq
sobre arrays. Por hipótesis inductiva, las colas tienen códigos resumen iguales, y sabemos que los elementos de la cabeza tienen códigos resumen iguales si la instancia de Hashable a
tiene que satisfacer la ley. Por lo tanto, los dos arrays tienen códigos resumen iguales, de manera que el Hashable (Array a)
obedece la ley de clase de tipos también.
El código fuente para este capítulo incluye varios ejemplos de instancias Hashable
, como instancias para los tipos Maybe
y Tuple
.
Conclusión
En este capítulo hemos visto las clases de tipos, una forma de abstracción orientada a tipos que permite formas potentes de reutilización de código. Hemos visto una colección de clases de tipos estándar de las bibliotecas estándar de PureScript, y hemos definido nuestra propia biblioteca basada en una clase de tipos para calcular códigos de función resumen.
Este capítulo también ha presentado la noción de leyes de clases de tipos, una técnica para probar propiedades acerca del código que usa clases de tipos como forma de abstracción. Las leyes de clases de tipos son parte de un tema más amplio llamado razonamiento ecuacional (equational reasoning), en el que las propiedades de un lenguaje de programación y su sistema de tipos se usan para permitir razonamiento lógico acerca de sus programas. Esta es una idea importante y es un tema al que vamos a volver durante el resto del libro.
Validación aplicativa (applicative validation)
Objetivos del capítulo
En este capítulo, vamos a conocer una importante abstracción nueva, el funtor aplicativo (applicative functor), descrito por la clase de tipos Applicative
. No te preocupes si el nombre suena raro. Daremos un motivo para el concepto con un ejemplo práctico: validar datos de formulario. Esta técnica nos permite convertir código que normalmente implica un montón de código de comprobación repetitivo en una descripción declarativa de nuestro formulario.
Veremos también otra clase de tipos, Traversable
, que describe los funtores transitables (traversable functors), y veremos cómo este concepto también aparece de manera muy natural a partir de soluciones a problemas del mundo real.
El código de ejemplo para este capítulo será una continuación del ejemplo de la agenda del capítulo 3. Esta vez, extenderemos los tipos de datos de nuestra agenda y escribiremos funciones para validar los valores de esos tipos. Estas funciones podrían usarse, por ejemplo, en una interfaz de usuario web para mostrar errores al usuario como parte del formulario de entrada de datos.
Preparación del proyecto
El código fuente para este capítulo está definido en los ficheros src/Data/AddressBook.purs
y src/Data/AddressBook/Validation.purs
.
El proyecto tiene unas cuantas dependencias Bower, muchas de las cuales ya hemos visto. Hay dos dependencias nuevas:
-
purescript-control
, que define funciones para abstraer el control de flujo usando clases de tipos comoApplicative
. -
purescript-validation
, que define un funtor para validación aplicativa, el tema de este capítulo.
El módulo Data.AddressBook
define tipos de datos e instancias Show
para los tipos de nuestro proyecto, y el módulo Data.AddressBook.Validation
contiene reglas de validación para esos tipos.
Generalizando la aplicación de funciones
Para explicar el concepto de un funtor aplicativo, consideremos el constructor de tipo Maybe
que vimos antes.
El código fuente para este módulo define una función address
que tiene el siguiente tipo:
Esta función se usa para construir valores de tipo Address
a partir de tres cadenas: un nombre de calle, una ciudad y un estado.
Podemos aplicar esta función fácilmente y ver el resultado en PSCi:
Sin embargo, supongamos que no necesariamente tendremos una calle, ciudad, o estado, y queremos usar el tipo Maybe
para indicar la ausencia de valor en cada uno de esos tres casos.
En un caso, podemos carecer de la ciudad. Si intentamos aplicar nuestra función directamente, recibiremos un error del comprobador de tipos:
Por supuesto, esperábamos este error. address
toma cadenas como argumentos, no valores de tipo Maybe String
.
Sin embargo, es razonable esperar que podamos “elevar” la función address
para trabajar con valores opcionales descritos por el tipo Maybe
. De hecho podemos, y el módulo Control.Apply
proporciona la función lift3
que hace exactamente lo que necesitamos:
En este caso, el resultado es Nothing
porque uno de los argumentos (la ciudad) no está presente. Si proporcionamos los tres argumentos usando el constructor Just
, el resultado contendrá un valor también:
El nombre de la función lift3
indica que se puede usar para elevar funciones de 3 argumentos. Hay funciones similares definidas en Control.Apply
para funciones de otro número de argumentos.
Elevando funciones arbitrarias
Podemos elevar funciones de un pequeño número de argumentos usando lift2
, lift3
, etc. ¿Pero cómo podemos generalizar esto a funciones arbitrarias?
Es instructivo ver el tipo de lift3
:
En el ejemplo de Maybe
anterior, el constructor de tipo f
es Maybe
, de manera que lift3
se especializa al siguiente tipo:
Este tipo dice que podemos tomar cualquier función de tres argumentos y elevarla para darnos una nueva función cuyos tipos de argumento y resultado están envueltos en Maybe
.
Ciertamente esto no es posible para cualquier constructor de tipo f
, de manera que ¿qué tiene Maybe que nos permite hacer esto? Bien, al especializar el tipo antes, hemos quitado la restricción de clase de tipos sobre f
de la clase Apply
. Apply
se define en el Prelude como sigue:
La clase de tipos Apply
es una subclase de Functor
y define una función adicional apply
. Al igual que <$>
se ha definido como un sinónimo de map
, el módulo Prelude
define <*>
como un alias para apply
. Como veremos, estos dos operadores se usan a menudo juntos.
El tipo de apply
se parece bastante al tipo de map
. La diferencia entre map
y apply
es que map
toma una función como argumento, mientras que el primer argumento de apply
está envuelto en el constructor de tipo f
. Veremos cómo se usa pronto, pero primero veamos cómo implementar la clase de tipos Apply
para el tipo Maybe
:
Esta instancia de clase de tipos dice que podemos aplicar una función opcional a un valor opcional, y el resultado está definido sólo si ambos están definidos.
Ahora veremos cómo map
y apply
se pueden usar juntas para elevar funciones de un número arbitrario de argumentos.
Para funciones de un argumento, podemos simplemente usar map
directamente.
Para funciones de dos argumentos, digamos que tenemos una función currificada g
de tipo a -> b -> c
. Esto es equivalente al tipo a -> (b -> c)
, de manera que podemos aplicar map
a g
para obtener una nueva función de tipo f a -> f (b -> c)
para cualquier constructor de tipo f
con una instancia de Functor
. Al aplicar parcialmente esta función al primer argumento elevado (de tipo f a
), obtenemos una nueva función envuelta de tipo f (b -> c)
. Si también tenemos una instancia de Apply
para f
, podemos entonces usar apply
para aplicar el segundo argumento elevado (de tipo f b
) para obtener nuestro valor final de tipo f c
.
Para juntarlo todo, vemos que si tenemos valores x :: f a
y y :: f b
, entonces la expresión (g <$> x) <*> y
tiene tipo f c
(recuerda, esta expresión es equivalente a apply (map g x) y
). Las reglas de precedencia definidas en el Prelude nos permiten quitar los paréntesis: g <$> x <*> y
.
En general, podemos usar <$>
sobre el primer argumento y <*>
para los argumentos restantes, como se muestra aquí para lift3
:
Se deja como ejercicio para el lector verificar los tipos involucrados en esta expresión.
Como ejemplo, podemos intentar elevar la función address
sobre Maybe
directamente usando las funciones <$>
y <*>
:
Intenta elevar otras funciones de varios argumentos sobre Maybe
de esta manera.
La clase de tipos Applicative
Hay una clase de tipos relacionada llamada Applicative
, definida como sigue:
Applicative
es una subclase de Apply
y define la función pure
. pure
toma un valor y devuelve un valor cuyo tipo ha sido envuelto en el constructor de tipo f
.
Aquí está la instancia Applicative
para Maybe
:
Si pensamos en los funtores aplicativos como funtores que permiten elevar funciones, entonces pure
puede verse como una función que eleva funciones de cero argumentos.
Intuición para Applicative
Las funciones en PureScript son puras y no soportan efectos secundarios. Los funtores aplicativos nos permiten trabajar en “lenguajes de programación” más grandes que soportan algún tipo de efectos secundarios codificados por el funtor f
.
Por ejemplo, el funtor Maybe
representa el efecto secundario de valores potencialmente ausentes. Otros ejemplos incluyen Either err
, que representa el efecto secundario de posibles errores de tipo err
, y el funtor flecha r ->
que representa el efecto secundario de leer de una configuración global. Por ahora consideraremos el funtor Maybe
.
Si el funtor f
representa este lenguaje de programación más grande con efectos, entonces las instancias Apply
y Applicative
nos permiten elevar valores y aplicación de funciones de nuestro lenguaje de programación más pequeño (PureScript) al nuevo lenguaje.
pure
eleva valores puros (libres de efectos secundarios) al lenguaje mayor, y para funciones podemos usar map
y apply
como hemos descrito antes.
Esto plantea una pregunta: si podemos usar Applicative
para empotrar funciones y valores PureScript en este nuevo lenguaje, ¿de que manera es el nuevo lenguaje mayor? La respuesta depende del funtor f
. Si podemos encontrar expresiones de tipo f a
que no pueden expresarse como pure x
para algún x
, entonces esa expresión representa un término que sólo existe en el lenguaje mayor.
Cuando f
es Maybe
, un ejemplo es la expresión Nothing
: no podemos escribir Nothing
como pure x
para cualquier x
. Por lo tanto, podemos pensar que PureScript se ha agrandado para incluir el nuevo término Nothing
que representa un valor ausente.
Más efectos
Veamos unos cuantos ejemplos de elevar funciones sobre distintos funtores aplicativos.
Aquí hay una simple función de ejemplo definida en PSCi que une tres nombres para formar un nombre completo:
Ahora supongamos que esta función forma la implementación de un servicio web con tres argumentos proporcionados como parámetros de la consulta. Queremos asegurarnos de que el usuario ha proporcionado los tres parámetros, así que usamos el tipo Maybe
para indicar la presencia o ausencia de un parámetro. Podemos elevar fullName
sobre Maybe
para crear una implementación del servicio web que comprueba parámetros ausentes:
Date cuenta de que la función elevada devuelve Nothing
si cualquiera de los argumentos era Nothing
.
Esto es bueno, porque ahora podemos devolver una respuesta de error desde nuestro servicio web si los parámetros son inválidos. Sin embargo, sería mejor si pudiésemos indicar qué campo era incorrecto en la respuesta.
En lugar de elevar sobre Maybe
, podemos elevar sobre Either String
que permite devolver un mensaje de error. Primero, escribamos un operador para convertir entradas opcionales en cálculos que señalan un error usando Either String
:
Nota: En el funtor aplicativo Either err
, el constructor Left
indica un error y el Right
indica éxito.
Ahora podemos elevar sobre Either String
proporcionando un mensaje de error apropiado para cada parámetro:
Ahora nuestra función toma tres parámetros opcionales usando Maybe
y devuelve o bien un mensaje de error String
o un resultado String
.
Podemos probar la función con diferentes entradas:
En este caso, vemos que el mensaje de error correspondiente al primer campo ausente, o un resultado exitoso si todos los campos han sido proporcionados. Sin embargo, si carecemos de varias entradas sólo vemos el primer error:
Esto puede ser suficientemente bueno, pero si queremos ver una lista de todos los campos ausentes en el error necesitamos algo más potente que Either String
. Veremos una solución más adelante en este capítulo.
Combinando efectos
Como ejemplo de la forma de trabajar con funtores aplicativos de manera abstracta, esta sección mostrará cómo escribir una función que combinará de manera genérica efectos secundarios codificados por un funtor aplicativo f
.
¿Qué significa esto? Bien, supongamos que tenemos una lista de valores envueltos de tipo f a
para algún a
. Esto es, supongamos que tenemos una lista de tipo List (f a)
. De manera intuitiva, esto representa una lista de cálculos con efectos secundarios registrados por f
, cada uno con tipo de retorno a
. Si pudiésemos ejecutar todos estos cálculos en orden, obtendríamos una lista de resultados de tipo List a
. Sin embargo, seguiríamos teniendo efectos secundarios registrados por f
. Esto es, esperamos ser capaces de convertir algo de tipo List (f a)
en algo de tipo f (List a)
“combinando” los efectos dentro de la lista original.
Para cualquier lista de tamaño fijo n
, hay una función de n
argumentos que construye una lista de tamaño n
a partir de esos argumentos. Por ejemplo, si n
es 3
, la función es \x y z -> x : y : z : Nil
. Esta función tiene tipo a -> a -> a -> List a
. Podemos usar la instancia de Applicative
para List
para elevar esta función sobre f
, obteniendo una función de tipo f a -> f a -> f a -> f (List a)
. Pero ya que podemos hacer esto para cualquier n
, tiene sentido que podamos ser capaces de realizar la misma elevación para cualquier lista de argumentos.
Esto significa que debemos ser capaces de escribir esta función:
Esta función toma una lista de argumentos, que posiblemente tienen efectos secundarios, y devolverá una única lista envuelta, aplicando los efectos secundarios de cada uno.
Para escribir esta función, consideraremos la longitud de la lista de argumentos. Si la lista está vacía, no necesitamos realizar ningún efecto y podemos usar pure
para simplemente devolver una lista vacía:
De hecho, !esto es lo único que podemos hacer!
Si la lista no está vacía, tenemos un elemento a la cabeza que es un argumento envuelto de tipo f a
, y una cola de tipo List (f a)
. Podemos combinar los efectos de manera recursiva en la cola, devolviendo un resultado de tipo f (List a)
. Podemos entonces usar <$>
y <*>
para elevar el constructor Cons
sobre la cabeza y la nueva cola:
De nuevo, esta era la única implementación sensata basándose en los tipos que nos han dado.
Podemos probar esta función en PSCi, usando el constructor de tipo Maybe
como ejemplo:
Cuando se especializa a Maybe
, nuestra función devuelve un Just
sólo si todos los elementos de la lista eran Just
, de otra manera devuelve Nothing
. Esto es consistente con nuestra intuición de trabajar en un lenguaje mayor que soporta valores opcionales; una lista de cálculos que devuelven valores opcionales sólo tiene un resultado si todos los cálculos contenían un resultado.
Pero la función combineList
¡funciona para cualquier Applicative
! Podemos usarla para combinar cálculos que posiblemente señalan un error usando Either err
, o que leen de una configuración global usando r ->
.
Veremos la función combineList
de nuevo más tarde cuando consideremos los funtores Traversable
.
Validación aplicativa
El código fuente para este capítulo define varios tipos de datos que pueden ser usados en aplicaciones de agenda. Omitimos los detalles aquí, pero las funciones clave que exporta el módulo Data.AddressBook
tienen los siguientes tipos:
Donde PhoneType
se define como un tipo de datos algebraico:
Estas funciones se pueden usar para construir una Person
representando una entrada de la agenda. Por ejemplo, el siguiente valor está definido en Data.AddressBook
:
Prueba este valor en PSCi (hemos dado formato al resultado):
Vimos en una sección anterior cómo podíamos usar el funtor Either String
para validar estructuras de datos de tipo Person
. Por ejemplo, dadas las funciones para validar los dos nombres de la estructura, podemos validar la estructura de datos completa como sigue:
En las dos primeras líneas, usamos la función nonEmpty
para validar una cadena no vacía. nonEmpty
devuelve un código de error (indicado por el constructor Left
) si su entrada es vacía, o un valor exitoso vacío (unit
) usando el constructor Right
en caso contrario. Usamos el operador de secuenciación *>
para indicar que queremos realizar dos validaciones, devolviendo el resultado del validador de la derecha. En este caso, el validador de la derecha simplemente usa pure
para devolver la entrada sin cambios.
Las líneas finales no realizan ninguna validación sino que simplemente proporcionan los campos address
y phones
a la función person
como argumentos restantes.
Podemos ver que esta función funciona en PSCi, pero tiene una limitación que ya hemos visto;
El funtor aplicativo Either String
sólo proporciona el primer error encontrado. Dada la entrada que hemos pasado, preferiríamos ver dos errores, uno para el nombre y otro para el apellido.
Hay otro funtor aplicativo proporcionado por la biblioteca purescript-validation
. Este funtor se llama V
y proporciona la capacidad de devolver errores en cualquier semigrupo. por ejemplo, podemos usar V (Array String)
para devolver un array de String
s como errores, concatenando nuevos errores al final del array.
El módulo Data.AddressBook.Validation
usa el funtor aplicativo V (Array String)
para validar las estructuras de datos del módulo Data.AddressBook
.
Aquí hay un ejemplo de validador tomado del módulo Data.AddressBook.Validation
:
validateAddress
valida una estructura Address
. Comprueba que los campos street
y city
no están vacíos y comprueba que la cadena del campo state
tiene longitud 2.
Date cuenta de cómo las funciones validadoras nonEmpty
y lengthIs
usan la función invalid
proporcionada por el módulo Data.Validation
para indicar un error. Como estamos trabajando en el semigrupo Array String
, invalid
toma un array de cadenas como argumento.
Podemos probar esta función en PSCi:
Esta vez, recibimos un array de todos los errores de validación.
Validadores con expresiones regulares (regular expression validators)
La función validatePhoneNumber
usa una expresión regular para validar la forma de sus argumentos. La clave es una función de validación matches
, que usa una Regex
del módulo Data.String.Regex
para validar su entrada:
De nuevo, date cuenta de cómo pure
se usa para indicar una validación exitosa, e invalid
se usa para señalar un array de errores.
validatePhoneNumber
se construye sobre matches
igual que antes:
De nuevo, intenta ejecutar este validador contra entradas válidas e inválidas en PSCi:
Funtores transitables (traversable functors)
El validador restante es validatePerson
, que combina los validadores que hemos visto hasta ahora para validar una estructura Person
completa:
Hay otra función interesante aquí que no hemos visto todavía: traverse
, que aparece en la última línea.
traverse
se define en el módulo Data.Traversable
en la clase de tipos Traversable
:
Traversable
define la clase de funtores transitables. Los tipos de sus funciones pueden resultar un poco intimidatorios, pero validatePerson
proporciona un buen ejemplo del motivo.
Todo funtor transitable es a la vez un Functor
y un Foldable
(recuerda que un funtor plegable era un constructor de tipo que soportaba una operación de pliegue, reduciendo una estructura a un valor único). Además, un funtor transitable proporciona la capacidad de combinar una colección de efectos secundarios que dependen de su estructura.
Esto puede sonar complicado, pero simplifiquemos las cosas especializando para el caso de los arrays. El constructor de tipo array es transitable, lo que significa que hay una función:
De manera intuitiva, dado cualquier funtor aplicativo f
y una función que toma un valor de tipo a
y devuelve un valor de tipo b
(con efectos secundarios registrados por f
), podemos aplicar la función a cada elemento del array de tipo Array a
para obtener un resultado de tipo Array b
(con efectos secundarios registrados por f
).
¿Todavía no está claro? Especialicemos todavía más al caso en que m
es el funtor aplicativo V Errors
de arriba. Ahora tenemos una función de tipo:
Esta firma de tipo dice que si tenemos una función de validación f
para un tipo a
, entonces traverse f
es una función de validación para arrays de tipo Array a
. ¡Pero eso es exactamente lo que necesitamos para poder validar el campo phones
de la estructura de datos Person
! Podemos pasar validatePhoneNumber
a traverse
para crear una función de validación que valida cada elemento sucesivamente.
En general, traverse
recorre los elementos de una estructura de datos, realizando cálculos con efectos secundarios y acumulando un resultado.
La firma de tipo para la otra función de Traversable
, sequence
, nos puede parecer más familiar:
De hecho, la función combineList
que escribimos antes es sólo un caso especial de la función sequence
de la clase de tipos Traversable
. Fijando t
para que sea el constructor de tipo List
, podemos recuperar el tipo de la función combineList
:
Los funtores transitables capturan la idea de recorrer una estructura de datos, recopilando un conjunto de cálculos con efectos, y combinando sus efectos. De hecho, sequence
y traverse
son igualmente importantes para la definición de Traversable
; cada uno puede ser implementado a partir del otro. Esto se deja como un ejercicio para el lector interesado.
La instancia Traversable
para listas está en el módulo Data.List
. La definición de traverse
es esta:
En el caso de una lista vacía podemos simplemente devolver una lista vacía usando pure
. Si la lista no está vacía, podemos usar la función f
para crear un cálculo de tipo f b
a partir del elemento frontal. Podemos también llamar a traverse
recursivamente sobre la cola. Finalmente, podemos elevar el constructor Cons
sobre el funtor aplicativo f
para combinar estos dos resultados.
Pero hay más ejemplos de funtores transitables aparte de arrays y listas. El constructor de tipo Maybe
que vimos antes también tiene una instancia de Traversable
. Podemos probarlo en PSCi:
Estos ejemplos muestran que recorrer el valor Nothing
devuelve Nothing
sin validación, y recorrer Just x
usa la función de validación para validar x
. Esto es, traverse
toma una función de validación para el tipo a
y devuelve una función de validación para Maybe a
, es decir, una función de validación para valores opcionales de tipo a
.
Otros funtores transitables son Array a
, Tuple a
y Either a
para cualquier tipo a
. Generalmente, la mayoría de constructores de tipos de datos “contenedores” tienen instancias de Traversable
. Como ejemplo, los ejercicios incluyen escribir una instancia de Traversable
par un tipo de árboles binarios.
Funtores aplicativos para paralelismo
Anteriormente he elegido la palabra “combinar” para describir cómo los funtores aplicativos “combinan efectos secundarios”. Sin embargo, en todos los ejemplos dados, sería igualmente válido decir que los funtores aplicativos nos permiten “secuenciar” efectos. Esto sería consistente con la intuición de que los funtores transitables proporcionan una función sequence
para combinar efectos en secuencia basados en una estructura de datos.
Sin embargo, en general, los funtores aplicativos son más generales que esto. Las leyes de funtor aplicativo no imponen ningún orden para los efectos secundarios que sus cálculos realizan. De hecho, sería válido para un funtor aplicativo realizar sus efectos secundarios en paralelo.
Por ejemplo, el funtor de validación V
devolvía un array de errores, pero funcionaría igualmente bien si eligiésemos el semigrupo Set
, en cuyo caso no importaría en qué orden ejecutásemos los distintos validadores. ¡Podríamos incluso ejecutarlos en paralelo sobre la estructura de datos!
Como segundo ejemplo, el paquete purescript-parallel
proporciona un constructor de tipo Parallel
que representa cálculos paralelos. Parallel
proporciona una función parallel
que usa un funtor aplicativo para calcular el resultado de sus cálculos de entrada en paralelo:
Este cálculo comenzaría a calcular valores de manera asíncrona usando computation 1
y computation 2
. Cuando ambos resultados hayan sido calculados, serán combinados en un resultado único usando la función f
.
Veremos esta idea en más detalle cuando apliquemos los funtores aplicativos al problema del infierno de retrollamadas (callback hell) más adelante.
Los funtores aplicativos son una manera natural de capturar efectos secundarios en paralelo que pueden ser combinados.
Conclusión
En este capítulo, hemos cubierto un montón de nuevas ideas:
- Hemos presentado el concepto de funtor aplicativo que generaliza la idea de aplicación de función a constructores de tipo que capturan alguna noción de efecto secundario.
- Hemos visto cómo los funtores aplicativos dieron una solución al problema de validar estructuras de datos, y cómo cambiando el funtor aplicativo podemos cambiar de informar de un único error a informar de todos los errores en una estructura de datos.
- Hemos conocido la clase de tipos
Traversable
que encapsula la idea de funtor transitable, un contenedor cuyos elementos se pueden usar para combinar valores con efectos secundarios.
Los funtores aplicativos son una abstracción interesante que proporciona soluciones limpias a varios problemas. Los veremos unas cuantas veces más a lo largo del libro. En este caso, el funtor aplicativo de validación proporcionó una forma de escribir validadores en estilo declarativo, permitiéndonos definir qué validan nuestros validadores y no cómo deben realizar la validación. En general, veremos que los funtores aplicativos son una herramienta útil para diseñar lenguajes específicos del dominio (domain specific languages).
En el siguiente capítulo veremos una idea relacionada, la clase de las mónadas, y extenderemos nuestro ejemplo de la agenda para funcionar en el navegador.
La mónada Eff
Objetivos del capítulo
En el último capítulo hemos presentado los funtores aplicativos, una abstracción que usamos para tratar con efectos secundarios: valores opcionales, mensajes de error y validación. Este capítulo presentará otra abstracción para tratar con efectos secundarios de una manera más expresiva: mónadas.
El objetivo de este capítulo es explicar por qué las mónadas son una abstracción útil, y su conexión con la notación do. Extenderemos el ejemplo de la agenda de capítulos anteriores, usando una mónada particular para gestionar los efectos secundarios de construir una interfaz de usuario en el navegador. La mónada que usaremos es una mónada importante en PureScript, la mónada Eff
, usada para encapsular los llamados efectos nativos.
Preparación del proyecto
El código fuente para este capítulo se basa en el del capítulo anterior. Los módulos del proyecto anterior se incluyen en el directorio src
de este proyecto.
El proyecto añade las siguientes dependencias Bower:
-
purescript-eff
, que define la mónadaEff
, el tema de la segunda mitad del capítulo. -
purescript-react
, un conjunto de vínculos a la biblioteca de interfaz de usuario React, que usaremos para construir una interfaz para nuestra aplicación de agenda.
Además de los módulos del capítulo anterior, este proyecto añade un módulo Main
que proporciona el punto de entrada para la aplicación, y funciones para representar la interfaz de usuario.
Para ejecutar este proyecto, instala primero React usando npm install
, y construye y empaqueta el código JavaScript con pulp browserify --to dist/Main.js
. Para ejecutar el proyecto, abre el fichero html/index.html
en tu navegador.
## Mónadas (monads) y notación do
La notación do se presentó cuando vimos los arrays por comprensión. Los arrays por comprensión proporcionan azúcar sintáctico (syntactic sugar) para la función concatMap
del módulo Data.Array
.
Considera el siguiente ejemplo. Supongamos que lanzamos dos dados y queremos contar el número de formas en que podemos obtener un total de n
. Podríamos hacerlo usando el siguiente algoritmo no determinista:
-
Elegimos el valor
x
del primer lanzamiento. -
Elegimos el valor
y
del segundo lanzamiento. - Si la suma de
x
ey
esn
entonces devolvemos el par[x, y]
, en caso contrario fallamos.
Los arrays por comprensión nos permiten escribir este algoritmo no determinista de una manera natural:
Podemos ver que esta función es correcta en PSCi:
En el último capítulo, formamos una intuición para el funtor aplicativo Maybe
; las funciones PureScript pueden empotrarse en un lenguaje de programación mayor que soporta valores opcionales. De la misma manera, podemos desarrollar la intuición para la mónada array; permite empotrar funciones PureScript en un lenguaje de programación mayor que soporta elección no determinista.
En general, una mónada para algún constructor de tipo m
proporciona una manera de usar notación do con valores de tipo m a
. Date cuenta de que en el array por comprensión anterior, cada línea contiene un cálculo de tipo Array a
para algún tipo a
. En general, cada línea de un bloque de notación do contendrá un cálculo de tipo m a
para algún tipo a
y nuestra mónada m
. La mónada m
debe ser la misma en cada línea (es decir, fijamos los efectos secundarios), pero los tipos a
pueden ser diferentes (es decir, cálculos individuales pueden tener distintos tipos de resultado).
Aquí hay otro ejemplo de notación do, esta vez aplicado al constructor de tipo Maybe
. Supongamos que tenemos un tipo XML
que representa nodos XML y una función
que busca un elemento hijo de un nodo y devuelve Nothing
si dicho elemento no existe.
En este caso, podemos buscar un elemento profundamente anidado usando notación do. Supongamos que queremos leer la ciudad de un usuario en un perfil de usuario codificado como un documento XML:
La función userCity
busca un elemento hijo profile
, un elemento address
dentro del elemento profile
, y finalmente un elemento city
dentro del elemento address
. Si cualquiera de estos elementos no existe, el valor de retorno será Nothing
. En caso contrario, el valor de retorno se construye usando Just
con el nodo city
.
Recuerda, la función pure
de la última línea está definida para todo funtor Applicative
. Como pure
está definido como Just
para el funtor aplicativo Maybe
, sería igualmente válido cambiar la última línea por Just city
.
La clase de tipos mónada
La clase de tipos Monad
se define como sigue:
La función clave aquí es bind
, definida en la clase de tipos Bind
. Al igual que los operadores <$>
y <*>
de las clases de tipos Functor
y Apply
, el Prelude define un alias infijo >>=
para la función bind
.
La clase de tipos Monad
extiende Bind
con las operaciones de la clase de tipos Applicative
que ya hemos visto.
Será útil ver algunos ejemplos de la clase de tipos Bind
. Una definición sensata para Bind
sobre arrays puede ser esta:
Esto explica la conexión entre los arrays por comprensión y la función concatMap
a la que nos hemos referido antes.
Aquí tenemos una implementación de Bind
para el constructor de tipo Maybe
:
Esta definición confirma la intuición de que los valores ausentes se propagan a traves de un bloque en notación do.
Veamos cómo la clase de tipos Bind
se relaciona con la notación do. Considera un bloque en notación do simple que comienza ligando un valor resultado de algún cálculo.
Cada vez que el compilador de PureScript ve este patrón, reemplaza el código por esto:
o, escrito de manera infija:
El cálculo whatToDoNext
puede depender de value
.
Si hay múltiples ligaduras involucradas, esta regla se aplica varias veces, comenzando por arriba. Por ejemplo, el ejemplo userCity
que vimos antes queda como sigue tras quitarle el azucar:
Merece la pena darse cuenta de que el código expresado usando notación do es a menudo más claro que el código equivalente usando el operador >>=
. Sin embargo, escribir ligaduras de manera explícita usando >>=
puede a menudo conducir a oportunidades para escribir código en forma libre de puntos, pero hay que tener en cuenta la advertencia habitual sobre la legibilidad.
Leyes de la mónada
La clase de tipos Monad
viene equipada con tres leyes llamadas las leyes de la mónada. Estas nos dicen qué podemos esperar de implementaciones sensatas de la clase de tipos Monad
.
Es más fácil explicar estas leyes usando notación do.
Leyes de identidad
La ley de elemento neutro por la derecha (right-identity) es la más simple de las tres leyes. Nos dice que podemos eliminar una llamada a pure
si es la última expresión en un bloque de notación do:
La ley de elemento neutro por la derecha dice que esto es equivalente a expr
.
La ley de elemento neutro por la izquierda dice que podemos eliminar una llamada a pure
si es la primera expresión de un bloque en notación do:
Esto código es equivalente a next
, después de que el nombre x
haya sido reemplazado por la expresión y
.
La última ley es la ley de asociatividad. Nos dice cómo tratar con bloques anidados en notación do. Dice que el siguiente fragmento de código:
es equivalente a este código:
Cada uno de estos cálculos involucra tres expresiones monádicas m1
, m2
y m3
. En cada caso, el resultado de m1
se liga al nombre x
, y el resultado de m2
se asocia al nombre y
.
En c1
, las dos expresiones m1
y m2
se agrupan en su propio bloque en notación do.
En c2
, las tres expresiones m1
, m2
y m3
aparecen en el mismo bloque en notación do.
La ley de asociatividad nos dice que es seguro simplificar los bloques anidados en notación do de esta manera.
Fíjate en que por la definición de cómo se quita el azúcar de la notación do convirtiéndola en llamadas a bind
, tanto c1
como c2
son equivalentes a este código:
Plegando con mónadas
Como ejemplo del modo de trabajar con mónadas de manera abstracta, esta sección presentará una función que es válida para cualquier constructor de tipo de la clase de tipos Monad
. Esto debe servir para solidificar la intuición de que el código monádico corresponde a programar “en un lenguaje mayor” con efectos secundarios, y también ilustra la generalidad que nos proporciona la programación con mónadas.
La función que vamos a escribir se llama foldM
. Generaliza la función foldl
que vimos antes a un contexto monádico. Aquí está su firma de tipo:
Fíjate en que esto es lo mismo que el tipo de foldl
, excepto por la aparición de la mónada m
:
De forma intuitiva, foldM
realiza un pliegue sobre una lista en algún contexto que soporta algún conjunto de efectos secundarios.
Por ejemplo, si m
fuese Maybe
, se permitiría a nuestro pliegue fallar devolviendo Nothing
en cualquier fase; cada paso devuelve un valor opcional y el resultado del pliegue es por lo tanto también opcional.
Si m
fuese el constructor de tipo Array
, cada paso del pliegue podría devolver cero o más resultados, y el pliegue continuaría con el siguiente paso independientemente para cada resultado. Al final, el conjunto do resultados consistiría en todos los pliegues sobre todos los caminos posibles. ¡Esto se corresponde al recorrido de un grafo!
Para escribir foldM
podemos simplemente descomponer la lista de entrada en casos.
Si la lista está vacía, para producir el resultado de tipo a
sólo tenemos una opción: tenemos que devolver el segundo argumento:
Fíjate en que tenemos que usar pure
para elevar a
a la mónada m
.
¿Qué pasa si la listo no está vacía? En ese caso, tenemos un valor de tipo a
, un valor de tipo b
, y una función de tipo a -> b -> m a
. Si aplicamos la función, obtenemos un resultado monádico de tipo m a
. Podemos ligar el resultado de este cálculo con la flecha hacia atrás <-
.
Sólo queda recurrir sobre la cola de la lista. La implementación es simple:
Date cuenta de que esta implementación es casi idéntica a la de foldl
sobre listas, con la excepción de la notación do.
Podemos definir y probar esta función en PSCi. Aquí hay un ejemplo: supongamos que definimos una función de “división segura” sobre enteros, que comprueba la división por cero y usa el constructor de tipo Maybe
para indicar fallo:
Podemos entonces usar foldM
para expresar división segura iterada:
La función foldM safeDivide
devuelve Nothing
si se intenta una división por cero en algún punto. En caso contrario, devuelve el resultado de dividir repetidamente el acumulador, envuelto en el constructor Just
.
Mónadas y aplicativos
Toda instancia de la clase de tipos Monad
es también una instancia de la clase de tipos Applicative
gracias a la relación de superclase entre ambas.
Sin embargo, hay una implementación de la clase de tipos Applicative
que viene “gratis” para cualquier instancia de Monad
, dada por la función ap
:
Si m
es un miembro de la clase de tipos Monad
que respeta las leyes, entonces hay una instancia Applicative
válida para m
dada por ap
.
El lector interesado puede comprobar que ap
concuerda con apply
para las mónadas que ya hemos encontrado: Array
, Maybe
y Either e
.
Si toda mónada es también un funtor aplicativo, debemos ser capaces de aplicar nuestra intuición para los funtores aplicativos a todas las mónadas. En particular, podemos esperar razonablemente que una mónada se corresponda, en cierto sentido, a programar “en un lenguaje mayor” aumentado con algún conjunto de efectos secundarios adicional. Debemos ser capaces de elevar funciones de aridad arbitraria, usando map
y apply
, a este nuevo lenguaje.
Pero las mónadas nos permiten hacer más de lo que podríamos hacer sólo con funtores aplicativos, y la diferencia clave se pone de relieve con la sintaxis de notación do. Considera de nuevo el ejemplo de userCity
, en el que buscábamos la ciudad de un usuario en un documento XML que codificaba su perfil de usuario:
La notación do permite al segundo cálculo depender del resultado prof
del primero, el tercer cálculo puede depender del resultado addr
del segundo, y así sucesivamente. Esta dependencia en valores previos no es posible usando sólo la interfaz de la clase de tipos Applicative
.
Intenta escribir userCity
usando sólo pure
y apply
: verás que es imposible. Los funtores aplicativos sólo nos permiten elevar argumentos de función que son independientes unos de otros, pero las mónadas nos permiten escribir cálculos que involucran dependencias de datos más interesantes.
En el último capítulo, vimos que la clase de tipos Applicative
se puede usar para expresar paralelismo. Esto era exactamente porque los argumentos de la función que elevábamos eran independientes unos de otros. Como la clase de tipos Monad
permite que los cálculos dependan de los resultados de cálculos previos, lo mismo no se aplica; una mónada tiene que combinar sus efectos secundarios en secuencia.
Efectos nativos (native effects)
Veremos una mónada particular que tiene una importancia central en PureScript; la mónada Eff
.
La mónada Eff
está definida en el Prelude, en el módulo Control.Monad.Eff
. Se usa para gestionar los llamados efectos secundarios nativos.
¿Qué son los efectos secundarios nativos? Son efectos secundarios que distinguen las expresiones JavaScript de las expresiones idiomáticas PureScript, que normalmente están libres de efectos secundarios. Algunos ejemplos de efectos nativos son:
- Entrada/salida por consola
- Generación de números aleatorios
- Excepciones
- Lectura/escritura de estado mutable
Y en el navegador:
- Manipulación del DOM
- Llamadas XMLHttpRequest / AJAX
- Interactuar con un websocket
- Escribir/leer de/a almacenamiento local
Hemos visto ya varios ejemplos de efectos secundarios “no nativos”:
- Valores opcionales representados por el tipo de datos
Maybe
- Errores representados por el tipo de datos
Either
- Multi-funciones, representadas por arrays o listas
Date cuenta de que la distinción es sutil. Es cierto, por ejemplo, que un mensaje de error es un posible efecto secundario de una expresión JavaScript, en forma de excepción. En ese sentido, las excepciones representan efectos secundarios nativos y es posible representarlas usando Eff
. Sin embargo, los mensajes de error implementados usando Either
no son un efecto secundario del runtime de JavaScript, de manera que no es apropiado implementar los mensajes de error de ese estilo usando Eff
. Entonces no es el efecto en sí lo que es nativo, sino la forma como se implementa en tiempo de ejecución.
Efectos secundarios y pureza
En un lenguaje puro como PureScript, una pregunta que se plantea es: sin efectos secundarios, ¿cómo se puede escribir código útil en el mundo real?
La respuesta es que PureScript no pretende eliminar los efectos secundarios. Tiene como objetivo representar los efectos secundarios de tal manera que los cálculos puros se puedan distinguir de los cálculos con efectos secundarios en el sistema de tipos. En este sentido, el lenguaje sigue siendo puro.
Los valores con efectos secundarios tienen tipo diferente al de los valores puros. Así, no es posible pasar un argumento con efectos secundarios a una función, por ejemplo, y tener efectos secundarios que se ejecutan de manera no esperada.
La única forma en que los efectos secundarios gestionados por la mónada Eff
se presentarán es ejecutar un cálculo de tipo Eff eff a
desde JavaScript.
La herramienta de construcción Pulp (y otras herramientas) proporciona un atajo, generando JavaScript adicional para invocar el cálculo main
cuando la aplicación comienza. main
tiene que ser un cálculo en la mónada Eff
.
De esta manera, sabemos exactamente qué efectos secundarios esperar: exactamente los usados por main
. Además, podemos usar la mónada Eff
para restringir qué tipo de efectos secundarios puede tener main
, de manera que podemos decir con exactitud, por ejemplo, que nuestra aplicación interactuará con la consola, pero nada más.
La mónada Eff
El objetivo de la mónada Eff
es proporcionar un API bien tipado para cálculos con efectos secundarios, al tiempo que genera JavaScript eficiente. También recibe el nombre de mónada de efectos extensibles (extensible effects), que explicaremos en breve.
Aquí hay un ejemplo. Usa el paquete purescript-random
que define funciones para generar números aleatorios:
Si salvamos este fichero como src/Main.purs
, podemos compilarlo y ejecutarlo usando Pulp:
Al ejecutar este comando, verás un número aleatorio elegido entre 0
y 1
impreso en la consola.
Este programa usa notación do para combinar dos tipos de efectos nativos proporcionados por el runtime de JavaScript: generación de números aleatorios y entrada/salida por consola.
Efectos extensibles (extensible effects)
Podemos inspeccionar el tipo de main
abriendo el módulo en PSCi:
Este tipo parece bastante complicado, pero es fácil de explicar por analogía con los registros de PureScript.
Considera una función simple que usa un tipo registro:
Esta función crea una cadena de nombre completo a partir de un registro que contiene propiedades firstName
y lastName
. Si averiguas el tipo de esta función en PSCi como antes, verás esto:
Este tipo se lee como sigue: “fullName
toma un registro con campos firstName
y lastName
y otras propiedades cualesquiera y devuelve una String
”.
Esto es, a fullName
no le importa si pasas un registro con más campos, siempre y cuando las propiedades firstName
y lastName
estén presentes:
De manera similar, el tipo de main
de arriba se puede interpretar como sigue: “main
es un cálculo con efectos secundarios, que se puede ejecutar en cualquier entorno que soporte generación de números aleatorios y entrada/salida por consola, y cualquier otro tipo de efectos secundarios, y que devuelve un valor de tipo Unit
”.
Este es el origen del nombre “efectos extensibles”: podemos siempre extender el conjunto de efectos secundarios, siempre y cuando soportemos el conjunto de efectos que necesitamos.
Intercalando efectos
Esta extensibilidad permite al código en la mónada Eff
intercalar (interleave) distintos tipos de efectos secundarios.
La función random
que hemos usado tiene el siguiente tipo:
El conjunto de efectos (random :: RANDOM | eff1)
que vemos aquí no es es el mismo que el que aparece en main
.
Sin embargo, podemos instanciar el tipo random
de tal manera que los efectos coinciden. Si elegimos que eff1
sea (console :: CONSOLE | eff)
, entonces ambos conjuntos de efectos son iguales, salvo por reordenación.
De manera similar, logShow
tiene un tipo que se puede especializar para que coincida con los efectos de main
:
Esta vez, hemos elegido que eff2
sea (random :: RANDOM | eff)
.
La cuestión es que los tipos de random
y logShow
indican los efectos secundarios que contienen, pero de tal manera que otros efectos secundarios puedan ser mezclados para construir cálculos más grandes con conjuntos de efectos secundarios más grandes.
Fíjate en que no tenemos que dar un tipo para main
. El compilador encontrará el tipo más general para main
dados los tipos polimórficos de random
y logShow
.
La familia de Eff
El tipo de main
no se parece a los otros tipos que hemos visto antes. Para explicarlo, necesitamos considerar la familia (kind) de Eff
. Recuerda que los tipos se clasifican por sus familias de la misma manera que los valores se clasifican por sus tipos. Hasta ahora hemos visto sólo familias construidas a partir de Type
(la familia de tipos) y ->
(que construye familias para constructores de tipos).
Para averiguar la familia de Eff
, usa el comando :kind
en PSCi:
Hay dos símbolos que no hemos visto antes.
Control.Monad.Eff.Effect
es la familia de efectos, que representa etiquetas a nivel de tipo (type-level labels) para distintos tipos de efectos secundarios. Para entender esto, fíjate en que las dos etiquetas que vimos en main
tienen ambas familia Control.Monad.Eff.Effect
:
El constructor de familia #
se usa para construir familias para filas, es decir, conjuntos etiquetados sin orden.
Así, Eff
está parametrizada por una fila de efectos y su tipo de retorno. Esto es, el primer argumento a Eff
es un conjunto etiquetado no ordenado de tipos de efectos, y el segundo parámetro es el tipo de retorno.
Podemos ya leer el tipo de main
expuesto antes:
El primer argumento a Eff
es (console :: CONSOLE, random :: RANDOM | eff)
. Esto es una fila que contiene el efecto CONSOLE
y el efecto RANDOM
. El símbolo barra |
separa los efectos etiquetados de la variable de fila (row variable) eff
que representa cualquier otro efecto secundario que queramos mezclar.
El segundo argumento a Eff
es Unit
, que es el tipo del valor de retorno del cálculo.
Objetos y filas
Considerar la familia de Eff
nos permite establecer una conexión más profunda entre efectos extensibles y registros.
Toma la función que definimos antes:
La familia del tipo a la izquierda de la flecha de función ha de ser Type
, porque sólo los tipos de familia Type
tienen valores.
Las llaves son de hecho azúcar sintáctico, y el tipo completo que el compilador PureScript entiende es como sigue:
Date cuenta de que las llaves han sido eliminadas y hay un constructor extra Record
. Record
es un constructor de tipo incorporado definido en el módulo Prim
. Si buscamos su familia vemos lo siguiente:
Esto es, Record
es un constructor de tipo que toma una fila de tipos y construye un tipo. Esto es lo que nos permite escribir funciones polimórficas por fila sobre registros.
El sistema de tipos usa la misma maquinaria para gestionar los efectos extensibles y se usa para registros polimórficos por fila (o registros extensibles). La única diferencia es la familia de los tipos que aparecen en las etiquetas. Los registros se parametrizan por una fila de tipos, y Eff
se parametriza por una fila de efectos.
La misma característica del sistema de tipos se podría usar para construir otros tipos parametrizados por filas de constructores de tipos, ¡o incluso filas de filas!
Efectos de grano fino (fine-grained effects)
Las anotaciones de tipo no suelen ser necesarias cuando usamos Eff
, ya que las filas de efectos se pueden inferir, pero se pueden usar para indicar al compilador qué efectos se esperan de un cálculo.
Si anotamos el ejemplo previo con una fila de efectos cerrada
(fíjate en que no hay variable de fila eff
aquí), entonces no podemos incluir accidentalmente un subcálculo que hace uso de un tipo de efectos diferente. De esta manera, podemos controlar los efectos secundarios que permitimos a nuestro código.
Gestores (handlers) y acciones (actions)
Las funciones como print
y random
se llaman acciones. Las acciones tienen el tipo Eff
a la parte derecha de sus funciones, y su propósito es intoducir nuevos efectos.
Esto contrasta con los gestores, en los que el tipo Eff
aparece como tipo de un argumento de la función. Mientras que las acciones suman al conjunto de efectos requeridos, un gestor normalmente resta efectos del conjunto.
Como ejemplo, considera el paquete purescript-exceptions
. Define dos funciones, throwException
y catchException
:
throwException
es una acción. Eff
aparece en la parte derecha e introduce el nuevo efecto EXCEPTION
.
catchException
es un gestor. Eff
aparece como tipo del segundo argumento de la función, y el efecto neto es eliminar el efecto EXCEPTION
.
Esto es útil, porque el sistema de tipos se puede usar para delimitar porciones de código que requieren un efecto concreto. Ese código se puede envolver en un gestor, permitiéndole ser empotrado dentro de un bloque de código que no permite ese efecto.
Por ejemplo, podemos escribir un fragmento de código que arroja excepciones usando el efecto Exception
, y luego envolver ese código usando catchException
para empotrar el cálculo en un fragmento de código que no permite excepciones.
Supongamos que queremos leer la configuración de nuestra aplicación de un documento JSON. El proceso de analizar el documento puede resultar en una excepción. El proceso de leer y analizar la configuración se puede escribir como una función con esta firma de tipo:
Entonces, en la función main
, podemos usar catchException
para gestionar el efecto EXCEPTION
anotando el error y devolviendo una configuración por defecto:
El paquete purescript-eff
también define el gestor runPure
, que toma un cálculo sin efectos secundarios y lo evalúa de manera segura como un valor puro:
Estado mutable
Hay otro efecto definido en las bibliotecas base: el efecto ST
.
El efecto ST
se usa para manipular estado mutable. Como programadores funcionales puros, sabemos que el estado mutable compartido puede ser problemático. Sin embargo, el efecto ST
usa el sistema de tipos para restringir el uso compartido de tal manera que sólo se permita mutación local segura.
El efecto ST
se define en el módulo Control.Monad.ST
. Para ver cómo funciona, necesitamos mirar los tipos de sus acciones:
newSTRef
se usa para crear una nueva referencia a una celda mutable de tipo STRef h a
, que se puede leer usando la acción readSTRef
y se puede modificar usando las acciones writeSTRef
y modifySTRef
. El tipo a
es el tipo del valor almacenado en la celda, y el tipo h
se usa para indicar una región de memoria en el sistema de tipos.
Aquí tenemos un ejemplo. Supongamos que queremos simular el movimiento de una partícula cayendo por la gravedad mediante la iteración de una función de actualización sobre un gran número de pequeños pasos de tiempo.
Podemos hacer esto creando una referencia a una celda mutable que contendrá la posición y velocidad de la partícula, y mediante un bucle for (usando la acción forE
de Control.Monad.Eff
) actualizar el valor almacenado en esa celda:
Al final del cálculo, leemos el valor final de la referencia a celda y devolvemos la posición de la partícula.
Fíjate en que aunque esta función usa estado mutable, sigue siendo una función pura siempre y cuando la referencia a celda ref
no se use en otras partes del programa. Veremos que esto es exactamente lo que el efecto ST
no permite.
Para ejecutar un cálculo con el efecto ST
tenemos que usar la función runST
:
Lo que tenemos que observar aquí es que el tipo de la region h
está cuantificado dentro de los paréntesis a la izquierda de la flecha de función. Significa que cualquier acción que pasemos a runST
tiene que funcionar con cualquier region h
.
Sin embargo, una vez que una referencia a celda ha sido creada por newSTRef
, su tipo de región ya se ha fijado, de manera que sería un error de tipos usar la referencia fuera del código delimitado por runST
. Esto es lo que permite a runST
eliminar el efecto ST
de manera segura.
De hecho, ya que ST
es el único efecto de nuestro ejemplo, podemos usar runST
junto a runPure
para convertir simulate
en una función pura:
Puedes incluso intentar ejecutar la función en PSCi:
De hecho, si expandimos la definición de simulate
en la llamada a runST
como sigue:
el compilador se dará cuenta de que la referencia a celda no puede escapar de su ámbito y puede convertirla de manera segura en una var
. Aquí está el JavaScript generado para el cuerpo de la llamada a runST
:
El efecto ST
es una buena forma de generar JavaScript corto cuando trabajamos con estado mutable en ámbito local, especialmente cuando se usa junto a acciones como forE
, foreachE
, whileE
y untilE
que generan bucles eficientes en la mónada Eff
.
Efectos DOM
En las secciones finales de este capítulo, aplicaremos lo que hemos aprendido sobre efectos en la mónada Eff
al problema de trabajar con el DOM.
Hay un número de paquetes PureScript para trabajar directamente con el DOM o con bibliotecas DOM de código abierto. Por ejemplo:
-
purescript-dom
es un conjunto amplio de vínculos de bajo nivel al API DOM del navegador. -
purescript-jquery
es un conjunto de vínculos a la biblioteca jQuery.
Hay también bibliotecas PureScript que construyen abstracciones sobre estas bibliotecas, como
-
purescript-thermite
, que se basa enpurescript-react
, y -
purescript-halogen
que proporciona un conjunto de abstracciones seguras a nivel de tipos sobre una biblioteca de DOM virtual propia.
En este capítulo, usaremos la biblioteca purescript-react
para añadir una interfaz de usuario a nuestra agenda, pero animamos al lector interesado a explorar enfoques alternativos.
Una interfaz de usuario para la agenda
Usando la biblioteca purescript-react
, definiremos nuestra aplicación como una componente React. Las componentes React describen elementos HTML en código como estructuras de datos puras, que son presentadas de manera eficiente al DOM. Además, las componentes pueden responder a eventos como pulsaciones de botón. La biblioteca purescript-react
usa la mónada Eff
para describir cómo gestionar estos eventos.
Un tutorial completo de la biblioteca React está bastante fuera del alcance de este capítulo, pero animamos al lector a consultar su documentación cuando sea necesario. Para nuestros propósitos, React proporciona un ejemplo práctico de la mónada Eff
.
Vamos a construir un formulario que permita a un usuario añadir una nueva entrada a nuestra agenda. El formulario contendrá cajas de texto para varios campos (nombre, apellido, ciudad, estado, etc.), y un área en la que mostraremos los errores de validación. Según vaya escribiendo texto el usuario en las cajas de texto, los errores de validación se actualizarán.
Para mantener las cosas simples, el formulario tendrá una forma fija: los diferentes tipos de número de teléfono (casa, móvil, trabajo, otro) se pedirán en cajas de texto separadas.
El fichero HTML está básicamente vacío, excepto por la siguiente línea:
Esta línea incluye el código JavaScript generado por Pulp. La ponemos al final del fichero para asegurarnos de que los elementos relevantes están en la página antes de que tratemos de accederlos. Para reconstruir el fichero Main.js
se puede usar Pulp con el comando browserify
. Asegúrate primero de que el directorio dist
existe y de que has instalado React como una dependencia NPM:
El módulo Main
define la función main
, que crea la componente agenda y la representa en pantalla. La función main
usa sólo los efectos CONSOLE
y DOM
como indica su firma de tipo:
Primero, main
registra un mensaje de estado en la consola:
Después, main
usa la API DOM para obtener una referencia (doc
) al cuerpo del documento:
Fíjate en que esto proporciona un ejemplo de efectos intercalados: la función log
usa el efecto CONSOLE
, y las funciones window
y document
usan ambas el efecto DOM
. El tipo de main
indica que usa ambos efectos.
main
usa la acción window
para obtener una referencia al objeto ventana y pasa el resultado a la función document
usando >>=
. document
toma un objeto ventana y devuelve una referencia a su documento.
Date cuenta de que, por la definición de la notación do, podríamos haber escrito esto como sigue:
Si esto es más o menos legible es un problema de preferencia personal. La primera versión es un ejemplo de estilo libre de puntos, ya que no hay argumentos a función con nombre, al contrario que la segundo versión que usa el nombre w
para el objeto ventana.
El módulo Main
define una componente agenda, llamada addressBook
. Para entender su definición, necesitaremos primero entender algunos conceptos.
Para crear una componente React, debemos primero crear una clase React, que actúa como una plantilla para una componente. En purescript-react
podemos crear clases usando la función createClass
. createClass
require una especificación de nuestra clase, que es esencialmente una colección de acciones Eff
que se usan para gestionar varias partes de ciclo de vida de la componente. La acción que nos interesa es la acción Render
.
Aquí están los tipos de algunas funciones relevantes proporcionadas por la biblioteca React:
Hay unas cuantas cosas interesantes en las que fijarse aquí:
- El sinónimo de tipo
Render
se porporciona para simplificar algunas firmas de tipo, y denota la función representadora de una componente. - Una acción
Render
toma una referencia a la componente (de tipoReactThis
), y devuelve unReactElement
en la mónadaEff
. UnReactElement
es una estructura de datos que describe el estado deseado del DOM tras la representación. - Cada componente React define algún tipo de estado. El estado puede cambiar en respuesta a eventos como pulsación de botones. En
purescript-react
, el valor inicial del estado se proporciona en la funciónspec
. - La fila de efectos del tipo
Render
usa algunos efectos interesantes para restringir el acceso al estado de la componente React en ciertas funciones. Por ejemplo, durante la representación, el acceso al objeto “refs” no está permitido (Disallowed
), y el acceso al estado de la componente es de sólo lectura (ReadOnly
).
El módulo Main
define un tipo de estados para la componente agenda y un estado inicial:
El estado contiene un registro Person
(que haremos editable usando componentes formulario) y una colección de errores (que se rellenará usando nuestro código de validación existente).
Veamos ahora la definición de nuestra componente:
Como ya hemos indicado, addressBook
usará createClass
y spec
para crear una clase React. Para hacerlo, proporcionará nuestro valor de estado inicial y una acción Render
. Sin embargo, ¿que podemos hacer en la acción Render
? Para responder a eso, purescript-react
proporciona algunas acciones simples que podemos usar:
Las funciones readState
y writeState
usan efectos extensibles para asegurarse de que tenemos acceso al estado de React (a través del efecto ReactState
), pero fíjate en que los permisos de lectura y escritura están separados, parametrizando el efecto ReactState
mediante otra fila.
Esto ilustra un punto interesante acerca de los efectos basados en fila de PureScript: los efectos que aparecen dentro de las filas no tienen por qué ser singletons simples, sino que pueden tener estructura interesante, y esta flexibilidad permite algunas restricciones útiles en tiempo de compilación. Si la biblioteca purescript-react
no usase esta restricción sería posible obtener excepciones en tiempo de ejecución si por ejemplo intentásemos escribir el estado en la acción Render
. En su lugar, dichos errores son ahora detectados en tiempo de compilación.
Ahora podemos leer la definición de nuestra componente addressBook
. Comienza leyendo el estado actual de la componente:
Fíjate en que:
- El nombre
ctx
se refiere a la referencia aReactThis
, y se puede usar para leer y escribir el estado donde sea apropiado. - El registro dentro de
AppState
se ajusta usando una ligatura de registro, incluyendo un doble sentido de registro (record pun) para el campo errors. Nombramos explícitamente varias partes de la estructura de estado por conveniencia.
Recuerda que Render
debe devolver una estructura ReactElement
, representando el estado deseado del DOM. La acción Render
se define en términos de unas funciones auxiliares. Una de dichas funciones auxiliares es renderValidationErrors
, que convierte la estructura Errors
en un array de ReactElement
s.
En purescript-react
’ los ReactElement
s se crean típicamente aplicando funciones como div
que crean elementos HTML. Estas funciones normalmente toman un array de atributos y un array de elementos hijos como argumentos. Sin embargo, los nombres que acaban con una comilla (como ul'
aquí) omiten el array de atributos y usan los atributos por defecto en su lugar.
Fíjate en que como estamos simplemente manipulando estructuras de datos normales, podemos usar funciones como map
para construir elementos más interesantes.
Una segunda función auxiliar es formField
, que crea un ReactElement
conteniendo una entrada de texto para un único campo del formulario:
De nuevo, date cuenta de que estamos componiendo elementos más interesantes a partir de elementos más simples, aplicando atributos a cada elemento sobre la marcha. Un atributo en el que debemos fijarnos aquí es el atributo onChange
aplicado al elemento input
. Esto es un gestor de eventos (event handler), y se usa para actualizar el estado de la componente cuando el usuario edita el texto de nuestra caja de texto. Nuestro gestor de eventos se define usando una tercera función auxiliar, updateAppState
:
updateAppState
toma una referencia a la componente del formulario de nuestro valor ReactThis
, una función para actualizar el registro Person
, y el registro Event
al que respondemos. Primero, extrae el nuevo valor de la caja de texto del evento change
(usando la función auxiliar valueOf
), y lo usa para crear un nuevo estado Person
:
Entonces ejecuta la función de validación y actualiza el estado de la componente (usando writeState
) en consecuencia:
Eso cubre lo esencial de la implementación de nuestra componente. Sin embargo, debes leer el código fuente que acompaña a este capítulo para obtener una comprensión completa de la forma en que funciona la componente.
Prueba también la interfaz de usuario ejecutando pulp browserify --to dist/Main.js
y abriendo el fichero html/index.html
en tu navegador. Debes ser capaz de introducir algunos valores en los campos del formulario y ver los errores de validación impresos en la página.
Obviamente, esta interfaz de usuario se puede mejorar de varias maneras. Los ejercicios explorarán varias formas en que podemos hacer la aplicación más usable.
Conclusión
Este capítulo ha cubierto un montón de ideas sobre gestión de efectos secundarios en PureScript:
- Hemos conocido la clase de tipos
Monad
y su conexión con la notación do. - Hemos presentado las leyes de la mónada, y hemos visto que nos permiten transformar código escrito usando notación do.
- Hemos visto cómo las mónadas se pueden usar de manera abstracta para escribir código que funciona con diferentes tipos de efectos secundarios.
- Hemos visto cómo las mónadas son ejemplos de funtores aplicativos, cómo ambos nos permiten calcular con efectos secundarios, y las diferencias entre ambos enfoques.
- Se ha definido el concepto de efectos nativos, y hemos conocido la mónada
Eff
, que se usa para gestionar efectos secundarios nativos. - Hemos visto cómo la mónada
Eff
soporta efectos extensibles, y cómo múltiples tipos de efectos nativos se pueden intercalar en el mismo cálculo. - Hemos visto como los efectos y los registros se gestionan en el sistema de familias, y la conexión entre registros extensibles y efectos extensibles.
- Hemos usado la mónada
Eff
para gestionar una variedad de efectos: generación de números aleatorios, excepciones, entrada/salida por consola, estado mutable, y manipulación del DOM usando React.
La mónada Eff
es una herramienta fundamental en el código PureScript del mundo real. Se usará en el resto del libro para gestionar efectos secundarios y en otros casos de uso.
Gráficos con Canvas
Objetivos del capítulo
Este capítulo será un ejemplo extendido enfocado en el paquete purescript-canvas
, que proporciona una forma de generar gráficos 2D desde Purescript usando la API Canvas de HTML5.
Preparación del proyecto
El módulo de este proyecto introduce las siguientes dependencias de Bower nuevas:
-
purescript-canvas
, que da tipos a los métodos de la API Canvas de HTML5 -
purescript-refs
, que proporciona un efecto secundario para usar referencias globales mutables
El código fuente para el capítulo está dividido en un conjunto de módulos, cada uno de los cuales define un método main
. Las distintas secciones de este capítulo están implementadas en ficheros diferentes, y el módulo Main
se puede cambiar modificando el comando de construcción de Pulp para ejecutar el método main
del fichero adecuado en cada momento.
El fichero HTML html/index.html
contiene un único elemento canvas
que se usará en cada ejemplo, y un elemento script
para cargar el código PureScript compilado. Para probar el código de cada sección, abre el fichero HTML en tu navegador.
Formas simples
El fichero Example/Rectangle.purs
contiene un ejemplo introductorio simple que dibuja un único rectángulo azul en el centro del lienzo. El módulo importa Control.Monad.Eff
, y también el módulo Graphics.Canvas
que contiene acciones en la mónada Eff
para trabajar con la API Canvas.
La acción main
comienza, como los otros módulos, usando la acción getCanvasElementById
para obtener una referencia al objeto lienzo, y la acción getContext2D
para acceder al contexto de representación 2D del canvas:
Nota: la llamada a unsafePartial
es necesaria ya que el ajuste de patrón sobre el resultado de getCanvasElementById
es parcial, coincidiendo sólo con el constructor Just
. Para nuestros propósitos es suficiente, pero en código de producción querremos probablemente ajustarnos también al constructor Nothing
y proporcionar un mensaje de error adecuado.
Los tipos de estas acciones se pueden averiguar usando PSCi o mirando la documentación:
CanvasElement
y Context2D
son tipos definidos en el módulo Graphics.Canvas
. El mismo módulo define también el efecto CANVAS
usado por todas las acciones del módulo.
El contexto gráfico ctx
gestiona el estado del canvas y proporciona métodos para dibujar formas primitivas, fijar estilos y colores, y aplicar transformaciones.
Continuamos fijando el estilo de relleno para que sea azul mediante la acción setFillStyle
:
Fíjate en que la acción setFillStyle
toma el contexto gráfico como argumento. Este es un patrón común en el módulo Graphics.Canvas
.
Finalmente, usamos la acción fillPath
para rellenar el rectángulo. fillPath
tiene el siguiente tipo:
fillPath
toma un contexto gráfico y otra acción que construye la trayectoria a dibujar. Para construir una trayectoria podemos usar la acción rect
. rect
toma un contexto gráfico y un registro que proporciona la posición y tamaño del rectángulo.
Construye el ejemplo del rectángulo proporcionando Example.Rectangle
como nombre del módulo principal:
Ahora abre el fichero html/index.html
y verifica que este código dibuja un rectángulo azul en el centro del lienzo.
Haciendo uso del polimorfismo de fila
Hay otras formas de representar trayectorias. La función arc
dibuja un segmento de arco, y las funciones moveTo
, lineTo
y closePath
se pueden usar para dibujar trayectorias lineales por tramos.
El fichero Shapes.purs
dibuja tres formas: un rectángulo, un segmento de arco y un triángulo.
Hemos visto que la función rect
toma un registro como argumento. De hecho, las propiedades del rectángulo se definen en un sinónimo de tipo:
Las propiedades x
e y
representan la ubicación de la esquina superior izquierda, mientras que las propiedades w
y h
representan el ancho y alto respectivamente.
Para dibujar un segmento de arco podemos usar la función arc
pasando un registro con el siguiente tipo:
Aquí, las propiedades x
e y
representan el punto central, r
es el radio, y start
y end
representan los extremos del arco en radianes.
Por ejemplo, este código rellena un segmento de arco centrado en (300, 300)
con radio 50
:
Date cuenta de que tanto Rectangle
como Arc
contienen propiedades x
e y
de tipo Number
. En ambos casos, este par representa un punto. Significa que podemos escribir funciones polimórficas por fila que actúan en cualquier tipo de registro.
Por ejemplo, el módulo Shapes
define una función translate
que traslada una forma modificando sus propiedades x
e y
:
Fíjate en el tipo polimórfico por fila. Dice que translate
acepta cualquier registro con propiedades x
e y
y otras propiedades cualesquiera, y devuelve el mismo tipo de registro. Los campos x
e y
se actualizan, pero el resto de campos permanecen intactos.
Esto es un ejemplo de sintaxis de actualización de registro (record update syntax). La expresión shape { ... }
crea un nuevo registro basado en el registro shape
, actualizando los campos entre llaves a los valores especificados. Date cuenta de que las expresiones entre llaves se separan de sus etiquetas por símbolos igual, no con dos puntos como en los registros literales.
La función translate
se puede usar tanto con Rectangle
como con Arc
, como veremos en el ejemplo Shapes
.
El tercer tipo de trayectoria dibujada en el ejemplo Shapes
es una trayectoria lineal por tramos. Aquí está el código correspondiente:
Usamos tres funciones aquí:
-
moveTo
mueve la posición actual de la trayectoria a las coordenadas especificadas -
lineTo
dibuja un segmento de línea entre la posición actual y las coordenadas especificadas, y actualiza la posición actual -
closePath
completa la trayectoria dibujando un segmento de línea uniendo la posición actual y la posición inicial
El resultado de este fragmento de código es rellenar un triángulo isosceles.
Construye el ejemplo especificando Example.Shapes
como módulo principal:
y abre html/index.html
de nuevo para ver el resultado. Debes ver tres tipos de formas dibujadas en el lienzo.
Dibujando círculos aleatorios
El fichero Example/Random.purs
contiene un ejemplo que usa la mónada Eff
para intercalar dos tipos diferentes de efectos secundarios: generación de números aleatorios y manipulación del lienzo. El ejemplo dibuja cien círculos generados aleatoriamente en el lienzo.
La acción main
obtiene una referencia al contexto gráfico como antes, y fija los estilos de trazo y relleno:
A continuación, el código usa la función for_
para iterar por los enteros entre 0
y 100
:
En cada iteración, el bloque de notación do comienza generando tres números aleatorios distribuidos entre 0
y 1
. Estos números representan las coordenadas x
e y
y el radio de un círculo.
A continuación, para cada círculo, el código crea un Arc
basándose en estos parámetros y finalmente rellena y perfila el arco con los estilos actuales:
Construye este ejemplo especificando el módulo Example.Random
como módulo principal:
y mira el resultado abriendo html/index.html
.
Transformaciones
Podemos hacer más cosas en el lienzo que dibujar formas simples. Todos los lienzos mantienen una transformación que se usa para transformar las formas antes de dibujarse. Las formas pueden ser trasladadas, rotadas, escaladas y distorsionadas.
La biblioteca purescript-canvas
soporta estas transformaciones usando las siguientes funciones:
La acción translate
realiza una traslación cuyas componentes se especifican en las propiedades del registro TranslateTransform
.
La acción rotate
realiza una rotación respecto al origen de un número de radianes especificado como primer argumento.
La acción scale
realiza un escalado con origen en el centro. El registro ScaleTransform
especifica los factores de escala junto a los ejes x
e y
.
Finalmente, transform
es la acción más general de las cuatro. Realiza una transformación afín especificada por una matriz.
Cualquier forma dibujada tras invocar a estas acciones recibirá automáticamente la transformación apropiada.
De hecho, el efecto de cada una de estas funciones es postmultiplicar la transformación por la transformación actual del contexto. El resultado es que si se aplican múltiples transformaciones una tras otra, sus efectos se aplican en orden inverso:
El efecto de esta secuencia de acciones es que la escena se rota, se escala, y finalmente se traslada.
Preservando el contexto
Un caso de uso común es representar un subconjunto de la escena usando una transformación y restablecer la transformación a continuación.
La API de Canvas proporciona los métodos save
y restore
que manipulan una pila de estados asociados con el lienzo. purescript-canvas
envuelve esta funcionalidad en las siguientes funciones:
La acción save
apila el estado actual del contexto (incluyendo la transformación actual y cualquier estilo) en la pila, y la acción restore
desapila el estado superior de la pila y lo restaura.
Esto permite salvar el estado actual, aplicar algunos estilos y transformaciones, dibujar primitivas, y finalmente restaurar la transformación y estado originales. Por ejemplo, la siguiente función dibuja algo en el canvas, pero aplica una rotación antes de hacerlo y restaura la transformación a continuación:
Para abstraer casos de uso comunes usando funciones de orden mayor, la biblioteca purescript-canvas
proporciona la función withContext
, que realiza una acción sobre el lienzo al tiempo que preserva el estado original del contexto:
Podríamos reescribir la función rotated
anterior usando withContext
como sigue:
Estado mutable global
En esta sección usaremos el paquete purescript-refs
para demostrar otro efecto en la mónada Eff
.
El módulo Control.Monad.Eff.Ref
proporciona un constructor de tipo para referencias a estado global mutable y un efecto asociado:
Un valor de tipo Ref a
es una referencia a una celda mutable que contiene un valor de tipo a
, bastante parecido a STRef h a
que vimos en el capítulo anterior. La diferencia es que, mientras que el efecto ST
se puede eliminar usando runST
, el efecto Ref
no proporciona un gestor. Mientras que ST
se usa para seguir la pista a la mutación local segura, Ref
se usa para mutaciones globales. Por lo tanto se debe usar escasamente.
El fichero Example/Refs.purs
contiene un ejemplo que usa el efecto REF
para detectar pulsaciones de ratón en el elemento canvas
.
El código comienza creando una nueva referencia conteniendo el valor 0
mediante la acción newRef
:
Dentro del gestor de pulsación de ratón, la acción modifyRef
se usa para actualizar la cuenta de pulsaciones:
La acción readRef
se usa para leer la nueva cuenta de pulsaciones:
En la función render
, usamos el contador de pulsaciones para determinar qué transformación aplicar a un rectángulo:
Esta acción usa withContext
para preservar la transformación original, y aplica la siguiente secuencia de transformaciones (recuerda que las transformaciones se aplican de abajo hacia arriba):
- El rectángulo se traslada a
(-100, -100)
de manera que su centro descanse en el origen - Escalamos el rectángulo con respecto al origen
- Rotamos el rectángulo un múltiplo de
10
grados respecto al origen - Trasladamos el rectángulo a
(300, 300)
de manera que su centro quede en el centro del lienzo
Construye el ejemplo:
y abre el fichero html/index.html
. Si pulsas sobre el lienzo repetidas veces verás un rectángulo verde rotando sobre el centro del lienzo.
Sistemas-L
En este último ejemplo, usaremos el paquete purescript-canvas
para escribir una función que represente sistemas-L (o sistemas de Lindenmayer).
Un sistema-L se define mediante un alfabeto, una secuencia inicial de letras del alfabeto y un conjunto de reglas de producción. Cada regla de producción toma una letra del alfabeto y devuelve una secuencia de letras de reemplazo. Este proceso se itera un cierto número de veces comenzando con la secuencia inicial de letras.
Si cada letra del alfabeto se asocia con alguna instrucción a realizar en el lienzo, el sistema-L se puede dibujar siguiendo las instrucciones por orden.
Por ejemplo, supongamos que el alfabeto consta de las letras L
(gira a la izquierda), R
(gira a la derecha) y F
(avanza). Podemos definir la siguiente regla de producción:
Si comenzamos con la secuencia inicial “FRRFRRFRR” e iteramos, obtenemos la siguiente secuencia:
y así sucesivamente. Dibujar una trayectoria lineal por tramos correspondiente a este conjunto de instrucciones aproxima una curva llamada la curva de Koch. Incrementar el número de iteraciones incrementa la resolución de la curva.
Traduzcamos esto al lenguaje de los tipos y funciones.
Podemos representar nuestro alfabeto mediante un tipo algebraico. Para nuestro ejemplo podemos usar el siguiente tipo:
Este tipo de datos define un constructor de datos para cada letra de nuestro alfabeto.
¿Cómo podemos representar la secuencia inicial de letras? Es simplemente un array de letras de nuestro alfabeto que llamaremos frase (Sentence
):
Nuestras reglas de producción se pueden expresar como una función de Alphabet
a Sentence
como sigue:
Esto es una copia directa de la especificación de arriba.
Ahora podemos implementar una función lsystem
que toma una especificación de esta forma y la dibuja en el lienzo. ¿Qué tipo debe tener lsystem
? Bien, necesita tomar valores como initial
y productions
como argumentos, así como una función para dibujar una letra del alfabeto en el canvas.
Aquí tenemos una primera aproximación al tipo de lsystem
:
Los dos primeros argumentos corresponden a los valores initial
y productions
.
El tercer argumento representa una función que toma una letra del alfabeto y la interpreta realizando algunas acciones sobre el lienzo. En nuestro ejemplo, esto significaría girar a la izquierda en el caso de la letra L
, girar a la derecha en el caso de la letra R
, y avanzar en el caso de la letra F
.
El argumento final es un número que representa el número de iteraciones de las reglas de producción que queremos realizar.
La primera observación es que la función lsystem
no debe funcionar sólo para un único tipo Alphabet
, sino para cualquier tipo, de manera que debemos generalizar nuestro tipo. Cambiemos Alphabet
y Sentence
por a
y Array a
para alguna variable de tipo cuantificada a
:
La segunda observación es que para implementar instrucciones como “gira a la izquierda” y “gira a la derecha” necesitamos mantener algún estado, esto es, la dirección en que la trayectoria se está moviendo en todo momento. Necesitamos modificar nuestra función para pasar el estado durante el cálculo. De nuevo, la función lsystem
debe funcionar para cualquier tipo de estado, así que lo representaremos usando la variable de tipo s
.
Necesitamos añadir el tipo s
en tres sitios:
En primer lugar, el tipo s
ha sido añadido como el tipo de un argumento adicional a lsystem
. Este argumento representará el estado inicial del sistema-L.
El tipo s
también aparece como argumento y tipo de retorno de la función de interpretación (el tercer argumento a lsystem
). La función de interpretación recibirá ahora el estado actual del sistema-L como argumento y devolverá un nuevo estado actualizado.
En el caso de nuestro ejemplo, podemos usar el siguiente tipo para representar el estado:
Las propiedades x
e y
representan la posición actual de la trayectoria, y la propiedad theta
representa la dirección actual de la trayectoria especificada como el ángulo entre la dirección de la trayectoria y el eje horizontal en radianes.
El estado inicial del sistema se puede especificar como sigue:
Ahora intentemos implementar la función lsystem
. Veremos que su definición es notablemente simple.
Parece razonable que lsystem
recurra sobre su cuarto argumento (de tipo Int
). En cada paso de la recursividad, la frase actual cambiará siendo actualizada mediante las reglas de producción. Teniendo en cuesta esto, asignemos nombres a los argumentos de la función y deleguemos en una función auxiliar:
La función go
trabaja recursivamente sobre su segundo argumento. Hay dos casos: cuando n
es cero y cuando no lo es.
En el primer caso, la recursividad finaliza y simplemente necesitamos interpretar la frase actual de acuerdo a la función de interpretación. Tenemos una frase de tipo Array a
, un estado de tipo s
y una función de tipo s -> a -> Eff (canvas :: CANVAS | eff) s
. Parece un trabajo para la función foldM
que definimos antes y que está disponible en el paquete purescript-control
:
¿Que pasa en el caso en que no es cero? En ese caso, podemos simplemente aplicar las reglas de producción a cada letra de la frase actual, concatenando los resultados, y repetir llamando a go
recursivamente:
¡Eso es todo! Fíjate en cómo el uso de funciones de orden mayor como foldM
y concatMap
nos ha permitido comunicar nuestras ideas de manera concisa.
Sin embargo aún no hemos terminado. El tipo que hemos dado todavía es demasiado específico. Fíjate en que no usamos ninguna operación de canvas en ningún sitio de nuestra implementación. Tampoco usamos la estructura de la mónada Eff
para nada. De hecho, ¡nuestra función es válida para cualquier mónada m
!
Aquí tenemos el tipo más general de lsystem
de la manera en que se especifica en el código fuente que acompaña este capítulo:
Podemos entender este tipo como si dijese que nuestra función de interpretación es libre de tener efectos secundarios, capturados por la mónada m
. Puede dibujar en el lienzo, o imprimir información a la consola, o soportar fallos o múltiples valores de retorno. Animamos al lector a que intente escribir sistemas-L que usen estos tipos de efectos secundarios.
Esta función es un buen ejemplo de la potencia de separar los datos de la implementación. La ventaja de este enfoque es que ganamos la libertad de interpretar nuestros datos de varias maneras distintas. Podemos incluso factorizar lsystem
en dos funciones más pequeñas: la primera construiría una frase usando aplicación repetida de concatMap
, y la segunda interpretaría la frase usando foldM
. Dejamos esto también como un ejercicio para el lector.
Completemos nuestro ejemplo implementando la función de interpretación. El tipo de lsystem
nos dice que su firma debe ser s -> a -> m s
para algunos tipos a
y s
y un constructor de tipo m
. Sabemos que queremos que a
sea Alphabet
y s
sea State
, y para la mónada m
podemos elegir Eff (canvas :: CANVAS)
. Esto nos da el siguiente tipo:
Para implementar esta función necesitamos gestionar los tres constructores del tipo Alphabet
. Para interpretar las letras L
(girar a la izquierda), y R
(girar a la derecha), simplemente tenemos que actualizar el estado para cambiar el ángulo theta
de manera apropiada:
Para interpretar la letra F
(avanzar), podemos calcular la nueva posición de la trayectoria, dibujar un segmento y actualizar el estado como sigue:
Date cuenta de que en el código fuente de este capítulo, la función interpret
se define usando una ligadura let
dentro de la función main
, de manera que el nombre ctx
esté en ámbito. Sería posible también mover el contexto al tipo State
, pero esto no sería apropiado porque no es una parte cambiante del estado del sistema.
Para representar este sistema-L podemos simplemente usar la acción strokePath
:
Compila el ejemplo del sistema-L usando
y abre html/index.html
. Debes ver la curva de Koch dibujada en el lienzo.
Conclusión
En este capítulo hemos aprendido cómo usar la API Canvas de HTML5 desde PureScript usando la biblioteca purescript-canvas
. Vimos también una demostración práctica de muchas de las técnicas que ya habíamos aprendido: asociaciones y pliegues, registros y polimorfismo de fila, y la mónada Eff
para gestionar efectos secundarios.
Los ejemplos demuestran también la potencia de las funciones de orden mayor y la separación de datos de la implementación. Sería posible extender estas ideas para separar por completo la representación de una escena de su función de dibujado usando un tipo de datos algebraico, por ejemplo:
Este es el enfoque tomado por el paquete purescript-drawing
y aporta la flexibilidad de ser capaz de manipular la escena como datos de varias maneras antes de dibujar.
En el siguiente capítulo veremos cómo implementar bibliotecas como purescript-canvas
que envuelven funcionalidad JavaScript existente, usando la interfaz para funciones externas de PureScript.
Interfaz para funciones externas (foreign function interface)
Objetivos del capítulo
Este capítulo presentará la interfaz para funciones externas (o FFI), que permite la comunicación desde el código PureScript al código JavaScript y viceversa. Cubriremos lo siguiente:
- Cómo llamar funciones JavaScript puras desde PureScript
- Cómo crear nuevos tipos de efecto y acciones para usar con la mónada
Eff
basadas en código JavaScript existente - Cómo llamar código PureScript desde JavaScript
- Cómo entender la representación de valores PureScript en tiempo de ejecución
- Cómo trabajar con datos no tipados usando el paquete
purescript-foreign
Al final del capítulo retomaremos nuestro ejemplo de la agenda. El objetivo del capítulo será añadir la siguiente funcionalidad nueva a nuestra aplicación usando la FFI:
- Alertar al usuario con una notificación emergente
- Almacenar los datos del formulario serializados en el almacenamiento local del navegador y recargarlos cuando la aplicación reinicia
Preparación del proyecto
El código fuente para este módulo es una continuación del código fuente de los capítulos 3, 7 y 8. Como tal, el árbol de fuentes incluye los ficheros fuente apropiados de dichos capítulos.
Este capítulo añade dos nuevas dependencias Bower:
- La biblioteca
purescript-foreign
que proporciona un tipo de datos y funciones para trabajar con datos no tipados. - La biblioteca
purescript-foreign-generic
, que añade soporte a la bibliotecapurescript-foreign
para programación sobre tipos de datos genéricos.
Nota: para evitar problemas concretos del navegador con el almacenamiento local al servir la página desde un fichero local, puede ser necesario ejecutar el proyecto de este capítulo por HTTP:
Una advertencia
PureScript proporciona una interfaz para funciones externas sencilla para hacer que trabajar con JavaScript sea tan simple como sea posible. Sin embargo, hay que hacer notar que la FFI es una capacidad avanzada del lenguaje. Para usarla de manera segura y efectiva, debes tener un conocimiento de la representación en tiempo de ejecución de los datos con los que planeas trabajar. Este capítulo intenta impartir dicho conocimiento.
La FFI de PureScript está diseñado para ser muy flexible. En la práctica esto significa que los desarrolladores pueden elegir entre dar a sus funciones externas tipos muy simples o usar el sistema de tipos como protección frente a usos incorrectos accidentales del código extreno. El código de las bibliotecas estándar tiende a favorecer el segundo enfoque.
Como ejemplo simple, una función JavaScript no garantiza que su valor de retorno no será null
. De hecho, ¡el código JavaScript idiomático devuelve null
con bastante frecuencia! Sin embargo, normalmente no hay un valor null en PureScript. Así, es la responsabilidad del desarrolador gestionar estos casos límite de forma apropiada cuando diseñe sus interfaces a código JavaScript usando la FFI.
Llamando a PureScript desde JavaScript
Llamar una función PureScript desde JavaScript es muy simple, al menos para funciones con tipos simples:
Tomemos el siguiente módulo como ejemplo:
Esta función encuentra el máximo común divisor de dos números mediante restas sucesivas. Es un buen ejemplo de un caso donde puedes querer usar PureScript para definir la función, pero tenemos el requerimiento de que debe llamarse desde JavaScript: definir esta función en PureScript usando ajuste de patrones y recursividad es simple, y la implementación se puede beneficiar del uso del comprobador de tipos.
Para entender cómo se puede llamar a esta función desde JavaScript, es importante darse cuenta de que las funciones PureScript siempre se convierten en funciones JavaScript de un único argumento, de manera que tenemos que aplicar sus argumentos uno a uno:
Aquí estoy asumiendo que el código ha sido compilado con pulp build
, que compila los módulos PureScript a módulos CommonJS. Por esa razón, he sido capaz de hacer referencia a la función gcd
del objeto Test
tras importar el módulo Test
usando require
.
También puedes querer empaquetar el código JavaScript para el navegador usando pulp build -O --to file.js
. En ese caso, accederías al módulo Test
del espacio de nombres global de PureScript, que es por defecto PS
:
Entendiendo la generación de nombres
PureScript intenta preservar los nombres durante la generación de código tanto como sea posible. En particular, se puede esperar que se conserven la mayoría de identificadores que no sean palabras reservadas de PureScript o JavaScript, al menos para los nombres de declaraciones de nivel superior.
Si decides usar una palabra clave de JavaScript como identificador, el nombre se escapará con un símbolo dólar doble. Por ejemplo,
genera el siguiente JavaScript:
Además, si quieres usar caracteres especiales en los nombres de tus identificadores, serán escapados usando un símbolo de dólar. Por ejemplo,
genera el siguiente JavaScript:
Cuando se pretenda que el código PureScript compilado sea llamado desde JavaScript, se recomienda que los identificadores usen caracteres alfanuméricos y evitar las palabras reservadas de JavaScript. Si se proporcionan operadores definidos por el usuario en código PureScript, es buena práctica proporcionar una función alternativa con un nombre alfanumérico para ser usada desde JavaScript.
Representación de datos en tiempo de ejecución (runtime data representation)
Los tipos nos permiten razonar en tiempo de compilación sobre si nuestros programas son “correctos” en cierto sentido; esto es, que no se van a romper en tiempo de ejecución. Pero ¿que significa eso? En PureScript significa que el tipo de una expresión debe ser compatible con su representación en tiempo de ejecución.
Por esa razón, es importante entender la representación de los datos en tiempo de ejecución para ser capaz de usar código PureScript y JavaScript juntos de manera efectiva. Esto significa que para cualquier expresión PureScript dada, debemos ser capaces de entender el comportamiento del valor a que se evaluará en tiempo de ejecución.
La buena noticia es que las expresiones PureScript tienen una representación particularmente simple en tiempo de ejecución. Debe ser siempre posible entender la representación de los datos en tiempo de ejecución considerando su tipo.
Para tipos simples, la correspondencia es casi trivial. Por ejemplo, si una expresión tiene el tipo Boolean
, su valor v
en tiempo de ejecución debe satisfacer que typeof v === 'boolean'
. Esto es, las expresiones de tipo Boolean
evalúan a uno de los valores (JavaScript) true
o false
. En particular, no hay ninguna expresión PureScript de tipo Boolean
que se evalúe a null
o undefined
.
Una ley similar es aplicable para las expresiones de tipo Int
, Number
y String
. Las expresiones de tipo Int
o Number
se evalúan a números JavaScript no nulos, y las expresiones de tipo String
se evalúan a cadenas JavaScript no nulas. Las expresiones de tipo Int
se evaluarán a enteros en tiempo de ejecución, aunque no puedan ser distinguidas de valores de tipo Number
mediante el uso de typeof
.
¿Qué pasa con los tipos más complejos?
Como ya hemos visto, las funciones de PureScript se corresponden con funciones de JavaScript de un único argumento. De forma más precisa, si una expresión f
tiene tipo a -> b
para algunos tipos a
y b
, y una expresión x
se evalúa a un valor con la representación en tiempo de ejecución correcta para el tipo a
, entonces f
se evalúa a una función JavaScript que cuando se aplica al resultado de evaluar x
tiene la representación correcta en tiempo de ejecución para el tipo b
. Como ejemplo simple, una expresión de tipo String -> String
se evalúa a una función que toma cadenas JavaScript no nulas y devuelve cadenas JavaScript no nulas.
Como puedes esperar, los arrays de PureScript se corresponden con arrays de JavaScript. Pero recuerda, los arrays de PureScript son homogéneos, de manera que todos los elementos tienen el mismo tipo. Concretamente, si una expresión PureScript e
tiene tipo Array a
para algún tipo a
, entonces e
se evalúa a un array JavaScript no nulo donde todos sus elementos tienen la representación en tiempo de ejecución correcta para el tipo a
.
Hemos visto ya que los registros de PureScript se evalúan a objetos JavaScript. Al igual que para las funciones y los arrays, podemos razonar sobre la la representación en tiempo de ejecución de los datos de los campos de un registro considerando los tipos asociados con sus etiquetas. Por supuesto, los campos de un registro no tienen por qué ser del mismo tipo.
Representando ADTs
Para todo constructor de tipo de datos algebraico, el compilador PureScript crea un nuevo objeto JavaScript definiendo una función. Sus constructores se corresponden con funciones que crean nuevos objetos JavaScript basados en esos prototipos.
Por ejemplo, considera el siguiente ADT simple:
El compilador PureScript genera el siguiente código:
Aquí vemos dos tipos de objeto JavaScript: Zero
y One
. Es posible crear valores de cada tipo usando la palabra reservada de JavaScript new
. Para los constructores con argumentos, el compilador almacena los datos asociados en campos llamados value0
, value1
, etc.
El compilador de PureScript también genera funciones auxiliares. Para los constructores sin argumentos, el compilador genera una propiedad value
que se puede reutilizar en lugar de usar el operador new
de forma repetida. Para constructores con uno o más argumentos, el compilador genera una función create
que toma argumentos con la representación apropiada y los aplica a los constructores apropiados.
¿Qué pasa con los constructores con más de un argumento? En ese caso, el compilador PureScript crea también un nuevo tipo de objeto y una función auxiliar. Esta vez, sin embargo, la función auxiliar es una función currificada de dos argumentos. Por ejemplo, este tipo de datos algebraico:
genera este código JavaScript:
Aquí, los valores del tipo de objeto Two
se pueden crear usando la palabra reservada new
o usando la función Two.create
.
El caso de los newtypes es un poco diferente. Recuerda que un newtype es como un tipo algebraico de datos, con la restricción de que tiene un único constructor que toma un único argumento. En este caso, la representación en tiempo de ejecución del newtype es de hecho la misma que la del tipo de su argumento.
Por ejemplo, este newtype que representa números de teléfono:
se representa como una cadena JavaScript en tiempo de ejecución. Esto es útil para diseñar bibliotecas, ya que los newtypes proporcionan una capa adicional de seguridad de tipos, pero sin el sobrecoste en tiempo de ejecución de realizar otra llamada a función.
Representando tipos cuantificados
Las expresiones con tipos cuantificados (polimórficos) tienen representaciones restrictivas en tiempo de ejecución. En la práctica, esto significa que hay relativamente pocas expresiones con un tipo cuantificado dado, pero que podemos razonar sobre ellas de manera bastante efectiva.
Considera este tipo polimórfico por ejemplo:
¿Qué clase de funciones tienen este tipo? Bien, hay ciertamente una función con este tipo, a saber, la función identidad id
, definida en el Prelude
:
De hecho, ¡la función id
es la unica función (total) con este tipo! Este parece ser el caso ciertamente (intenta escribir una expresión con este tipo que no sea equivalente a id
), pero ¿cómo podemos estar seguros? Podemos estar seguros considerando la representación en tiempo de ejecución del tipo.
¿Cuál es la representación en tiempo de ejecución de un tipo cuantificado forall a. t
? Bien, cualquier expresión con la representación en tiempo de ejecución para este tipo tiene que tener la representación en tiempo de ejecución correcta para el tipo t
para cualquier elección del tipo a
. En nuestro ejemplo anterior, una función de tipo forall a. a -> a
tiene que tener la representación en tiempo de ejecución correcta para los tipos String -> String
, Number -> Number
, Array Boolean -> Array Boolean
, y así sucesivamente. Debe convertir cadenas en cadenas, números en números, etc.
Pero eso no es suficiente, la representación en tiempo de ejecución de un tipo cuantificado es más estricta que esto. Requerimos que cualquier expresión sea paramétricamente polimórfica, esto es, no puede usar ninguna información sobre el tipo de su argumento en su implementación. Esta condición adicional impide que implementaciones problemáticas como la siguiente función JavaScript pertenezcan a un tipo polimórfico:
Ciertamente, esta función convierte cadenas en cadenas, números en números, etc. Pero no satisface la condición adicional, ya que inspecciona el tipo (en tiempo de ejecución) de sus argumentos, así que esta función no sería un elemento válido dol tipo forall a. a -> a
.
Sin ser capaces de inspeccionar el tipo en tiempo de ejecución de nuestro argumento, nuestra única opción es devolver el argumento sin cambios, así que id
es de hecho la única habitante del tipo forall a. a -> a
.
Una discusión completa del polimorfismo paramétrico y la parametricidad está fuera del alcance de este libro. Fíjate sin embargo en que como los tipos de PureScript se borran en tiempo de ejecución, una función polimórfica en PureScript no puede inspeccionar la representación en tiempo de ejecución de sus argumentos (sin usar la FFI), de manera que esta representación de datos polimórficos es apropiada.
Representando tipos restringidos
Las funciones con una restricción de clase de tipos tienen una representación interesante en tiempo de ejecución. Ya que el comportamiento de la función dependerá de la instancia de clase de tipos elegida por el compilador, a la función se le pasa un argumento adicional, llamado diccionario de clase de tipos (type class dictionary), que contiene la implementación de la funciones de la clase de tipos proporcionada por la instancia elegida.
Por ejemplo, aquí tenemos una función PureScript simple con un tipo restringido que usa la clase de tipos Show
:
El JavaScript generado tiene esta pinta:
Fíjate en que shout
se compila a una función (currificada) de dos argumentos, no uno. El primer argumento dict
es el diccionario de clase de tipos para la restricción Show
. dict
contiene la implementación de la función show
para le tipo a
.
Podemos llamar a esta función desde JavaScript pasando un diccionario de clase de tipos del Prelude de manera explicita como primer argumento:
Usando código JavaScript desde PureScript
La forma más simple de usar código JavaScript desde PureScript es dar un tipo a un valor JavaScript existente usando una declaración de importación externa (foreign import). Las declaraciones de importaciones externas deben tener una declaración JavaScript correspondiente en un módulo externo JavaScript (foreign JavaScript module).
Por ejemplo, considera la función encodeURIComponent
que se puede usar en JavaScript para codificar una componente de un URI escapando caracteres especiales:
Esta función tiene la representación correcta en tiempo de ejecución para el tipo de función String -> String
, ya que transforma cadenas no nulas en cadenas no nulas, y no tiene otros efectos secundarios.
Podemos asignar este tipo a la función con la siguiente declaración de importación externa:
Necesitamos también escribir un módulo externo JavaScript. Si el módulo de arriba se salva como src/Data/URI.purs
, el módulo externo debe salvarse como src/Data/URI.js
:
src/Data/URI.js
:
Pulp encuentra ficheros .js
en el directorio src
y los pasa al compilador como módulos externos JavaScript.
Las funciones JavaScript y los valores se exportan de los módulos externos JavaScript asignándolos al objeto exports
igual que en un módulo CommonJS normal. El compilador purs
trata este módulo como un módulo CommonJS normal y simplemente lo añade como una dependencia al módulo PureScript compilado. Sin embargo, cuando empaquetamos código para el navegador con purs bundle
o pulp build -O --to
es muy importante seguir el patrón de arriba, asignando lo que queremos exportar al objeto exports
usando una asignación de propiedad. Esto es porque purs bundle
reconoce este formato, permitiendo eliminar funciones o valores no usados exportados por JavaScript del código que empaqueta.
Con estas dos piezas en su sitio, podemos ahora usar la función encodeURIComponent
desde PureScript igual que cualquier función escrita en PureScript. Por ejemplo, si esta declaración se salva como un módulo y se carga en PSCi, podemos reproducir el cálculo de arriba:
Est enfoque funciona bien para valores JavaScript simples, pero tiene un uso limitado para ejemplos más complicados. La razón es que la mayoría del código idiomático JavaScript no cumple los criterios estrictos que impone la representación en tiempo de ejecución de los tipos básicos de PureScript. En esos casos, tenemos otra opción; podemos envolver el código JavaScript de tal manera que lo forzamos a que se ajuste a la representación en tiempo de ejecución correcta.
Envolviendo valores JavaScript
Podemos querer envolver valores JavaScript y funciones por una serie de razones:
- Una función toma múltiples argumentos pero queremos llamarla como una función currificada
- Podemos querer usar la mónada
Eff
para llevar registro de cualquier efecto secundario de JavaScript - Puede ser necesario gestionar casos límite como
null
oundefined
para obtener la representación en tiempo de ejecución correcta
Por ejemplo, supongamos que queremos recrear la función head
sobre arrays usando una declaración externa. En JavaScript podríamos escribir la función así:
Pero hay un problema con esta función. Podemos intentar darle el tipo forall a. Array a -> a
, pero para arrays vacíos esta función devuelve undefined
. Por lo tanto, esta función no tiene la representación correcta en tiempo de ejecución y debemos usar una función envoltorio para gestionar este caso límite.
Para mantenerlo simple, podemos lanzar una excepción en el caso de un array vacío. Hablando de manera estricta, las funciones puras no deben lanzar excepciones, pero es suficiente para nuestro propósito ilustrativo y podemos indicar la ausencia de seguridad en el nombre de la función:
En nuestro módulo externo JavaScript podemos definir unsafeHead
como sigue:
Definiendo tipos externos
Lanzar una excepción en caso de fallo es menos que ideal; el código PureScript idiomático usa el sistema de tipos para representar efectos secundarios como valores ausentes. Un ejemplo de este enfoque es el constructor de tipo Maybe
. En esta sección construiremos otra solución usando la FFI.
Supongamos que queremos definir un nuevo tipo Undefined a
cuya representación en tiempo de ejecución sea como la del tipo a
pero que también permita el valor undefined
.
Podemos definir un tipo externo (foreign type) usando la FFI mediante una declaración de tipo externo (foreign type declaration). La sintaxis es similar a la de definir una función externa:
Fíjate en que la palabra reservada data
indica que estamos definiendo un tipo, no un valor. En lugar de una firma de tipo, damos la familia del nuevo tipo. En este caso, declaramos que la familia de Undefined
sea Type -> Type
. En otras palabras, Undefined
es un constructor de tipo.
Podemos ahora simplificar nuestra definición original de head
:
Y en el módulo PureScript:
Fíjate en los dos cambios: el cuerpo de la función head
es ahora mucho más simple y devuelve arr[0]
incluso si ese valor no está definido, y la firma de tipo se ha cambiado para reflejar el hecho de que nuestra función puede devolver un valor indefinido.
Esta función tiene la representación correcta en tiempo de ejecución para su tipo, pero es bastante inútil porque no tenemos manera de usar un valor de tipo Undefined a
. Pero ¡podemos arreglar eso escribiendo unas funciones nuevas usando la FFI!
La función más básica que necesitamos nos dirá si un valor está definido o no:
Esto se define fácilmente en nuestro módulo JavaScript como sigue:
Podemos ahora usar isUndefined
y head
juntos desde JavaScript para definir una función útil:
Aquí, las funciones externas que hemos definido eran muy simples, lo que significa que nos podíamos beneficiar del uso del comprobador de tipos de PureScript tanto como era posible. Esto es una buena práctica en general: las funciones externas deben mantenerse tan pequeñas como sea posible, y la lógica de la aplicación debe moverse a código PureScript donde sea posible.
Funciones de múltiples argumentos
El Prelude de PureScript contiene un conjunto interesante de ejemplos de tipos externos. Como ya hemos visto, las funciones PureScript toman un único argumento y se pueden usar para simular funciones de varios argumentos mediante currificado. Esto tiene varias ventajas; podemos aplicar funciones parcialmente y dar instancias de clase de tipos para los tipos de función, pero viene con un sobrecoste de rendimiento. Para código de rendimiento crítico es necesario a veces definir funciones JavaScript que aceptan múltiples argumentos. El Prelude define tipos externos que nos ayudan a trabajar de manera segura con dichas funciones.
Por ejemplo, la siguiente declaración externa está sacada del módulo Data.Function.Uncurried
del Prelude:
Define el constructor de tipo Fn2
que toma tres argumentos de tipo. Fn2 a b c
es un tipo que representa funciones JavaScript de dos argumentos de tipos a
y b
con valor de retorno de tipo c
.
El paquete purescript-functions
define constructores de tipo similares para funciones de aridades entre 0 y 10.
Podemos crear una función de dos argumentos usando la función mkFn2
como sigue:
y podemos aplicar una función de dos argumentos usando la función runFn2
:
La clave aquí es que el compilador expande in situ (inlines) las funciones mkFn2
y runFn2
cuando se aplican por completo. El resultado es que el código generado es muy compacto:
Representando efectos secundarios
La mónada Eff
se define también como un tipo externo en el Prelude. Su representación en tiempo de ejecución es bastante simple; una expresión de tipo Eff eff a
debe evaluarse a una función JavaScript sin argumentos que realiza cualquier efecto secundario y devuelve un valor con la representación en tiempo de ejecución para el tipo a
.
La definición del constructor de tipo Eff
viene dada en el módulo Control.Monad.Eff
como sigue:
Recuerda que el constructor de tipo Eff
está parametrizado por una fila de efectos y un tipo de retorno, lo que viene reflejado en su familia.
Como ejemplo simple, considera la función random
definida en el paquete purescript-random
. Recuerda que su tipo era:
La definición de la función random
es esta:
Fíjate en que la función random
está representada en tiempo de ejecución como una función sin argumentos. Tiene el efecto secundario de generar un valor aleatorio, lo devuelve, y el valor de retorno coincide con la representación en tiempo de ejecución del tipo Number
: es un número JavaScript no nulo.
Como ejemplo algo más interesante, considera la función log
definida por el módulo Control.Monad.Eff.Console
del paquete purescript-console
. La función log
tiene el siguiente tipo:
Y aquí está su definición:
La representación de log
en tiempo de ejecución es una función JavaScript de un único argumento que devuelve una función sin argumentos. La función interna realiza el efecto secundario de escribir un mensaje a la consola.
Los efectos RANDOM
y CONSOLE
se definen también como tipos externos. Sus familias están definidas como Effect
, la familia de los efectos. Por ejemplo:
De hecho, es posible definir nuevos efectos de esta manera como veremos pronto.
Las expresiones de tipo Eff eff a
se pueden invocar desde JavaScript como métodos JavaScript normales. Por ejemplo, como la función main
tiene que tener el tipo Eff eff a
para algún conjunto de efectos eff
y algún tipo a
, se puede invocar como sigue:
Cuando usamos pulp build -O --to
o pulp run
, esta llamada a main
se genera automáticamente si el módulo Main
está definido.
Definiendo nuevos efectos
El código fuente para este capítulo define dos nuevos efectos. El más simple es el efecto ALERT
definido en el módulo Control.Monad.Eff.Alert
. Se usa para indicar que un cálculo puede alertar al usuario usando una ventana emergente.
El efecto se define primero usando una declaración de tipo externo:
Se da a ALERT
la familia Effect
, indicando que representa un efecto y no un tipo.
A continuación, se define la acción alert
, que muestra un aviso emergente y añade el efecto ALERT
a la fila de efectos:
El módulo JavaScript externo es sencillo, definiendo la función alert
mediante asignación a la variable exports
:
La acción alert
es muy similar a la acción log
del módulo Control.Monad.Eff.Console
. La única diferencia es que la acción alert
usa el método window.alert
y la acción log
usa el método console.log
. Como tal, alert
sólo se puede usar en entornos donde window.alert
esté definido, como en un navegador web.
Date cuenta de que al igual que en el caso de log
, la función alert
usa una función sin argumentos para representar el cálculo de tipo Eff (alert :: ALERT | eff) Unit
.
El segundo efecto definido en este capítulo es el efecto STORAGE
definido en el módulo Control.Monad.Eff.Storage
. Se usa para indicar que un cálculo puede leer o escribir valores usando la API Web Storage.
El efecto se define de la misma manera:
El módulo Control.Monad.Eff.Storage
define dos acciones: getItem
que extrae un valor del almacenamiento local, y setItem
que inserta o actualiza un valor en el almacenamiento local. Las dos funciones tienen los tipos siguientes:
El lector interesado puede inspeccionar el código fuente de este módulo para ver las definiciones de estas acciones.
setItem
toma una clave y un valor (ambos cadenas), y devuelve un cálculo que almacena en el almacenamiento local el valor en la clave especificada.
El tipo de getItem
es más interesante. Toma una clave y trata de extraer el valor asociado del almacenamiento local. Sin embargo, como el método getItem
de window.localStorage
puede devolver null
, el tipo de retorno no es String
, sino Foreign
que está definido en el paquete purescript-foreign
en el módulo Data.Foreign
.
Data.Foreign
proporciona una forma de trabajar con datos no tipados, o de manera más general, datos cuya representación en tiempo de ejecución es incierta.
Trabajando con datos no tipados
En esta sección veremos cómo podemos usar la biblioteca Data.Foreign
para convertir datos no tipados en datos tipados con la representación en tiempo de ejecución correcta para su tipo.
El código para este capítulo se basa en el ejemplo de la agenda del capítulo 8, añadiendo un botón de salvar en la parte inferior del formulario. Cuando se pulsa el botón de salvar, el estado del formulario se serializa a JSON y se almacena en el almacenamiento local. Cuando la página se recarga, el documento JSON se saca del almacenamiento local y se analiza.
El módulo Main
define un tipo para los datos del formulario salvados:
El problema es que no tenemos garantías de que el JSON tendrá la forma correcta. Planteándolo de otra manera, no sabemos que el JSON representa el tipo de datos correcto en tiempo de ejecución. Esta es la clase de problema que resuelve la biblioteca purescript-foreign
. Aquí hay otros ejemplos:
- Una respuesta JSON de un servicio web
- Un valor pasado a una función desde código JavaScript
Probemos la biblioteca purescript-foreign
y purescript-foreign-generic
en PSCi.
Comienza importando algunos módulos:
Una buena forma de obtener un valor Foreign
es analizar un documento JSON. purescript-foreign-generic
define las siguientes funciones:
El constructor de tipo F
es de hecho un sinónimo de tipo definido en Data.Foreign
:
Aquí, Except
es una mónada para gestionar excepciones en código puro, similar a Either
. Podemos convertir un valor en la mónada F
en un valor en la mónada Either
usando la función runExcept
.
La mayoría de las funciones de la biblioteca purescript-foreign
y purescript-foreign-generic
devuelven un valor en la mónada F
, lo que significa que podemos usar notación do y los combinadores de funtor aplicativo para construir valores tipados.
La clase de tipos Decode
representa aquellos tipos que se pueden obtener a partir de datos no tipados. Hay instancias de la clase de tipos definidas para los tipos primitivos y arrays, y podemos también definir nuestras propias instancias.
Intentemos analizar algún documento JSON simple usando readJSON
en PSCI (recordando usar runExcept
para desenvolver los resultados):
Recuerda que en la mónada Either
, el constructor de datos Right
indica éxito. Fíjate sin embargo en que un JSON inválido o un tipo incorrecto acaba en error:
La biblioteca purescript-foreign-generic
nos dice en qué parte del documento JSON ha ocurrido el error.
Gestionando valores nulos e indefinidos
Los documentos JSON del mundo real contienen valores nulos e indefinidos, así que necesitamos ser capaces de gestionar estos también.
purescript-foreign-generic
define un constructor de tipo para resolver este problema: NullOrUndefined
. Sirve un propósito similar al constructor de tipo Undefined
que definimos previamente, pero usa el constructor de tipo Maybe
internamente para representar valores ausentes.
El módulo también proporciona una función unNullOrUndefined
para desenvolver el valor interno. Podemos elevar la función apropiada sobre la acción readJSON
para analizar documentos JSON que permiten valores nulos:
En cada caso, la anotación de tipo se aplica al término a la derecha del operador <$>
. Por ejemplo, decodeJSON "42"
tiene el tipo F (NullOrUndefined Int)
. La función unNullOrUndefined
se eleva entonces sobre F
para dar el tipo final F (Maybe Int)
.
El tipo NullOrUndefined Int
representa valores que son o bien enteros o null. ¿Qué pasa si queremos analizar valores más interesantes, como arrays de enteros, donde cada elemento puede ser null
? En ese caso, podemos elevar la función map unNullOrUndefined
sobre la acción decodeJSON
como sigue:
En general, usar newtypes para envolver un tipo existente es una buena forma de proporcionar estrategias de serialización diferentes para el mismo tipo. El tipo NullOrUndefined
se define como un newtype en torno al constructor de tipos Maybe
.
Serialización genérica de JSON
De hecho, ráramente necesitamos escribir instancias de la clase Decode
, ya que la biblioteca purescript-foreign-generic
nos permite derivar instancias usando una técnica llamada programación sobre tipos de datos genéricos
(datatype-generic programming
). Una explicación de esta técnica está más allá del ámbito de este libro, pero nos permite escribir una funcion una vez y reusarla sobre muchos tipos de datos diferentes, basándose en la estructura de los propios tipos.
Para derivar una instancia Decode
para nuestro tipo FormData
(de manera que podamos deserializarlo a partir de su representación JSON), primero usamos la palabra clave derive
para derivar una instancia de la clase de tipos Generic
, de esta forma:
A continuación, símplemente definimos la función decode
usando la función genericDecode
como sigue:
De hecho, podemos también derivar un codificador de la misma forma:
Es importante que usemos las mismas opciones en el codificador y en el decodificador, de otra manera nuestros documentos JSON pueden no decodificarse correctamente.
Cuando se pulsa el botón de salvar, un valor de tipo FormData
se pasa a la función encode
, serializándose como un documento JSON. El tipo FormData
es un newtype para un registro, así que un valor de tipo FormData
pasado a encode
será serializado como un objeto JSON. Esto es porque hemos usado la opción unwrapSingleConstructors
cuendo hemos definido nuestro codificador JSON.
Nuestra instancia de la clase de tipos Decode
se usa con decodeJSON
para analizar el documento JSON cuando se lee de almacenamiento local como sigue:
La acción savedData
lee la estructura FormData
en dos pasos: primero analiza el valor Foreign
obtenido de getItem
. El compilador infiere el tipo de jsonOrNull
como Maybe String
(ejercicio para el lector, ¿cómo se infiere este tipo?). La función traverse
se usa entonces para aplicar decodeJSON
al (posiblemente ausente) elemento del resultado de tipo Maybe String
. La instancia de clase de tipos inferida para decodeJSON
es la que acabamos de escribir, resultando en un valor de tipo F (Maybe FormData)
.
Necesitamos usar la estructura monádica de F
, ya que el argumento a traverse
usa el resultado jsonOrNull
obtenido en la primera línea.
Hay tres posibilidades para el resultado de FormData
:
- Si el constructor externo es
Left
, hubo un error analizando la cadena JSON o representaba un valor del tipo erróneo. En este caso, la aplicación muestra un error usando la acciónalert
que escribimos antes. - Si el constructor externo es
Right
pero el interno esNothing
, entoncesgetItem
devolvió tambiénNothing
, lo que significa que la clave no existía en el almacenamiento local. En este caso, la aplicación continúa silenciosamente. - Finalmente, un valor que se ajuste al patrón
Right (Just _)
indica un documento JSON analizado con éxito. En este caso, la aplicación actualiza los campos del formulario con los valores apropiados.
Prueba el código ejecutando pulp build -O --to dist/Main.js
, y abriendo html/index.html
en el navegador. Debes poder salvar el contenido de los campos del formulario a almacenamiento local pulsando el botón de salvar. Cuando refresques la página los campos deben rellenarse de nuevo.
Nota: Puede que sea necesario servir los ficheros HTML y JavaScript a través de un servidor HTTP local para no tener problemas con algunos navegadores.
Conclusión
En este capítulo hemos aprendido cómo trabajar con código JavaScript externo desde PureScript y viceversa, y hemos visto los problemas asociados con escribir código fiable usando la FFI:
- Hemos visto la importancia de la representación en tiempo de ejecución de los datos, y de asegurarse de que las funciones externas tienen la representación correcta.
- Hemos aprendido cómo tratar con casos límite como valores null y otros tipos de datos JavaScript, usando tipos externos o el tipo de datos
Foreign
. - Hemos visto algunos tipos externos comunes definidos en el Prelude y cómo se pueden usar para interoperar con código JavaScript idiomático. En particular, hemos presentado la representación de efectos secundarios en la mónada
Eff
y vimos cómo usar la mónadaEff
para capturar nuevos efectos secundarios. - Vimos cómo deserializar datos JSON usando la clase de tipos
Decode
.
Para ver más ejemplos, las organizaciones purescript
y purescript-contrib
en GitHub proporcionan muchos ejemplos de bibliotecas que usan la FFI. En los capítulos restantes usaremos algunas de estas bibliotecas para resolver problemas del mundo real de una manera segura a nivel de tipos.
Aventuras monádicas
Objetivos del capítulo
El objetivo de este capítulo será aprender sobre los transformadores de mónadas, que ofrecen una manera de combinar efectos secundarios proporcionados por diferentes mónadas. El ejemplo motivador será una juego de aventuras textual que se puede jugar en la consola de NodeJS. Los diferentes efectos secundarios del juego (registro de mensajes, estado y configuración) se proporcionarán por medio de una pila de transformadores de mónadas.
Preparación del proyecto
El módulo de este proyecto añade las siguientes dependencias Bower nuevas:
-
purescript-maps
, que proporciona un tipo de datos para asociaciones inmutables -
purescript-sets
, que proporciona un tipo de datos para conjuntos inmutables -
purescript-transformers
, que proporciona implementaciones de transformadores de mónadas estándar -
purescript-node-readline
, que proporciona ligaduras FFI a la interfazreadline
de NodeJS -
purescript-yargs
, que proporciona una interfaz aplicativa a la biblioteca de procesamiento de argumentos de línea de comandosyargs
También es necesario instalar el módulo yargs
usando NPM:
Cómo jugar
Para ejecutar el proyecto, usa pulp run
.
Por defecto verás un mensaje de uso:
Proporciona el nombre del jugador usando la opción -p
:
Desde el símbolo de espera de órdenes puedes introducir comandos como look
, inventory
, take
, use
, north
, south
, east
, y west
. Hay también un comando debug
que se puede usar para imprimir el estado del juego cuando se proporciona la opción de línea de comandos --debug
.
El juego se desarrolla en una rejilla bidimensional, y el jugador se mueve mandando comandos north
, south
, east
, and west
. El juego contiene una colección de objetos que pueden estar en posesión del jugador (en el inventario del usuario), o en la rejilla del juego en alguna posición. Los objetos pueden ser recogidos por el jugador usando el comando take
.
Para referencia, aquí hay un recorrido completo del juego:
El juego es muy simple, pero el objetivo del capítulo es usar el paquete purescript-transformers
para construir una biblioteca que permita desarrollo rápido de este tipo de juegos.
La mónada State
Comenzaremos viendo algunas de las mónadas suministradas por el paquete purescript-transformers
.
El primer ejemplo es la mónada State
, que proporciona una forma de modelar estado mutable en código puro. Hemos visto ya dos enfoques al estado mutable proporcionados por la mónada Eff
, a saber, los efectos REF
y ST
. State
proporciona una tercera alternativa que no está implementada usando la mónada Eff
.
El constructor de tipo State
toma dos parámetros de tipo: el tipo s
del estado y un tipo de retorno a
. Aunque hablamos de la “mónada State
”, la instancia de la clase de tipos Monad
está de hecho proporcionada para el constructor de tipo State s
, para cualquier tipo s
.
El módulo Control.Monad.State
proporciona la siguiente API:
Esto parece muy similar a la API proporcionada por los efectos REF
y ST
. Sin embargo, fíjate en que no pasamos una referencia a celda mutable como Ref
o STRef
a las acciones. La diferencia entre State
y las soluciones provistas por la mónada Eff
es que la mónada State
sólo soporta un único fragmento de estado que está implícito; el estado se implementa como un argumento de función oculto en el constructor de datos de la mónada State
, de manera que no hay que pasar una referencia explícita.
Veamos un ejemplo. Un uso de la mónada State
puede ser sumar los valores de un array de números sobre el estado actual. Podríamos hacerlo eligiendo Number
como el tipo de estado s
y usar traverse_
para recorrer el array, con una llamada a modify
para cada elemento del array:
El módulo Control.Monad.State
proporciona tres funciones para ejecutar un cálculo en la mónada State
:
Cada una de estas funciones toma un estado inicial de tipo s
y un cálculo de tipo State s a
. evalState
sólo devuelve el valor de retorno, execState
sólo devuelve el estado final, y runState
devuelve ambos expresados como un valor de tipo Tuple a s
.
Dada la función sumArray
de arriba, podemos usar execState
en PSCi para sumar los números de varios arrays como sigue:
La mónada Reader
Otra mónada suministrada por el paquete purescript-transformers
es la mónada Reader
. Esta mónada proporciona la capacidad de leer de una configuración global. Mientras que la mónada State
proporciona la capacidad de leer y escribir un único fragmento de estado mutable, la mónada Reader
sólo proporciona la capacidad de leer un único fragmento de datos.
El constructor de tipo Reader
toma dos argumentos de tipo: un tipo r
que representa el tipo de configuración y el tipo de retorno a
.
El módulo Control.Monad.Reader
provee la siguiente API:
La acción ask
se puede usar para leer la configuración actual, y la acción local
se puede usar para ejecutar un cálculo con una configuración modificada.
Por ejemplo, supongamos que estamos desarrollando una aplicación controlada por permisos y queremos usar la mónada Reader
para mantener el objeto con los permisos del usuario actual. Podemos elegir que el tipo r
sea un tipo Permissions
con la siguiente API:
Cuando queramos comprobar si el usuario tiene un permiso concreto, podemos usar ask
para extraer el objeto de permisos actual. Por ejemplo, podemos querer que sólo los administradores puedan crear usuarios nuevos:
Para elevar los permisos de usuario, podemos usar la acción local
para modificar el objeto Permissions
durante la ejecución de algún cálculo:
Podemos entonces escribir una función para crear un nuevo usuario incluso si el usuario no tiene los permisos admin
:
Para ejecutar un cálculo en la mónada Reader
se puede usar la función runReader
para suministrar la configuración global:
La mónada Writer
La mónada Writer
proporciona la capacidad de acumular un valor secundario además del valor de retorno de un cálculo.
Un caso de uso común es acumular un registro de mensajes de tipo String
o Array String
, pero la mónada Writer
es más general que esto. Puede de hecho ser usada para acumular un valor en cualquier monoide, de manera que se puede usar para llevar registro de un total entero usando el monoide Additive Int
, o registrar si alguno de varios valores Boolean
intermedios era true
usando el monoide Disj Boolean
.
El constructor de tipo Writer
toma dos argumentos de tipo. Un tipo w
que debe ser una instancia de la clase de tipos Monoid
, y el tipo de retorno a
.
El elemento clave de la API Writer
es la función tell
:
La acción tell
añade el valor proporcionado al resultado acumulado actual.
Como ejemplo, añadamos un registro de mensajes a una función existente usando el monoide Array String
. Considera nuestra implementación previa de la función máximo común divisor:
Podemos añadir registro de mensajes a esta función cambiando el tipo de retorno a Writer (Array String) Int
:
Sólo tenemos que cambiar ligeramente nuestra función para que registre ambas entradas en cada paso:
Podemos ejecutar un cálculo en la mónada Writer
usando las funciones execWriter
o runWriter
:
Como en el caso de la mónada State
, execWriter
sólo devuelve el registro de mensajes acumulado, mientras que runWriter
devuelve tanto el registro de mensajes como el resultado.
Podemos probar nuestra función modificada en PSCi:
Transformadores de mónada (monad transformers)
Cada una de las tres mónadas anteriores, State
, Reader
y Writer
, son también ejemplos de los llamados transformadores de mónada. Los transformadores de mónada equivalentes se llaman StateT
, ReaderT
, y WriterT
respectivamente.
¿Qué es un transformador de mónada? Como hemos visto, una mónada aumenta el código PureScript con algún tipo de efecto secundario, que se puede interpretar en PureScript usando el gestor apropiado (runState
, runReader
, runWriter
, etc.) Esto está bien si sólo necesitamos usar un efecto secundario. Sin embargo, a menudo es útil usar más de un efecto secundario a la vez. Por ejemplo, podemos querer usar Reader
junto a Maybe
para expresar un resultado opcional en el contexto de alguna configuración global. O podemos querer el estado mutable proporcionado por la mónada State
junto a la capacidad pura de registrar errores de la mónada Either
. Este es el problema resuelto por los trasformadores de mónada.
Fíjate en que ya hemos visto que lo mónada Eff
proporciona una solución parcial a este problema, ya que les efectos nativos se pueden intercalar usando la estrategia de los efectos extensibles. Los transformadores de mónada proporcionan otra solución y cada aproximación tiene sus beneficios y limitaciones.
Un transformador de mónada es un constructor de tipo que está parametrizado no sólo por un tipo, sino también por un constructor de otro tipo. Toma una mónada y la convierte en otra mónada añadiendo su propia variedad de efectos secundarios.
Veamos un ejemplo. La version transformadora de mónada de la mónada State
es StateT
, definida en el módulo Control.Monad.State.Trans
. Podemos averiguar la familia de StateT
usando PSCi:
Esto parece bastante confuso, pero podemos aplicar a StateT
un argumento cada vez para entender cómo usarlo.
El primer argumento de tipo es el tipo de estado que queremos usar, como en el caso de State
. Intentemos usar un estado de tipo String
:
El siguiente argumento es un constructor de tipo con familia Type -> Type
. Representa la mónada subyacente a la que queremos añadir los efectos de StateT
. Como ejemplo elijamos la mónada Either String
:
Nos queda un constructor de tipo. El argumento final representa el tipo de retorno, y podemos instanciarlo a Number
por ejemplo:
Finalmente nos queda algo con familia Type
, que significa que ya podemos intentar buscar valores de este tipo.
La mónada que hemos construido (StateT String (Either String)
) representa cálculos que pueden fallar con un error y que pueden usar estado mutable.
Podemos usar las acciones de la mónada externa StateT String
(get
, put
, y modify
) directamente, pero para usar los efectos de la mónada envuelta (Either String
) necesitamos “elevarlas” sobre el transformador de mónada. El módulo Control.Monad.Trans
define la clase de tipos MonadTrans
que captura los constructores de tipo que son transformadores de mónada como sigue:
Esta clase contiene un único miembro, lift
, que toma cálculos en cualquier mónada subyacente m
y los eleva a la mónada envuelta t m
. En nuestro caso, el constructor de tipo t
es StateT String
, y m
es la mónada Either String
, de manera que lift
proporciona una manera de elevar cálculos de tipo Either String a
a cálculos de tipo StateT String (Either String) a
. Esto significa que podemos usar los efectos de StateT String
y Either String
uno al lado del otro, siempre y cuando usemos lift
cada vez que usamos un cálculo de tipo Either String a
.
Por ejemplo, el siguiente cálculo lee el estado subyacente y devuelve un error si el estado es la cadena vacía:
Si el estado no está vacío, el cálculo usa put
para actualizar el estado a drop 1 s
(esto es, s
quitando el primer carácter), y devuelve take 1 s
(esto es, el primer carácter de s
).
Probémoslo en PSCi:
Esto no es muy destacable, ya que podríamos haberlo implementado sin StateT
. Sin embargo, como estamos trabajando en una mónada, podemos usar notación do o combinadores aplicativos para construir cálculos más grandes a partir de cálculos más pequeños. Por ejemplo, podemos aplicar split
dos veces para leer los dos primeros caracteres de una cadena:
Podemos usar la función split
junto a otras cuantas acciones para construir una biblioteca de análisis básica. De hecho, este es el método usado por la biblioteca purescript-parsing
. Esta es la potencia de los transformadores de mónadas; podemos crear mónadas a medida para una variedad de problemas, elegiendo los efectos secundarios que necesitamos y manteniendo la expresividad de la notación do y los combinadores aplicativos.
El transformador de mónada ExceptT
El paquete purescript-transformers
define también el transformador de mónada ExceptT e
, que es el transformador correspondiente a la mónada Either e
. Proporciona la siguiente API:
La clase MonadError
captura aquellas mónadas que soportan lanzar y cazar errores de algún tipo e
, y proporciona una instancia para el transformador de mónada ExceptT e
. La acción throwError
se puede usar para indicar fallo, igual que Left
en la mónada Either e
. La acción catchError
nos permite continuar tras lanzar un error usando throwError
.
El gestor runExceptT
se usa para ejecutar cálculos de tipo ExceptT e m a
.
Esta API es similar a la proporcionada por el paquete purescript-exceptions
y el efecto Exception
. Sin embargo hay diferencias importantes:
-
Exception
usa excepciones JavaScript mientras queExceptT
modela los errores como una estructura de datos pura - El efecto
Exception
sólo soporta excepciones de un tipo, el tipoError
de JavaScript, mientras queExceptT
soporta errores de cualquier tipo. En particular, somos libres de definir nuevos tipos de error.
Probemos ExceptT
usándolo para envolver la mónada Writer
. De nuevo, somos libres de usar acciones del transformador de mónada ExceptT e
directamente, pero los cálculos en la mónada Writer
deben elevarse usando lift
:
Si probamos esta función en PSCi, podemos ver cómo los dos efectos de acumular un registro de mensajes y lanzar errores interactúan. Primero, podemos ejecutar el cálculo externo ExceptT
usando runExceptT
, devolviendo un resultado de tipo Writer String (Either String String)
. Podemos entonces usar runWriter
para ejecutar el cálculo interno Writer
:
Fíjate en que sólo los mensajes escritos antes de que se lanzase el error han sido añadidos al registro.
Pilas de transformadores de mónada (monad transformer stacks)
Como hemos visto, los transformadores de mónada se pueden usar para construir nuevas mónadas sobre mónadas existentes. Para algún transformador de mónada t1
y alguna mónada m
, la aplicación t1 m
es también una mónada. Esto significa que podemos aplicar un segundo transformador de mónada t2
al resultado t1 m
para construir una tercera mónada t2 (t1 m)
. De esta forma, podemos construir una pila de transformadores de mónada que pueden combinar los efectos secundarios proporcionados por sus mónadas constituyentes.
En la práctica, la mónada subyacente m
es o bien la mónada Eff
si se requieren efectos secundarios nativos, o la mónada Identity
definida en el módulo Data.Identity
. La mónada Identity
no añade efectos secundarios nuevos, de manera que transformar la mónada Identity
sólo proporciona los efectos del transformador de mónada. De hecho, las mónadas State
, Reader
y Writer
se implementan transformando la mónada Identity
con StateT
, ReaderT
y WriterT
respectivamente.
Veamos un ejemplo en el que combinamos tres efectos secundarios. Usaremos los efectos StateT
, WriterT
y ExceptT
, con la mónada Identity
en la base de la pila. Esta pila de transformadores de mónada proporcionará los efectos secundarios de estado mutable, llevar un registro de mensajes y errores puros.
Podemos usar esta pila de transformadores de mónada para reproducir nuestra acción split
con la capacidad añadida de registrar mensajes.
Si probamos este cálculo en PSCi vemos que el estado se añade al registro de mensajes para cada invocación de split
.
Date cuenta de que tenemos que eliminar los efectos secundarios en el orden en que aparecen en la pila de transformadores de mónada: primero usamos runStateT
para quitar el constructor de tipo StateT
, luego runWriterT
, y a continuación runExceptT
. Finalmente, ejecutamos el cálculo en la mónada Identity
usando runIdentity
.
Sin embargo, si el análisis no tiene éxito porque el estado está vacío no se registra ningún mensaje:
Esto se debe a la forma en que los efectos secundarios suministrados por el transformador de mónada ExceptT
interactúa con los efectos secundarios proporcionados por el transformador de mónada WriterT
. Podemos abordar esto cambiando el orden en que componemos la pila de transformadores de mónada. Si movemos el transformador ExceptT
al tope de la pila, el registro de mensajes contendrá todos los mensajes escritos hasta el primer error, como vimos previamente cuando transformamos Writer
con ExceptT
.
Un problema con este código es que tenemos que usar la función lift
múltiples veces para elevar cálculos sobre múltiples transformadores de mónada: por ejemplo, la llamada a throwError
tiene que elevarse dos veces, una vez sobre WriterT
y una segunda vez sobre StateT
. Esto está bien para pequeñas pilas de transformadores de mónada, pero se vuelve molesto rápidamente.
Afortunadamente, como veremos, podemos usar la generación automática de código proporcionada por la inferencia de clases de tipo para hacer la mayor parte de este “levantamiento pesado” por nosotros.
¡Clases de tipos al rescate!
Cuando vimos la mónada State
al comienzo del capítulo, di los siguientes tipos a las acciones de la mónada State
:
En realidad, los tipos dados en el módulo Control.Monad.State.Class
son más generales que eso:
El módulo Control.Monad.State.Class
define la clase de tipos (multiparámetro) MonadState
, que nos permite abstraer sobre “mónadas que soportan estado puro mutable”. Como cabe esperar, el constructor de tipo State s
es una instancia de la clase de tipos MonadState s
, pero hay más instancias interesantes de esta clase.
En particular, hay instancias de MonadState
para los transformadores de mónada WriterT
, ReaderT
y ExceptT
proporcionados en el paquete purescript-transformers
. Cada uno de estos transformadores de mónada tiene una instancia de MonadState
cuando la Monad
subyacente la tenga. En la práctica, esto significa que siempre y cuando StateT
aparezca en algún sitio de la pila de transformadores de mónada, y todo lo que haya por encima de StateT
sea una instancia de MonadState
, somos libres de usar get
, put
y modify
directamente sin usar lift
.
De hecho, lo mismo es cierto para las acciones que hemos visto para los transformadores ReaderT
, WriterT
y ExceptT
. purescript-transformers
define una clase de tipos para cada uno de los transformadores principales, permitiéndonos abstraer sobre mónadas que soportan sus operaciones.
En el caso de la función split
de arriba, la pila de mónadas que construimos es una instancia de cada una de las clases de tipos MonadState
, MonadWriter
y MonadError
. ¡Esto significa que no necesitamos llamar a lift
para nada! Podemos simplemente usar las acciones get
, put
, tell
y throwError
como si estuviesen definidas en la misma pila de mónadas:
Parece como si realmente hubiésemos extendido nuestro lenguaje de programación para soportar los tres efectos secundarios de estado mutable, registro de mensajes y gestión de errores. Sin embargo, todo está implementado usando funciones puras y estado inmutable bajo el capó.
Alternativas
El paquete purescript-control
define un número de abstracciones para trabajar con cálculos que pueden fallar. Una de estas es la clase de tipos Alternative
:
Alternative
proporciona dos nuevos combinadores: el valor empty
que proporciona un prototipo para un cálculo fallido, y la función alt
(y su sinónimo <|>
) que proporciona la capacidad de recurrir a un cálculo alternativo en caso de error.
El módulo Data.List
proporciona dos funciones útiles para trabajar con constructores de tipo de la clase de tipos Alternative
:
El combinador many
usa la clase de tipos Alternative
para ejecutar repetidamente un cálculo cero o más veces. El combinador some
es similar, pero requiere que al menos el primer cálculo tenga éxito.
En el caso de nuestra pila de transformadores de mónada Parser
, hay una instancia de Alternative
inducida por la componente ExceptT
, que soporta fallo mediante composición de errores en diferentes ramas usando una instancia Monoid
(esto es por lo que elegimos Array String
para nuestro tipo Errors
). Esto significa que podemos usar las funciones many
y some
para ejecutar un analizador múltiples veces:
Aquí, la cadena de entrada "test"
se ha partido repetidamente para devolver un array de cuatro cadenas con un único carácter, el estado resultante está vacío, y el registro muestra que hemos aplicado el combinador split
cuatro veces.
Otros ejemplos de constructores de tipo Alternative
son Maybe
y Array
.
Mónadas por comprensión
El módulo Control.MonadPlus
define una subclase de la clase de tipos Alternative
llamada MonadPlus
. MonadPlus
captura los constructores de tipo que son mónadas e instancias de Alternative
:
En particular, nuestra mónada Parser
es instancia de MonadPlus
.
Cuando vimos los arrays por comprensión presentamos la función guard
, que se puede usar para eliminar resultados no deseados. De hecho, la función guard
es más general y se puede usar para cualquier mónada que sea una instancia de MonadPlus
:
El operador <|>
nos permite volver hacia atrás en caso de fallo. Para ver cómo es útil esto, definamos una variante del combinador split
que sólo detecta mayúsculas:
Aquí hemos usado guard
para fallar si la cadena no está en mayúsculas. Fíjate en que este código es muy similar a los arrays por comprensión que vimos antes. Cuando usamos MonadPlus
de esta forma, decimos que estamos construyendo mónadas por comprensión.
Retroceso (backtracking)
Podemos usar el operador <|>
para retroceder a otra alternativa en caso de fallo. Para demostrarlo, definamos otro analizador que busca caracteres minúsculos:
Con esto, podemos definir un analizador que ajusta ávidamente muchas mayúsculas si el primer carácter es mayúscula, o muchas minúsculas si el primer carácter es minúscula:
Este analizador encontrará coincidencias hasta que cambiemos de mayúsculas a minúsculas o viceversa:
Podemos incluso usar many
para partir una cadena por completo en sus componentes mayúsculas y minúsculas:
De nuevo, esto ilustra la potencia de reutilización que proporcionan los transformadores de mónada. ¡Hemos sido capaces de escribir un analizador con retroceso en estilo declarativo con sólo unas pocas líneas de código reutilizando abstracciones estándar!
La mónada RWS
Hay una combinación concreta de transformadores de mónada tan común que se proporciona como un transformador único en el paquete purescript-transformers
. Las mónadas Reader
, Writer
y State
se combinan para formar la mónada reader-writer-state, o simplemente la mónada RWS
. Esta mónada tiene un transformador de mónada correspondiente llamado transformador de mónada RWST
.
Usaremos la mónada RWS
para modelar la lógica del juego de nuestro juego de aventuras textual.
La mónada RWS
está definida por tres parámetros de tipo (además de su valor de retorno):
Fíjate en que la mónada RWS
está definida en términos de su propio transformador de mónada, fijando la mónada base a Identity
que no tiene efectos secundarios.
El primer parámetro de tipo, r
, representa el tipo de la configuración global. El segundo, w
, representa el monoide que usaremos para acumular un registro de mensajes, y el tercero, s
, es el tipo de nuestro estado mutable.
En el caso de nuestro juego, nuestra configuración global está definida en un tipo llamada GameEnvironment
en el módulo Data.GameEnvironment
:
Define el nombre del jugador y un indicador de si estamos ejecutando el juego en modo de depuración. Estas opciones se fijarán desde la línea de comandos cuando vayamos a ejecutar nuestro transformador de mónada.
El estado mutable está definido en un tipo llamado GameState
del módulo Data.GameState
:
El tipo de datos Coords
representa puntos en una rejilla bidimensional, y el tipo de datos GameItem
es una enumeración de los objetos del juego:
El tipo GameState
usa dos nuevas estructuras de datos: Map
y Set
, que representan asociaciones ordenadas y conjuntos ordenados respectivamente. La propiedad items
es un mapeo de coordenadas en la rejilla del juego a conjuntos de objetos del juego en esa ubicación. La propiedad player
almacena la ubicación actual del jugador, y la propiedad inventory
almacena el conjunto de objetos del juego acarreados por el jugador.
Las estructuras de datos Map
y Set
están ordenadas por sus claves, que pueden ser cualquier tipo en la clase de tipos Ord
. Esto significa que las claves de nuestras estructuras de datos deben estar completamente ordenadas.
Veremos cómo las estructuras Map
y Set
se usan para escribir las acciones de nuestro juego.
Para nuestro registro de mensajes usaremos el monoide List String
. Podemos definir un sinónimo de tipo para nuestra mónada Game
implementada mediante RWS
:
Implementando la lógica del juego
Vamos a construir nuestro juego a partir de acciones simples definidas en la mónada Game
, reutilizando acciones de las mónadas Reader
, Writer
y State
. En el nivel superior de nuestra aplicación, ejecutaremos los cálculos puros en la mónada Game
y usaremos la mónada Eff
para convertir los resultados en efectos secundarios observables como imprimir texto en la consola.
Una de las acciones más simples de nuestro juego es la acción has
. Esta acción comprueba si el inventario del jugador contiene un objeto del juego concreto. Se define como sigue:
La función usa la acción get
de la clase de tipos MonadState
para leer el estado actual del juego y luego usa la función member
definida en Data.Set
para comprobar si el GameItem
especificado aparece en el Set
de objetos del inventario.
Otra acción es pickUp
. Añade un objeto del juego al inventario del jugador si está en la habitación actual. Usa acciones de las clases de tipos MonadWriter
y MonadState
. Primero lee el estado actual del juego:
A continuación, pickUp
busca el conjunto de objetos en la habitación actual. Lo hace usando la función lookup
definida en Data.Map
:
La función lookup
devuelve un resultado opcional indicado por el constructor de tipo Maybe
. Si la clave no aparece en el mapa, la función lookup
devuelve Nothing
, en caso contrario devuelve el valor correspondiente envuelto en el constructor Just
.
Estamos interesados en el caso en que el conjunto de objetos correspondiente contiene el objeto del juego especificado. De nuevo, podemos comprobar esto usando la función member
:
En este caso, podemos usar put
para actualizar el estado del juego y tell
para añadir un mensaje al registro:
Fíjate en que no necesitamos usar lift
en ninguno de los dos cálculos aquí porque hay instancias apropiadas de MonadState
y MonadWriter
en nuestra pila de transformadores de mónada Game
.
El argumento a put
usa una actualización de registro para modificar los campos items
e inventory
del estado del juego. Usamos la función update
de Data.Map
que modifica un valor en una clave particular. En este caso, modificamos el conjunto de objetos en la ubicación actual del jugador, usando la función delete
para eliminar el elemento especificado del conjunto. Actualizamos también inventory
usando insert
para añadir el nuevo objeto al conjunto de inventario del jugador.
Finalmente, la función pickUp
gestiona el resto de casos notificando al usuario mediante tell
:
Como ejemplo de uso de la mónada Reader
, podemos mirar el código del comando debug
. Este comando permite al usuario inspeccionar el estado del juego en tiempo de ejecución si el juego está ejecutándose en modo de depuración:
Aquí usamos la acción ask
para leer la configuración del juego. De nuevo, fíjate en que no hemos necesitado usar lift
en ningún cálculo, y que podemos usar acciones definidas en las clases de tipos MonadState
, MonadReader
y MonadWriter
en el mismo bloque en notación do.
Si el indicador debugMode
está activo se usa la acción tell
para escribir el estado en el registro de mensajes. En otro caso, añadimos un mensaje de error.
El resto del módulo Game
define un conjunto similar de acciones, usando cada una únicamente las acciones definidas por las clases de tipos MonadState
, MonadReader
y MonadWriter
.
Ejecutando el cálculo
Como nuestra lógica de juego se ejecuta en la mónada RWS
, es necesario ejecutar el cálculo para responder a los comandos del usuario.
La interfaz de usuario de nuestro juego se construye usando dos paquetes: purescript-yargs
, que proporciona una interfaz aplicativa a la biblioteca de análisis de línea de comandos yargs
, y purescript-node-readline
, que envuelve el módulo NodeJS readline
, permitiéndonos escribir aplicaciones interactivas basadas en consola.
La interfaz para la lógica de juego está proporcionada por la función game
del módulo Game
:
Para ejecutar este cálculo, pasamos una lista de palabras introducidas por el usuario como un array de cadenas, y ejecutamos el cálculo RWS
resultante usando runRWS
:
runRWS
parece una combinación de runReader
, runWriter
y runState
. Toma una configuración global y un estado inicial como argumentos y devuelve una estructura de datos que contiene el registro de mensajes, el resultado y el estado final.
La interfaz de usuario de nuestra aplicación está definida por una función runGame
con la siguiente firma de tipo:
El efecto CONSOLE
indica que esta función interactúa con el usuario mediante la consola (usando los paquetes purescript-node-readline
y purescript-console
). runGame
toma la configuración del juego como argumento a la función.
El paquete purescript-node-readline
proporciona el tipo LineHandler
, que representa acciones en la mónada Eff
que gestionan entrada de usuario de la terminal. Aquí esta la API correspondiente:
El tipo Interface
representa un descriptor de la consola, y se pasa como argumento a la función que interactúa con ella. Se puede crear un Interface
usando la funcióncreateConsoleInterface
:
El primer paso es establecer el símbolo de espera de comandos de la consola. Pasamos el descriptor interface
y proporcionamos la cadena de espera y el nivel de sangría:
En nuestro caso, estamos interesados en implementar la función gestora de línea. Nuestro gestor de línea se define usando una función auxiliar en una declaración let
como sigue:
La ligadura let está cerrada tanto sobre la configuración del juego, llamada env
, como sobre el descriptor de consola, llamado interface
.
Nuestro gestor toma un primer argumento adicional, el estado del juego. Esto es necesario porque necesitamos pasar el estado del juego a runRWS
para ejecutar la lógica del juego.
Lo primero que hace esta acción es partir la entrada del usuario en palabras usando la función split
del módulo Data.String
. Usa entonces runRWS
para ejecutar la acción del juego game
(en la mónada RWS
), pasando el entorno y estado actual del juego.
Habiendo ejecutado la lógica del juego, que es un cálculo puro, necesitamos imprimir cualquier mensaje registrado a la pantalla y mostrar al usuario la cadena de espera para el siguiente comando. La acción for_
se usa para recorrer el registro de mensajes (de tipo List String
) e imprimir sus entradas por consola. Finalmente, setLineHandler
se usa para actualizar la función gestora de línea para que use el estado actualizado del juego, y se muestra la cadena de espera de nuevo usando la acción prompt
.
La función runGame
finalmente engancha el gestor de línea inicial a la interfaz de consola y muestra la cadena de espera inicial:
Gestionando opciones de línea de comandos
La última parte de la aplicación es responsable de analizar las opciones de línea de comandos y crear el registro de configuración GameEnvironment
. Para esto usamos el paquete purescript-yargs
.
purescript-yargs
es un ejemplo de análisis de línea de comandos aplicativo. Recuerda que un funtor aplicativo nos permite elevar funciones de aridad arbitraria sobre un constructor de tipo que representa algunos efectos secundarios. En el caso del paquete purescript-yargs
, el funtor en que estamos interesados es el funtor Y
, que añade los efectos secundarios de leer de las opciones de línea de comandos. Proporciona el siguiente gestor:
Esto se ilustra mejor con un ejemplo. La función main
de la aplicación se define usando runY
como sigue:
El primer argumento se usa para configurar la biblioteca yargs
. En nuestro caso simplemente proporcionamos un mensaje de uso, pero el módulo Node.Yargs.Setup
tiene varias opciones más.
El segundo ejemplo usa la función map
para elevar la función runGame
sobre el constructor de tipo Y
. El argumento env
se construye en una declaración where
usando los operadores aplicativos <$>
y <*>
:
Aquí, la función gameEnvironment
, que tiene el tipo PlayerName -> Boolean -> GameEnvironment
, se eleva sobre Y
. Los dos argumentos especifican cómo leer el nombre de usuario y el indicador de depuración de la línea de comandos. El primer argumento describe la opción del nombre de jugador, que se especifica mediante las opciones p
o --player
, y el segundo describe el indicador de depuración, que se activa usando las opciones -d
o --debug
.
Esto demuestra dos funciones básicas definidas en el módulo Node.Yargs.Applicative
: yarg
, que define una opción de línea de comandos que toma un argumento opcional (de tipo String
, Number
o Boolean
) y flag
, que define un indicador de línea de comando de tipo Boolean
.
Fíjate en que hemos podido usar la notación otorgada por los operadores aplicativos para dar una especificación compacta y declarativa de nuestra interfaz de línea de comandos. Además, es simple añadir nuevos argumentos de línea de comando añadiendo un nuevo argumento de función a runGame
y usando <*>
para elevar runGame
sobre un argumento adicional en la definición de env
.
Conclusión
Este capítulo ha sido una demostración práctica de las técnicas que hemos aprendido hasta ahora, usando transformadores de mónadas para construir una especificación pura de nuestro juego, y la mónada Eff
para construir una interfaz de usuario usando la consola.
Como hemos separado nuestra implementación de la interfaz de usuario, debería ser posible crear otras interfaces para nuestro juego. Por ejemplo, podríamos usar la mónada Eff
para representar nuestro juego en el navegador usando la API Canvas o el DOM.
Hemos visto cómo los transformadores de mónadas nos permiten escribir código seguro en un estilo imperativo, donde los efectos están registrados en el sistema de tipos. Además, las clases de tipos proporcionan una manera potente de abstraer sobre las acciones proporcionadas por una mónada, permitiendo la reutilización de código. Hemos podido usar abstracciones estándar como Alternative
y MonadPlus
para construir mónadas útiles combinando transformadores de mónadas estándar.
Los transformadores de mónadas son una demostración excelente de la clase de código expresivo que se puede escribir basándose en capacidades avanzadas del sistema de tipos como polimorfismo de orden mayor y clases de tipos multiparamétricas.
En el siguiente capítulo veremos como los transformadores de mónadas se pueden usar para dar una solución a una queja común cuando se trabaja con código JavaScript asíncrono; el problema del infierno de retrollamadas.
El infierno de retrollamadas (callback hell)
Objetivos del capítulo
En este capítulo veremos cómo las herramientas que hemos visto hasta ahora (a saber, transformadores de mónada y funtores aplicativos) se pueden usar para resolver algunos problemas del mundo real. En particular, veremos cómo podemos resolver el problema del infierno de retrollamadas.
Preparación del proyecto
El código fuente de este capítulo se puede compilar y ejecutar usando pulp run
. También es necesario instalar el módulo request
usando NPM:
El problema
El código asíncrono en JavaScript usa normalmente retrollamadas (callbacks) para estructurar el flujo del programa. Por ejemplo, para leer texto de un fichero, el método preferido es usar la función readFile
y pasar una retrollamada (una función que se llamará cuando el texto esté disponible):
Sin embargo, si hay involucradas múltiples operaciones, esto puede llevar rápidamente a retrollamadas anidadas, lo que puede acabar en código difícil de leer:
Una solución a este problema es descomponer las llamadas asíncronas individuales en sus propias funciones:
Esta solución funciona, pero tiene algunos problemas:
- Es necesario pasar resultados intermedios a funciones asíncronas como argumentos de función, de la misma manera que hemos pasado
data
awriteCopy
arriba. Esto está bien para funciones pequeñas, pero si hay muchas retrollamadas involucradas, las dependencias de datos pueden volverse complejas, resultando en muchos argumentos de función adicionales. - Hay un patrón común. Las retrollamadas
onSuccess
yonFailure
se especifican normalmente como argumentos a cada función asíncrona. Pero este patrón se tiene que documentar en la documentación del módulo que acompaña el código fuente. Es mejor capturar este patrón en el sistema de tipos y usarlo para forzar su uso.
A continuación veremos cómo usar las técnicas que hemos aprendido hasta ahora para resolver estos problemas.
La mónada de continuación
Traduzcamos el ejemplo copyFile
de arriba a PureScript usando la FFI. Al hacerlo, la estructura del cálculo resultará evidente, y seremos conducidos de manera natural a un transformador de mónada definido en el paquete purescript-transformers
; el transformador de mónada de continuación ContT
.
Nota: en la práctica no es necesario escribir estas funciones a mano cada vez. Puedes encontrar funciones de entrada/salida asíncrona en las bibliotecas purescript-node-fs
y purescript-node-fs-aff
.
Primero tenemos que dar tipos a readFile
y writeFile
usando la FFI. Comencemos definiendo algunos sinónimos de tipo y un nuevo efecto para entrada/salida de fichero:
readFile
toma un nombre de fichero y una retrollamada que toma dos argumentos. Si el fichero ha sido leído con éxito, el segundo argumento tendrá el contenido del fichero, y si no, el primer argumento se usará para indicar el error.
En nuestro caso, envolveremos readFile
con una función que toma dos retrollamadas: una retrollamada de error (onFailure
) y una retrollamada de resultado (onSuccess
), igual que hicimos con copyFile
y writeCopy
arriba. Usando el soporte de funciones de múltiples argumentos de Data.Function
por simplicidad, nuestra función envuelta readFileImpl
puede tener esta pinta:
En el módulo externo JavaScript, readFileImpl
estaría definida como:
Esta firma de tipo indica que readFileImpl
toma tres argumentos: una ruta de fichero, una retrollamada de éxito y una retrollamada de error, y devuelve un cálculo con efectos secundarios que devuelve un resultado vacío (Unit
). Fíjate en que a las retrollamadas les damos tipos que usan la mónada Eff
para registrar sus efectos.
Debes intentar entender por qué esta implementación tiene la representación correcta en tiempo de ejecución para su tipo.
writeFileImpl
es muy similar; difiere únicamente en que el contenido del fichero se pasa a la función, no a la retrollamada. Su implementación tiene esta pinta:
Dadas estas declaraciones FFI, podemos escribir las implementaciones de readFile
y writeFile
. Estas usarán el módulo Data.Function.Uncurried
para convertir las ligaduras FFI de múltiples argumentos en funciones currificadas normales de PureScript, y que por lo tanto tienen tipos algo más legibles.
Además, en lugar de requerir dos retrollamadas, una para éxitos y otra para fallos, podemos requerir una única retrollamada que responde a ambas cosas. Esto es, la nueva retrollamda toma un valor en la mónada Either ErrorCode
como argumento:
Ahora podemos identificar un patrón importante. Cada una de estas funciones toma una retrollamada que devuelve un valor en alguna mónada (en este caso, Eff (fs :: FS | eff)
) y devuelve un valor en la misma mónada. Esto significa que cuando la primera retrollamada devuelve un resultado, esa mónada se puede usar para ligar el resultado a la entrada de la siguiente función asíncrona. De hecho, eso es exactamente lo que hicimos a mano en el ejemplo copyFile
.
Esto es la base del transformador de mónada de continuación, que está definido en el módulo Control.Monad.Cont.Trans
de purescript-transformers
.
ContT
se define como un newtype como sigue:
Una continuación es simplemente otro nombre para una retrollamada. Una continuación captura el resto de un cálculo; en nuestro caso, qué sucede después de que el resultado sea proporcionado tras una llamada asíncrona.
El argumento al constructor de datos ContT
se parece notablemente a los tipos de readFile
y writeFile
. De hecho, si hacemos que el tipo a
sea el tipo Either ErrorCode String
, r
sea Unit
y m
la mónada Eff (fs :: FS | eff)
, podemos recuperar la parte derecha del tipo de readFile
.
Esto motiva el siguiente sinónimo de tipo, definiendo una mónada Async
, que usaremos para componer acciones asíncronas como readFile
y writeFile
:
Para nuestros propósitos, siempre usaremos ContT
para transformar la mónada Eff
, y el tipo r
será siempre Unit
, pero esto no es obligatorio.
Podemos tratar readFile
y writeFile
como cálculos en la mónada Async
aplicando simplemente el constructor de datos ContT
:
Con eso, podemos escribir nuestra rutina de copia de ficheros usando notación do para el transformador de mónada ContT
:
Fíjate en cómo la naturaleza asíncrona de readFileCont
queda oculta por la ligadura monádica expresada usando notación do; parece código síncrono, pero la mónada ContT
es la que se ocupa de hilar nuestras funciones asíncronas.
Podemos ejecutar este cálculo usando el gestor runContT
suministrando una continuación. La continuación representa qué hacer a continuación, es decir, qué hacer cuando la rutina asíncrona de copia de ficheros finaliza. Para nuestro ejemplo simple, podemos elegir logShow
como función de continuación, que imprimirá el resultado de tipo Either ErrorCode Unit
a la consola:
Poniendo ExceptT a trabajar
Esta solución funciona, pero puede mejorarse.
En la implementación de copyFileCont
tuvimos que usar ajuste de patrones para analizar el resultado del cálculo readFileCont
(de tipo Either ErrorCode String
) para determinar qué hacer a continuación. Sin embargo, sabemos que la mónada Either
tiene un transformador de mónada correspondiente, ExceptT
, de forma que es razonable esperar que podamos usar ExceptT
con ContT
para combinar los dos efectos de cálculo asíncrono y gestión de errores.
De hecho es posible, y podemos ver por qué si miramos la definición de ExceptT
:
ExceptT
simplemente cambia el resultado de la mónada subyacente de a
a Either e a
. Esto significa que podemos reescribir copyFileCont
transformando nuestra pila de mónadas actual con el transformador ExceptT ErrorCode
. Es tan simple como aplicar el constructor ExceptT
a nuestra solución existente:
Ahora, nuestra rutina de copia de fichero es mucho más simple, ya que la gestión de errores asíncrona queda oculta dentro del transformador de mónada ExceptT
:
Un cliente HTTP
Como otro ejemplo de uso de ContT
para gestionar funciones asíncronas, vamos a ver el módulo Network.HTTP.Client
del código fuente de este capítulo. Este módulo usa la mónada Async
para soportar peticiones HTTP asíncronas usando el módulo request
, disponible via NPM.
El módulo request
suministra una función que toma un URL y una retrollamada, hace una petición HTTP(S) e invoca la retrollamada cuando la respuesta está disponible, o cuando ocurre un error. Aquí tenemos un ejemplo de petición:
Vamos a recrear este ejemplo simple en PureScript usando la mónada Async
.
En el módulo Network.HTTP.Client
, el método request
está envuelto con una función getImpl
:
De nuevo, podemos usar el módulo Data.Function.Uncurried
para convertir esto en una función currificada PureScript normal. Como antes, combinamos las dos retrollamadas en una, esta vez aceptando un valor de tipo Either String String
, y aplicamos el constructor ContT
para construir una acción en nuestra mónada Async
:
Cálculos paralelos
Hemos visto cómo usar la mónada ContT
y la notación do para componer cálculos asíncronos en secuencia. Sería también útil poder componer cálculos asíncronos en paralelo.
Si usamos ContT
para transformar la mónada Eff
, podemos realizar cálculos en paralelo simplemente iniciando nuestros dos cálculos uno tras otro.
El paquete purescript-parallel
define una clase de tipos MonadPar
para mónadas como Async
que soportan ejecución paralela. Cuando nos encontramos los funtores aplicativos en un capítulo previo, observamos cómo los funtores aplicativos pueden ser útiles para combinar cálculos paralelos. De hecho, una instancia de Parallel
define una correspondencia entre una mónada m
(como Async
) y un funtor aplicativo f
que se puede usar para combinar cálculos en paralelo:
La clase define dos funciones:
-
parallel
, que toma un cálculo en la mónadam
y lo convierte en cálculos en el funtor aplicativof
, y -
sequential
, que realiza una conversión en sentido inverso.
La biblioteca purescript-parallel
proporciona una instancia Parallel
para la mónada Async
. Usa referencias mutables para combinar acciones Async
en paralelo, llevando registro de cual de las dos continuaciones se ha llamado. Cuando ambos resultados están disponibles, podemos calcular el resultado final y pasarlo a la continuación principal.
Podemos usar la función parallel
para crear una versión de nuestra acción readFileCont
que se puede combinar en paralelo. Aquí hay un simple ejemplo que lee dos ficheros de texto en paralelo, los concatena e imprime el resultado:
Fíjate en que, ya que readFileCont
devuelve un valor de tipo Either ErrorCode String
, necesitamos elevar la función append
sobre el constructor de tipo Either
usando lift2
para formar nuestra función combinadora.
Dado que los funtores aplicativos soportan elevar funciones de aridad arbitraria, podemos realizar más cálculos en paralelo usando los combinadores aplicativos. ¡Podemos también beneficiarnos de todas las funciones de la biblioteca estándar que trabajan con funtores aplicativos, como traverse
y sequence
!
También podemos combinar cálculos paralelos con porciones de código secuencial, usando combinadores aplicativos en un bloque en notación do, o viceversa, usando parallel
y sequential
para cambiar los constructores de tipo como corresponda.
Conclusión
En este capítulo, hemos visto una demostración práctica de los transformadores de mónada:
- Vimos cómo el idioma común en JavaScript de pasar retrollamadas se puede capturar con el transformador de mónada
ContT
. - Vimos cómo el problema del infierno de las retrollamadas se puede resolver usando notación do para expresar cálculos asíncronos secuenciales, y un funtor aplicativo para expresar paralelismo.
- Hemos usado
ExceptT
pare expresar errores asíncronos.
Verificación generativa (generative testing)
Objetivos del capítulo
En este capítulo veremos una aplicación particularmente elegante de las clases de tipos al problema de probar código. En lugar de probar nuestro código diciendo al compilador cómo probar, simplemente decimos qué propiedades debe tener nuestro código. Los casos de prueba se pueden generar de manera aleatoria a partir de esta especificación usando clases de tipos para ocultar el código repetitivo que genera los datos aleatorios. Esto se llama verificación generativa (generative testing) o verificación basada en propiedades (property-based testing), una técnica popularizada en Haskell por la biblioteca QuickCheck.
El paquete purescript-quickcheck
es un conversión de la biblioteca QuickCheck de Haskell a PureScript, que mayormente preserva los tipos y sintaxis de la biblioteca original. Veremos cómo usar purescript-quickcheck
para verificar una biblioteca simple, usando Pulp para integrar nuestro conjunto de pruebas en nuestro proceso de desarrollo.
Preparación del proyecto
El proyecto de este capítulo añade purescript-quickcheck
como dependencia Bower.
En un proyecto Pulp, el código fuente de las pruebas debe ponerse en el directorio test
, y el módulo principal del conjunto de pruebas debe llamarse Test.Main
. El conjunto de pruebas se puede ejecutar usando el comando pulp test
.
Escribiendo propiedades
El módulo Merge
implementa una función simple merge
que usaremos para demostrar las capacidades de la biblioteca purescript-quickcheck
.
merge
toma dos arrays de enteros ordenados y mezcla sus elementos de manera que el resultado también está ordenado. Por ejemplo:
En un conjunto de pruebas típico, podemos probar merge
generando unos pocos casos de prueba pequeños como este a mano, asegurando que los resultados deben ser iguales a los valores apropiados. Sin embargo, todo lo que necesitamos saber sobre la función merge
se puede resumir en dos propiedades:
- (Orden) Si
xs
eys
están ordenados, entoncesmerge xs ys
está también ordenado. - (Subarray)
xs
eys
son ambos subarrays demerge xs ys
, y sus elementos aparecen en el mismo orden.
purescript-quickcheck
nos permite verificar estas propiedades directamente generando casos de prueba aleatorios. Podemos simplemente dar las propiedades que queremos que tenga nuestro código como funciones:
Aquí, isSorted
e isSubarrayOf
se implementan como funciones auxiliares con los siguientes tipos:
Cuando ejecutamos este código, purescript-quickcheck
intentará falsear las propiedades que afirmamos, generando entradas aleatorias de xs
e ys
, y pasándolas a nuestras funciones. Si nuestra función devuelve false
para cualquier entrada, la propiedad es incorrecta y la biblioteca lanzará un error. Afortunadamente, la biblioteca es incapaz de falsear nuestras propiedades tras generar 100 casos de prueba aleatorios:
Si introducimos un fallo deliberadamente en la función merge
(por ejemplo, cambiando la comprobación menor-que por mayor-que), se lanza una excepción en tiempo de ejecución tras el primer caso de prueba fallido:
Como vemos, este mensaje de error no es de mucha ayuda, pero puede mejorarse con un poco de trabajo.
Mejorando los mensajes de error
Para proporcionar mensajes de error junto a nuestros casos de prueba fallidos, purescript-quickcheck
suministra el operador <?>
. Simplemente separa la definición de la propiedad del mensaje de error usando <?>
como sigue:
Esta vez, si modificamos el código para introducir un fallo, vemos nuestro mensaje mejorado tras el primer caso de prueba fallido:
Fíjate en cómo las entradas xs
e ys
fueron generadas como arrays de enteros seleccionados al azar.
Verificando código polimórfico
El módulo Merge
define una generalización de la función merge
llamada mergePoly
, que trabaja no sólo con arrays de números, sino con cualquier array que pertenezca a la clase de tipos Ord
:
Si modificamos nuestras pruebas originales para usar mergePoly
en lugar de merge
, vemos el siguiente mensaje de error:
Este mensaje de error indica que el compilador no pude generar casos de prueba aleatorios, porque no sabía qué tipo de elementos queríamos que tuviesen nuestros arrays. En estos casos, podemos usar una función auxiliar para forzar al compilador a inferir un tipo particular. Por ejemplo, si definimos una función ints
como sinónimo de la función identidad:
podemos modificar nuestras pruebas de manera que el compilador infiera el tipo Array Int
para nuestros dos argumentos de tipo array:
Aquí, xs
e ys
tienen ambos el tipo Array Int
, ya que hemos usado la función ints
para desambiguar los tipos desconocidos.
Generando datos arbitrarios
Ahora veremos cómo la biblioteca purescript-quickcheck
es capaz de generar casos de prueba para nuestras propiedades de manera aleatoria.
Los tipos cuyos valores pueden ser generados aleatoriamente están capturados por la clase de tipos Arbitrary
:
El constructor de tipo Gen
representa los efectos secundarios de generación de datos aleatorios determinista. Usa un generador de números pseudo-aleatorios para generar argumentos de función deterministas a partir de un valor semilla. El módulo Test.QuickCheck.Gen
define varios combinadores útiles para construir generadores.
Gen
es también una mónada y un funtor aplicativo, de manera que tenemos la habitual colección de combinadores a nuestra disposición para crear nuevas instancias de la clase de tipos Arbitrary
.
Por ejemplo, podemos usar la instancia Arbitrary
para el tipo Int
proporcionada en la biblioteca purescript-quickcheck
para crear una distribución en los 256 valores posibles de un byte, usando la instancia Functor
de Gen
para mapear una función de enteros a bytes sobre valores enteros arbitrarios:
Aquí definimos un tipo Byte
de valores enteros entre 0 y 255. La instancia Arbitrary
usa la función map
para elevar la función intToByte
sobre la acción arbitrary
. El tipo de la acción arbitrary
interna se infiere como Gen Int
.
Podemos también usar esta idea para mejorar nuestra prueba de orden de merge
:
En esta prueba, hemos generado los arrays arbitrarios xs
e ys
, pero hemos tenido que ordenarlos porque merge
espera entradas ordenadas. Por otra parte, podríamos crear un newtype representando arrays ordenados y escribir una instancia Arbitrary
que genera datos ordenados:
Con este constructor de tipo, podemos modificar nuestras pruebas como sigue:
Esto parece un cambio pequeño, pero los tipos de xs
e ys
han cambiado a Sorted Int
en lugar de simplemente Array Int
. Esto comunica nuestra intención de manera más clara; la función mergePoly
toma entradas ordenadas. Idealmente, el tipo de la función mergePoly
se actualizaría para usar el constructor de tipo Sorted
.
Como ejemplo más interesante, el módulo Tree
define un tipo de árboles binarios ordenados con valores en las ramas:
El módulo Tree
define la siguiente API:
La función insert
se usa para insertar un nuevo elemento en un árbol ordenado, y la función member
se puede usar para preguntar al árbol por un valor particular. Por ejemplo:
Las funciones toArray
y fromArray
se pueden usar para convertir árboles ordenados en arrays y viceversa. Podemos usar fromArray
para escribir una instancia Arbitrary
para árboles:
Podemos ahora usar Tree a
como el tipo de un argumento a nuestras propiedades de prueba, siempre que haya una instancia de Arbitrary
disponible para el tipo a
. Por ejemplo, podemos verificar que member
siempre devuelve true
para un cierto valor tras insertarlo:
Aquí, el argumento t
es un árbol generado aleatoriamente de tipo Tree Int
, donde el tipo del argumento está desambiguado por la función identidad treeOfInt
.
Probando funciones de orden mayor
El módulo Merge
define otra generalización de la función merge
; la función mergeWith
toma como argumento adicional una función que se usa para determinar el orden en que los elementos deben mezclarse. Esto es, mergeWith
es una función de orden mayor.
Por ejemplo, podemos pasar la función length
como primer argumento para mezclar dos arrays que están ordenados por la longitud de sus elementos. El resultado debe estar también ordenado:
¿Cómo podemos verificar dicha función? Idealmente, querríamos generar valores para los tres argumentos, incluyendo el primer argumento que es una función.
Hay una segunda clase de tipos que nos permite crear funciones generadas aleatoriamente. Se llama Coarbitrary
y está definida como sigue:
La función coarbitrary
toma como argumento una función de tipo t
, y un generador aleatorio para un resultado de función de tipo r
, y usa la función argumento para perturbar el generador aleatorio. Esto es, para obtener el resultado aplica la función proporcionada para modificar la salida del generador aleatorio.
Además, hay una instancia de clase de tipos que nos da funciones Arbitrary
si el dominio de la función es Coarbitrary
y el codominio de la función es Arbitrary
:
En la práctica esto significa que podemos escribir propiedades que toman funciones como argumentos. En el caso de la función mergeWith
podemos generar el primer argumento aleatoriamente, modificando nuestras pruebas para que tengan en cuenta el nuevo argumento.
En el caso de la propiedad de orden, no podemos garantizar que el resultado estará ordenado (no tenemos necesariamente una instancia de Ord
) pero podemos esperar que el resultado esté ordenado con respecto a la función f
que pasamos como argumento. Además, necesitamos que los arrays de entrada estén ordenados con respecto a f
, de manera que usamos la función sortBy
para ordenar xs
e ys
basándonos en la comparación tras aplicar la función f
:
Aquí usamos una función intToBool
para desambiguar el tipo de la función f
:
En el caso de la propiedad de subarray, simplemente tenemos que cambiar el nombre de la función a mergeWith
; seguimos esperando que nuestros arrays de entrada sean subarrays del resultado:
Además de ser Arbitrary
, las funciones son también Coarbitrary
:
Esto significa que no estamos limitados únicamente a valores y funciones; podemos generar aleatoriamente funciones de orden mayor, o funciones cuyos argumentos son funciones de orden mayor, etc.
Escribiendo instancias de Coarbitrary
Al igual que podemos escribir instancias de Arbitrary
para nuestros tipos de datos usando las instancias Monad
y Applicative
de Gen
, podemos escribir nuestras propias instancias de Coarbitrary
también. Esto nos permite usar nuestros propios tipos de datos como dominio de funciones generadas aleatoriamente.
Escribamos una instancia de Coarbitrary
para nuestro tipo Tree
. Necesitaremos una instancia de Coarbitrary
para el tipo de los elementos almacenados en las ramas:
Tenemos que escribir una función que perturba un generador aleatorio dado un valor de tipo Tree a
. Si el valor de entrada es una Leaf
, simplemente devolvemos el generador sin cambios:
Si el árbol es una Branch
, perturbaremos el generador usando el subárbol izquierdo, el valor y el subárbol derecho, usando composición de funciones para crear nuestra función perturbadora:
Ahora somos libres de escribir propiedades cuyos argumentos incluyan funciones que toman árboles como argumentos. Por ejemplo, el módulo Tree
define una función anywhere
que comprueba si un predicado se mantiene en cualquier subárbol de su argumento:
Ahora somos capaces de generar la función predicado aleatoriamente. Por ejemplo, esperamos que la función anywhere
respete la disyunción:
Aquí, la función treeOfInt
se usa para fijar el tipo de los valores contenidos en el árbol al tipo Int
:
Verificando sin efectos secundarios
Para propósitos de verificación, normalmente incluimos llamadas a la función quickCheck
en la acción main
de nuestro conjunto de pruebas. Sin embargo, hay una variante de la función quickCheck
llamada quickCheckPure
que no usa efectos secundarios. En su lugar, es una función pura que toma una semilla aleatoria como entrada y devuelve un array de resultados de la prueba.
Podemos probar quickCheckPure
usando PSCi. Aquí probamos que la operación merge
es asociativa:
quickCheckPure
toma tres argumentos: la semilla del generador aleatorio, el número de casos de prueba a generar, y la propiedad a verificar. Si todas las pruebas pasan, debes ver un array de constructores de dato Success
impresos en la consola.
quickCheckPure
puede ser útil en otras situaciones, como generar datos de entrada aleatorios para pruebas de rendimiento, o para generar datos de formulario de ejemplo para aplicaciones web.
Conclusión
En este capítulo hemos conocido el paquete purescript-quickcheck
, que se puede usar para escribir pruebas de manera declarativa usando el paradigma de verificación generativa. En particular:
- Vimos cómo automatizar las pruebas QuickCheck usando
pulp test
. - Vimos cómo escribir propiedades como funciones, y cómo usar el operador
<?>
para mejorar los mensajes de error. - Vimos cómo las clases de tipos
Arbitrary
yCoarbitrary
permiten la generación de código de prueba repetitivo, y cómo nos permiten probar propiedades de orden mayor. - Vimos cómo implementar instancias
Arbitrary
yCoarbitrary
a medida para nuestros propios tipos de datos.
Lenguajes específicos del dominio (domain-specific languages)
Objetivos del capítulo
En este capítulo exploraremos la implementación de lenguajes específicos del dominio (o DSLs) en PureScript, usando un número de técnicas estándar.
Un lenguaje específico del dominio es un lenguaje que es particularmente apropiado para desarrollar en un dominio de problemas concreto. Su sintaxis y funciones se eligen para maximizar la legibilidad del código usado para expresar ideas en ese dominio. Hemos visto varios ejemplos de lenguajes específicos del dominio en este libro:
- La mónada
Game
y sus acciones asociadas, desarrolladas en el capítulo 11, constituyen un lenguaje específico del dominio para el dominio del desarrollo de juegos de aventuras textuales. - La biblioteca de combinadores que escribimos para los funtores
Async
yParallel
del capítulo 12 se pueden considerar un ejemplo de lenguaje específico del dominio para el dominio de la programación asíncrona. - El paquete
purescript-quickcheck
cubierto en el capítulo 13 es un lenguaje específico del dominio para el dominio de verificación generativa. Sus combinadores permiten una notación particularmente expresiva para escribir propiedades de verificación.
Este capítulo tomará una aproximación más estructurada a algunas de las técnicas estándar para la implementación de lenguajes específicos del dominio. No es de ninguna manera una exposición completa del tema, pero debe proporcionarte suficiente conocimiento para construir algunos DSLs prácticos para tus tareas.
Nuestro ejemplo será un lenguaje específico del dominio para crear documentos HTML. Nuestro objetivo será desarrollar un lenguaje de tipos seguros para describir documentos HTML correctos, y trabajaremos mejorando en pequeños pasos una implementación ingenua.
Preparación del proyecto
El proyecto que acompaña este capítulo añade una nueva dependencia Bower; la biblioteca purescript-free
que define la mónada libre (free monad), una de las herramientas que usaremos.
Probaremos el proyecto de este capítulo en PSCi.
Un tipo de datos HTML
La versión más básica de nuestra biblioteca HTML está definida el módulo Data.DOM.Simple
. El módulo contiene las siguientes definiciones de tipos:
El tipo Element
representa elementos HTML. Cada elemento consiste en un nombre de elemento, un array de pares de atributos y algún contenido. La propiedad de contenido usa el tipo Maybe
para indicar si un elemento puede estar abierto (contiene otros elementos y texto) o cerrado.
La función clave de nuestra biblioteca es una función
que representa elementos HTML como cadenas HTML. Podemos probar esta versión de la biblioteca construyendo valores de los tipos apropiados explícitamente en PSCi:
Esta biblioteca tiene varios problemas tal cual está:
- Crear documentos HTML es difícil; cada nuevo elemento requiere al menos un registro y un constructor de datos.
- Es posible representar documentos inválidos:
- El desarrollador puede escribir mal el nombre del elemento
- El desarrollador puede asociar un atributo con el tipo de elemento erróneo
- El desarrollador puede usar un elemento cerrado cuando lo correcto es un elemento abierto
En el resto del capítulo, aplicaremos ciertas técnicas para resolver estos problemas y convertir nuestra biblioteca en un lenguaje específico del dominio para crear documentos HTML.
Constructores inteligentes (smart constructors)
La primera técnica que aplicaremos es simple pero puede ser muy efectiva. En lugar de exponer la representación de los datos a los usuarios del módulo, podemos usar la lista de exportaciones del módulo para ocultar los constructores de datos Element
, Content
y Attribute
, y exportar únicamente los llamados constructores inteligentes, que construyen datos que se saben correctos.
Aquí tenemos un ejemplo. Primero proporcionamos una función de conveniencia para crear elementos HTML:
A continuación, creamos constructores inteligentes para aquellos elementos HTML que queremos que puedan crear nuestros usuarios, aplicando la función element
:
Finalmente, actualizamos la lista de exportaciones del módulo para que exporte sólo las funciones que sabemos que construyen estructuras de datos correctas:
La lista de exportaciones del módulo se proporciona entre paréntesis inmediatamente después del nombre de módulo. Cada exportación del módulo puede ser de tres tipos:
- Un valor (o función) indicado/a por su nombre.
- Una clase de tipos indicada por el nombre de la clase.
- Un constructor de tipo y cualquier constructor de datos asociado, indicado por el nombre del tipo seguido por una lista entre paréntesis de constructores de datos exportados.
Aquí exportamos el tipo Element
, pero no exportamos sus constructores de datos. Si lo hiciésemos, el usuario sería capaz de construir elementos HTML inválidos.
En el caso de los tipos Attribute
y Content
, seguimos exportando todos los constructores de datos (usando el símbolo ..
en la lista de exportación). Aplicaremos la técnica de los constructores inteligentes a estos tipos en breve.
Fíjate en que ya hemos hecho grandes mejoras a nuestra biblioteca:
- Es imposible representar elementos HTML con nombres inválidos (por supuesto, estamos restringidos al conjunto de nombres de elemento proporcionados por la biblioteca).
- Los elementos cerrados no pueden tener contenido por construcción.
Podemos aplicar esta técnica al tipo Content
muy fácilmente. Simplemente quitamos los constructores de datos del tipo Content
de la lista de exportación y proporcionamos los siguientes constructores inteligentes:
Apliquemos la misma técnica al tipo Attribute
. Primero proporcionamos un constructor inteligente de propósito general para los atributos. Aquí hay un primer intento:
Esta representación sufre del mismo problema que el tipo Element
original; es posible representar atributos que no existen o cuyos nombres se han escrito mal. Para resolver este problema, podemos crear un newtype que representa nombres de atributo:
Con eso, podemos modificar nuestro operador como sigue:
Si no exportamos el constructor de datos AttributeKey
, el usuario no tiene manera de construir valores de tipo AttributeKey
que no sea usando las funciones que exportamos explícitamente. Aquí hay algunos ejemplos:
Aquí tenemos la lista final de exportaciones de nuestro nuevo módulo. Date cuenta de que ya no exportamos ningún constructor de datos directamente:
Si probamos este nuevo módulo en PSCi, podemos ver grandes mejoras en la concisión del código de usuario:
Fíjate sin embargo en que no tuvimos que hacer cambios a la función render
, ya que la representación de los datos subyacentes no ha cambiado. Este es uno de los beneficios de la aproximación de los constructores inteligentes: nos permite separar la representación interna de los datos de un módulo y la representación percibida por los usuarios de su API externa.
Tipos fantasma (phantom types)
Para motivar la siguiente técnica, considera el siguiente código:
El problema aquí es que hemos proporcionado valores cadena para los atributos width
y height
, donde se esperaban valores numéricos en unidades de píxeles o porcentajes.
Para resolver este problema, podemos introducir un argumento de tipo fantasma a nuestro tipo AttributeKey
:
La variable de tipo a
se llama tipo fantasma porque no hay valores de tipo a
involucrados en la parte derecha de la definición. El tipo a
sólo existe para proporcionar más información en tiempo de compilación. Cualquier valor de tipo AttributeKey a
es simplemente una cadena en tiempo de ejecución, pero en tiempo de compilación, el tipo del valor nos dice el tipo deseado para los valores asociados con esta clave.
Podemos modificar el tipo de nuestra función attribute
para que tome en consideración la nueva forma de AttributeKey
:
Aquí, el argumento de tipo fantasma a
se usa para asegurarnos de que la clave y valor del atributo tienen tipos compatibles. Como el usuario no puede crear valores de tipo AttributeKey a
directamente (sólo mediante las constantes que proporcionamos en la biblioteca), todos los atributos serán correctos por construcción.
Fíjate en que la restricción IsValue
nos asegura que asociemos el tipo de valor que asociemos a una clave, sus valores se pueden convertir en cadenas para mostrarse en el HTML generado. La clase de tipos IsValue
se define como sigue:
Proporcionamos instancias de la clase de tipos para los tipos String
e Int
:
Tenemos también que actualizar nuestras constantes AttributeKey
de manera que sus tipos reflejen el nuevo parámetro de tipo:
Ahora nos encontramos con que es imposible representar estos documentos HTML inválidos, y que estamos obligados a usar números para representar los atributos width
y height
:
La mónada libre (free monad)
En nuestro conjunto final de modificaciones a nuestra API, usaremos un constructor llamado la mónada libre para convertir nuestro tipo Content
en una mónada, habilitando la notación do. Esto nos permitirá estructurar nuestro documento HTML en una forma en que el anidamiento de elementos se vuelve más claro; en lugar de esto:
podremos escribir esto:
Sin embargo, la notación do no es el único beneficio de una mónada libre. La mónada libre nos permite separar la representación de nuestras acciones monádicas de su interpretación, e incluso soportar múltiples interpretaciones de las mismas acciones.
La mónada Free
se define en la biblioteca purescript-free
en el módulo Control.Monad.Free
. Podemos averiguar alguna información básica sobre ella en PSCi como sigue:
La familia de Free
indica que toma un constructor de tipo como argumento y devuelve otro constructor de tipo. De hecho, ¡la mónada Free
se puede usar para convertir cualquier Functor
en una Monad
!
Comenzamos definiendo la representación de nuestras acciones monádicas. Para hacer esto necesitamos crear un Functor
con un constructor de datos para cada acción monádica que deseamos soportar. En nuestro caso, nuestras dos acciones monádicas serán elem
y text
. De hecho podemos simplemente modificar nuestro tipo Content
como sigue:
Aquí el constructor de tipo ContentF
se parece a nuestro viejo tipo de datos Content
; sin embargo, ahora toma un argumento de tipo a
, y cada constructor de datos se ha modificado para que tome un valor de tipo a
como argumento adicional. La instancia Functor
simplemente aplica la función f
al valor de tipo a
en cada constructor de datos.
Con eso, podemos definir nuestra nueva mónada Content
como un sinónimo de tipo para la mónada Free
, que construimos usando nuestro constructor de tipo ContentF
como primer argumento de tipo:
En lugar de un sinónimo de tipo podemos usar un newtype
para evitar exponer la representación interna de nuestra biblioteca a los usuarios. Ocultando el constructor de datos Content
restringimos a nuestros usuarios a que usen únicamente las acciones monádicas que suministramos.
Como ContentF
es un Functor
, obtenemos automáticamente una instancia Monad
para Free ContentF
.
Tenemos que modificar nuestro tipo de datos Element
ligeramente para tomar en cuenta el nuevo argumento de tipo de Content
. Simplemente requeriremos que el tipo de retorno de nuestros cálculos monádicos sea Unit
:
Además tenemos que modificar nuestras funciones elem
y text
, que se convertirán en nuestras nuevas acciones monádicas para la mónada Content
. Para hacer esto podemos usar la función liftF
suministrada por el módulo Control.Monad.Free
. Aquí está su tipo:
liftF
nos permite construir una acción en nuestra mónada libre a partir de un valor de tipo f a
para algún tipo a
. En nuestro caso, podemos simplemente usar los constructores de datos de nuestro constructor de tipo ContentF
directamente:
Hay que hacer otras modificaciones rutinarias, pero los cambios interesantes están en la función render
, donde tenemos que interpretar nuestra mónada libre.
Interpretando la mónada
El módulo Control.Monad.Free
proporciona funciones para interpretar un cálculo en una mónada libre:
La función runFree
se usa para calcular un resultado puro. La función runFreeM
nos permite usar una mónada para interpretar las acciones de nuestra mónada libre.
Nota: Técnicamente, estamos restringidos a usar monadas m
que satisfagan la restricción más fuerte MonadRec
. En la práctica, esto significa que no necesitamos preocuparnos por los desbordamientos de pila, ya que m
soporta recursividad final mónadica segura.
Primero tenemos que elegir una mónada en la que podamos interpretar nuestras acciones. Usaremos la mónada Writer String
para acumular una cadena HTML como resultado.
Nuestro nuevo método render
comienza delegando en una función auxiliar renderElement
y usando execWriter
para ejecutar nuestro cálculo en la mónada Writer
:
renderElement
se define en un bloque where:
La definición de renderElement
es simple, usa la acción tell
de la mónada Writer
para acumular varias cadenas pequeñas:
A continuación definimos la función renderAttribute
que es igualmente simple:
La función renderContent
es más interesante. Aquí usamos la función runFreeM
para interpretar el cálculo dentro de la mónada libre, delegando en una función auxiliar renderContentItem
:
El tipo de renderContentItem
se puede deducir de la firma de tipo de runFreeM
. El funtor f
es nuestro constructor de tipo ContentF
, y la mónada m
es la mónada en la que estamos interpretando el cálculo, a saber, Writer String
. Esto nos da la siguiente firma de tipo para renderContentItem
:
Podemos implementar esta función simplemente mediante ajuste de patrones sobre los dos constructores de datos de ContentF
:
En cada caso, la expresión rest
tiene el tipo Content Unit
, y representa el resto del cálculo interpretado. Podemos completar cada caso devolviendo la acción rest
.
¡Eso es todo! Podemos probar nuestra nueva API monádica en PSCi como sigue:
Extendiendo el lenguaje
Una mónada en la que todas las acciones devuelven algo de tipo Unit
no es particularmente interesante. De hecho, aparte de una sintaxis probablemente más bonita, nuestra mónada no añade funcionalidad extra con respecto a un Monoid
.
Ilustremos la potencia de la construcción de mónada libre extendiendo nuestro lenguaje con una nueva acción monádica que devuelve un resultado no trivial.
Supongamos que queremos generar documentos HTML que contienen hipervínculos a diferentes secciones del documento usando anclas (anchors). Podemos conseguir esto actualmente generando nombres de ancla a mano e incluyéndolos al menos dos veces en el documento: una vez en la definición de la propia ancla, y otra en cada hipervínculo. Sin embargo, este enfoque tiene algunos problemas básicos:
- El desarrollador puede equivocarse generando nombres de ancla únicos.
- El desarrollador puede equivocarse al escribir una o más instancias del nombre del ancla.
Queriendo proteger al desarrollador de sus propios errores, podemos introducir un nuevo tipo que representa nombres de ancla y proporcionar una acción monádica para generar nuevos nombres únicos.
El primer paso es añadir un nuevo tipo para los nombres:
De nuevo, definimos esto como un newtype en torno a String
, pero debemos ser cuidadosos de no exportar el constructor de datos en la lista de exportaciones del módulo.
A continuación definimos una instancia de la clase de tipos IsValue
para nuestro nuevo tipo, de manera que seamos capaces de usar nombres en valores de atributo:
Definimos también un nuevo tipo de datos para hipervínculos que pueden aparecer en elementos a
como sigue:
Con este nuevo tipo, podemos modificar el tipo de valor del atributo href
, forzando a nuestros usuarios a usar nuestro nuevo tipo Href
. Podemos también crear un nuevo atributo name
que se puede usar para convertir un elemento en un ancla:
El problema restante es que nuestros usuarios no tienen actualmente manera de generar nombres nuevos. Podemos proporcionar esta funcionalidad en nuestra mónada Content
. Primero necesitamos añadir un nuevo constructor de datos a nuestro constructor de tipo ContentF
:
El constructor de datos NewName
corresponde a una acción que devuelve un valor de tipo Name
. Fíjate en que en lugar de requerir un Name
como argumento de constructor de datos, requerimos que el usuario proporcione una función de tipo Name -> a
. Recordando que el tipo a
representa el resto del cálculo, podemos ver que esta función proporciona una manera de continuar el cálculo después de que un valor de tipo Name
se haya devuelto.
Necesitamos también actualizar la instancia Functor
de ContentF
para que tenga cuenta el nuevo constructor de datos como sigue:
Podemos ahora construir nuestra nueva acción mediante la función liftF
como antes:
Date cuenta de que proporcionamos la función id
como nuestra continuación, lo que significa que devolvemos el resultado de tipo Name
sin cambiar.
Finalmente necesitamos actualizar nuestra función de interpretación para interpretar la nueva acción. Previamente hemos usado la mónada Writer String
para interpretar nuestros cálculos, pero esa mónada no tiene la capacidad de generar nuevos nombres, de manera que cambiamos a otra cosa. El transformador de mónada WriterT
se puede usar con la mónada State
para combinar los efectos que necesitamos. Definimos nuestra mónada de interpretación como un sinónimo de tipo para mantener nuestras firmas de tipo cortas:
Aquí, el estado de tipo Int
actuará como un contador incremental usado para generar nombres únicos.
Ya que las mónadas Writer
y WriterT
usan los mismos miembros de clase de tipos para abstraer sus acciones, no necesitamos cambiar ninguna acción; sólo tenemos que cambiar todas las referencias a Writer String
por Interp
. Sin embargo tenemos que modificar el gestor usado para ejecutar nuestro cálculo. En lugar de usar execWriter
, tenemos ahora que usar evalState
y execWriter
:
También necesitamos añadir un nuevo caso a renderContentItem
para interpretar el constructor de datos NewName
:
Aquí se nos da una continuación k
de tipo Name -> Content a
, y necesitamos construir una interpretación de tipo Content a
. Nuestra interpretación es simple: usamos get
para leer el estado, usamos ese estado para generar un nombre único, y usamos put
para incrementar el estado. Finalmente pasamos nuestro nuevo nombre a la continuación para completar el cálculo.
Con eso, podemos probar nuestra nueva funcionalidad en PSCi, generando un nombre único dentro de la mónada Content
y usándolo tanto como nombre de elemento como destino de un hipervínculo:
Puedes verificar que múltiples llamadas a newName
resultan de hecho en nombres únicos.
Conclusión
En este capítulo hemos desarrollado un lenguaje específico del dominio para crear documentos HTML, mejorando incrementalmente una implementación ingenua usando algunas técnicas estándar:
- Hemos usado constructores inteligentes para esconder detalles de nuestra representación de datos, permitiendo únicamente al usuario crear documentos que sean correctos por construcción.
- Hemos usado un operador binario infijo definido por el usuario para mejorar la sintaxis del lenguaje.
- Hemos usado tipos fantasma para codificar información adicional en los tipos de nuestros datos, evitando que el usuario proporcione valores de tipo erróneo.
- Hemos usado la mónada libre para convertir nuestra representación array del contenido en una representación monádica que soporta notación do. Hemos extendido esta representación para soportar una nueva acción monádica, y hemos interpretado los cálculos monádicos usando transformadores de mónada estándar.
Estas técnicas aprovechan el sistema de tipos y módulos de JavaScript, bien para evitar que el usuario cometa errores, bien para mejorar la sintaxis del lenguaje específico del dominio.
La implementación de lenguajes específicos del dominio en lenguajes de programación funcional es un área de investigación activa, pero con suerte esto proporciona una introducción útil a algunas técnicas simples e ilustra la potencia de trabajar en un lenguaje con tipos expresivos.