web-dev-qa-db-fra.com

Quel est le but de la monade de lecture?

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 de lecture, si je ne me trompe pas.

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

110
chipbk10

N'ayez pas peur! La monade de lecture n'est en fait pas si compliquée et possède un véritable utilitaire facile à utiliser.

Il y a deux façons d'approcher 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-elle?

De la première approche, la monade de 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 idéale pour transmettre des informations de configuration (implicites) via un calcul.

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

Les monades de lecture sont également utilisées pour faire ce que les gens OO appellent injection de dépendance . Par exemple, l'algorithme negamax est fréquemment utilisé (en très formes optimisées) pour calculer la valeur d'une position dans un jeu à deux joueurs. L'algorithme lui-même ne se soucie toutefois 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 savoir 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 alors avec n'importe quel jeu à deux joueurs fini 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 travailliez dans la finance, vous pourriez concevoir une logique compliquée pour la tarification d'un actif (un dérivé disons), ce qui est bien beau et vous pouvez vous passer de monades puantes. Mais ensuite, vous modifiez votre programme pour gérer plusieurs devises. Vous devez pouvoir 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 ensuite appeler ce dictionnaire dans votre code .... mais attendez! Ça ne marchera pas! Le dictionnaire des devises est immuable et doit donc être le même non seulement pour la durée de vie de votre programme, mais à 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 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 lambda calculus . 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 aux 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 obtenir 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 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-elle mise en œuvre? La réponse est que la monade de lecture est en fait l'une des monades les plus simples et les plus élégantes.

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

Reader n'est qu'un nom de fantaisie pour les fonctions! Nous avons déjà défini runReader alors qu'en est-il des autres parties de l'API? Eh bien, chaque Monad est 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. ask est vraiment simple:

ask = Reader $ \x -> x

tandis que local n'est pas si mal.

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

D'accord, la monade de lecture n'est donc qu'une fonction. Pourquoi avoir Reader? 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

Ce sont encore plus simples. De plus, ask est juste id et local est juste la composition des fonctions avec l'ordre des fonctions changé!

150
Philip JF

Je me souviens avoir été perplexe comme vous l'étiez, jusqu'à ce que je découvre par moi-même que les 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 gérer les valeurs historique; des valeurs qui changent avec le temps. Un modèle très simple de ceci est les fonctions des points de temps à la valeur à ce moment:

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'instance Applicative signifie que si vous avez employees :: History Day [Person] et customers :: History Day [Person] tu peux le faire:

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

C'est-à-dire que Functor et Applicative nous permettent d'adapter des fonctions régulières et non historiques pour travailler avec des historiques.

L'instance 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 b est une fonction qui mappe un a à un historique des valeurs de b; par exemple, vous pourriez avoir getSupervisor :: Person -> History Day Supervisor, et getVP :: Supervisor -> History Day VP. Ainsi, l'instance Monad pour History concerne la composition de fonctions comme celles-ci; par exemple, getSupervisor >=> getVP :: Person -> History Day VP est la fonction qui obtient, pour tout Person, l'historique des VP qu'ils ont eu.

Eh bien, cette monade History est en fait exactement identique à Reader. History t a est vraiment la même chose que Reader t a (qui est identique à t -> a).

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

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

Une opération courante sur les hypercubes consiste à appliquer des fonctions scalaires à emplacements multiples aux points correspondants d'un hypercube. Ceci peut être obtenu en définissant une instance de Applicative 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 code History ci-dessus et de changer les noms. Comme vous pouvez le constater, Hypercube est également juste Reader.

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

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

Une bonne analogie est qu'un Reader r a représente un a avec des "trous", qui vous empêchent de savoir de quel a nous parlons. Vous ne pouvez obtenir un a réel qu'une fois que vous avez fourni un r pour 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 ne spécifiez pas une heure, un hypercube est une valeur qui ne peut pas être calculée tant que vous ne spécifiez pas une intersection, et une expression de langage est une valeur qui peut 't être calculé jusqu'à ce que vous fournissez les valeurs des variables. Il vous donne également une intuition sur la raison pour laquelle Reader r a est le même que r -> a, car une telle fonction est aussi intuitivement un a manquant un r.

Ainsi, les instances Functor, Applicative et Monad de Reader sont une généralisation très utile pour les cas où vous modélisez quelque chose du genre "an a qui manque un r ", et vous permet de traiter ces objets" incomplets "comme s'ils étaient complets.

Encore une autre façon de dire la même chose: un Reader r a est quelque chose qui consomme r et produit a, et les instances Functor, Applicative et Monad sont des modèles de base pour travailler avec Reader s. Functor = créer un Reader qui modifie la sortie d'un autre Reader; Applicative = connectez deux Reader à la même entrée et combinez leurs sorties; Monad = inspecter le résultat d'un Reader et l'utiliser pour construire un autre Reader. Les fonctions local et withReader = créent un Reader qui modifie l'entrée en une autre Reader.

52
Luis Casillas

Dans 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 multi-thread.

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 façon - la liste des paramètres d'entrée vous souffle juste. 2) en séquence d'appels de fonction: fn1 -> fn2 -> fn3, une fonction fn2 peut ne pas avoir besoin d'un paramètre que vous passez de fn1 à fn3.
  • Vous passez la valeur de portée d'une monade. L'inconvénient est: vous devez bien comprendre ce qu'est la conception de Monad. La transmission des 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 compris tout de suite. Continuez simplement à essayer et lisez différents didacticiels. Les connaissances que vous obtiendrez porteront leurs fruits.

Le Reader monad passe 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 tout. Il existe également un certain nombre de fonctions comme local, mais pour la première fois, vous pouvez vous en tenir à asks uniquement.

19
Dmitry Bespalov