Quel est le but de la monade de lecture?

122

La monade du lecteur est si complexe et semble inutile. Dans un langage impératif comme Java ou C ++, il n'y a pas de concept équivalent pour la monade du lecteur, si je ne me trompe pas.

Pouvez-vous me donner un exemple simple et éclaircir un peu cela?

chipbk10
la source
21
Vous utilisez la monade de lecture si vous voulez - à l'occasion - lire certaines valeurs d'un environnement (non modifiable), mais ne voulez pas transmettre explicitement cet environnement. En Java ou C ++, vous utiliseriez des variables globales (bien que ce ne soit pas exactement la même chose).
Daniel Fischer
5
@Daniel: Cela ressemble beaucoup à une réponse
SingleNegationElimination
@TokenMacGuy Trop court pour une réponse, et il est trop tard maintenant pour que je réfléchisse à quelque chose de plus. Si personne d'autre ne le fait, je le ferai après avoir dormi.
Daniel Fischer
8
En Java ou C ++, la monade Reader serait analogue aux paramètres de configuration passés à un objet dans son constructeur qui ne sont jamais modifiés pendant la durée de vie de l'objet. Dans Clojure, ce serait un peu comme une variable à portée dynamique utilisée pour paramétrer le comportement d'une fonction sans avoir besoin de la passer explicitement en paramètre.
danidiaz

Réponses:

169

N'aie pas peur! La monade de lecture n'est en fait pas si compliquée et possède une réelle utilité facile à utiliser.

Il y a deux manières d'aborder une monade: on peut demander

  1. Qu'est-ce que la monade faire ? De quelles opérations est-il équipé? À quoi ça sert?
  2. Comment la monade est-elle mise en œuvre? D'où vient-il?

Dès la première approche, la monade du lecteur est un type abstrait

data Reader env a

tel 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

Alors, comment utilisons-nous cela? Eh bien, la monade de lecture est bonne pour passer des informations de configuration (implicites) à travers un calcul.

Chaque fois que vous avez une "constante" dans un calcul dont vous avez besoin à différents moments, mais que vous souhaitez vraiment pouvoir effectuer le même calcul avec des valeurs différentes, vous devez utiliser une monade de lecture.

Les monades de lecteur sont également utilisées pour faire ce que les personnes OO appellent l' injection de dépendances . Par exemple, l' algorithme negamax est fréquemment utilisé (sous des formes hautement optimisées) pour calculer la valeur d'une position dans une partie à deux joueurs. L'algorithme lui-même ne se soucie pas du jeu auquel vous jouez, sauf que vous devez être en mesure de déterminer quelles sont les "prochaines" positions dans le jeu, et vous devez être capable de dire si la position actuelle est une position de victoire.

 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

Cela fonctionnera ensuite avec n'importe quelle partie à deux joueurs finie et déterministe.

Ce modèle est utile même pour les choses qui ne sont pas vraiment une injection de dépendance. Supposons que vous travaillez dans la finance, vous pouvez concevoir une logique compliquée pour évaluer un actif (un dérivé, par exemple), ce qui est très bien et vous pouvez vous passer de monades puantes. Mais ensuite, vous modifiez votre programme pour traiter plusieurs devises. Vous devez être capable de convertir entre les devises à la volée. Votre première tentative consiste à définir une fonction de niveau supérieur

type CurrencyDict = Map CurrencyName Dollars
currencyDict :: CurrencyDict

pour obtenir des prix au comptant. Vous pouvez alors appeler ce dictionnaire dans votre code .... mais attendez! Cela ne fonctionnera pas! Le dictionnaire de devises est immuable et doit donc être le même non seulement pour la durée de vie de votre programme, mais aussi à partir du moment où il est compilé ! Donc que fais-tu? Eh bien, une option serait d'utiliser la monade Reader:

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

Le cas d'utilisation le plus classique est peut-être celui de l'implémentation d'interprètes. Mais, avant de regarder cela, nous devons introduire une autre fonction

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

D'accord, donc Haskell et d'autres langages fonctionnels sont basés sur le calcul lambda . Le calcul Lambda a une syntaxe qui ressemble à

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

et nous voulons écrire un évaluateur pour cette langue. Pour ce faire, nous devrons garder une trace d'un environnement, qui est une liste de liaisons associées à des termes (en fait, ce seront des fermetures parce que nous voulons faire une portée statique).

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

Lorsque nous avons terminé, nous devrions sortir une valeur (ou une erreur):

 data Value = Lam String Closure | Failure String

