Una Introducción agradable a Haskell
anterior siguiente inicio


9  Sobre las Mónadas

La mayoría de los recien llegados a Haskell quedan desconcertados ante el concepto de mónada. Las mónadas aparecen a menudo en Haskell: el sistema de E/S está diseñado usando una mónada, se proporciona una sintaxis especial para el uso de las mónadas (las expresiones do), y uno de los módulos en las librerías estandarizadas está dedicado por completo a las mónadas. En esta sección exploramos la programación monádica con mayor detalle.

Esta sección del tutorial es posiblemente menos `agradable' que las otras. Aquí trataremos no solo las características del lenguaje relacionadas con las mónadas sino que también intentaremos dar una visión más amplia: mostraremos porque son las mónadas una herramienta tan importante y como son usadas. No hay un módo único de explicar las mónadas que sea adecuado para todo el mundo; otras explicaciones encontrarse en haskell.org. Una buena introducción al uso práctico de las mónadas es el artículo Monads for Functional Programming [10] de Wadler.

9.1  Las clases monádicas

El Prelude contiene varias clases que definen mónadas del modo en que son usadas en Haskell. Estas clases están basadas en el concepto de mónada que aparece en la teoría de categorías; aunque la terminología en esta teórica proporciona los nombres para las clases monádicas y las correspondientes operaciones, no es necesario ahondar en las matemáticas abstractas para obtener una idea intuitiva de cómo usar las clases monádicas..

Una mónada se construye sobre un tipo polimórfico como IO. La mónada propiamente queda definida mediante declaraciones de instancia que asocian el tipo con algunas o todas las clases monádicas, Functor, Monad, y MonadPlus. Ninguna de las clases monádicas puede ser derivada. Además de IO, otros dos tipos en el Prelude son miembros de las clases monádicas: las listas ([]) y el tipo Maybe.

Matemáticamente, las mónadas están caracterizadas por un conjunto de leyes (o propiedades) que deberían cumplir las operaciones monádicas. Este concepto de ley no es exclusivo de las mónadas: otras de las operaciones de Haskell están caracterizadas, al menos informalmente, por leyes. Por ejemplo, x /= y y not (x == y) deberían ser lo mismo para cualquier tipo de valor para el que tenga sentido dicha comparación. Sin embargo, no hay garantía de de que esto ocurra: tanto == como /= son métodos independientes en la la clase Eq y no hay modo de asegurar que == y =/ estén relacionados de este modo. Del mismo modo, las leyes monádicas que presentamos a continuación no son impuestas por Haskell, pero deberían cumplirse para cualquier instancia de una clase monádica. Las leyes de las mónadas proporcionan un mayor entendimiento de la estructura subyacente de las mónadas: esperamos que al examinar estas leyes proporcionemos al lector una primera impresión de cómo se usan las mónadas.

La clase Functor, ya discutida en la sección 5, define una única operación: fmap. La función fmap aplica una operación a los objetos pertenecientes a un contenedor (los tipos polimórficos pueden ser considerados como contenedores de valores de otro tipo), devolviendo un contenedor con la misma forma. Estas leyes caracterizan a fmap en la clase Functor:

fmap id=id
fmap (f . g)=fmap f . fmap g

Estas leyes aseguran que la forma del contenedor no es modificada por fmap y que el contenido de un contenedor no es reorganizado por la aplicación.

La clase Monad define dos operaciones básicas: >>= (bind) y return.

infixl 1  >>, >>=
class  Monad m  where
    (>>=)            :: m a -> (a -> m b) -> m b
    (>>)             :: m a -> m b -> m b
    return           :: a -> m a
    fail             :: String -> m a

    m >> k           =  m >>= \_ -> k

Las operaciones >> y >>=, combinan dos valores monádicos mientras que la función return sumerje un valor en la mónada (el contenedor). El tipo de >>=, Monad m => m a -> (a -> m b) -> m b, nos ayuda a comprender esta operación: ma >>= \v -> mb combina un valor monádico ma que contenga valores de tipo a y una función que opera sobre sobre un valor v de tipo a devolviendo el valor monádico mb. El resultado es combinar ma y mb en un valor monádico que contenga b. El operador >> es usado cuando la función no usa el valor producido por la primera operación monádica.

El significado de los operadores bind depende, por supesto, de la mónada. Por ejemplo, en la mónada IO (mónada de E/S), x >>= y lleva a cabo dos acciones secuencialmente, pasando el resultado de la primera a la segunda. Para las otras mónadas predefinidas, las listas y el tipo Maybe, estas operaciones monádicas pueden ser entendidas en términos de pasar cero o más valores desde una computación a la próxima. Veremos ejemplos de esto a continuación.

La notación do proporciona una abreviatura sintáctica simple para encadenamientos de operaciones monádicas. La esencia de traducción de las expresiones do está reflejada en las siguientes dos reglas:

  do e1 ; e2      =        e1 >> e2
  do p <- e1; e2  =        e1 >>= \p -> e2

Cuando el patrón en la segunda ecuación do es refutable, un fallo en el encaje de patrones llama a la función fail. Esto puede elevar un error (como ocurre en la mónada IO) o devolver un `cero' (como ocurre en la mónada lista). Así, la traducción completa es

   do p <- e1; e2  =   e1 >>= (\v -> case v of p -> e2; _ -> fail "s")    

