PureScript mediante ejemplos
PureScript mediante ejemplos
Phil Freeman y Jorge Acereda
Buy on Leanpub

Tabla de contenidos

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 y reduce para crear programas más grandes a partir de programas más pequeños mediante composición:
    1   var sumOfPrimes =
    2       _.chain(_.range(1000))
    3        .filter(isPrime)
    4        .reduce(function(x, y) {
    5            return x + y;
    6        })
    7        .value();
    
  • La programación asíncrona en NodeJS se apoya firmemente en las funciones como valores de primera clase para definir retrollamadas (callbacks).
    1   require('fs').readFile(sourceFile, function (error, data) {
    2     if (!error) {
    3       require('fs').writeFile(destFile, data, function (error) {
    4         if (!error) {
    5           console.log("File copied");
    6         }
    7       });
    8     }
    9   });
    
  • 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:

1 iAmANumber =
2   let square x = x * x
3   in square 42.0

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:

1 iterate f 0 x = x
2 iterate f n x = iterate f (n - 1) (f x)

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:

1 module Example where
2 
3 import Control.Monad.Eff.Console (log)
4 
5 main = log "Hello, World!"

Los comandos que deben escribirse en la línea de comandos estarán precedidos por un símbolo de dólar:

1 $ pulp build

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’.

1 > 1 + 2
2 3

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:

1 $ purs

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:

1 $ npm install -g pulp bower

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:

1 $ mkdir my-project
2 $ cd my-project
3 $ pulp init
4 
5 * Generating project skeleton in ~/my-project
6 
7 $ ls
8 
9 bower.json	src		test

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:

1 module Main where
2 
3 import Control.Monad.Eff.Console
4 
5 main = log "Hello, World!"

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ón log.
  • 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:

1 $ pulp run
2 
3 * Building project in ~/my-project
4 * Build successful.
5 Hello, World!

¡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:

1 $ pulp browserify
2 
3 * Browserifying project in ~/my-project
4 * Building project in ~/my-project
5 * Build successful.
6 * Browserifying...

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:

1 $ pulp build -O --to output.js
2 
3 * Building project in ~/my-project
4 * Build successful.
5 * Bundling Javascript...
6 * Bundled.

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:

1 (function(exports) {
2   "use strict";
3 
4   var Control_Monad_Eff_Console = PS["Control.Monad.Eff.Console"];
5 
6   var main = Control_Monad_Eff_Console.log("Hello, World!");
7   exports["main"] = main;
8 })(PS["Main"] = PS["Main"] || {});

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):

1 $ pulp build
2 
3 * Building project in ~/my-project
4 * Build successful.

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:

1 $ bower install purescript-math --save

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:

1 import Math (sqrt)

También es necesario importar el módulo Prelude que define operaciones muy básicas como la suma y multiplicación de números:

1 import Prelude

Ahora define la función diagonal como sigue:

1 diagonal w h = sqrt (w * w + h * h)

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:

1 main = logShow (diagonal 3.0 4.0)

Ahora compila y ejecuta el proyecto de nuevo usando pulp run:

1 $ pulp run
2 
3 * Building project in ~/my-project
4 * Build successful.
5 5.0

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.

1 $ pulp repl
2 >

Puedes escribir :? para ver una lista de comandos:

 1 > :?
 2 The following commands are available:
 3 
 4     :?                        Show this help menu
 5     :quit                     Quit PSCi
 6     :reset                    Reset
 7     :browse      <module>     Browse <module>
 8     :type        <expr>       Show the type of <expr>
 9     :kind        <type>       Show the kind of <type>
10     :show        import       Show imported modules
11     :show        loaded       Show loaded modules
12     :paste       paste        Enter multiple lines, terminated by ^D

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:

1 > import Prelude

Intenta evaluar unas cuantas expresiones ahora:

1 > 1 + 2
2 3
3 
4 > "Hello, " <> "World!"
5 "Hello, World!"

Probemos ahora nuestra nueva función diagonal en PSCi:

1 > import Main
2 > diagonal 5.0 12.0
3 
4 13.0

También puedes usar PSCi para definir funciones:

1 > double x = x * 2
2 
3 > double 10
4 20

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:

1 > :type true
2 Boolean
3 
4 > :type [1, 2, 3]
5 Array Int

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:

1 module Data.AddressBook where
2 
3 import Prelude
4 
5 import Control.Plus (empty)
6 import Data.List (List(..), filter, head)
7 import Data.Maybe (Maybe)

Aquí importamos varios módulos:

  • El módulo Control.Plus que define el valor empty.
  • El módulo Data.List proporcionado por el paquete purescript-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:

1 $ cd chapter3
2 $ bower update
3 $ pulp build

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:

 1 $ pulp repl
 2 
 3 > :type 1.0
 4 Number
 5 
 6 > :type "test"
 7 String
 8 
 9 > :type true
10 Boolean

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.

1 > :type 1
2 Int

Los caracteres literales van rodeados por comillas simples, a diferencia de las cadenas literales que usan dobles comillas:

1 > :type 'a'
2 Char

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:

1 > :type [1, 2, 3]
2 Array Int
3 
4 > :type [true, false]
5 Array Boolean
6 
7 > :type [1, false]
8 Could not match type Int with Boolean.

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:

1 > author = { name: "Phil", interests: ["Functional Programming", "JavaScript"] }
2 
3 > :type author
4 { name :: String
5 , interests :: Array String
6 }

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:

1 > author.name
2 "Phil"
3 
4 > author.interests
5 ["Functional Programming","JavaScript"]

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:

1 > import Prelude
2 > :type flip
3 forall a b c. (a -> b -> c) -> b -> a -> c
4 
5 > :type const
6 forall a b. a -> b -> a

Las funciones pueden ser definidas en el nivel superior de un fichero especificando los argumentos antes del signo igual:

1 add :: Int -> Int -> Int
2 add x y = x + y

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:

1 > :paste
2 … add :: Int -> Int -> Int
3 … add = \x y -> x + y
4 … ^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:

1 > add 10 20
2 30

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:

1 > :type flip
2 forall a b c. (a -> b -> c) -> b -> a -> c

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:

1 (Int -> String -> String) -> String -> Int -> String

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:

1 > flip (\n s -> show n <> s) "Ten" 10
2 
3 "10Ten"

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:

1 > flip (\n s -> show n <> s) 10 "Ten"
2 
3 Could not match type Int with type String

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:

1 add x y z = x +
2   y + z

Pero esto no es código válido:

1 add x y z = x +
2 y + z

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:

1 > :paste
2 … x = 1
3 … y = 2
4 … ^D

Pero esto no lo es:

1 > :paste
2 … x = 1
3 …  y = 2
4 … ^D

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:

1 example x y z = foo + bar
2   where
3     foo = x * y
4     bar = y * z

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:

1 type Entry =
2   { firstName :: String
3   , lastName  :: String
4   , address   :: Address
5   }

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:

1 type Address =
2   { street :: String
3   , city   :: String
4   , state  :: String
5   }

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:

1 type AddressBook = List Entry

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:

1 > import Data.List
2 > Nil :: List
3 In a type-annotated expression x :: t, the type t must have kind Type

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:

1 > :kind Number
2 Type
3 
4 > import Data.List
5 > :kind List
6 Type -> Type
7 
8 > :kind List String
9 Type

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:

1 showEntry :: Entry -> String

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:

1 showEntry entry = entry.lastName <> ", " <>
2                   entry.firstName <> ": " <>
3                   showAddress entry.address

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:

1 showAddress :: Address -> String
2 showAddress addr = addr.street <> ", " <>
3                    addr.city <> ", " <>
4                    addr.state

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:

1 $ pulp build

A continuación, carga PSCi y usa el comando import para importar tu nuevo módulo:

1 $ pulp repl
2 
3 > import Data.AddressBook

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:

1 > address = { street: "123 Fake St.", city: "Faketown", state: "CA" }

Ahora intenta aplicar nuestra función al ejemplo:

1 > showAddress address
2 
3 "123 Fake St., Faketown, CA"

Probemos también showEntry creando una entrada de la agenda conteniendo nuestra dirección de ejemplo:

1 > entry = { firstName: "John", lastName: "Smith", address: address }
2 > showEntry entry
3 
4 "Smith, John: 123 Fake St., Faketown, CA"

Creando agendas

Ahora escribamos algunas funciones útiles para trabajar con agendas. Necesitaremos un valor que representa una agenda vacía: una lista vacía.

1 emptyBook :: AddressBook
2 emptyBook = empty

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:

1 insertEntry :: Entry -> AddressBook -> AddressBook

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:

1 $ pulp repl
2 
3 > import Data.List
4 > :type Cons
5 
6 forall a. a -> List a -> List a

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:

1 Entry -> List Entry -> List Entry

Pero List Entry es lo mismo que AddressBook, de manera que esto es equivalente a:

1 Entry -> AddressBook -> AddressBook

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:

1 insertEntry entry book = Cons entry book

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:

1 Entry -> (AddressBook -> AddressBook)

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:

1 > :type insertEntry entry
2 
3 AddressBook -> AddressBook

Como esperábamos, el tipo de retorno es una función. Podemos aplicar la función resultante a un segundo argumento:

1 > :type (insertEntry entry) emptyBook
2 AddressBook

Date cuenta de que los paréntesis son innecesarios. Lo que sigue es equivalente:

1 > :type insertEntry example emptyBook
2 AddressBook

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:

1 insertEntry :: Entry -> AddressBook -> AddressBook
2 insertEntry entry book = Cons entry book

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:

1 insertEntry :: Entry -> AddressBook -> AddressBook
2 insertEntry entry = Cons entry

Pero ahora, con el mismo razonamiento, podemos quitar entry de ambos lados:

1 insertEntry :: Entry -> AddressBook -> AddressBook
2 insertEntry = Cons

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:

 1 $ pulp repl
 2 
 3 > import Data.List
 4 > :type filter
 5 
 6 forall a. (a -> Boolean) -> List a -> List a
 7 
 8 > :type head
 9 
10 forall a. List a -> Maybe a

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:

1 filter :: (Entry -> Boolean) -> AddressBook -> AddressBook
2 
3 head :: AddressBook -> Maybe Entry

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:

1 findEntry :: String -> String -> AddressBook -> Maybe Entry

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:

1 findEntry firstName lastName book = head $ filter filterEntry book
2   where
3     filterEntry :: Entry -> Boolean
4     filterEntry entry = entry.firstName == firstName && entry.lastName == lastNa\
5 me

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:

1 apply :: forall a b. (a -> b) -> a -> b
2 apply f x = f x
3 
4 infixr 0 apply as $

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:

1 street (address (boss employee))

Es probablemente más legible cuando se expresa usando $:

1 street $ address $ boss employee

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:

1 (head <<< filter filterEntry) book

En esta forma, podemos aplicar el truco anterior de conversión eta para llegar a la forma final de findEntry:

1 findEntry firstName lastName = head <<< filter filterEntry
2   where
3     ...

Una parte derecha igualmente válida sería:

1 filter filterEntry >>> head

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:

1 $ pulp repl
2 
3 > import Data.AddressBook

Vamos primero a intentar buscar una entrada en una agenda vacía (obviamente esperamos que esto devuelva un resultado vacío):

 1 > findEntry "John" "Smith" emptyBook
 2 
 3 No type class instance was found for
 4 
 5     Data.Show.Show { firstName :: String
 6                    , lastName :: String
 7                    , address :: { street :: String
 8                                 , city :: String
 9                                 , state :: String
10                                 }
11                    }

¡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):

1 > import Prelude
2 > map showEntry (findEntry "John" "Smith" emptyBook)
3 
4 Nothing

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:

1 > printEntry firstName lastName book = map showEntry (findEntry firstName lastNa\
2 me book)

Ahora creemos una agenda no vacía e intentemos de nuevo. Reutilizaremos nuestra entrada de ejemplo anterior:

1 > book1 = insertEntry entry emptyBook
2 
3 > printEntry "John" "Smith" book1
4 
5 Just ("Smith, John: 123 Fake St., Faketown, CA")

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 tipo Maybe.
  • 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:

1 fact :: Int -> Int
2 fact 0 = 1
3 fact n = n * fact (n - 1)

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:

1 fib :: Int -> Int
2 fib 0 = 1
3 fib 1 = 1
4 fib n = fib (n - 1) + fib (n - 2)

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:

 1 import Prelude
 2 
 3 import Data.Array (null)
 4 import Data.Array.Partial (tail)
 5 import Partial.Unsafe (unsafePartial)
 6 
 7 length :: forall a. Array a -> Int
 8 length arr =
 9   if null arr
10     then 0
11     else 1 + length (unsafePartial tail arr)

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:

1 $ pulp repl
2 
3 > import Prelude
4 > map (\n -> n + 1) [1, 2, 3, 4, 5]
5 [2, 3, 4, 5, 6]

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:

1 > (\n -> n + 1) `map` [1, 2, 3, 4, 5]
2 [2, 3, 4, 5, 6]

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:

1 > (\n -> n + 1) <$> [1, 2, 3, 4, 5]
2 [2, 3, 4, 5, 6]

Veamos el tipo de map:

1 > :type map
2 forall a b f. Functor f => (a -> b) -> f a -> f b

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:

1 forall a b. (a -> b) -> Array a -> Array b

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:

1 > show <$> [1, 2, 3, 4, 5]
2 
3 ["1","2","3","4","5"]

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:

1 > (<$>) show [1, 2, 3, 4, 5]
2 ["1","2","3","4","5"]

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:

1 infix 8 range as ..

Podemos usar este operador como sigue:

1 > import Data.Array
2 
3 > 1 .. 5
4 [1, 2, 3, 4, 5]
5 
6 > show <$> (1 .. 5)
7 ["1","2","3","4","5"]

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:

1 > show <$> 1 .. 5
2 ["1","2","3","4","5"]

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:

1 > import Data.Array
2 
3 > filter (\n -> n `mod` 2 == 0) (1 .. 10)
4 [2,4,6,8,10]

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:

1 > import Data.Array
2 
3 > :type concat
4 forall a. Array (Array a) -> Array a
5 
6 > concat [[1, 2, 3], [4, 5], [6]]
7 [1, 2, 3, 4, 5, 6]

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:

1 > import Data.Array
2 
3 > :type concatMap
4 forall a b. (a -> Array b) -> Array a -> Array b
5 
6 > concatMap (\n -> [n, n * n]) (1 .. 5)
7 [1,1,2,4,3,9,4,16,5,25]

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:

1 > pairs n = concatMap (\i -> 1 .. n) (1 .. n)

Podemos probar nuestra función:

1 > pairs 3
2 [1,2,3,1,2,3,1,2,3]

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:

1 > :paste
2 … pairs' n =
3 …   concatMap (\i ->
4 …     map (\j -> [i, j]) (1 .. n)
5 …   ) (1 .. n)
6 … ^D
7 
8 > pairs' 3
9 [[1,1],[1,2],[1,3],[2,1],[2,2],[2,3],[3,1],[3,2],[3,3]]

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:

1 > :paste
2 … pairs'' n =
3 …   concatMap (\i ->
4 …     map (\j -> [i, j]) (i .. n)
5 …   ) (1 .. n)
6 … ^D
7 > pairs'' 3
8 [[1,1],[1,2],[1,3],[2,2],[2,3],[3,3]]

¡Estupendo! Ahora que tenemos todos los pares de factores potenciales, podemos usar filter para elegir los pares cuya multiplicación da n:

1 > import Data.Foldable
2 
3 > factors n = filter (\pair -> product pair == n) (pairs'' n)
4 
5 > factors 10
6 [[1,10],[2,5]]

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:

1 factors :: Int -> Array (Array Int)
2 factors n = filter (\xs -> product xs == n) $ do
3   i <- 1 .. n
4   j <- i .. n
5   pure [i, j]

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:

1 > pure [1, 2] :: Array (Array Int)
2 [[1, 2]]

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:

1 factors :: Int -> Array (Array Int)
2 factors n = filter (\xs -> product xs == n) $ do
3   i <- 1 .. n
4   j <- i .. n
5   [[i, j]]

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):

1 import Control.MonadZero (guard)
2 
3 factors :: Int -> Array (Array Int)
4 factors n = do
5   i <- 1 .. n
6   j <- i .. n
7   guard $ i * j == n
8   pure [i, j]

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í:

1 > import Control.MonadZero
2 
3 > :type guard
4 forall m. MonadZero m => Boolean -> m Unit

En nuestro caso, podemos asumir que PSCi reportó el siguiente tipo:

1 Boolean -> Array Unit

Para nuestros propósitos, los siguientes cálculos nos dicen todo lo que necesitamos saber de la función guard sobre arrays:

1 > import Data.Array
2 
3 > length $ guard true
4 1
5 
6 > length $ guard false
7 0

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

1 > import Data.Foldable
2 
3 > :type foldl
4 forall a b f. Foldable f => (b -> a -> b) -> b -> f a -> b
5 
6 > :type foldr
7 forall a b f. Foldable f => (a -> b -> b) -> b -> f a -> b

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):

1 > :type foldl
2 forall a b. (b -> a -> b) -> b -> Array a -> b
3 
4 > :type foldr
5 forall a b. (a -> b -> b) -> b -> Array a -> b

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:

1 > foldl (+) 0 (1 .. 5)
2 15

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:

1 > foldr (+) 0 (1 .. 5)
2 15

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:

1 > foldl (\acc n -> acc <> show n) "" [1,2,3,4,5]
2 "12345"
3 
4 > foldr (\n acc -> acc <> show n) "" [1,2,3,4,5]
5 "54321"

Esto ilustra la diferencia entre ambas funciones. La expresión de pliegue por la izquierda es equivalente a la siguiente aplicación:

1 ((((("" <> show 1) <> show 2) <> show 3) <> show 4) <> show 5)

Mientras que el pliegue por la derecha es equivalente a esto:

1 ((((("" <> show 5) <> show 4) <> show 3) <> show 2) <> show 1)

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:

1 > f 0 = 0
2 > f n = 1 + f (n - 1)
3 
4 > f 10
5 10
6 
7 > f 100000
8 RangeError: Maximum call stack size exceeded

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:

1 fact :: Int -> Int -> Int
2 fact 0 acc = acc
3 fact n acc = fact (n - 1) (acc * n)

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:

1 reverse :: forall a. Array a -> Array a
2 reverse [] = []
3 reverse xs = snoc (reverse (unsafePartial tail xs))
4                   (unsafePartial head xs)

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:

1 reverse :: forall a. Array a -> Array a
2 reverse = reverse' []
3   where
4     reverse' acc [] = acc
5     reverse' acc xs = reverse' (unsafePartial head xs : acc)
6                                (unsafePartial tail xs)

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:

1 > import Data.Foldable
2 
3 > :paste
4 … reverse :: forall a. Array a -> Array a
5 … reverse = foldr (\x xs -> xs <> [x]) []
6 … ^D
7 
8 > reverse [1, 2, 3]
9 [3,2,1]

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 un Path.
  • La función size devuelve el tamaño de fichero para un Path que representa un fichero.
  • La función isDirectory comprueba si un Path es un fichero o un directorio.

En términos de tipos, tenemos las siguientes definiciones de tipos:

1 root :: Path
2 
3 ls :: Path -> Array Path
4 
5 filename :: Path -> String
6 
7 size :: Path -> Maybe Number
8 
9 isDirectory :: Path -> Boolean

Podemos probar la API en PSCi:

 1 $ pulp repl
 2 
 3 > import Data.Path
 4 
 5 > root
 6 /
 7 
 8 > isDirectory root
 9 true
10 
11 > ls root
12 [/bin/,/etc/,/home/]

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:

1 allFiles :: Path -> Array Path

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:

1 allFiles file = file : concatMap allFiles (ls file)

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:

1 > import FileOperations
2 > import Data.Path
3 
4 > allFiles root
5 
6 [/,/bin/,/bin/cp,/bin/ls,/bin/mv,/etc/,/etc/hosts, ...]

¡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:

1 allFiles' :: Path -> Array Path
2 allFiles' file = file : do
3   child <- ls file
4   allFiles' child

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 modulo Math 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:

1 module Data.Picture where
2 
3 import Prelude
4 import Data.Foldable (foldl)

El módulo Data.Picture también importa los módulos Global y Math, pero esta vez usando la palabra reservada as:

1 import Global as Global
2 import Math as Math

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:

1 gcd :: Int -> Int -> Int
2 gcd n 0 = n
3 gcd 0 m = m
4 gcd n m = if n > m
5             then gcd (n - m) m
6             else gcd n (m - n)

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 y Boolean.
  • 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:

1 fromString :: String -> Boolean
2 fromString "true" = true
3 fromString _      = false
4 
5 toString :: Boolean -> String
6 toString true  = "true"
7 toString false = "false"

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:

1 gcd :: Int -> Int -> Int
2 gcd n 0 = n
3 gcd 0 n = n
4 gcd n m | n > m     = gcd (n - m) m
5         | otherwise = gcd n (m - n)

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:

1 isEmpty :: forall a. Array a -> Boolean
2 isEmpty [] = true
3 isEmpty _ = false

Aquí tenemos otra función que se ajusta a arrays de longitud 5, ligando cada uno de sus cinco argumentos de distinta manera:

1 takeFive :: Array Int -> Int
2 takeFive [0, 1, a, b, _] = a * b
3 takeFive _ = 0

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:

 1 > :paste
 2 … takeFive [0, 1, a, b, _] = a * b
 3 … takeFive _ = 0
 4 … ^D
 5 
 6 > takeFive [0, 1, 2, 3, 4]
 7 6
 8 
 9 > takeFive [1, 2, 3, 4, 5]
10 0
11 
12 > takeFive []
13 0

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:

1 showPerson :: { first :: String, last :: String } -> String
2 showPerson { first: x, last: y } = y <> ", " <> x

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:

1 > showPerson { first: x, last: y } = y <> ", " <> x
2 
3 > :type showPerson
4 forall r. { first :: String, last :: String | r } -> String

¿Qué es la variable de tipo r aquí? Bien, si probamos showPerson en PSCi vemos algo interesante:

1 > showPerson { first: "Phil", last: "Freeman" }
2 "Freeman, Phil"
3 
4 > showPerson { first: "Phil", last: "Freeman", location: "Los Angeles" }
5 "Freeman, Phil"

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:

1 > showPerson { first: "Phil" }
2 
3 Type of expression lacks required label "last"

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:

1 > showPerson p = p.last <> ", " <> p.first

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:

1 type Address = { street :: String, city :: String }
2 
3 type Person = { name :: String, address :: Address }
4 
5 livesInLA :: Person -> Boolean
6 livesInLA { address: { city: "Los Angeles" } } = true
7 livesInLA _ = false

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:

1 sortPair :: Array Int -> Array Int
2 sortPair arr@[x, y]
3   | x <= y = arr
4   | otherwise = [y, x]
5 sortPair arr = arr

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):

1 import Data.Array.Partial (tail)
2 import Partial.Unsafe (unsafePartial)
3 
4 lzs :: Array Int -> Array Int
5 lzs [] = []
6 lzs xs = case sum xs of
7            0 -> xs
8            _ -> lzs (unsafePartial tail xs)

Por ejemplo:

1 > lzs [1, 2, 3, 4]
2 []
3 
4 > lzs [1, -1, -2, 3]
5 [-1, -2, 3]

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:

1 import Partial.Unsafe (unsafePartial)
2 
3 partialFunction :: Boolean -> Boolean
4 partialFunction = unsafePartial \true -> true

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:

1 > partialFunction false
2 
3 Failed pattern match

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:

1 A case expression could not be determined to cover all inputs.
2 The following additional cases are required to cover all inputs:
3 
4   false

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:

1 partialFunction true = true

PSCi infiere un tipo curioso:

1 > :type partialFunction
2 
3 Partial => Boolean -> Boolean

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):

1 redundantCase :: Boolean -> Boolean
2 redundantCase true = true
3 redundantCase false = false
4 redundantCase false = false

En este caso, el último caso se identifica correctamente como redundante:

1 Redundant cases have been detected.
2 The definition has the following redundant cases:
3 
4   false

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 Shapes 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:

1 data Shape
2   = Circle Point Number
3   | Rectangle Point Number Number
4   | Line Point Point
5   | Text Point String

El tipo Point se puede definir también como un tipo de datos algebraico como sigue:

1 data Point = Point
2   { x :: Number
3   , y :: Number
4   }

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 datos Point; 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:

1 data Maybe a = Nothing | Just a

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:

1 data List a = Nil | Cons a (List 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 Points, así que para construir un Shape usando el constructor Line tenemos que proporcionar dos argumentos de tipo Point:

1 exampleLine :: Shape
2 exampleLine = Line p1 p2
3   where
4     p1 :: Point
5     p1 = Point { x: 0.0, y: 0.0 }
6 
7     p2 :: Point
8     p2 = Point { x: 100.0, y: 50.0 }

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:

1 showPoint :: Point -> String
2 showPoint (Point { x: x, y: y }) =
3   "(" <> show x <> ", " <> show y <> ")"
4 
5 showShape :: Shape -> String
6 showShape (Circle c r)      = ...
7 showShape (Rectangle c w h) = ...
8 showShape (Line start end)  = ...
9 showShape (Text p text) = ...

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:

1 showPoint :: Point -> String
2 showPoint (Point { x, y }) = ...

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}`:

1 origin :: Point
2 origin = Point { x, y }
3   where
4     x = 0.0
5     y = 0.0

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:

1 newtype Pixels = Pixels Number
2 newtype Inches = Inches Number

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 Shapes:

1 type Picture = Array Shape

Para depurar, querremos ser capaces de convertir una Picture en algo legible. La función showPicture nos permite hacerlo:

1 showPicture :: Picture -> Array String
2 showPicture = map showShape

Probémosla. Compila tu módulo con pulp build y abre PSCi con pulp repl:

 1 $ pulp build
 2 $ pulp repl
 3 
 4 > import Data.Picture
 5 
 6 > :paste
 7 … showPicture
 8 …   [ Line (Point { x: 0.0, y: 0.0 })
 9 …          (Point { x: 1.0, y: 1.0 })
10 …   ]
11 … ^D
12 
13 ["Line [start: (0.0, 0.0), end: (1.0, 1.0)]"]

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:

1 data Bounds = Bounds
2   { top    :: Number
3   , left   :: Number
4   , bottom :: Number
5   , right  :: Number
6   }

bounds usa la función foldl de Data.Foldable para recorrer el array de Shapes en una Picture, y acumular el rectángulo de delimitación mínimo:

1 bounds :: Picture -> Bounds
2 bounds = foldl combine emptyBounds
3   where
4     combine :: Bounds -> Shape -> Bounds
5     combine b shape = union (shapeBounds shape) b

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 datos Maybe, que representa valores opcionales.
  • purescript-tuples, definiendo el tipo de datos Tuple, que representa pares de valores.
  • purescript-either, definiendo el tipo de datos Either, 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:

1 class Show a where
2   show :: a -> String

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:

1 instance showBoolean :: Show Boolean where
2   show true = "true"
3   show false = "false"

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:

 1 > import Prelude
 2 
 3 > show true
 4 "true"
 5 
 6 > show 1.0
 7 "1.0"
 8 
 9 > show "Hello World"
10 "\"Hello World\""

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:

1 > import Data.Tuple
2 
3 > show (Tuple 1 true)
4 "(Tuple 1 true)"
5 
6 > import Data.Maybe
7 
8 > show (Just "testing")
9 "(Just \"testing\")"

Si intentamos mostrar un valor del tipo Data.Either obtenemos un mensaje de error interesante:

1 > import Data.Either
2 > show (Left 10)
3 
4 The inferred type
5 
6     forall a. Show a => String
7 
8 has type variables which are not mentioned in the body of the type. Consider add\
9 ing a type annotation.

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:

1 > show (Left 10 :: Either Int String)
2 "(Left 10)"

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:

1 > import Prelude
2 > show $ \n -> n + 1
3 
4 No type class instance was found for
5 
6   Data.Show.Show (Int -> Int)

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.

1 class Eq a where
2   eq :: a -> a -> Boolean

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:

1 > 1 == 2
2 false
3 
4 > "Test" == "Test"
5 true

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.

1 data Ordering = LT | EQ | GT
2 
3 class Eq a <= Ord a where
4   compare :: a -> a -> Ordering

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:

1 > compare 1 2
2 LT
3 
4 > compare "A" "Z"
5 LT

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.

1 class EuclideanRing a <= Field a

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:

1 class Semigroup a where
2   append :: a -> a -> a

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:

1 class Semigroup m <= Monoid m where
2   mempty :: m

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:

1 > import Data.Monoid
2 > import Data.Foldable
3 
4 > foldl append mempty ["Hello", " ", "World"]  
5 "Hello World"
6 
7 > foldl append mempty [[1, 2, 3], [4, 5], [6]]
8 [1,2,3,4,5,6]

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:

1 class Foldable f where
2   foldr :: forall a b. (a -> b -> b) -> b -> f a -> b
3   foldl :: forall a b. (b -> a -> b) -> b -> f a -> b
4   foldMap :: forall a m. Monoid m => (a -> m) -> f a -> m

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:

1 > import Data.Foldable
2 
3 > foldMap show [1, 2, 3, 4, 5]
4 "12345"

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:

1 class Functor f where
2   map :: forall a b. (a -> b) -> f a -> f b

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:

 1 > import Prelude
 2 
 3 > map (\n -> n < 3) [1, 2, 3, 4, 5]
 4 [true, true, false, false, false]
 5 
 6 > import Data.Maybe
 7 > import Data.String (length)
 8 
 9 > map length (Just "testing")
10 (Just 7)

¿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.

1 threeAreEqual :: forall a. Eq a => a -> a -> a -> Boolean
2 threeAreEqual a1 a2 a3 = a1 == a2 && a2 == a3

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:

1 showCompare :: forall a. Ord a => Show a => a -> a -> String
2 showCompare a1 a2 | a1 < a2 =
3   show a1 <> " is less than " <> show a2
4 showCompare a1 a2 | a1 > a2 =
5   show a1 <> " is greater than " <> show a2
6 showCompare a1 a2 =
7   show a1 <> " is equal to " <> show a2

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 tipo a al tipo b, mientras que
  • a => b aplica la restricción a al tipo b.

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:

1 > import Prelude
2 
3 > :type \x -> x + x
4 forall a. Semiring a => a -> a

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 Ints como con Numbers.

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:

 1 module Overlapped where
 2 
 3 import Prelude
 4 
 5 data T = T
 6 
 7 instance showT1 :: Show T where
 8   show _ = "Instance 1"
 9 
10 instance showT2 :: Show T where
11   show _ = "Instance 2"

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:

1 Overlapping instances found for Prelude.Show T

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.

1 instance showArray :: Show a => Show (Array a) where
2   ...

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 =>:

1 instance showEither :: (Show a, Show b) => Show (Either a b) where
2   ...

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.

 1 module Stream where
 2 
 3 import Data.Array as Array
 4 import Data.Maybe (Maybe)
 5 import Data.String as String
 6 
 7 class Stream stream element where
 8   uncons :: stream -> Maybe { head :: element, tail :: stream }
 9 
10 instance streamArray :: Stream (Array a) a where
11   uncons = Array.uncons
12 
13 instance streamString :: Stream String Char where
14   uncons = String.uncons

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:

1 import Prelude
2 import Data.Monoid (class Monoid, mempty)
3 
4 foldStream :: forall l e m. Stream l e => Monoid m => (e -> m) -> l -> m
5 foldStream f list =
6   case uncons list of
7     Nothing -> mempty
8     Just cons -> f cons.head <> foldStream f cons.tail

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:

1 genericTail xs = map _.tail (uncons xs)

Esto nos da un mensaje de error algo confuso:

1 The inferred type
2 
3   forall stream a. Stream stream a => stream -> Maybe stream
4 
5 has type variables which are not mentioned in the body of the type. Consider add\
6 ing a type annotation.

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:

1 > map _.tail (uncons "testing")
2 
3 The inferred type
4 
5   forall a. Stream String a => Maybe String
6 
7 has type variables which are not mentioned in the body of the type. Consider add\
8 ing a type annotation.

Aquí, podemos esperar que el compilador elija la instancia streamString. Después de todo, una String es un flujo de Chars, 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:

1 class Stream stream element | stream -> element where
2   uncons :: stream -> Maybe { head :: element, tail :: stream }

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:

1 > :type genericTail
2 forall stream element. Stream stream element => stream -> Maybe stream
3 
4 > genericTail "testing"
5 (Just "esting")

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:

1 head :: forall a. Partial => Array a -> a
2 
3 tail :: forall a. Partial => Array a -> Array a

¡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:

1 > head [1, 2, 3]
2 
3 No type class instance was found for
4 
5   Prim.Partial

En su lugar, podemos volver a publicar la restricción Partial para cualquier función que haga uso de funciones parciales:

1 secondElement :: forall a. Partial => Array a -> a
2 secondElement xs = head (tail xs)

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:

1 unsafePartial :: forall a. (Partial => a) -> a

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:

1 newtype HashCode = HashCode Int
2 
3 hashCode :: Int -> HashCode
4 hashCode h = HashCode (h `mod` 65535)
5 
6 class Eq a <= Hashable a where
7   hash :: a -> HashCode

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:

1 combineHashes :: HashCode -> HashCode -> HashCode
2 combineHashes (HashCode h1) (HashCode h2) = hashCode (73 * h1 + 51 * h2)

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:

1 hashEqual :: forall a. Hashable a => a -> a -> Boolean
2 hashEqual = eq `on` hash

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:

1 instance hashInt :: Hashable Int where
2   hash = hashCode

Podemos también definir una instancia simple para valores Boolean usando ajuste de patrones:

1 instance hashBoolean :: Hashable Boolean where
2   hash false = hashCode 0
3   hash true  = hashCode 1

Con una instancia para resumir enteros, podemos crear una instancia para resumir Chars usando la función toCharCode de Data.Char:

1 instance hashChar :: Hashable Char where
2   hash = hash <<< toCharCode

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:

1 instance hashArray :: Hashable a => Hashable (Array a) where
2   hash = foldl combineHashes (hashCode 0) <<< map hash

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 Strings, convirtiendo una String en un array de Chars:

1 instance hashString :: Hashable String where
2   hash = hash <<< toCharArray

¿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 como Applicative.
  • 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:

1 address :: String -> String -> String -> Address

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:

1 > import Data.AddressBook
2 
3 > address "123 Fake St." "Faketown" "CA"
4 Address { street: "123 Fake St.", city: "Faketown", state: "CA" }

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:

 1 > import Data.Maybe
 2 > address (Just "123 Fake St.") Nothing (Just "CA")
 3 
 4 Could not match type
 5 
 6   Maybe String
 7 
 8 with type
 9 
10   String

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:

1 > import Control.Apply
2 > lift3 address (Just "123 Fake St.") Nothing (Just "CA")
3 
4 Nothing

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:

1 > lift3 address (Just "123 Fake St.") (Just "Faketown") (Just "CA")
2 
3 Just (Address { street: "123 Fake St.", city: "Faketown", state: "CA" })

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:

1 > :type lift3
2 forall a b c d f. Apply f => (a -> b -> c -> d) -> f a -> f b -> f c -> f d

En el ejemplo de Maybe anterior, el constructor de tipo f es Maybe, de manera que lift3 se especializa al siguiente tipo:

1 forall a b c d. (a -> b -> c -> d) -> Maybe a -> Maybe b -> Maybe c -> Maybe d

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:

1 class Functor f where
2   map :: forall a b. (a -> b) -> f a -> f b
3 
4 class Functor f <= Apply f where
5   apply :: forall a b. f (a -> b) -> f a -> f b

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:

1 instance functorMaybe :: Functor Maybe where
2   map f (Just a) = Just (f a)
3   map f Nothing  = Nothing
4 
5 instance applyMaybe :: Apply Maybe where
6   apply (Just f) (Just x) = Just (f x)
7   apply _        _        = Nothing

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:

1 lift3 :: forall a b c d f
2        . Apply f
3       => (a -> b -> c -> d)
4       -> f a
5       -> f b
6       -> f c
7       -> f d
8 lift3 f x y z = f <$> x <*> y <*> z

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 <*>:

1 > address <$> Just "123 Fake St." <*> Just "Faketown" <*> Just "CA"
2 Just (Address { street: "123 Fake St.", city: "Faketown", state: "CA" })
3 
4 > address <$> Just "123 Fake St." <*> Nothing <*> Just "CA"
5 Nothing

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:

1 class Apply f <= Applicative f where
2   pure :: forall a. a -> f a

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:

1 instance applicativeMaybe :: Applicative Maybe where
2   pure x = Just x

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:

1 > import Prelude
2 
3 > fullName first middle last = last <> ", " <> first <> " " <> middle
4 
5 > fullName "Phillip" "A" "Freeman"
6 Freeman, Phillip A

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:

1 > import Data.Maybe
2 
3 > fullName <$> Just "Phillip" <*> Just "A" <*> Just "Freeman"
4 Just ("Freeman, Phillip A")
5 
6 > fullName <$> Just "Phillip" <*> Nothing <*> Just "Freeman"
7 Nothing

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:

1 > :paste
2 … withError Nothing  err = Left err
3 … withError (Just a) _   = Right a
4 … ^D

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:

1 > :paste
2 … fullNameEither first middle last =
3 …   fullName <$> (first  `withError` "First name was missing")
4 …            <*> (middle `withError` "Middle name was missing")
5 …            <*> (last   `withError` "Last name was missing")
6 … ^D
7 
8 > :type fullNameEither
9 Maybe String -> Maybe String -> Maybe String -> Either String String

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:

1 > fullNameEither (Just "Phillip") (Just "A") (Just "Freeman")
2 (Right "Freeman, Phillip A")
3 
4 > fullNameEither (Just "Phillip") Nothing (Just "Freeman")
5 (Left "Middle name was missing")
6 
7 > fullNameEither (Just "Phillip") (Just "A") Nothing
8 (Left "Last name was missing")

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:

1 > fullNameEither Nothing Nothing Nothing
2 (Left "First name was missing")

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:

1 combineList :: forall f a. Applicative f => List (f a) -> f (List a)

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:

1 combineList Nil = pure Nil

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:

1 combineList (Cons x xs) = Cons <$> x <*> combineList xs

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:

1 > import Data.List
2 > import Data.Maybe
3 
4 > combineList (fromFoldable [Just 1, Just 2, Just 3])
5 (Just (Cons 1 (Cons 2 (Cons 3 Nil))))
6 
7 > combineList (fromFoldable [Just 1, Nothing, Just 2])
8 Nothing

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:

1 address :: String -> String -> String -> Address
2 
3 phoneNumber :: PhoneType -> String -> PhoneNumber
4 
5 person :: String -> String -> Address -> Array PhoneNumber -> Person

Donde PhoneType se define como un tipo de datos algebraico:

1 data PhoneType = HomePhone | WorkPhone | CellPhone | OtherPhone

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:

1 examplePerson :: Person
2 examplePerson =
3   person "John" "Smith"
4          (address "123 Fake St." "FakeTown" "CA")
5   	     [ phoneNumber HomePhone "555-555-5555"
6          , phoneNumber CellPhone "555-555-0000"
7   	     ]

Prueba este valor en PSCi (hemos dado formato al resultado):

 1 > import Data.AddressBook
 2 
 3 > examplePerson
 4 Person
 5   { firstName: "John",
 6   , lastName: "Smith",
 7   , address: Address
 8       { street: "123 Fake St."
 9       , city: "FakeTown"
10       , state: "CA"
11       },
12   , phones: [ PhoneNumber
13                 { type: HomePhone
14                 , number: "555-555-5555"
15                 }
16             , PhoneNumber
17                 { type: CellPhone
18                 , number: "555-555-0000"
19                 }
20             ]
21   }  

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:

 1 nonEmpty :: String -> Either String Unit
 2 nonEmpty "" = Left "Field cannot be empty"
 3 nonEmpty _  = Right unit
 4 
 5 validatePerson :: Person -> Either String Person
 6 validatePerson (Person o) =
 7   person <$> (nonEmpty o.firstName *> pure o.firstName)
 8          <*> (nonEmpty o.lastName  *> pure o.lastName)
 9          <*> pure o.address
10          <*> pure o.phones

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;

1 > validatePerson $ person "" "" (address "" "" "") []
2 (Left "Field cannot be empty")

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 Strings 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:

 1 type Errors = Array String
 2 
 3 nonEmpty :: String -> String -> V Errors Unit
 4 nonEmpty field "" = invalid ["Field '" <> field <> "' cannot be empty"]
 5 nonEmpty _     _  = pure unit
 6 
 7 lengthIs :: String -> Number -> String -> V Errors Unit
 8 lengthIs field len value | S.length value /= len =
 9   invalid ["Field '" <> field <> "' must have length " <> show len]
10 lengthIs _     _   _     =
11   pure unit
12 
13 validateAddress :: Address -> V Errors Address
14 validateAddress (Address o) =
15   address <$> (nonEmpty "Street" o.street *> pure o.street)
16           <*> (nonEmpty "City"   o.city   *> pure o.city)
17           <*> (lengthIs "State" 2 o.state *> pure o.state)

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:

 1 > import Data.AddressBook
 2 > import Data.AddressBook.Validation
 3 
 4 > validateAddress $ address "" "" ""
 5 (Invalid [ "Field 'Street' cannot be empty"
 6          , "Field 'City' cannot be empty"
 7          , "Field 'State' must have length 2"
 8          ])
 9 
10 > validateAddress $ address "" "" "CA"
11 (Invalid [ "Field 'Street' cannot be empty"
12          , "Field 'City' cannot be empty"
13          ])

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:

1 matches :: String -> R.Regex -> String -> V Errors Unit
2 matches _     regex value | R.test regex value =
3   pure unit
4 matches field _     _     =
5   invalid ["Field '" <> field <> "' did not match the required format"]

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:

1 validatePhoneNumber :: PhoneNumber -> V Errors PhoneNumber
2 validatePhoneNumber (PhoneNumber o) =
3   phoneNumber <$> pure o."type"
4               <*> (matches "Number" phoneNumberRegex o.number *> pure o.number)

De nuevo, intenta ejecutar este validador contra entradas válidas e inválidas en PSCi:

1 > validatePhoneNumber $ phoneNumber HomePhone "555-555-5555"
2 Valid (PhoneNumber { type: HomePhone, number: "555-555-5555" })
3 
4 > validatePhoneNumber $ phoneNumber HomePhone "555.555.5555"
5 Invalid (["Field 'Number' did not match the required format"])

Funtores transitables (traversable functors)

El validador restante es validatePerson, que combina los validadores que hemos visto hasta ahora para validar una estructura Person completa:

 1 arrayNonEmpty :: forall a. String -> Array a -> V Errors Unit
 2 arrayNonEmpty field [] =
 3   invalid ["Field '" <> field <> "' must contain at least one value"]
 4 arrayNonEmpty _     _  =
 5   pure unit
 6 
 7 validatePerson :: Person -> V Errors Person
 8 validatePerson (Person o) =
 9   person <$> (nonEmpty "First Name" o.firstName *>
10               pure o.firstName)
11          <*> (nonEmpty "Last Name"  o.lastName  *>
12               pure o.lastName)
13 	       <*> validateAddress o.address
14          <*> (arrayNonEmpty "Phone Numbers" o.phones *>
15               traverse validatePhoneNumber o.phones)

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:

1 class (Functor t, Foldable t) <= Traversable t where
2   traverse :: forall a b f. Applicative f => (a -> f b) -> t a -> f (t b)
3   sequence :: forall a f. Applicative f => t (f a) -> f (t a)

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:

1 traverse :: forall a b f. Applicative f => (a -> f b) -> Array a -> f (Array b)

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:

1 traverse :: forall a b. (a -> V Errors b) -> Array a -> V Errors (Array b)

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:

1 sequence :: forall a f. Applicative f => t (f a) -> f (t a)

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:

1 combineList :: forall f a. Applicative f => List (f a) -> f (List a)

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:

1 -- traverse :: forall a b f. Applicative f => (a -> f b) -> List a -> f (List b)
2 traverse _ Nil = pure Nil
3 traverse f (Cons x xs) = Cons <$> f x <*> traverse f xs

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:

 1 > import Data.Maybe
 2 > import Data.Traversable
 3 
 4 > traverse (nonEmpty "Example") Nothing
 5 (Valid Nothing)
 6 
 7 > traverse (nonEmpty "Example") (Just "")
 8 (Invalid ["Field 'Example' cannot be empty"])
 9 
10 > traverse (nonEmpty "Example") (Just "Testing")
11 (Valid (Just unit))

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:

1 f <$> parallel computation1
2   <*> parallel computation2

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ónada Eff, 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 e y es n 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:

 1 import Prelude
 2 
 3 import Control.Plus (empty)
 4 import Data.Array ((..))
 5 
 6 countThrows :: Int -> Array (Array Int)
 7 countThrows n = do
 8   x <- 1 .. 6
 9   y <- 1 .. 6
10   if x + y == n
11     then pure [x, y]
12     else empty

Podemos ver que esta función es correcta en PSCi:

1 > countThrows 10
2 [[4,6],[5,5],[6,4]]
3 
4 > countThrows 12  
5 [[6,6]]

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

1 child :: XML -> String -> Maybe XML

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:

1 userCity :: XML -> Maybe XML
2 userCity root = do
3   prof <- child root "profile"
4   addr <- child prof "address"
5   city <- child addr "city"
6   pure city

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:

1 class Apply m <= Bind m where
2   bind :: forall a b. m a -> (a -> m b) -> m b
3 
4 class (Applicative m, Bind m) <= Monad m

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:

1 instance bindArray :: Bind Array where
2   bind xs f = concatMap f xs

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:

1 instance bindMaybe :: Bind Maybe where
2   bind Nothing  _ = Nothing
3   bind (Just a) f = f a

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.

1 do value <- someComputation
2    whatToDoNext

Cada vez que el compilador de PureScript ve este patrón, reemplaza el código por esto:

1 bind someComputation \value -> whatToDoNext

o, escrito de manera infija:

1 someComputation >>= \value -> whatToDoNext

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:

1 userCity :: XML -> Maybe XML
2 userCity root =
3   child root "profile" >>= \prof ->
4     child prof "address" >>= \addr ->
5       child addr "city" >>= \city ->
6         pure city

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:

1 do
2   x <- expr
3   pure x

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:

1 do
2   x <- pure y
3   next

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:

1 c1 = do
2   y <- do
3     x <- m1
4     m2
5   m3

es equivalente a este código:

1 c2 = do
2   x <- m1
3   y <- m2
4   m3

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:

1 c3 = do
2   x <- m1
3   do
4     y <- m2
5     m3

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:

1 foldM :: forall m a b
2        . Monad m
3       => (a -> b -> m a)
4       -> a
5       -> List b
6       -> m a

Fíjate en que esto es lo mismo que el tipo de foldl, excepto por la aparición de la mónada m:

1 foldl :: forall a b
2        . (a -> b -> a)
3       -> a
4       -> List b
5       -> a

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:

1 foldM _ a Nil = pure a

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:

1 foldM f a (b : bs) = do
2   a' <- f a b
3   foldM f a' bs

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:

1 safeDivide :: Int -> Int -> Maybe Int
2 safeDivide _ 0 = Nothing
3 safeDivide a b = Just (a / b)

Podemos entonces usar foldM para expresar división segura iterada:

1 > import Data.List
2 
3 > foldM safeDivide 100 (fromFoldable [5, 2, 2])
4 (Just 5)
5 
6 > foldM safeDivide 100 (fromFoldable [2, 0, 4])
7 Nothing

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:

1 ap :: forall m a b. Monad m => m (a -> b) -> m a -> m b
2 ap mf ma = do
3   f <- mf
4   a <- ma
5   pure (f a)

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:

1 userCity :: XML -> Maybe XML
2 userCity root = do
3   prof <- child root "profile"
4   addr <- child prof "address"
5   city <- child addr "city"
6   pure city

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:

 1 module Main where
 2 
 3 import Prelude
 4 
 5 import Control.Monad.Eff.Random (random)
 6 import Control.Monad.Eff.Console (logShow)
 7 
 8 main = do
 9   n <- random
10   logShow n

Si salvamos este fichero como src/Main.purs, podemos compilarlo y ejecutarlo usando Pulp:

1 $ pulp run

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:

1 > import Main
2 
3 > :type main
4 forall eff. Eff (console :: CONSOLE, random :: RANDOM | eff) Unit

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:

1 fullName person = person.firstName <> " " <> person.lastName

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:

1 forall r. { firstName :: String, lastName :: String | r } -> String

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:

1 > firstName { firstName: "Phil", lastName: "Freeman", location: "Los Angeles" }
2 Phil Freeman

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:

1 forall eff1. Eff (random :: RANDOM | eff1) Number

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:

1 forall eff2. Show a => a -> Eff (console :: CONSOLE | eff2) Unit

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:

1 > import Control.Monad.Eff
2 
3 > :kind Eff
4 # Control.Monad.Eff.Effect -> Type -> Type

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:

1 > import Control.Monad.Eff.Console
2 > import Control.Monad.Eff.Random
3 
4 > :kind CONSOLE
5 Control.Monad.Eff.Effect
6 
7 > :kind RANDOM
8 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:

1 forall eff. Eff (console :: CONSOLE, random :: RANDOM | eff) Unit

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:

1 fullName :: forall r. { firstName :: String, lastName :: String | r } -> String
2 fullName person = person.firstName <> " " <> person.lastName

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:

1 fullName :: forall r. Record (firstName :: String, lastName :: String | r) -> St\
2 ring

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:

1 > :kind Record
2 # Type -> Type

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

1 main :: Eff (console :: CONSOLE, random :: RANDOM) Unit
2 main = do
3   n <- random
4   print n

(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:

1 throwException :: forall a eff
2                 . Error
3                -> Eff (exception :: EXCEPTION | eff) a
4 
5 catchException :: forall a eff
6                 . (Error -> Eff eff a)
7                -> Eff (exception :: EXCEPTION | eff) a
8                -> Eff eff a

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:

1 readConfig :: forall eff. Eff (exception :: EXCEPTION | eff) Config

Entonces, en la función main, podemos usar catchException para gestionar el efecto EXCEPTION anotando el error y devolviendo una configuración por defecto:

1 main = do
2     config <- catchException printException readConfig
3     runApplication config
4   where
5     printException e = do
6       log (message e)
7       pure defaultConfig

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:

1 type Pure a = Eff () a
2 
3 runPure :: forall a. Pure a -> a

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:

1 newSTRef :: forall a h eff. a -> Eff (st :: ST h | eff) (STRef h a)
2 
3 readSTRef :: forall a h eff. STRef h a -> Eff (st :: ST h | eff) a
4 
5 writeSTRef :: forall a h eff. STRef h a -> a -> Eff (st :: ST h | eff) a
6 
7 modifySTRef :: forall a h eff. STRef h a -> (a -> a) -> Eff (st :: ST h | eff) a

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:

 1 import Prelude
 2 
 3 import Control.Monad.Eff (Eff, forE)
 4 import Control.Monad.ST (ST, newSTRef, readSTRef, modifySTRef)
 5 
 6 simulate :: forall eff h. Number -> Number -> Int -> Eff (st :: ST h | eff) Numb\
 7 er
 8 simulate x0 v0 time = do
 9   ref <- newSTRef { x: x0, v: v0 }
10   forE 0 (time * 1000) \_ -> do
11     modifySTRef ref \o ->
12       { v: o.v - 9.81 * 0.001
13       , x: o.x + o.v * 0.001
14       }
15     pure unit
16   final <- readSTRef ref
17   pure final.x

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:

1 runST :: forall a eff. (forall h. Eff (st :: ST h | eff) a) -> Eff eff a

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:

1 simulate' :: Number -> Number -> Number -> Number
2 simulate' x0 v0 time = runPure (runST (simulate x0 v0 time))

Puedes incluso intentar ejecutar la función en PSCi:

 1 > import Main
 2 
 3 > simulate' 100.0 0.0 0.0
 4 100.00
 5 
 6 > simulate' 100.0 0.0 1.0
 7 95.10
 8 
 9 > simulate' 100.0 0.0 2.0
10 80.39
11 
12 > simulate' 100.0 0.0 3.0
13 55.87
14 
15 > simulate' 100.0 0.0 4.0
16 21.54

De hecho, si expandimos la definición de simulate en la llamada a runST como sigue:

 1 simulate :: Number -> Number -> Int -> Number
 2 simulate x0 v0 time = runPure $ runST do
 3   ref <- newSTRef { x: x0, v: v0 }
 4   forE 0 (time * 1000) \_ -> do
 5     modifySTRef ref \o ->  
 6       { v: o.v - 9.81 * 0.001
 7       , x: o.x + o.v * 0.001  
 8       }
 9     pure unit  
10   final <- readSTRef ref
11   pure final.x

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:

 1 var ref = { x: x0, v: v0 };
 2 
 3 Control_Monad_Eff.forE(0)(time * 1000 | 0)(function (i) {
 4   return function __do() {
 5     ref = (function (o) {
 6       return {
 7         v: o.v - 9.81 * 1.0e-3,
 8         x: o.x + o.v * 1.0e-3
 9       };
10     })(ref);
11     return Prelude.unit;
12   };
13 })();
14 
15 return ref.x;

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:

Hay también bibliotecas PureScript que construyen abstracciones sobre estas bibliotecas, como

  • purescript-thermite, que se basa en purescript-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:

1 <script type="text/javascript" src="../dist/Main.js"></script>

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:

1 $ npm install # Install React
2 $ mkdir dist/
3 $ pulp browserify --to dist/Main.js

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:

1 main :: Eff (console :: CONSOLE, dom :: DOM) Unit

Primero, main registra un mensaje de estado en la consola:

1 main = void do
2   log "Rendering address book component"

Después, main usa la API DOM para obtener una referencia (doc) al cuerpo del documento:

1   doc <- window >>= document

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:

1   w <- window
2   doc <- document w

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:

 1 createClass
 2   :: forall props state eff
 3    . ReactSpec props state eff
 4   -> ReactClass props
 5 
 6 type Render props state eff
 7    = ReactThis props state
 8   -> Eff ( props :: ReactProps
 9          , refs :: ReactRefs Disallowed
10          , state :: ReactState ReadOnly
11          | eff
12          ) ReactElement
13 
14 spec
15   :: forall props state eff
16    . state
17   -> Render props state eff
18   -> ReactSpec props state eff

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 tipo ReactThis), y devuelve un ReactElement en la mónada Eff. Un ReactElement 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ón spec.
  • 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:

 1 newtype AppState = AppState
 2   { person :: Person
 3   , errors :: Errors
 4   }
 5 
 6 initialState :: AppState
 7 initialState = AppState
 8   { person: examplePerson
 9   , errors: []
10   }

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:

1 addressBook :: forall props. ReactClass props

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:

 1 readState
 2   :: forall props state access eff
 3    . ReactThis props state
 4   -> Eff ( state :: ReactState ( read :: Read
 5                                | access
 6                                )
 7          | eff
 8          ) state
 9 
10 writeState
11   :: forall props state access eff
12    . ReactThis props state
13   -> state
14   -> Eff ( state :: ReactState ( write :: Write
15                                | access
16                                )
17          | eff
18          ) state

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:

1 addressBook = createClass $ spec initialState \ctx -> do
2   AppState { person: Person person@{ homeAddress: Address address }
3            , errors
4            } <- readState ctx

Fíjate en que:

  • El nombre ctx se refiere a la referencia a ReactThis, 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 ReactElements.

1 renderValidationError :: String -> ReactElement
2 renderValidationError err = D.li' [ D.text err ]
3 
4 renderValidationErrors :: Errors -> Array ReactElement
5 renderValidationErrors [] = []
6 renderValidationErrors xs =
7   [ D.div [ P.className "alert alert-danger" ]
8           [ D.ul' (map renderValidationError xs) ]
9   ]

En purescript-react’ los ReactElements 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:

 1 formField
 2   :: String
 3   -> String
 4   -> String
 5   -> (String -> Person)
 6   -> ReactElement
 7 formField name hint value update =
 8   D.div [ P.className "form-group" ]
 9         [ D.label [ P.className "col-sm-2 control-label" ]
10                   [ D.text name ]
11         , D.div [ P.className "col-sm-3" ]
12                 [ D.input [ P._type "text"
13                           , P.className "form-control"
14                           , P.placeholder hint
15                           , P.value value
16                           , P.onChange (updateAppState ctx update)
17                           ] []
18                 ]
19         ]

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:

1 updateAppState
2   :: forall props eff
3    . ReactThis props AppState
4   -> (String -> Person)
5   -> Event
6   -> Eff ( console :: CONSOLE
7          , state :: ReactState ReadWrite
8          | eff
9          ) Unit

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:

1   for_ (valueOf e) \s -> do
2     let newPerson = update s

Entonces ejecuta la función de validación y actualiza el estado de la componente (usando writeState) en consecuencia:

 1     log "Running validators"
 2     case validatePerson' newPerson of
 3       Left errors ->
 4         writeState ctx (AppState { person: newPerson
 5                                  , errors: errors
 6                                  })
 7       Right _ ->
 8         writeState ctx (AppState { person: newPerson
 9                                  , errors: []
10                                  })

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:

1 main = void $ unsafePartial do
2   Just canvas <- getCanvasElementById "canvas"
3   ctx <- getContext2D 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:

1 getCanvasElementById :: forall eff. String -> Eff (canvas :: CANVAS | eff) (Mayb\
2 e CanvasElement)
3 
4 getContext2D :: forall eff. CanvasElement -> Eff (canvas :: CANVAS | eff) Contex\
5 t2D

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:

1   setFillStyle "#0000FF" ctx

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:

1 fillPath :: forall eff a. Context2D ->
2                           Eff (canvas :: CANVAS | eff) a ->
3                           Eff (canvas :: CANVAS | eff) a

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.

1   fillPath ctx $ rect ctx
2     { x: 250.0
3     , y: 250.0
4     , w: 100.0
5     , h: 100.0
6     }

Construye el ejemplo del rectángulo proporcionando Example.Rectangle como nombre del módulo principal:

1 $ mkdir dist/
2 $ pulp build -O --main Example.Rectangle --to dist/Main.js

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:

1 type Rectangle =
2   { x :: Number
3   , y :: Number
4   , w :: Number
5   , h :: Number
6   }

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:

1 type Arc =
2   { x     :: Number
3   , y     :: Number
4   , r     :: Number
5   , start :: Number
6   , end   :: Number
7   }

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:

1   fillPath ctx $ arc ctx
2     { x      : 300.0
3     , y      : 300.0
4     , r      : 50.0
5     , start  : Math.pi * 5.0 / 8.0
6     , end    : Math.pi * 2.0
7     }

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:

 1 translate
 2   :: forall r
 3    . Number
 4   -> Number
 5   -> { x :: Number, y :: Number | r }
 6   -> { x :: Number, y :: Number | r }
 7 translate dx dy shape = shape
 8   { x = shape.x + dx
 9   , y = shape.y + dy
10   }

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:

1   setFillStyle "#FF0000" ctx
2 
3   fillPath ctx $ do
4     moveTo ctx 300.0 260.0
5     lineTo ctx 260.0 340.0
6     lineTo ctx 340.0 340.0
7     closePath ctx

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:

1 $ pulp build -O --main Example.Shapes --to dist/Main.js

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:

1   setFillStyle "#FF0000" ctx
2   setStrokeStyle "#000000" ctx

A continuación, el código usa la función for_ para iterar por los enteros entre 0 y 100:

1   for_ (1 .. 100) \_ -> do

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.

1     x <- random
2     y <- random
3     r <- random

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:

1     let path = arc ctx
2          { x     : x * 600.0
3          , y     : y * 600.0
4          , r     : r * 50.0
5          , start : 0.0
6          , end   : Math.pi * 2.0
7          }
8     fillPath ctx path
9     strokePath ctx path

Construye este ejemplo especificando el módulo Example.Random como módulo principal:

1 $ pulp build -O --main Example.Random --to dist/Main.js

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:

 1 translate :: forall eff
 2            . TranslateTransform
 3           -> Context2D
 4           -> Eff (canvas :: Canvas | eff) Context2D
 5 
 6 rotate    :: forall eff
 7            . Number
 8           -> Context2D
 9           -> Eff (canvas :: Canvas | eff) Context2D
10 
11 scale     :: forall eff
12            . ScaleTransform
13           -> Context2D
14           -> Eff (canvas :: CANVAS | eff) Context2D
15 
16 transform :: forall eff
17            . Transform
18           -> Context2D
19           -> Eff (canvas :: CANVAS | eff) Context2D

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:

1 transformations ctx = do
2   translate { translateX: 10.0, translateY: 10.0 } ctx
3   scale { scaleX: 2.0, scaleY: 2.0 } ctx
4   rotate (Math.pi / 2.0) ctx
5 
6   renderScene

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:

1 save
2   :: forall eff
3    . Context2D
4   -> Eff (canvas :: CANVAS | eff) Context2D
5 
6 restore
7   :: forall eff
8    . Context2D
9   -> Eff (canvas :: CANVAS | eff) Context2D

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:

1 rotated ctx render = do
2   save ctx
3   rotate Math.pi ctx
4   render
5   restore ctx

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:

1 withContext
2   :: forall eff a
3    . Context2D
4   -> Eff (canvas :: CANVAS | eff) a
5   -> Eff (canvas :: CANVAS | eff) a          

Podríamos reescribir la función rotated anterior usando withContext como sigue:

1 rotated ctx render =
2   withContext ctx do
3     rotate Math.pi ctx
4     render

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:

1 > import Control.Monad.Eff.Ref
2 
3 > :kind Ref
4 Type -> Type
5 
6 > :kind REF
7 Control.Monad.Eff.Effect

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:

1   clickCount <- newRef 0

Dentro del gestor de pulsación de ratón, la acción modifyRef se usa para actualizar la cuenta de pulsaciones:

1     modifyRef clickCount (\count -> count + 1)

La acción readRef se usa para leer la nueva cuenta de pulsaciones:

1     count <- readRef clickCount

En la función render, usamos el contador de pulsaciones para determinar qué transformación aplicar a un rectángulo:

 1     withContext ctx do
 2       let scaleX = Math.sin (toNumber count * Math.pi / 4.0) + 1.5
 3       let scaleY = Math.sin (toNumber count * Math.pi / 6.0) + 1.5
 4 
 5       translate { translateX: 300.0, translateY:  300.0 } ctx
 6       rotate (toNumber count * Math.pi / 18.0) ctx
 7       scale { scaleX: scaleX, scaleY: scaleY } ctx
 8       translate { translateX: -100.0, translateY: -100.0 } ctx
 9 
10       fillPath ctx $ rect ctx
11         { x: 0.0
12         , y: 0.0
13         , w: 200.0
14         , h: 200.0
15         }

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:

1 $ pulp build -O --main Example.Refs --to dist/Main.js

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:

1 L -> L
2 R -> R
3 F -> FLFRRFLF

Si comenzamos con la secuencia inicial “FRRFRRFRR” e iteramos, obtenemos la siguiente secuencia:

1 FRRFRRFRR
2 FLFRRFLFRRFLFRRFLFRRFLFRRFLFRR
3 FLFRRFLFLFLFRRFLFRRFLFRRFLFLFLFRRFLFRRFLFRRFLF...

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:

1 data Alphabet = L | R | F

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):

1 type Sentence = Array Alphabet
2 
3 initial :: Sentence
4 initial = [F, R, R, F, R, R, F, R, R]

Nuestras reglas de producción se pueden expresar como una función de Alphabet a Sentence como sigue:

1 productions :: Alphabet -> Sentence
2 productions L = [L]
3 productions R = [R]
4 productions F = [F, L, F, R, R, F, L, F]

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:

1 forall eff. Sentence
2          -> (Alphabet -> Sentence)
3          -> (Alphabet -> Eff (canvas :: Canvas | eff) Unit)
4          -> Int
5          -> Eff (canvas :: CANVAS | eff) Unit

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:

1 forall a eff. Array a
2            -> (a -> Array a)
3            -> (a -> Eff (canvas :: CANVAS | eff) Unit)
4            -> Int
5            -> Eff (canvas :: CANVAS | eff) Unit

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:

1 forall a s eff. Array a
2              -> (a -> Array a)
3              -> (s -> a -> Eff (canvas :: CANVAS | eff) s)
4              -> Int
5              -> s
6              -> Eff (canvas :: CANVAS | eff) s

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:

1 type State =
2   { x :: Number
3   , y :: Number
4   , theta :: Number
5   }

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:

1 initialState :: State
2 initialState = { x: 120.0, y: 200.0, theta: 0.0 }

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:

1 lsystem :: forall a s eff
2          . Array a
3         -> (a -> Array a)
4         -> (s -> a -> Eff (canvas :: CANVAS | eff) s)
5         -> Int
6         -> s
7         -> Eff (canvas :: CANVAS | eff) s
8 lsystem init prod interpret n state = go init n
9   where

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:

1   go s 0 = foldM interpret state s

¿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:

1   go s n = go (concatMap prod s) (n - 1)

¡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:

1 lsystem :: forall a m s
2          . Monad m
3         => Array a
4         -> (a -> Array a)
5         -> (s -> a -> m s)
6         -> Int
7         -> s
8         -> m s

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:

1 interpret :: State -> Alphabet -> Eff (canvas :: CANVAS) State

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:

1 interpret state L = pure $ state { theta = state.theta - Math.pi / 3 }
2 interpret state R = pure $ state { theta = state.theta + Math.pi / 3 }

Para interpretar la letra F (avanzar), podemos calcular la nueva posición de la trayectoria, dibujar un segmento y actualizar el estado como sigue:

1 interpret state F = do
2   let x = state.x + Math.cos state.theta * 1.5
3       y = state.y + Math.sin state.theta * 1.5
4   moveTo ctx state.x state.y
5   lineTo ctx x y
6   pure { x, y, theta: state.theta }

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:

1 strokePath ctx $ lsystem initial productions interpret 5 initialState

Compila el ejemplo del sistema-L usando

1 $ pulp build -O --main Example.LSystem --to dist/Main.js

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:

1 data Scene
2   = Rect Rectangle
3   | Arc Arc
4   | PiecewiseLinear (Array Point)
5   | Transformed Transform Scene
6   | Clipped Rectangle Scene
7   | ...

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:

  1. La biblioteca purescript-foreign que proporciona un tipo de datos y funciones para trabajar con datos no tipados.
  2. La biblioteca purescript-foreign-generic, que añade soporte a la biblioteca purescript-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:

1 module Test where
2 
3 gcd :: Int -> Int -> Int
4 gcd 0 m = m
5 gcd n 0 = n
6 gcd n m
7   | n > m     = gcd (n - m) m
8   | otherwise = gcd (m - n) n

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:

1 var Test = require('Test');
2 Test.gcd(15)(20);

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:

1 var Test = PS.Test;
2 Test.gcd(15)(20);

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,

1 null = []

genera el siguiente JavaScript:

1 var $$null = [];

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,

1 example' = 100

genera el siguiente JavaScript:

1 var example$prime = 100;

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:

1 data ZeroOrOne a = Zero | One a

El compilador PureScript genera el siguiente código:

 1 function One(value0) {
 2     this.value0 = value0;
 3 };
 4 
 5 One.create = function (value0) {
 6     return new One(value0);
 7 };
 8 
 9 function Zero() {
10 };
11 
12 Zero.value = new Zero();

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:

1 data Two a b = Two a b

genera este código JavaScript:

 1 function Two(value0, value1) {
 2     this.value0 = value0;
 3     this.value1 = value1;
 4 };
 5 
 6 Two.create = function (value0) {
 7     return function (value1) {
 8         return new Two(value0, value1);
 9     };
10 };

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:

1 newtype PhoneNumber = PhoneNumber String

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:

1 forall a. a -> a

¿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:

1 id :: forall a. a -> a
2 id a = a

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:

1 function invalid(a) {
2     if (typeof a === 'string') {
3         return "Argument was a string.";
4     } else {
5         return a;
6     }
7 }

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:

1 shout :: forall a. Show a => a -> String
2 shout a = show a <> "!!!"

El JavaScript generado tiene esta pinta:

1 var shout = function (dict) {
2     return function (a) {
3         return show(dict)(a) + "!!!";
4     };
5 };

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:

1 shout(require('Prelude').showNumber)(42);

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:

1 $ node
2 
3 node> encodeURIComponent('Hello World')
4 'Hello%20World'

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:

1 module Data.URI where
2 
3 foreign import encodeURIComponent :: String -> String

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:

1 "use strict";
2 
3 exports.encodeURIComponent = encodeURIComponent;

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:

1 $ pulp repl
2 
3 > import Data.URI
4 > encodeURIComponent "Hello World"
5 "Hello%20World"

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 o undefined 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í:

1 function head(arr) {
2     return arr[0];
3 }

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:

1 foreign import unsafeHead :: forall a. Array a -> a

En nuestro módulo externo JavaScript podemos definir unsafeHead como sigue:

1 exports.unsafeHead = function(arr) {
2   if (arr.length) {
3     return arr[0];
4   } else {
5     throw new Error('unsafeHead: empty array');
6   }
7 };

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:

1 foreign import data Undefined :: Type -> Type

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:

1 exports.head = function(arr) {
2   return arr[0];
3 };

Y en el módulo PureScript:

1 foreign import head :: forall a. Array a -> Undefined a

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:

1 foreign import isUndefined :: forall a. Undefined a -> Boolean

Esto se define fácilmente en nuestro módulo JavaScript como sigue:

1 exports.isUndefined = function(value) {
2   return value === undefined;
3 };

Podemos ahora usar isUndefined y head juntos desde JavaScript para definir una función útil:

1 isEmpty :: forall a. Array a -> Boolean
2 isEmpty = isUndefined <<< head

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:

1 foreign import data Fn2 :: Type -> Type -> Type -> Type

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:

1 import Data.Function.Uncurried
2 
3 divides :: Fn2 Int Int Boolean
4 divides = mkFn2 \n m -> m % n == 0

y podemos aplicar una función de dos argumentos usando la función runFn2:

1 > runFn2 divides 2 10
2 true
3 
4 > runFn2 divides 3 10
5 false

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:

1 exports.divides = function(n, m) {
2     return m % n === 0;
3 };

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:

1 foreign import data Eff :: # Effect -> Type -> Type

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:

1 foreign import random :: forall eff. Eff (random :: RANDOM | eff) Number

La definición de la función random es esta:

1 exports.random = function() {
2   return Math.random();
3 };

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:

1 foreign import log :: forall eff. String -> Eff (console :: CONSOLE | eff) Unit

Y aquí está su definición:

1 exports.log = function (s) {
2   return function () {
3     console.log(s);
4   };
5 };

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:

1 foreign import data RANDOM :: Effect

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:

1 require('Main').main();

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:

1 foreign import data ALERT :: Effect

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:

1 foreign import alert :: forall eff. String -> Eff (alert :: ALERT | eff) Unit

El módulo JavaScript externo es sencillo, definiendo la función alert mediante asignación a la variable exports:

1 "use strict";
2 
3 exports.alert = function(msg) {
4     return function() {
5         window.alert(msg);
6     };
7 };

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:

1 foreign import data STORAGE :: Effect

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:

 1 foreign import getItem
 2   :: forall eff
 3    . String
 4   -> Eff (storage :: STORAGE | eff) Foreign
 5 
 6 foreign import setItem
 7   :: forall eff
 8    . String
 9   -> String
10   -> Eff (storage :: STORAGE | eff) Unit

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:

1 newtype FormData = FormData
2   { firstName  :: String
3   , lastName   :: String
4   , street     :: String
5   , city       :: String
6   , state      :: String
7   , homePhone  :: String
8   , cellPhone  :: String
9   }

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:

1 > import Data.Foreign
2 > import Data.Foreign.Generic
3 > import Data.Foreign.JSON

Una buena forma de obtener un valor Foreign es analizar un documento JSON. purescript-foreign-generic define las siguientes funciones:

1 parseJSON :: String -> F Foreign
2 decodeJSON :: forall a. Decode a => String -> F a

El constructor de tipo F es de hecho un sinónimo de tipo definido en Data.Foreign:

1 type F = Except (NonEmptyList ForeignError)

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):

 1 > import Control.Monad.Except
 2 
 3 > runExcept (decodeJSON "\"Testing\"" :: F String)
 4 Right "Testing"
 5 
 6 > runExcept (decodeJSON "true" :: F Boolean)
 7 Right true
 8 
 9 > runExcept (decodeJSON "[1, 2, 3]" :: F (Array Int))
10 Right [1, 2, 3]

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:

1 > runExcept (decodeJSON "[1, 2, true]" :: F (Array Int))
2 (Left (NonEmptyList (NonEmpty (ErrorAtIndex 2 (TypeMismatch "Int" "Boolean")) Ni\
3 l)))

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:

1 > import Prelude
2 > import Data.Foreign.NullOrUndefined
3 
4 > runExcept (unNullOrUndefined <$> decodeJSON "42" :: F (NullOrUndefined Int))
5 (Right (Just 42))
6 
7 > runExcept (unNullOrUndefined <$> decodeJSON "null" :: F (NullOrUndefined Int))
8 (Right Nothing)

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:

1 > runExcept (map unNullOrUndefined <$> decodeJSON "[1, 2, null]" :: F (Array (Nu\
2 llOrUndefined Int)))
3 (Right [(Just 1),(Just 2),Nothing])

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:

1 derive instance genericFormData :: Generic FormData _

A continuación, símplemente definimos la función decode usando la función genericDecode como sigue:

1 instance decodeFormData :: Decode FormData where
2   decode = genericDecode (defaultOptions { unwrapSingleConstructors = true })

De hecho, podemos también derivar un codificador de la misma forma:

1 instance encodeFormData :: Encode FormData where
2   encode = genericEncode (defaultOptions { unwrapSingleConstructors = true })

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:

1 loadSavedData = do
2   item <- getItem "person"
3 
4   let
5     savedData :: Either (NonEmptyList ForeignError) (Maybe FormData)
6     savedData = runExcept do
7       jsonOrNull <- traverse readString =<< readNullOrUndefined item
8       traverse decodeJSON jsonOrNull

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ón alert que escribimos antes.
  • Si el constructor externo es Right pero el interno es Nothing, entonces getItem devolvió también Nothing, 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ónada Eff 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 interfaz readline de NodeJS
  • purescript-yargs, que proporciona una interfaz aplicativa a la biblioteca de procesamiento de argumentos de línea de comandos yargs

También es necesario instalar el módulo yargs usando NPM:

1 npm install

Cómo jugar

Para ejecutar el proyecto, usa pulp run.

Por defecto verás un mensaje de uso:

1 node ./dist/Main.js -p <player name>
2 
3 Options:
4   -p, --player  Player name  [required]
5   -d, --debug   Use debug mode
6 
7 Missing required arguments: p
8 The player name is required

Proporciona el nombre del jugador usando la opción -p:

1 pulp run -- -p Phil
2 >

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:

 1 $ pulp run -- -p Phil
 2 
 3 > look
 4 You are at (0, 0)
 5 You are in a dark forest. You see a path to the north.
 6 You can see the Matches.
 7 
 8 > take Matches
 9 You now have the Matches
10 
11 > north
12 > look
13 You are at (0, 1)
14 You are in a clearing.
15 You can see the Candle.
16 
17 > take Candle
18 You now have the Candle
19 
20 > inventory
21 You have the Candle.
22 You have the Matches.
23 
24 > use Matches
25 You light the candle.
26 Congratulations, Phil!
27 You win!

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:

1 get    :: forall s.             State s s
2 put    :: forall s. s        -> State s Unit
3 modify :: forall s. (s -> s) -> State s Unit

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:

1 import Data.Foldable (traverse_)
2 import Control.Monad.State
3 import Control.Monad.State.Class
4 
5 sumArray :: Array Number -> State Number Unit
6 sumArray = traverse_ \n -> modify \sum -> sum + n

El módulo Control.Monad.State proporciona tres funciones para ejecutar un cálculo en la mónada State:

1 evalState :: forall s a. State s a -> s -> a
2 execState :: forall s a. State s a -> s -> s
3 runState  :: forall s a. State s a -> s -> Tuple a s

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:

1 > :paste
2 … execState (do
3 …   sumArray [1, 2, 3]
4 …   sumArray [4, 5]
5 …   sumArray [6]) 0
6 … ^D
7 21

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:

1 ask   :: forall r. Reader r r
2 local :: forall r a. (r -> r) -> Reader r a -> Reader r a

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:

1 hasPermission :: String -> Permissions -> Boolean
2 addPermission :: String -> Permissions -> Permissions

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:

1 createUser :: Reader Permissions (Maybe User)
2 createUser = do
3   permissions <- ask
4   if hasPermission "admin" permissions
5     then map Just newUser
6     else pure Nothing

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:

1 runAsAdmin :: forall a. Reader Permissions a -> Reader Permissions a
2 runAsAdmin = local (addPermission "admin")

Podemos entonces escribir una función para crear un nuevo usuario incluso si el usuario no tiene los permisos admin:

1 createUserAsAdmin :: Reader Permissions (Maybe User)
2 createUserAsAdmin = runAsAdmin createUser

Para ejecutar un cálculo en la mónada Reader se puede usar la función runReader para suministrar la configuración global:

1 runReader :: forall r a. Reader r a -> r -> a

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:

1 tell :: forall w a. Monoid w => w -> Writer w Unit

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:

1 gcd :: Int -> Int -> Int
2 gcd n 0 = n
3 gcd 0 m = m
4 gcd n m = if n > m
5             then gcd (n - m) m
6             else gcd n (m - n)

Podemos añadir registro de mensajes a esta función cambiando el tipo de retorno a Writer (Array String) Int:

1 import Control.Monad.Writer
2 import Control.Monad.Writer.Class
3 
4 gcdLog :: Int -> Int -> Writer (Array String) Int

Sólo tenemos que cambiar ligeramente nuestra función para que registre ambas entradas en cada paso:

1     gcdLog n 0 = pure n
2     gcdLog 0 m = pure m
3     gcdLog n m = do
4       tell ["gcdLog " <> show n <> " " <> show m]
5       if n > m
6         then gcdLog (n - m) m
7         else gcdLog n (m - n)

Podemos ejecutar un cálculo en la mónada Writer usando las funciones execWriter o runWriter:

1 execWriter :: forall w a. Writer w a -> w
2 runWriter  :: forall w a. Writer w a -> Tuple a w

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:

1 > import Control.Monad.Writer
2 > import Control.Monad.Writer.Class
3 
4 > runWriter (gcdLog 21 15)
5 Tuple 3 ["gcdLog 21 15","gcdLog 6 15","gcdLog 6 9","gcdLog 6 3","gcdLog 3 3"]

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:

1 > import Control.Monad.State.Trans
2 > :kind StateT
3 Type -> (Type -> Type) -> Type -> Type

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:

1 > :kind StateT String
2 (Type -> Type) -> Type -> Type

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:

1 > :kind StateT String (Either String)
2 Type -> Type

Nos queda un constructor de tipo. El argumento final representa el tipo de retorno, y podemos instanciarlo a Number por ejemplo:

1 > :kind StateT String (Either String) Number
2 Type

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:

1 class MonadTrans t where
2   lift :: forall m a. Monad m => m a -> t m a

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:

 1 import Data.String (drop, take)
 2 
 3 split :: StateT String (Either String) String
 4 split = do
 5   s <- get
 6   case s of
 7     "" -> lift $ Left "Empty string"
 8     _ -> do
 9       put (drop 1 s)
10       pure (take 1 s)

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:

1 > runStateT split "test"
2 Right (Tuple "t" "est")
3 
4 > runStateT split ""
5 Left "Empty string"

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:

1 > runStateT ((<>) <$> split <*> split) "test"
2 (Right (Tuple "te" "st"))

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:

1 class MonadError e m where
2   throwError :: forall a. e -> m a
3   catchError :: forall a. m a -> (e -> m a) -> m a
4 
5 instance monadErrorExceptT :: Monad m => MonadError e (ExceptT e m)
6 
7 runExceptT :: forall e m a. ExceptT e m a -> m (Either e a)

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 que ExceptT modela los errores como una estructura de datos pura
  • El efecto Exception sólo soporta excepciones de un tipo, el tipo Error de JavaScript, mientras que ExceptT 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:

 1 import Control.Monad.Trans
 2 import Control.Monad.Writer
 3 import Control.Monad.Writer.Class
 4 import Control.Monad.Error.Class
 5 import Control.Monad.Except.Trans
 6 
 7 writerAndExceptT :: ExceptT String (Writer (Array String)) String
 8 writerAndExceptT = do
 9   lift $ tell ["Before the error"]
10   throwError "Error!"
11   lift $ tell ["After the error"]
12   pure "Return value"

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:

1 > runWriter $ runExceptT writerAndExceptT
2 Tuple (Left "Error!") ["Before the error"]

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.

 1 type Errors = Array String
 2 
 3 type Log = Array String
 4 
 5 type Parser = StateT String (WriterT Log (ExceptT Errors Identity))
 6 
 7 split :: Parser String
 8 split = do
 9   s <- get
10   lift $ tell ["The state is " <> show s]
11   case s of
12     "" -> lift $ lift $ throwError ["Empty string"]
13     _ -> do
14       put (drop 1 s)
15       pure (take 1 s)

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.

1 > runParser p s = runIdentity $ runExceptT $ runWriterT $ runStateT p s
2 
3 > runParser split "test"
4 (Right (Tuple (Tuple "t" "est") ["The state is test"]))
5 
6 > runParser ((<>) <$> split <*> split) "test"
7 (Right (Tuple (Tuple "te" "st") ["The state is test", "The state is est"]))

Sin embargo, si el análisis no tiene éxito porque el estado está vacío no se registra ningún mensaje:

1 > runParser split ""
2 (Left ["Empty string"])

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:

1 get    :: forall s.             State s s
2 put    :: forall s. s        -> State s Unit
3 modify :: forall s. (s -> s) -> State s Unit

En realidad, los tipos dados en el módulo Control.Monad.State.Class son más generales que eso:

1 get    :: forall m s. MonadState s m =>             m s
2 put    :: forall m s. MonadState s m => s        -> m Unit
3 modify :: forall m s. MonadState s m => (s -> s) -> m Unit

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:

1 split :: Parser String
2 split = do
3   s <- get
4   tell ["The state is " <> show s]
5   case s of
6     "" -> throwError "Empty string"
7     _ -> do
8       put (drop 1 s)
9       pure (take 1 s)

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:

1 class Functor f <= Alt f where
2   alt :: forall a. f a -> f a -> f a
3 
4 class Alt f <= Plus f where
5   empty :: forall a. f a
6 
7 class (Applicative f, Plus f) <= Alternative f

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:

1 many :: forall f a. Alternative f => Lazy (f (List a)) => f a -> f (List a)
2 some :: forall f a. Alternative f => Lazy (f (List a)) => f a -> f (List a)

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:

 1 > import Split
 2 > import Control.Alternative
 3 
 4 > runParser (many split) "test"
 5 (Right (Tuple (Tuple ["t", "e", "s", "t"] "")
 6               [ "The state is \"test\""
 7               , "The state is \"est\""
 8               , "The state is \"st\""
 9               , "The state is \"t\""
10               ]))

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:

1 class (Monad m, Alternative m) <= MonadZero m
2 
3 class MonadZero m <= MonadPlus m

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:

1 guard :: forall m. MonadZero m => Boolean -> m Unit

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:

1 upper :: Parser String
2 upper = do
3   s <- split
4   guard $ toUpper s == s
5   pure s

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:

1 lower :: Parser String
2 lower = do
3   s <- split
4   guard $ toLower s == s
5   pure s

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:

1 > upperOrLower = some upper <|> some lower

Este analizador encontrará coincidencias hasta que cambiemos de mayúsculas a minúsculas o viceversa:

1 > runParser upperOrLower "abcDEF"
2 (Right (Tuple (Tuple ["a","b","c"] ("DEF"))
3               [ "The state is \"abcDEF\""
4               , "The state is \"bcDEF\""
5               , "The state is \"cDEF\""
6               ]))

Podemos incluso usar many para partir una cadena por completo en sus componentes mayúsculas y minúsculas:

 1 > components = many upperOrLower
 2 
 3 > runParser components "abCDeFgh"
 4 (Right (Tuple (Tuple [["a","b"],["C","D"],["e"],["F"],["g","h"]] "")
 5               [ "The state is \"abCDeFgh\""
 6               , "The state is \"bCDeFgh\""
 7               , "The state is \"CDeFgh\""
 8               , "The state is \"DeFgh\""
 9               , "The state is \"eFgh\""
10               , "The state is \"Fgh\""
11               , "The state is \"gh\""
12               , "The state is \"h\""
13               ]))

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):

1 type RWS r w s = RWST r w s Identity

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:

1 type PlayerName = String
2 
3 newtype GameEnvironment = GameEnvironment
4   { playerName    :: PlayerName
5   , debugMode     :: Boolean
6   }

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:

1 import qualified Data.Map as M
2 import qualified Data.Set as S
3 
4 newtype GameState = GameState
5   { items       :: M.Map Coords (S.Set GameItem)
6   , player      :: Coords
7   , inventory   :: S.Set GameItem
8   }

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:

1 data GameItem = Candle | Matches

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:

1 type Log = L.List String
2 
3 type Game = RWS GameEnvironment Log GameState

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:

1 has :: GameItem -> Game Boolean
2 has item = do
3   GameState state <- get
4   pure $ item `S.member` state.inventory

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:

1 pickUp :: GameItem -> Game Unit
2 pickUp item = do
3   GameState state <- get

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:

1   case state.player `M.lookup` state.items of

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:

1     Just items | item `S.member` items -> do

En este caso, podemos usar put para actualizar el estado del juego y tell para añadir un mensaje al registro:

1       let newItems = M.update (Just <<< S.delete item) state.player state.items
2           newInventory = S.insert item state.inventory
3       put $ GameState state { items     = newItems
4                             , inventory = newInventory
5                             }
6       tell (L.singleton ("You now have the " <> show item))

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:

1     _ -> tell (L.singleton "I don't see that item here.")

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:

1   GameEnvironment env <- ask
2   if env.debugMode
3     then do
4       state <- get
5       tell (L.singleton (show state))
6     else tell (L.singleton "Not running in debug mode.")

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:

1 game :: Array String -> Game Unit

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:

1 data RWSResult state result writer = RWSResult state result writer
2 
3 runRWS :: forall r w s a. RWS r w s a -> r -> s -> RWSResult s a w

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:

1 runGame
2   :: forall eff
3    . GameEnvironment
4   -> Eff ( exception :: EXCEPTION
5          , readline :: RL.READLINE
6          , console :: CONSOLE
7          | eff
8          ) Unit

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:

1 type LineHandler eff a = String -> Eff eff a
2 
3 setLineHandler
4   :: forall eff a
5    . Interface
6   -> LineHandler (readline :: READLINE | eff) a
7   -> Eff (readline :: READLINE | eff) Unit

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:

1 runGame env = do
2   interface <- createConsoleInterface noCompletion

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:

1   setPrompt "> " 2 interface

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:

 1 lineHandler
 2   :: GameState
 3   -> String
 4   -> Eff ( exception :: EXCEPTION
 5          , console :: CONSOLE
 6          , readline :: RL.READLINE
 7          | eff
 8          ) Unit
 9 lineHandler currentState input = do
10   case runRWS (game (split " " input)) env currentState of
11     RWSResult state _ written -> do
12       for_ written log
13       setLineHandler interface $ lineHandler state
14   prompt interface
15   pure unit

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:

1   setLineHandler interface $ lineHandler initialGameState
2   prompt interface

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:

1 runY :: forall a eff.
2           YargsSetup ->
3           Y (Eff (exception :: EXCEPTION, console :: CONSOLE | eff) a) ->
4              Eff (exception :: EXCEPTION, console :: CONSOLE | eff) a

Esto se ilustra mejor con un ejemplo. La función main de la aplicación se define usando runY como sigue:

1 main = runY (usage "$0 -p <player name>") $ map runGame env

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 <*>:

1   where
2   env :: Y GameEnvironment
3   env = gameEnvironment
4           <$> yarg "p" ["player"]
5                    (Just "Player name")
6                    (Right "The player name is required")
7                    false
8           <*> flag "d" ["debug"]
9                    (Just "Use debug mode")

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:

1 npm install

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):

 1 function readText(onSuccess, onFailure) {
 2   var fs = require('fs');
 3   fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data) {
 4     if (error) {
 5       onFailure(error.code);
 6     } else {
 7       onSuccess(data);
 8     }   
 9   });
10 }

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:

 1 function copyFile(onSuccess, onFailure) {
 2   var fs = require('fs');
 3   fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data1) {
 4     if (error) {
 5       onFailure(error.code);
 6     } else {
 7       fs.writeFile('file2.txt', data, { encoding: 'utf-8' }, function (error) {
 8         if (error) {
 9           onFailure(error.code);
10         } else {
11           onSuccess();
12         }
13       });
14     }   
15   });
16 }

Una solución a este problema es descomponer las llamadas asíncronas individuales en sus propias funciones:

 1 function writeCopy(data, onSuccess, onFailure) {
 2   var fs = require('fs');
 3   fs.writeFile('file2.txt', data, { encoding: 'utf-8' }, function (error) {
 4     if (error) {
 5       onFailure(error.code);
 6     } else {
 7       onSuccess();
 8     }
 9   });
10 }
11 
12 function copyFile(onSuccess, onFailure) {
13   var fs = require('fs');
14   fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data) {
15     if (error) {
16       onFailure(error.code);
17     } else {
18       writeCopy(data, onSuccess, onFailure);
19     }   
20   });
21 }

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 a writeCopy 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 y onFailure 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:

1 foreign import data FS :: Effect
2 
3 type ErrorCode = String
4 type FilePath = String

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:

1 foreign import readFileImpl
2   :: forall eff
3    . Fn3 FilePath
4          (String -> Eff (fs :: FS | eff) Unit)
5          (ErrorCode -> Eff (fs :: FS | eff) Unit)
6          (Eff (fs :: FS | eff) Unit)

En el módulo externo JavaScript, readFileImpl estaría definida como:

 1 exports.readFileImpl = function(path, onSuccess, onFailure) {
 2   return function() {
 3     require('fs').readFile(path, {
 4       encoding: 'utf-8'
 5     }, function(error, data) {
 6       if (error) {
 7         onFailure(error.code)();
 8       } else {
 9         onSuccess(data)();
10       }
11     });
12   };
13 };

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:

1 foreign import writeFileImpl
2   :: forall eff
3    . Fn4 FilePath
4          String
5          (Eff (fs :: FS | eff) Unit)
6          (ErrorCode -> Eff (fs :: FS | eff) Unit)
7          (Eff (fs :: FS | eff) Unit)
 1 exports.writeFileImpl = function(path, data, onSuccess, onFailure) {
 2   return function() {
 3     require('fs').writeFile(path, data, {
 4       encoding: 'utf-8'
 5     }, function(error) {
 6       if (error) {
 7         onFailure(error.code)();
 8       } else {
 9         onSuccess();
10       }
11     });
12   };
13 };

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:

 1 readFile
 2   :: forall eff
 3    . FilePath
 4   -> (Either ErrorCode String -> Eff (fs :: FS | eff) Unit)
 5   -> Eff (fs :: FS | eff) Unit
 6 readFile path k =
 7   runFn3 readFileImpl
 8          path
 9          (k <<< Right)
10          (k <<< Left)
11 
12 writeFile
13   :: forall eff
14    . FilePath
15   -> String
16   -> (Either ErrorCode Unit -> Eff (fs :: FS | eff) Unit)
17   -> Eff (fs :: FS | eff) Unit
18 writeFile path text k =
19   runFn4 writeFileImpl
20          path
21          text
22          (k $ Right unit)
23          (k <<< Left)

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:

1 newtype ContT r m a = ContT ((a -> m r) -> m r)

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:

1 type Async eff = ContT Unit (Eff eff)

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:

 1 readFileCont
 2   :: forall eff
 3    . FilePath
 4   -> Async (fs :: FS | eff) (Either ErrorCode String)
 5 readFileCont path = ContT $ readFile path
 6 
 7 writeFileCont
 8   :: forall eff
 9    . FilePath
10   -> String
11   -> Async (fs :: FS | eff) (Either ErrorCode Unit)
12 writeFileCont path text = ContT $ writeFile path text

Con eso, podemos escribir nuestra rutina de copia de ficheros usando notación do para el transformador de mónada ContT:

 1 copyFileCont
 2   :: forall eff
 3    . FilePath
 4   -> FilePath
 5   -> Async (fs :: FS | eff) (Either ErrorCode Unit)
 6 copyFileCont src dest = do
 7   e <- readFileCont src
 8   case e of
 9     Left err -> pure $ Left err
10     Right content -> writeFileCont dest content

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:

1 import Prelude
2 
3 import Control.Monad.Eff.Console (logShow)
4 import Control.Monad.Cont.Trans (runContT)
5 
6 main =
7   runContT
8     (copyFileCont "/tmp/1.txt" "/tmp/2.txt")
9     logShow

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:

1 newtype ExceptT e m a = ExceptT (m (Either e a))

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:

 1 readFileContEx
 2   :: forall eff
 3    . FilePath
 4   -> ExceptT ErrorCode (Async (fs :: FS | eff)) String
 5 readFileContEx path = ExceptT $ readFileCont path
 6 
 7 writeFileContEx
 8   :: forall eff
 9    . FilePath
10   -> String
11   -> ExceptT ErrorCode (Async (fs :: FS | eff)) Unit
12 writeFileContEx path text = ExceptT $ writeFileCont path text

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:

1 copyFileContEx
2   :: forall eff
3    . FilePath
4   -> FilePath
5   -> ExceptT ErrorCode (Async (fs :: FS | eff)) Unit
6 copyFileContEx src dest = do
7   content <- readFileContEx src
8   writeFileContEx dest content

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:

1 require('request')('http://purescript.org'), function(err, _, body) {
2   if (err) {
3     console.error(err);
4   } else {
5     console.log(body);
6   }
7 });

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:

 1 foreign import data HTTP :: Effect
 2 
 3 type URI = String
 4 
 5 foreign import getImpl
 6   :: forall eff
 7    . Fn3 URI
 8          (String -> Eff (http :: HTTP | eff) Unit)
 9          (String -> Eff (http :: HTTP | eff) Unit)
10          (Eff (http :: HTTP | eff) Unit)
 1 exports.getImpl = function(uri, done, fail) {
 2   return function() {
 3     require('request')(uri, function(err, _, body) {
 4       if (err) {
 5         fail(err)();
 6       } else {
 7         done(body)();
 8       }
 9     });
10   };
11 };

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:

1 get :: forall eff.
2   URI ->
3   Async (http :: HTTP | eff) (Either String String)
4 get req = ContT \k ->
5   runFn3 getImpl req (k <<< Right) (k <<< Left)

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:

1 class (Monad m, Applicative f) <= Parallel f m | m -> f, f -> m where
2   sequential :: forall a. f a -> m a
3   parallel :: forall a. m a -> f a

La clase define dos funciones:

  • parallel, que toma un cálculo en la mónada m y lo convierte en cálculos en el funtor aplicativo f, 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:

 1 import Prelude
 2 import Control.Apply (lift2)
 3 import Control.Monad.Cont.Trans (runContT)
 4 import Control.Monad.Eff.Console (logShow)
 5 import Control.Monad.Parallel (parallel, sequential)
 6 
 7 main = flip runContT logShow do
 8   sequential $
 9    lift2 append
10      <$> parallel (readFileCont "/tmp/1.txt")
11      <*> parallel (readFileCont "/tmp/2.txt")

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.

1 merge :: Array Int -> Array Int -> Array Int

merge toma dos arrays de enteros ordenados y mezcla sus elementos de manera que el resultado también está ordenado. Por ejemplo:

1 > import Merge
2 > merge [1, 3, 5] [2, 4, 6]
3 
4 [1, 2, 3, 4, 5, 6]

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 e ys están ordenados, entonces merge xs ys está también ordenado.
  • (Subarray) xs e ys son ambos subarrays de merge 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:

1 main = do
2   quickCheck \xs ys ->
3     isSorted $ merge (sort xs) (sort ys)
4   quickCheck \xs ys ->
5     xs `isSubarrayOf` merge xs ys

Aquí, isSorted e isSubarrayOf se implementan como funciones auxiliares con los siguientes tipos:

1 isSorted :: forall a. Ord a => Array a -> Boolean
2 isSubarrayOf :: forall a. Eq a => Array a -> Array a -> Boolean

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:

1 $ pulp test
2 
3 * Build successful. Running tests...
4 
5 100/100 test(s) passed.
6 100/100 test(s) passed.
7 
8 * Tests OK.

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:

1 Error: Test 1 failed:
2 Test returned false

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:

1 quickCheck \xs ys ->
2   let
3     result = merge (sort xs) (sort ys)
4   in
5     xs `isSubarrayOf` result <?> show xs <> " not a subarray of " <> show result

Esta vez, si modificamos el código para introducir un fallo, vemos nuestro mensaje mejorado tras el primer caso de prueba fallido:

1 Error: Test 6 failed:
2 [79168] not a subarray of [-752832,686016]

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:

1 mergePoly :: forall a. Ord a => Array a -> Array a -> Array a

Si modificamos nuestras pruebas originales para usar mergePoly en lugar de merge, vemos el siguiente mensaje de error:

1 No type class instance was found for
2 
3   Test.QuickCheck.Arbitrary.Arbitrary t0
4 
5 The instance head contains unknown type variables.
6 Consider adding a type annotation.

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:

1 ints :: Array Int -> Array Int
2 ints = id

podemos modificar nuestras pruebas de manera que el compilador infiera el tipo Array Int para nuestros dos argumentos de tipo array:

1 quickCheck \xs ys ->
2   isSorted $ ints $ mergePoly (sort xs) (sort ys)
3 quickCheck \xs ys ->
4   ints xs `isSubarrayOf` mergePoly xs ys

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:

1 class Arbitrary t where
2   arbitrary :: Gen t

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:

1 newtype Byte = Byte Int
2 
3 instance arbitraryByte :: Arbitrary Byte where
4   arbitrary = map intToByte arbitrary
5     where
6     intToByte n | n >= 0 = Byte (n `mod` 256)
7                 | otherwise = intToByte (-n)

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:

1 quickCheck \xs ys ->
2   isSorted $ numbers $ mergePoly (sort xs) (sort ys)

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:

1 newtype Sorted a = Sorted (Array a)
2 
3 sorted :: forall a. Sorted a -> Array a
4 sorted (Sorted xs) = xs
5 
6 instance arbSorted :: (Arbitrary a, Ord a) => Arbitrary (Sorted a) where
7   arbitrary = map (Sorted <<< sort) arbitrary

Con este constructor de tipo, podemos modificar nuestras pruebas como sigue:

1 quickCheck \xs ys ->
2   isSorted $ ints $ mergePoly (sorted xs) (sorted ys)

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:

1 data Tree a
2   = Leaf
3   | Branch (Tree a) a (Tree a)

El módulo Tree define la siguiente API:

1 insert    :: forall a. Ord a => a -> Tree a -> Tree a
2 member    :: forall a. Ord a => a -> Tree a -> Boolean
3 fromArray :: forall a. Ord a => Array a -> Tree a
4 toArray   :: forall a. Tree a -> Array a

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:

1 > import Tree
2 
3 > member 2 $ insert 1 $ insert 2 Leaf
4 true
5 
6 > member 1 Leaf
7 false

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:

1 instance arbTree :: (Arbitrary a, Ord a) => Arbitrary (Tree a) where
2   arbitrary = map fromArray arbitrary

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:

1 quickCheck \t a ->
2   member a $ insert a $ treeOfInt t

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:

1 > import Data.String
2 
3 > mergeWith length
4     ["", "ab", "abcd"]
5     ["x", "xyz"]
6 
7 ["","x","ab","xyz","abcd"]

¿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:

1 class Coarbitrary t where
2   coarbitrary :: forall r. t -> Gen r -> Gen r

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:

1 instance arbFunction :: (Coarbitrary a, Arbitrary b) => Arbitrary (a -> b)

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:

1 quickCheck \xs ys f ->
2   isSorted $
3     map f $
4       mergeWith (intToBool f)
5                 (sortBy (compare `on` f) xs)
6                 (sortBy (compare `on` f) ys)

Aquí usamos una función intToBool para desambiguar el tipo de la función f:

1 intToBool :: (Int -> Boolean) -> Int -> Boolean
2 intToBool = id

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:

1 quickCheck \xs ys f ->
2   xs `isSubarrayOf` mergeWith (numberToBool f) xs ys

Además de ser Arbitrary, las funciones son también Coarbitrary:

1 instance coarbFunction :: (Arbitrary a, Coarbitrary b) => Coarbitrary (a -> b)

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:

1 instance coarbTree :: Coarbitrary a => Coarbitrary (Tree a) where

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:

1   coarbitrary Leaf = id

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:

1   coarbitrary (Branch l a r) =
2     coarbitrary l <<<
3     coarbitrary a <<<
4     coarbitrary r

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:

1 anywhere :: forall a. (Tree a -> Boolean) -> Tree a -> Boolean

Ahora somos capaces de generar la función predicado aleatoriamente. Por ejemplo, esperamos que la función anywhere respete la disyunción:

1 quickCheck \f g t ->
2   anywhere (\s -> f s || g s) t ==
3     anywhere f (treeOfInt t) || anywhere g t

Aquí, la función treeOfInt se usa para fijar el tipo de los valores contenidos en el árbol al tipo Int:

1 treeOfInt :: Tree Int -> Tree Int
2 treeOfInt = id

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:

 1 > import Prelude
 2 > import Merge
 3 > import Test.QuickCheck
 4 > import Test.QuickCheck.LCG (mkSeed)
 5 
 6 > :paste
 7 … quickCheckPure (mkSeed 12345) 10 \xs ys zs ->
 8 …   ((xs `merge` ys) `merge` zs) ==
 9 …     (xs `merge` (ys `merge` zs))
10 … ^D
11 
12 Success : Success : ...

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 y Coarbitrary 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 y Coarbitrary 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 y Parallel 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:

 1 newtype Element = Element
 2   { name         :: String
 3   , attribs      :: Array Attribute
 4   , content      :: Maybe (Array Content)
 5   }
 6 
 7 data Content
 8   = TextContent String
 9   | ElementContent Element
10 
11 newtype Attribute = Attribute
12   { key          :: String
13   , value        :: String
14   }

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

1 render :: Element -> String

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:

 1 $ pulp repl
 2 
 3 > import Prelude
 4 > import Data.DOM.Simple
 5 > import Data.Maybe
 6 > import Control.Monad.Eff.Console
 7 
 8 > :paste
 9 … log $ render $ Element
10 …   { name: "p"
11 …   , attribs: [
12 …       Attribute
13 …         { key: "class"
14 …         , value: "main"
15 …         }
16 …     ]
17 …   , content: Just [
18 …       TextContent "Hello World!"
19 …     ]
20 …   }
21 … ^D
22 
23 <p class="main">Hello World!</p>
24 unit

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:

1 element :: String -> Array Attribute -> Maybe (Array Content) -> Element
2 element name attribs content = Element
3   { name:      name
4   , attribs:   attribs
5   , content:   content
6   }

A continuación, creamos constructores inteligentes para aquellos elementos HTML que queremos que puedan crear nuestros usuarios, aplicando la función element:

1 a :: Array Attribute -> Array Content -> Element
2 a attribs content = element "a" attribs (Just content)
3 
4 p :: Array Attribute -> Array Content -> Element
5 p attribs content = element "p" attribs (Just content)
6 
7 img :: Array Attribute -> Element
8 img attribs = element "img" attribs Nothing

Finalmente, actualizamos la lista de exportaciones del módulo para que exporte sólo las funciones que sabemos que construyen estructuras de datos correctas:

 1 module Data.DOM.Smart
 2   ( Element
 3   , Attribute(..)
 4   , Content(..)
 5 
 6   , a
 7   , p
 8   , img
 9 
10   , render
11   ) where

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:

1 text :: String -> Content
2 text = TextContent
3 
4 elem :: Element -> Content
5 elem = ElementContent

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:

1 attribute :: String -> String -> Attribute
2 attribute key value = Attribute
3   { key: key
4   , value: value
5   }
6 
7 infix 4 attribute as :=

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:

1 newtype AttributeKey = AttributeKey String

Con eso, podemos modificar nuestro operador como sigue:

1 attribute :: AttributeKey -> String -> Attribute
2 attribute (AttributeKey key) value = Attribute
3   { key: key
4   , value: value
5   }

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:

 1 href :: AttributeKey
 2 href = AttributeKey "href"
 3 
 4 _class :: AttributeKey
 5 _class = AttributeKey "class"
 6 
 7 src :: AttributeKey
 8 src = AttributeKey "src"
 9 
10 width :: AttributeKey
11 width = AttributeKey "width"
12 
13 height :: AttributeKey
14 height = AttributeKey "height"

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:

 1 module Data.DOM.Smart
 2   ( Element
 3   , Attribute
 4   , Content
 5   , AttributeKey
 6 
 7   , a
 8   , p
 9   , img
10 
11   , href
12   , _class
13   , src
14   , width
15   , height
16 
17   , attribute, (:=)
18   , text
19   , elem
20 
21   , render
22   ) where

Si probamos este nuevo módulo en PSCi, podemos ver grandes mejoras en la concisión del código de usuario:

1 $ pulp repl
2 
3 > import Prelude
4 > import Data.DOM.Smart
5 > import Control.Monad.Eff.Console
6 > log $ render $ p [ _class := "main" ] [ text "Hello World!" ]
7 
8 <p class="main">Hello World!</p>
9 unit

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:

1 > log $ render $ img
2     [ src    := "cat.jpg"
3     , width  := "foo"
4     , height := "bar"
5     ]
6 
7 <img src="cat.jpg" width="foo" height="bar" />
8 unit

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:

1 newtype AttributeKey a = AttributeKey String

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:

1 attribute :: forall a. IsValue a => AttributeKey a -> a -> Attribute
2 attribute (AttributeKey key) value = Attribute
3   { key: key
4   , value: toValue value
5   }

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:

1 class IsValue a where
2   toValue :: a -> String

Proporcionamos instancias de la clase de tipos para los tipos String e Int:

1 instance stringIsValue :: IsValue String where
2   toValue = id
3 
4 instance intIsValue :: IsValue Int where
5   toValue = show

Tenemos también que actualizar nuestras constantes AttributeKey de manera que sus tipos reflejen el nuevo parámetro de tipo:

 1 href :: AttributeKey String
 2 href = AttributeKey "href"
 3 
 4 _class :: AttributeKey String
 5 _class = AttributeKey "class"
 6 
 7 src :: AttributeKey String
 8 src = AttributeKey "src"
 9 
10 width :: AttributeKey Int
11 width = AttributeKey "width"
12 
13 height :: AttributeKey Int
14 height = AttributeKey "height"

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:

 1 > import Prelude
 2 > import Data.DOM.Phantom
 3 > import Control.Monad.Eff.Console
 4 
 5 > :paste
 6 … log $ render $ img
 7 …   [ src    := "cat.jpg"
 8 …   , width  := 100
 9 …   , height := 200
10 …   ]
11 … ^D
12 
13 <img src="cat.jpg" width="100" height="200" />
14 unit

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:

1 p [ _class := "main" ]
2   [ elem $ img
3       [ src    := "cat.jpg"
4       , width  := 100
5       , height := 200
6       ]
7   , text "A cat"
8   ]

podremos escribir esto:

1 p [ _class := "main" ] $ do
2   elem $ img
3     [ src    := "cat.jpg"
4     , width  := 100
5     , height := 200
6     ]
7   text "A cat"

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:

1 > import Control.Monad.Free
2 
3 > :kind Free
4 (Type -> Type) -> Type -> Type

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:

1 data ContentF a
2   = TextContent String a
3   | ElementContent Element a
4 
5 instance functorContentF :: Functor ContentF where
6   map f (TextContent s x) = TextContent s (f x)
7   map f (ElementContent e x) = ElementContent e (f x)

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:

1 type Content = Free ContentF

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:

1 newtype Element = Element
2   { name         :: String
3   , attribs      :: Array Attribute
4   , content      :: Maybe (Content Unit)
5   }

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:

1 liftF :: forall f a. f a -> Free f a

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:

1 text :: String -> Content Unit
2 text s = liftF $ TextContent s unit
3 
4 elem :: Element -> Content Unit
5 elem e = liftF $ ElementContent e unit

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:

 1 runFree
 2   :: forall f a
 3    . Functor f
 4   => (f (Free f a) -> Free f a)
 5   -> Free f a
 6   -> a
 7 
 8 runFreeM
 9   :: forall f m a
10    . (Functor f, MonadRec m)
11   => (f (Free f a) -> m (Free f a))
12   -> Free f a
13   -> m a

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:

1 render :: Element -> String
2 render = execWriter <<< renderElement

renderElement se define en un bloque where:

1   where
2     renderElement :: Element -> Writer String Unit
3     renderElement (Element e) = do

La definición de renderElement es simple, usa la acción tell de la mónada Writer para acumular varias cadenas pequeñas:

1       tell "<"
2       tell e.name
3       for_ e.attribs $ \x -> do
4         tell " "
5         renderAttribute x
6       renderContent e.content

A continuación definimos la función renderAttribute que es igualmente simple:

1     where
2       renderAttribute :: Attribute -> Writer String Unit
3       renderAttribute (Attribute x) = do
4         tell x.key
5         tell "=\""
6         tell x.value
7         tell "\""

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:

1       renderContent :: Maybe (Content Unit) -> Writer String Unit
2       renderContent Nothing = tell " />"
3       renderContent (Just content) = do
4         tell ">"
5         runFreeM renderContentItem content
6         tell "</"
7         tell e.name
8         tell ">"

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:

1       renderContentItem :: ContentF (Content Unit) -> Writer String (Content Uni\
2 t)

Podemos implementar esta función simplemente mediante ajuste de patrones sobre los dos constructores de datos de ContentF:

1       renderContentItem (TextContent s rest) = do
2         tell s
3         pure rest
4       renderContentItem (ElementContent e rest) = do
5         renderElement e
6         pure rest

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:

 1 > import Prelude
 2 > import Data.DOM.Free
 3 > import Control.Monad.Eff.Console
 4 
 5 > :paste
 6 … log $ render $ p [] $ do
 7 …   elem $ img [ src := "cat.jpg" ]
 8 …   text "A cat"
 9 … ^D
10 
11 <p><img src="cat.jpg" />A cat</p>
12 unit

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:

1 newtype Name = Name String
2 
3 runName :: Name -> String
4 runName (Name n) = n

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:

1 instance nameIsValue :: IsValue Name where
2   toValue (Name n) = n

Definimos también un nuevo tipo de datos para hipervínculos que pueden aparecer en elementos a como sigue:

1 data Href
2   = URLHref String
3   | AnchorHref Name
4 
5 instance hrefIsValue :: IsValue Href where
6   toValue (URLHref url) = url
7   toValue (AnchorHref (Name nm)) = "#" <> nm

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:

1 href :: AttributeKey Href
2 href = AttributeKey "href"
3 
4 name :: AttributeKey Name
5 name = AttributeKey "name"

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:

1 data ContentF a
2   = TextContent String a
3   | ElementContent Element a
4   | NewName (Name -> a)

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:

1 instance functorContentF :: Functor ContentF where
2   map f (TextContent s x) = TextContent s (f x)
3   map f (ElementContent e x) = ElementContent e (f x)
4   map f (NewName k) = NewName (f <<< k)

Podemos ahora construir nuestra nueva acción mediante la función liftF como antes:

1 newName :: Content Name
2 newName = liftF $ NewName id

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:

1 type Interp = WriterT String (State Int)

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:

1 render :: Element -> String
2 render e = evalState (execWriterT (renderElement e)) 0

También necesitamos añadir un nuevo caso a renderContentItem para interpretar el constructor de datos NewName:

1 renderContentItem (NewName k) = do
2   n <- get
3   let fresh = Name $ "name" <> show n
4   put $ n + 1
5   pure (k fresh)

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:

 1 > import Prelude
 2 > import Data.DOM.Name
 3 > import Control.Monad.Eff.Console
 4 
 5 > :paste
 6 … render $ p [ ] $ do
 7 …   top <- newName
 8 …   elem $ a [ name := top ] $
 9 …     text "Top"
10 …   elem $ a [ href := AnchorHref top ] $
11 …     text "Back to top"
12 … ^D
13 
14 <p><a name="name0">Top</a><a href="#name0">Back to top</a></p>
15 unit

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.