Alors, écrivons l'interpréteur:

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!

Enfin, nous pouvons l'utiliser en passant un environnement trivial:

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

Et c'est tout. Un interpréteur entièrement fonctionnel pour le calcul lambda.


L'autre façon d'y penser est de se demander: comment est-il mis en œuvre? La réponse est que la monade du lecteur est en fait l'une des monades les plus simples et les plus élégantes.

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

Reader est juste un nom sophistiqué pour les fonctions! Nous avons déjà défini runReaderalors qu'en est-il des autres parties de l'API? Eh bien, tout Monadest aussi un Functor:

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

Maintenant, pour obtenir une monade:

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

ce qui n'est pas si effrayant. askc'est vraiment simple:

ask = Reader $ \x -> x

alors que ce localn'est pas si mal:

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

D'accord, donc la monade du lecteur n'est qu'une fonction. Pourquoi avoir Reader du tout? Bonne question. En fait, vous n'en avez pas besoin!

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

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

Celles-ci sont encore plus simples. De plus, askc'est juste idet localc'est juste une composition de fonction avec l'ordre des fonctions commuté!

Philippe JF
la source
6
Réponse très intéressante. Honnêtement, je l'ai relu plusieurs fois, quand je veux revoir monade. Au fait, à propos de l'algorithme nagamax, "les valeurs <- mapM (negate. Negamax (negate color)) possibles" ne semblent pas correctes. Je sais que, le code que vous fournissez est juste pour montrer comment fonctionne la monade de lecteurs. Mais si vous avez le temps, pourriez-vous corriger le code de l'algorithme negamax? Parce que, c'est intéressant, lorsque vous utilisez la monade de lecteur pour résoudre negamax.
chipbk10
4
Alors, Readerune fonction avec une implémentation particulière de la classe de type monade? Le dire plus tôt m'aurait aidé à être un peu moins perplexe. D'abord, je ne comprenais pas. A mi-chemin, j'ai pensé "Oh, cela vous permet de retourner quelque chose qui vous donnera le résultat souhaité une fois que vous aurez fourni la valeur manquante." J'ai pensé que c'était utile, mais j'ai soudainement réalisé qu'une fonction faisait exactement cela.
ziggystar
1
Après avoir lu ceci, j'en comprends la plupart. La localfonction a cependant besoin de plus d'explications.
Christophe De Troyer
@Philip J'ai une question sur l'instance Monad. Ne pouvons-nous pas écrire la fonction de liaison comme (Reader f) >>= g = (g (f x))?
zeronone
@zeronone où est x?
Ashish Negi
56

Je me souviens avoir été perplexe comme vous l'étiez, jusqu'à ce que je découvre par moi-même que des variantes de la monade Reader sont partout . Comment l'ai-je découvert? Parce que j'ai continué à écrire du code qui s'est avéré être de petites variations.

Par exemple, à un moment donné, j'écrivais du code pour traiter des valeurs historiques ; des valeurs qui changent avec le temps. Un modèle très simple de ceci est des fonctions de points de temps à la valeur à ce moment-là:

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

L' Applicativeinstance signifie que si vous avez employees :: History Day [Person]et que customers :: History Day [Person]vous pouvez le faire:

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

C'est-à-dire, Functoret Applicativenous permettent d'adapter des fonctions régulières et non historiques pour travailler avec des histoires.

L'instance de monade est plus intuitivement comprise en considérant la fonction (>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c. Une fonction de type a -> History t best une fonction qui mappe un aà une histoire de bvaleurs; par exemple, vous pourriez avoir getSupervisor :: Person -> History Day Supervisor, et getVP :: Supervisor -> History Day VP. Ainsi, l'instance Monad pour Historyest de composer des fonctions comme celles-ci; par exemple, getSupervisor >=> getVP :: Person -> History Day VPest la fonction qui obtient, pour tout Person, l'historique des VPs qu'ils ont eu.

Eh bien, cette Historymonade est en fait exactement la même que Reader. History t aest vraiment le même que Reader t a(qui est le même que t -> a).

Un autre exemple: j'ai récemment prototypé des conceptions OLAP dans Haskell. Une idée ici est celle d'un «hypercube», qui est une correspondance entre les intersections d'un ensemble de dimensions et les valeurs. On y va encore une fois:

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

Une opération courante sur les hypercubes est d'appliquer des fonctions scalaires multi-places aux points correspondants d'un hypercube. Nous pouvons l'obtenir en définissant une Applicativeinstance pour 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)