donde "s" es una cadena de caracteres identificando la localización de la sentencia do para un posible uso en un mensaje de error. Por ejemplo, en la mónada IO, una acción como 'a' <- getChar llamará a fail si el caracter tecleado no es 'a'. Esto, a su vez, terminará el programa ya que en la mónada IO fail llama a error.

Las leyes que caracterizan a >>= y return son:

return a >>= k=k a
m >>= return=m
xs >>= return . f=fmap f xs
m >>= (\x -> k x >>= h)=(m >>= k) >>= h

La clase MonadZero se usa para mónadas que definen un elemento cero y una operación plus:

class  (Monad m) => MonadZero m  where
    mzero             :: m a
    mplus             :: m a -> m a -> m a

El elemento cero debe verificar las siguientes propiedades:

m >>= \x -> mzero=mzero
mzero >>= m=mzero

Para las listas, el valor cero es [], la lista vacía. La mónada IO no posee un cero por lo que no es un miembro de esta clase.

Las propiedades que caracterizan al operador mplus son las siguientes:

m `mplus` mzero=m
mzero `mplus` m=m

En el caso de las listas, el operador mplus es la concatenación habitual.

9.2  Mónadas predefinidas

A partir de las operaciones monádicas y las correspondientes propiedades, ¿Qué podemos hacer? Dado que ya hemos examinado la mónada IO detalladamente, comenzaremos por las otras dos mónadas predefinidas.

Para las listas, el operador de binding se corresponde con agrupar un conjunto de computaciones para cada valor de la lista. Cuando se usa sobre listas, el tipo de >>= es:

(>>=)                   :: [a] -> (a -> [b]) -> [b] 

Esto se interpreta como, dada una lista cuyos elementos tienen tipo a y una función que transforma un valor de tipo a en una lista de valores de tipo b, el operador de binding aplica esta función a cada uno de los valores de tipo a en la entrada y devuelve todos los valores de tipo b generados como una lista. La función return crea una lista unitaria. Estas operaciones deberían ser ya familiares: las listas por comprensión pueden ser expresadas fácilmente usando las operaciones monádicas definidas para las listas. Las siguientes tres expresiones corresponden a diferentes modos de escribir lo mismo:

[(x,y) | x <- [1,2,3] , y <- [1,2,3], x /= y]

do x <- [1,2,3]
   y <- [1,2,3]
   True <- return (x /= y)
   return (x,y)

[1,2,3] >>= (\ x -> [1,2,3] >>= (\y -> return (x/=y) >>=
   (\r -> case r of True -> return (x,y)
                    _    -> fail "")))

