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 interfazreadlinede NodeJS -
purescript-yargs, que proporciona una interfaz aplicativa a la biblioteca de procesamiento de argumentos de línea de comandosyargs
También es necesario instalar el módulo yargs usando NPM:
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:
-
Exceptionusa excepciones JavaScript mientras queExceptTmodela los errores como una estructura de datos pura - El efecto
Exceptionsólo soporta excepciones de un tipo, el tipoErrorde JavaScript, mientras queExceptTsoporta 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.