Je viens de copier le Historycode ci-dessus et j'ai changé de nom. Comme vous pouvez le voir, Hypercubec'est aussi juste Reader.

Et ça continue, encore et encore. Par exemple, les interprètes linguistiques se résument également à Reader, lorsque vous appliquez ce modèle:

  • Expression = a Reader
  • Variables libres = utilisations de ask
  • Environnement d'évaluation = Readerenvironnement d'exécution.
  • Constructions de liaison = local

Une bonne analogie est que a Reader r areprésente un aavec des «trous», qui vous empêchent de savoir de quoi anous parlons. Vous ne pouvez obtenir un réel aqu'une fois que vous avez fourni un an rpour remplir les trous. Il y a des tonnes de choses comme ça. Dans les exemples ci-dessus, un «historique» est une valeur qui ne peut pas être calculée tant que vous n’avez pas spécifié une heure, un hypercube est une valeur qui ne peut pas être calculée tant que vous n’avez pas spécifié une intersection, et une expression de langage est une valeur qui peut ne sera pas calculé tant que vous ne fournissez pas les valeurs des variables. Cela vous donne également une intuition sur pourquoi Reader r aest la même chose que r -> a, car une telle fonction est également intuitivement un afichier r.

Ainsi Functor, les instances , Applicativeet Monadde Readersont une généralisation très utile pour les cas où vous modélisez quelque chose du genre «et aqui manque un r», et vous permettent de traiter ces objets «incomplets» comme s'ils étaient complets.

Encore une autre façon de dire la même chose: a Reader r aest quelque chose qui consomme ret produit a, et les instances Functor, Applicativeet Monadsont des modèles de base pour travailler avec Readers. Functor= faire un Readerqui modifie la sortie d'un autre Reader; Applicative= connecter deux Readers à la même entrée et combiner leurs sorties; Monad= inspecter le résultat de a Readeret l'utiliser pour en construire un autre Reader. Les fonctions localet withReader= font un Readerqui modifie l'entrée en une autre Reader.

Luis Casillas
la source
5
Très bonne réponse. Vous pouvez également utiliser l' GeneralizedNewtypeDerivingextension Derive Functor, Applicative, Monad, etc. pour newtypes en fonction de leurs types fondamentaux.
Rein Henrichs
20

En Java ou C ++, vous pouvez accéder à n'importe quelle variable de n'importe où sans aucun problème. Des problèmes apparaissent lorsque votre code devient multithread.

Dans Haskell, vous n'avez que deux façons de passer la valeur d'une fonction à une autre:

  • Vous passez la valeur via l'un des paramètres d'entrée de la fonction appelable. Les inconvénients sont: 1) vous ne pouvez pas passer TOUTES les variables de cette manière - la liste des paramètres d'entrée vous époustouflera. 2) en séquence d'appels de fonction:, la fn1 -> fn2 -> fn3fonction fn2peut ne pas avoir besoin du paramètre que vous passez de fn1à fn3.
  • Vous passez la valeur dans la portée de certaines monades. L'inconvénient est que vous devez bien comprendre ce qu'est la conception de Monad. Faire passer les valeurs n'est qu'une des nombreuses applications où vous pouvez utiliser les Monades. En fait, la conception Monad est incroyablement puissante. Ne vous fâchez pas si vous n'avez pas obtenu de perspicacité à la fois. Continuez d'essayer et lisez différents didacticiels. Les connaissances que vous obtiendrez seront payantes.

Le Reader monad transmet simplement les données que vous souhaitez partager entre les fonctions. Les fonctions peuvent lire ces données, mais ne peuvent pas les modifier. C'est tout ce que fait la monade Reader. Enfin, presque tous. Il existe également un certain nombre de fonctions comme local, mais pour la première fois, vous ne pouvez vous en tenir asksqu'à.

Dmitry Bespalov
la source
3
Un autre inconvénient de l'utilisation de monades pour transmettre implicitement des données est qu'il est très facile de se retrouver à écrire beaucoup de code de type `` impératif '' en do-notation, ce qui serait mieux d'être refactorisé en une fonction pure.
Benjamin Hodgson
4
@BenjaminHodgson Ecrire du code «impératif» avec des monades dans do -notation ne signifie pas nécessairement écrire du code (impur) à effet latéral. En fait, le code à effet latéral dans Haskell peut être possible uniquement dans la monade IO.
Dmitry Bespalov
Si l'autre fonction est attachée à l'une par une whereclause, sera-t-elle acceptée comme troisième moyen de passer des variables?
Elmex80s