La definición depende del hecho de que la definición de la función fail para la mónada lista es la lista vacía. Esencialmente, cada <- genera un conjunto de valores que se pasa al resto de la computación monádica. Así x <- [1,2,3] invoca el resto de la computación monádica tres veces, una por cada elemento en la lista. La expresión devuelta, (x,y), será evaluada para todas las posibles combinaciones de valores. En este sentido, podemos ver que la mónada lista es útil para funciones que trabajan con argumentos multi-valuados. Por ejemplo, la siguiente función:

mvLift2                :: (a -> b -> c) -> [a] -> [b] -> [c]
mvLift2 f x y          =  do  x' <- x
                              y' <- y
                              return (f x' y')

convierte una función ordinaria de dos argumentos (f) en una función sobre múltiples valores (listas de argumentos), devolviendo un valor para cada posible combinación de dos argumentos de entrada. Por ejemplo,

mvLift2 (+) [1,3] [10,20,30] => [11,21,31,13,23,33]
mvLift2 (\a b->[a,b]) "ab" "cd" => ["ac","ad","bc","bd"]
mvLift2 (*) [1,2,4] [] => []

La función es una especialización de la función liftM2 definida en la biblioteca estandarizada para mónadas. Puedes verla como una función que transporta a cierta función, f, desde fuera de la mónada de las listas a la mónada de las listas en la que los cómputos se realizan sobre valores múltiples.

La mónada definida para el tipo Maybe es parecida a la mónada de las listas: el valor Nothing hace el papel de [] y Just x el de [x].

9.3  Usando Mónadas

Explicar tan solo los operadores monádicos y las propiedades asociadas a estos no es suficiente para mostrar la utilidad de las mónadas. Lo que las mónadas realmente proporcionan es modularidad. Con esto queremos decir que, al definir una operación de modo monádico, podemos ocultar la maquinaria subyacente de modo que es posible añadir características a la mónada de un modo transparente. El artículo [10] de Wadler es un ejemplo excelente de cómo pueden usarse las mónadas para construir programas modulares. Comenzaremos con una mónada tomada directamente de dicho artículo, la mónada de los estados, para construir posteriormente una mónada más compleja con una definición similar.

De un modo breve, una mónada de estados construída sobre un tipo S toma la siguiente forma:

data SM a = SM (S -> (a,S))  -- The monadic type

instance Monad SM where
  -- define la propagación de estados
  SM c1 >>= fc2         =  SM (\s0 -> let (r,s1) = c1 s0 
                                          SM c2 = fc2 r in
                                         c2 s1)
  return k              =  SM (\s -> (k,s))

 -- extrae el estado de la mónada
readSM                  :: SM S
readSM                  =  SM (\s -> (s,s))

 -- modifica el estado
updateSM                :: (S -> S) -> SM ()  
updateSM f              =  SM (\s -> ((), f s)) 

-- ejecuta una computación en la mónada
runSM                   :: S -> SM a -> (a,S)
runSM s0 (SM c)         =  c s0

El ejemplo define un nuevo tipo, SM, para representar cómputos que implícitamente arrastran un valor de tipo S. Es decir, una computación de tipo SM t define un valor de tipo t que a la vez interactúa (leyendo y modificando) con un estado de tipo S. La definición SM es simple: se trata de funciones que toman un estado y producen dos resultados: el valor devuelto (de cualquier tipo) y el valor del estado actualizado. No podemos usar aquí un sinónimo de tipos: es necesario un nombre de tipo como SM para poder usarlo en declaraciones de instancia. En este caso, se suele usar una declaración newtype en vez de la declaración data.

