¿Cuál es el propósito de la mónada lectora?

122

La mónada lectora es tan compleja y parece inútil. En un lenguaje imperativo como Java o C ++, no existe un concepto equivalente para la mónada del lector, si no me equivoco.

¿Puedes darme un ejemplo sencillo y aclarar esto un poco?

chipbk10
fuente
21
Utilice la mónada del lector si desea, en ocasiones, leer algunos valores de un entorno (no modificable), pero no desea pasar explícitamente ese entorno. En Java o C ++, usarías variables globales (aunque no es exactamente lo mismo).
Daniel Fischer
5
@Daniel: Eso suena muchísimo como una respuesta
SingleNegationElimination
@TokenMacGuy Demasiado corto para una respuesta, y ya es demasiado tarde para pensar en algo más largo. Si nadie más lo hace, lo haré yo después de haberme dormido.
Daniel Fischer
8
En Java o C ++, la mónada de Reader sería análoga a los parámetros de configuración pasados ​​a un objeto en su constructor que nunca se cambian durante la vida útil del objeto. En Clojure, sería un poco como una variable de ámbito dinámico utilizada para parametrizar el comportamiento de una función sin necesidad de pasarla explícitamente como parámetro.
danidiaz

Respuestas:

169

¡No tengas miedo! La mónada del lector en realidad no es tan complicada y tiene una utilidad realmente fácil de usar.

Hay dos formas de abordar una mónada: podemos preguntar

  1. Lo que hace la mónada hacer ? ¿Con qué operaciones está equipado? ¿Para que sirve?
  2. ¿Cómo se implementa la mónada? ¿De dónde surge?

Desde el primer enfoque, la mónada lectora es un tipo abstracto

data Reader env a

tal que

-- Reader is a monad
instance Monad (Reader env)

-- and we have a function to get its environment
ask :: Reader env env

-- finally, we can run a Reader
runReader :: Reader env a -> env -> a

Entonces, ¿cómo usamos esto? Bueno, la mónada lectora es buena para pasar información de configuración (implícita) a través de un cálculo.

Siempre que tenga una "constante" en un cálculo que necesite en varios puntos, pero realmente le gustaría poder realizar el mismo cálculo con diferentes valores, entonces debería usar una mónada de lectura.

Las mónadas lectoras también se utilizan para hacer lo que la gente de OO llama inyección de dependencia . Por ejemplo, el algoritmo negamax se usa con frecuencia (en formas altamente optimizadas) para calcular el valor de una posición en un juego de dos jugadores. Sin embargo, al algoritmo en sí no le importa en qué juego estás jugando, excepto que debes poder determinar cuáles son las posiciones "siguientes" en el juego, y debes poder saber si la posición actual es una posición de victoria.

 import Control.Monad.Reader

 data GameState = NotOver | FirstPlayerWin | SecondPlayerWin | Tie

 data Game position
   = Game {
           getNext :: position -> [position],
           getState :: position -> GameState
          }

 getNext' :: position -> Reader (Game position) [position]
 getNext' position
   = do game <- ask
        return $ getNext game position

 getState' :: position -> Reader (Game position) GameState
 getState' position
   = do game <- ask
        return $ getState game position


 negamax :: Double -> position -> Reader (Game position) Double
 negamax color position
     = do state <- getState' position 
          case state of
             FirstPlayerWin -> return color
             SecondPlayerWin -> return $ negate color
             Tie -> return 0
             NotOver -> do possible <- getNext' position
                           values <- mapM ((liftM negate) . negamax (negate color)) possible
                           return $ maximum values

Esto funcionará con cualquier juego de dos jugadores finito y determinista.

Este patrón es útil incluso para cosas que no son realmente una inyección de dependencia. Suponga que trabaja en finanzas, podría diseñar una lógica complicada para fijar el precio de un activo (por ejemplo, un derivado), lo cual está muy bien y puede hacerlo sin mónadas apestosas. Pero luego, modifica su programa para manejar múltiples monedas. Necesita poder convertir entre monedas sobre la marcha. Su primer intento es definir una función de nivel superior

type CurrencyDict = Map CurrencyName Dollars
currencyDict :: CurrencyDict

para obtener precios al contado. A continuación, puede llamar a este diccionario en su código ... ¡pero espere! ¡Eso no funcionará! El diccionario de divisas es inmutable y, por lo tanto, debe ser el mismo no solo durante la vida útil de su programa, ¡sino desde el momento en que se compila ! Entonces, ¿Qué haces? Bueno, una opción sería usar la mónada Reader:

 computePrice :: Reader CurrencyDict Dollars
 computePrice
    = do currencyDict <- ask
      --insert computation here

Quizás el caso de uso más clásico es la implementación de intérpretes. Pero, antes de ver eso, necesitamos introducir otra función

 local :: (env -> env) -> Reader env a -> Reader env a

Bien, entonces Haskell y otros lenguajes funcionales se basan en el cálculo lambda . El cálculo lambda tiene una sintaxis similar a

 data Term = Apply Term Term | Lambda String Term | Var Term deriving (Show)

y queremos escribir un evaluador para este idioma. Para hacerlo, necesitaremos realizar un seguimiento de un entorno, que es una lista de enlaces asociados con términos (en realidad, serán cierres porque queremos hacer un alcance estático).

 newtype Env = Env ([(String, Closure)])
 type Closure = (Term, Env)

Cuando hayamos terminado, deberíamos obtener un valor (o un error):

 data Value = Lam String Closure | Failure String

Entonces, escribamos el intérprete:

interp' :: Term -> Reader Env Value
--when we have a lambda term, we can just return it
interp' (Lambda nv t)
   = do env <- ask
        return $ Lam nv (t, env)
--when we run into a value, we look it up in the environment
interp' (Var v)
   = do (Env env) <- ask
        case lookup (show v) env of
          -- if it is not in the environment we have a problem
          Nothing -> return . Failure $ "unbound variable: " ++ (show v)
          -- if it is in the environment, then we should interpret it
          Just (term, env) -> local (const env) $ interp' term
--the complicated case is an application
interp' (Apply t1 t2)
   = do v1 <- interp' t1
        case v1 of
           Failure s -> return (Failure s)
           Lam nv clos -> local (\(Env ls) -> Env ((nv, clos) : ls)) $ interp' t2
--I guess not that complicated!

Finalmente, podemos usarlo pasando un entorno trivial:

interp :: Term -> Value
interp term = runReader (interp' term) (Env [])

Y eso es todo. Un intérprete completamente funcional para el cálculo lambda.


La otra forma de pensar en esto es preguntarse: ¿Cómo se implementa? La respuesta es que la mónada lectora es en realidad una de las mónadas más simples y elegantes.

newtype Reader env a = Reader {runReader :: env -> a}

¡Lector es solo un nombre elegante para funciones! Ya lo hemos definido, runReaderentonces, ¿qué pasa con las otras partes de la API? Bueno, cada Monades también un Functor:

instance Functor (Reader env) where
   fmap f (Reader g) = Reader $ f . g

Ahora, para obtener una mónada:

instance Monad (Reader env) where
   return x = Reader (\_ -> x)
   (Reader f) >>= g = Reader $ \x -> runReader (g (f x)) x

que no da tanto miedo. askes realmente simple:

ask = Reader $ \x -> x

mientras localque no es tan malo:

local f (Reader g) = Reader $ \x -> runReader g (f x)

Bien, entonces la mónada del lector es solo una función. ¿Por qué tiene Reader en absoluto? Buena pregunta. En realidad, ¡no lo necesitas!

instance Functor ((->) env) where
  fmap = (.)

instance Monad ((->) env) where
  return = const
  f >>= g = \x -> g (f x) x

Estos son aún más simples. Además, askes justa idy locales sólo composición de funciones con el orden de las funciones cambiado.

Philip JF
fuente
6
Respuesta muy interesante. Honestamente, lo vuelvo a leer muchas veces, cuando quiero revisar la mónada. Por cierto, sobre el algoritmo nagamax, "valores <- mapM (negar. Negamax (negar color)) posible" no parece correcto. Lo sé, el código que proporciona es solo para mostrar cómo funciona la mónada del lector. Pero si tiene tiempo, ¿podría corregir el código del algoritmo negamax? Porque, es interesante, cuando usas reader mónada para resolver negamax.
chipbk10
4
Entonces, ¿ Readeres una función con alguna implementación particular de la clase de tipo mónada? Decirlo antes me habría ayudado a desconcertarme un poco menos. Primero no lo entendía. A mitad de camino pensé "Oh, te permite devolver algo que te dará el resultado deseado una vez que proporciones el valor faltante". Pensé que era útil, pero de repente me di cuenta de que una función hace exactamente esto.
ziggystar
1
Después de leer esto, entiendo la mayor parte. Sin localembargo, la función necesita más explicación ..
Christophe De Troyer
@Philip Tengo una pregunta sobre la instancia de Monad. ¿No podemos escribir la función de enlace como (Reader f) >>= g = (g (f x))?
zeronone
@zeronone ¿dónde está x?
Ashish Negi
56

Recuerdo estar desconcertado como tú, hasta que descubrí por mi cuenta que las variantes de la mónada Lectora están en todas partes. . ¿Cómo lo descubrí? Porque seguí escribiendo código que resultó ser pequeñas variaciones.

Por ejemplo, en un momento estaba escribiendo un código para tratar con valores históricos ; valores que cambian con el tiempo. Un modelo muy simple de esto son las funciones desde puntos de tiempo hasta el valor en ese momento:

import Control.Applicative

-- | A History with timeline type t and value type a.
newtype History t a = History { observe :: t -> a }

instance Functor (History t) where
    -- Apply a function to the contents of a historical value
    fmap f hist = History (f . observe hist)

instance Applicative (History t) where
    -- A "pure" History is one that has the same value at all points in time
    pure = History . const

    -- This applies a function that changes over time to a value that also 
    -- changes, by observing both at the same point in time.
    ff <*> fx = History $ \t -> (observe ff t) (observe fx t)

instance Monad (History t) where
    return = pure
    ma >>= f = History $ \t -> observe (f (observe ma t)) t

La Applicativeinstancia significa que si tiene employees :: History Day [Person]y customers :: History Day [Person]puede hacer esto:

-- | For any given day, the list of employees followed by the customers
employeesAndCustomers :: History Day [Person]
employeesAndCustomers = (++) <$> employees <*> customers

Es decir, Functory Applicativenos permiten adaptar funciones regulares, no históricas, para trabajar con historias.

La instancia de la mónada se entiende más intuitivamente considerando la función (>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c. Una función de tipo a -> History t bes una función que asigna una aa un historial de bvalores; por ejemplo, podría tener getSupervisor :: Person -> History Day Supervisory getVP :: Supervisor -> History Day VP. Entonces, la instancia de Monad para Historyse trata de componer funciones como estas; por ejemplo, getSupervisor >=> getVP :: Person -> History Day VPes la función que obtiene, para cualquiera Person, el historial de VPcorreos electrónicos que ha tenido.

Bueno, esta Historymónada es exactamente igual que Reader. History t aes realmente lo mismo que Reader t a(que es lo mismo que t -> a).

Otro ejemplo: he estado creando prototipos de diseños OLAP en Haskell recientemente. Una idea aquí es la de un "hipercubo", que es un mapeo de las intersecciones de un conjunto de dimensiones a valores. Aquí vamos de nuevo:

newtype Hypercube intersection value = Hypercube { get :: intersection -> value }

Una operación común en hipercubos es aplicar funciones escalares de múltiples lugares a los puntos correspondientes de un hipercubo. Esto lo podemos conseguir definiendo una Applicativeinstancia para Hypercube:

instance Functor (Hypercube intersection) where
    fmap f cube = Hypercube (f . get cube)


instance Applicative (Hypercube intersection) where
    -- A "pure" Hypercube is one that has the same value at all intersections
    pure = Hypercube . const

    -- Apply each function in the @ff@ hypercube to its corresponding point 
    -- in @fx@.
    ff <*> fx = Hypercube $ \x -> (get ff x) (get fx x)

Acabo de copiar el Historycódigo anterior y cambiar los nombres. Como puedes ver, Hypercubetambién es justo Reader.

Lo sigue y sigue. Por ejemplo, los intérpretes de idiomas también se reducen a Reader, cuando aplica este modelo:

  • Expresión = a Reader
  • Variables libres = usos de ask
  • Entorno de evaluación = Readerentorno de ejecución.
  • Construcciones de unión = local

Una buena analogía es que a Reader r arepresenta una acon "agujeros" que le impiden saber de qué aestamos hablando. Solo puede obtener un real auna vez que proporcione un rpara rellenar los agujeros. Hay toneladas de cosas así. En los ejemplos anteriores, un "historial" es un valor que no se puede calcular hasta que se especifica una hora, un hipercubo es un valor que no se puede calcular hasta que se especifica una intersección y una expresión de lenguaje es un valor que se puede No se calculará hasta que proporcione los valores de las variables. También te da una idea de por qué Reader r aes lo mismo que r -> a, porque esa función también es intuitivamente una afalta r.

Por lo tanto Functor, las instancias Applicativey Monadde Readerson una generalización muy útil para los casos en los que modela algo del tipo " ay le falta un r" y le permite tratar estos objetos "incompletos" como si estuvieran completos.

Otra forma más de decir lo mismo: a Reader r aes algo que consume ry produce a, y las instancias Functor, Applicativey Monadson patrones básicos para trabajar con Readers. Functor= hacer una Readerque modifique la salida de otra Reader; Applicative= conectar dos Readersa la misma entrada y combinar sus salidas; Monad= inspeccionar el resultado de a Readery usarlo para construir otro Reader. Las funciones localy withReader= hacen una Readerque modifica la entrada a otra Reader.

Luis Casillas
fuente
5
Gran respuesta. También puede utilizar la GeneralizedNewtypeDerivingextensión para derivar Functor, Applicative, Monad, etc., para Newtypes en función de sus tipos subyacentes.
Rein Henrichs
20

En Java o C ++ puedes acceder a cualquier variable desde cualquier lugar sin ningún problema. Aparecen problemas cuando su código se vuelve multiproceso.

En Haskell, solo tiene dos formas de pasar el valor de una función a otra:

  • Pasa el valor a través de uno de los parámetros de entrada de la función invocable. Los inconvenientes son: 1) no puede pasar TODAS las variables de esa manera: la lista de parámetros de entrada simplemente le dejará boquiabierto. 2) en la secuencia de llamadas a funciones: fn1 -> fn2 -> fn3, la función fn2puede no necesitar parámetro que se pasa de fn1a fn3.
  • Pasas el valor en el alcance de alguna mónada. El inconveniente es: tienes que comprender con firmeza qué es la concepción de Monad. Pasar los valores es solo una de las muchas aplicaciones en las que puede usar las mónadas. En realidad, la concepción de Monad es increíblemente poderosa. No se enfade si no obtuvo información de inmediato. Sigue intentándolo y lee diferentes tutoriales. El conocimiento que obtendrá valdrá la pena.

La mónada Reader simplemente pasa los datos que desea compartir entre funciones. Las funciones pueden leer esos datos, pero no pueden cambiarlos. Eso es todo lo que hace la mónada Reader. Bueno, casi todos. También hay una serie de funciones como local, pero por primera vez solo puedes quedarte asks.

Dmitry Bespalov
fuente
3
Un inconveniente adicional de usar mónadas para pasar datos implícitamente es que es muy fácil encontrarse escribiendo muchas doanotaciones de código de 'estilo imperativo' , que sería mejor refactorizar en una función pura.
Benjamin Hodgson
4
@BenjaminHodgson Escribir código de 'apariencia imperativa' con mónadas en la notación do no significa necesariamente escribir código de efecto lateral (impuro). En realidad, el código de efecto lateral en Haskell puede ser posible solo dentro de la mónada IO.
Dmitry Bespalov
Si la otra función se adjunta a la una mediante una wherecláusula, ¿se aceptará como una tercera forma de pasar variables?
Elmex80s