La declaración de instancia define la `fontanería' correspondiente a la mónada: como secuenciar dos computaciones y la definición de la computación vacía. La secuenciación (el operador >>=) define un cómputo (denotado con el constructor SM) que pasa un estado inicial, s0, a c1, y entonces pasa el valor resultante de este cómputo, r, a la función que devuelve el segundo cómputo, c2. Por último, el estado resultado de c1 se pasa a c2 y el resultado final se define como el resultado de c2.

La defición de return es más simple: return no modifica el estado en absoluto; simplemente vale para convertir un valor en un cómputo monádico.

Aunque >>= y return son los operadores de secuenciación básicos para la mónada, tambien necesitamos algunas primitivas monádicas. Una primitiva monádica es simplemente una operación que usa la representación interna de la mónada accediendo a las `ruedas y engranajes' que la hacen funcionar. Por ejemplo, en la mónada IO, operaciones como putChar son primitivas ya que se ocupan del funcionamiento interno de la mónada IO. De un modo similar, nuestra mónada de estados usa dos primitivas: readSM y updateSM. Obsérvese que estas dependen de la estructura interna de la mónada - un cambio en la definición del tipo SM requeriría que se realizase un cambio en estas primitivas.

Las definiciones de readSM y updateSM son simples: readSM presenta, con objeto de que sea observado, el estado de la mónada mientras que updateSM permite al usuario alterar el estado de la mónada. (Podríamos también haber usado una función como writeSM como primitiva pero la actualización es a menudo un modo más natural de manejar el estado).

Por último, necesitamos una función para ejecutar cómputos en la mónada, runSM. Ésta toma un valor inicial para el estado y un cómputo y devuelve tanto el valor del resultado del cómputo como el estado final.

De un modo más genérico, lo que estamos intentando es definir toda una computación como una serie de pasos (funciones con tipo SM a), secuenciadas mediante >>= y return. Estos pasos pueden interactuar con el estado (a través de readSM o updateSM) o pueden incluso ignorar el estado. En cualquier caso, el uso (o no uso) del estado se encuentra oculto: no invocamos o secuenciamos los cómputos de un modo diferente dependiendo de que usen o no S.

En lugar de presentar algún ejemplo usando esta mónada tan simple, avanzamos hacia un ejemplo más elaborado que engloba también la mónada de estados. Definimos un pequeño lenguaje embebido para el cálculo del uso de recursos. Con esto queremos decir que construiremos un lenguaje de propósito especial, implementándolo como un conjunto de tipos y funciones en Haskell, para construir una biblioteca de operaciones y tipos hechos a la medida del dominio de interés.

En este ejemplo, consideramos un cómputo que requiere alguna clase de recurso. Si el recurso está disponible, el cómputo prosigue; cuando el recurso no está disponible, el cómputo queda suspendido. Usaremos el tipo R para denotar una computación que use los recursos controlados por nuestra mónada. La definición de R es la siguiente:

data R a = R (Resource -> (Resource, Either a (R a)))

Cada cómputo es una función de los recursos disponibles a los recursos sobrantes, emparejados con, o bien un resultado, de tipo a, o un cómputo suspendido, de tipo R a, que captura el trabajo hecho hasta el punto en el cual los recursos se agotaron.

La instancia para la clase Monad correspondiente al tipo R es:

instance Monad R where
  R c1 >>= fc2          = R (\r -> case c1 r of
                                (r', Left v)    -> let R c2 = fc2 v in
                                                     c2 r'
                                (r', Right pc1) -> (r', Right (pc1 >>= fc2)))
  return v              = R (\r -> (r, (Left v)))

El tipo Resource se usa del mismo modo que el estado en la mónada de estados. Esta definición se puede interpretar del siguiente modo: para combinar dos cómputos `dependientes de recursos', c1 y fc2 (una función que produzca c2), pasamos los recursos iniciales a c1. El resultado será o bien

La suspensión debe tener en cuenta el segundo cómputo: pc1 suspende solamente el primer cómputo, c1, por lo que debemos seguir éste de c2 para capturar la suspensión de todo el cómputo. La definición de return no cambia los recursos y a la vez introduce v en la mónada.

Esta declaración de instancia define la estructura básica de la mónada pero no indica cómo se usan los recursos. Así, esta mónada podría ser usada para manejar distintos tipos de recursos o para implementar distintas políticas para el uso de recursos. Como ejemplo, mostraremos una definición de recursos muy simple: haremos que el tipo Resource sea un Integer, que represente los pasos computacionales disponibles:

type Resource           =  Integer

Esta función consume un paso a no ser que no queden pasos disponibles:

step                    :: a -> R a
step v                  =  c where
                              c = R (\r -> if r /= 0 then (r-1, Left v)
                                                     else (r, Right c))

Los constructores Left y Right son parte del tipo Either. La función continúa con el cómputo en R y devuelve v siempre que exista al menos un paso computacional disponible. Si no hay pasos disponibles, la función step suspende el computo actual (esta suspensión queda capturada en c) y vuelve a introducir este cómputo suspendido en la mónada.

Hasta ahora, tenemos las herramientas necesarias para definir una secuencia de computaciones `dependientes de recursos' (la mónada) y podemos expresar una forma de uso de recursos mediante step. Por último, necesitamos considerar cómo se expresarán los cómputos correspondientes a esta mónada.

Consideremos una función de incremento para nuestra mónada:

inc                     :: R Integer -> R Integer
inc i                   =  do iValue <- i
                              step (iValue+1)

De este modo definimos el incremento como un único paso computacional. La <- es necesaria para extraer el valor de argumento de la mónada; el tipo de iValue es Integer en vez de R Integer.

Esta definición no es totalmente satisfactoria, especialmente, si la comparamos con la definición estandarizada de la función de incremento. ¿Podríamos mejor `arropar' operaciones existentes como + para que funcionen en nuestro mundo monádico? Comenzaremos con un conjunto de funciones de elevamiento (lifting). Éstas permiten introducir funcionalidad existente en la mónada. Consideremos la definición de lift1 (se trata de una definición ligeramente diferente a la función liftM de la biblioteca Monad):

lift1                   :: (a -> b) -> (R a -> R b)
lift1 f                 =  \ra1 -> do a1 <- ra1
                                      step (f a1)

Esta función toma una función de un único argumento, f, y crea una función R que ejecuta la función elevada en un único paso. Usando lift1, inc puede ser definida como

inc                     :: R Integer -> R Integer
inc                     =  lift1 (\i -> i+1)

Esta definición es mejor, pero todavía no es idónea. Primero, añadimos lift2:

lift2                   :: (a -> b -> c) -> (R a -> R b -> R c)
lift2 f                 =  \ra1 ra2 -> do a1 <- ra1
                                          a2 <- ra2
                                          step (f a1 a2)

Obsérvese que esta función explícitamente establece el orden de evaluación en la función elevada: el cómputo que devuelve a1 ocurre antes que el cómputo para a2.

Usando lift2, podemos crear una nueva versión de == en la mónada R:

(==*)                   :: Ord a => R a -> R a -> R Bool
(==*)                   =  lift2 (==)

Hemos tenido que usar un nombre un poco diferente para esta función ya que == ya está definido, pero en algunos casos podemos usar el mismo nombre para las funciones elevadas y la original. Esta declaración de instancia permite que todos los operadores de la clase Num puedan ser usados en la mónada R:

instance Num a => Num (R a) where
  (+)                   =  lift2 (+)
  (-)                   =  lift2 (-)
  negate                =  lift1 negate
  (*)                   =  lift2 (*)
  abs                   =  lift1 abs
  fromInteger           =  return . fromInteger

La función fromInteger se aplica implícitamente a todas las constantes enteras en un programa Haskell (véase la sección 10.3); esta definición permite que las constantes enteras puedan actuar con el tipo R Integer. Podemos ahora, por fin, escribir la función de incremento de un modo completamente natural:

inc                     :: R Integer -> R Integer
inc x                   =  x + 1

Obsérvese que no podemos elevar la clase Eq del mismo modo que la clase Num: el tipo de ==* no es compatible con las posibles sobrecargas de == ya que el resultado de ==* tiene tipo R Bool en lugar de Bool.

Para poder expresar cómputos interesantes en R necesitaremos una condicional. Como no podemos usar if (requiere que la condición tenga tipo Bool en lugar de R Bool), definimos la función ifR:

ifR                     :: R Bool -> R a -> R a -> R a
ifR tst thn els         =  do t <- tst
                              if t then thn else els

Ahora estamos preparados para escribir un programa más extenso en la mónada R:

fact                    :: R Integer -> R Integer
fact x                  =  ifR (x ==* 0) 1 (x * fact (x-1))

Aunque ésta no es la definición habitual de la función factorial es bastante leíble. La idea de proporcionar nuevas definiciones para operaciones existentes como + o if es una parte esencial para crear un lenguaje embebido en Haskell. Las mónadas son especialmente útiles para encapsular la semántica de estos lenguajes embebidos de un modo claro y modular.

Ahora estamos preparados para ejecutar algunos programas. Esta función ejecuta un programa en R dado un número máximo de pasos computacionales:

run                     :: Resource -> R a -> Maybe a
run s (R p)             =  case (p s) of 
                             (_, Left v) -> Just v
                             _           -> Nothing

Hemos usado el tipo Maybe para tratar la posibilidad de que el cómputo no termine con el número de pasos reservados. Podemos ahora computar

run 10 (fact 2) => Just 2
run 10 (fact 20) => Nothing

Podemos, por último, añadir alguna funcionalidad más interesante a la mónada. Consideremos la siguiente función:

(|||)                   :: R a -> R a -> R a

que ejecuta dos cómputos en paralelo, devolviendo el valor del primero que acabe. Una posible definición de esta función es:

c1 ||| c2                =  oneStep c1 (\c1' -> c2 ||| c1')
   where
        oneStep          :: R a -> (R a -> R a) -> R a
        oneStep (R c1) f =
             R (\r -> case c1 1 of
                         (r', Left v) -> (r+r'-1, Left v)
                         (r', Right c1') ->  -- r' must be 0
                          let R next = f c1' in
                            next (r+r'-1))

Se lleva a cabo un paso de c1, devolviendo su valor si se completo el cómputo o, si c1 devuelve un cómputo suspendido (c1'), se evalúa c2 ||| c1'. La función oneStep lleva a cabo un único paso de su argumento, devolviendo bien un valor evaluado o pasando el resto del cómputo a f. La definición de oneStep es simple: pasa a c1 un 1 como recurso. Si se alcanza un valor final, éste es devuelto, ajustando el contador de pasos (es posible que un cómputo retorne sin llevar a cabo ningún paso por lo que el contador de recursos devuelto no ha de ser necesariamente 0). Si la computación queda suspendida, un contador de recursos enmendado es pasado a la continuación final.

Podemos ahora evaluar expresiones como run 100 (fact (-1) ||| (fact 3)) sin que ésta no acabe ya que los dos cómputos son intercalados. (Obsérvese que nuestra definición de fact no acaba para -1). Podemos llevar a cabo muchas variaciones sobre esta estructura básica. Por ejemplo, podríamos extender el estado para incluir una traza de los pasos computados. También podríamos embeber esta mónada en la mónada estandarizada IO, de modo que los cómputos en R pudiesen interactuar con el mundo externo. Aunque este ejemplo es quizás más avanzado que otros en este tutorial, permite ilustrar la potencia de las mónadas como una herramienta para definir la semántica básica de un sistema. Este ejemplo también es un modelo de un pequeño Lenguaje de Dominio Específico, algo para los que Haskell es especialmente adecuado. Muchos otros LDEs han sido desarrollados en Haskell; véase haskell.org para más ejemplos. Especialmente interesantes son Fran, un lenguaje de animaciones reactivas, y Haskore, un lenguaje para la música por ordenador.


Una Introducción agradable a Haskell
anterior siguiente inicio