Jaki jest cel monady czytelnika?

122

Monada czytelnicza jest tak złożona i wydaje się bezużyteczna. W języku imperatywnym, takim jak Java czy C ++, nie ma odpowiednika koncepcji monady czytelnika, jeśli się nie mylę.

Czy możesz podać mi prosty przykład i trochę to wyjaśnić?

chipbk10
źródło
21
Używasz monady czytnika, jeśli chcesz - od czasu do czasu - odczytać niektóre wartości z (niemodyfikowalnego) środowiska, ale nie chcesz jawnie przekazywać tego środowiska. W Javie lub C ++ używałbyś zmiennych globalnych (choć to nie jest dokładnie to samo).
Daniel Fischer,
5
@Daniel: To brzmi bardzo jak odpowiedź
SingleNegationElimination
@TokenMacGuy Zbyt krótkie na odpowiedź, a teraz jest już za późno, abym wymyślał coś dłużej. Jeśli nikt inny tego nie zrobi, zrobię to po tym, jak będę spał.
Daniel Fischer,
8
W Javie lub C ++ monada Reader byłaby analogiczna do parametrów konfiguracyjnych przekazywanych do obiektu w jego konstruktorze, które nigdy nie są zmieniane w czasie życia obiektu. W Clojure byłoby to trochę jak zmienna o zakresie dynamicznym, używana do parametryzacji zachowania funkcji bez konieczności jawnego przekazywania jej jako parametru.
danidiaz

Odpowiedzi:

169

Nie bój się! Monada czytnika nie jest w rzeczywistości tak skomplikowana i ma naprawdę łatwe w użyciu narzędzie.

Istnieją dwa sposoby podejścia do monady: możemy zapytać

  1. Co oznacza monada zrobić ? W jakie operacje jest wyposażony? Do czego to jest dobre?
  2. Jak wdrażana jest monada? Skąd się to bierze?

Od pierwszego podejścia monada czytelnika jest jakimś abstrakcyjnym typem

data Reader env a

takie że

-- 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

Jak więc tego używamy? Cóż, monada czytnika jest dobra do przekazywania (niejawnych) informacji konfiguracyjnych przez obliczenia.

Za każdym razem, gdy masz w obliczeniach „stałą”, której potrzebujesz w różnych punktach, ale tak naprawdę chciałbyś móc wykonać te same obliczenia z różnymi wartościami, powinieneś użyć monady czytającej.

Monady czytnika są również używane do robienia tego, co ludzie OO nazywają wstrzykiwaniem zależności . Na przykład algorytm negamax jest często używany (w wysoce zoptymalizowanych formach) do obliczania wartości pozycji w grze dwuosobowej. Jednak sam algorytm nie dba o to, w jaką grę grasz, poza tym, że musisz być w stanie określić, jakie "następne" pozycje są w grze i musisz być w stanie stwierdzić, czy aktualna pozycja jest pozycją zwycięską.

 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

To będzie działać z każdą skończoną, deterministyczną grą dla dwóch graczy.

Ten wzorzec jest przydatny nawet w przypadku rzeczy, które tak naprawdę nie są wstrzykiwaniem zależności. Załóżmy, że pracujesz w finansach, możesz zaprojektować skomplikowaną logikę wyceny aktywów (powiedzmy pochodną), co jest dobre i dobre i możesz obejść się bez śmierdzących monad. Ale potem modyfikujesz swój program, aby obsługiwał wiele walut. Musisz mieć możliwość przeliczania walut w locie. Pierwszą próbą jest zdefiniowanie funkcji najwyższego poziomu

type CurrencyDict = Map CurrencyName Dollars
currencyDict :: CurrencyDict

aby uzyskać ceny spot. Następnie możesz wywołać ten słownik w swoim kodzie ... ale czekaj! To nie zadziała! Słownik walut jest niezmienny i dlatego musi być taki sam nie tylko przez cały czas trwania programu, ale od momentu jego kompilacji ! Więc co robisz? Cóż, jedną z opcji byłoby użycie monady Reader:

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

Być może najbardziej klasycznym przypadkiem użycia jest implementacja interpreterów. Ale zanim się temu przyjrzymy, musimy wprowadzić inną funkcję

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

OK, więc Haskell i inne języki funkcjonalne są oparte na rachunku lambda . Rachunek lambda ma składnię, która wygląda następująco

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

i chcemy napisać ewaluatora dla tego języka. Aby to zrobić, będziemy musieli śledzić środowisko, które jest listą powiązań powiązanych z terminami (w rzeczywistości będą to zamknięcia, ponieważ chcemy wykonywać statyczne określanie zakresu).

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

Kiedy skończymy, powinniśmy otrzymać wartość (lub błąd):

 data Value = Lam String Closure | Failure String

A więc napiszmy tłumacza:

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!

Wreszcie możemy go użyć, przekazując trywialne środowisko:

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

I to wszystko. W pełni funkcjonalny interpreter rachunku lambda.


Innym sposobem myślenia o tym jest pytanie: jak to jest realizowane? Odpowiedź jest taka, że ​​monada czytelnika jest właściwie jedną z najprostszych i najbardziej eleganckich ze wszystkich monad.

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

Czytnik to po prostu wymyślna nazwa funkcji! Zdefiniowaliśmy już runReader, co z pozostałymi częściami API? Cóż, każdy Monadjest również Functor:

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

Teraz, aby otrzymać monadę:

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

co nie jest takie straszne. askjest naprawdę proste:

ask = Reader $ \x -> x

podczas gdy localnie jest tak źle:

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

OK, więc monada czytelnika to tylko funkcja. Dlaczego w ogóle czytnik? Dobre pytanie. Właściwie nie potrzebujesz tego!

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

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

Te są jeszcze prostsze. Co więcej, askjest tylko idi localjest tylko kompozycją funkcji z przełączoną kolejnością funkcji!

Philip JF
źródło
6
Bardzo ciekawa odpowiedź. Szczerze mówiąc, czytałem to wielokrotnie, gdy chcę zrecenzować monadę. Przy okazji, jeśli chodzi o algorytm nagamax, „wartości <- mapM (negacja. Negamax (kolor negacji)) możliwe” wydaje się niepoprawne. Wiem, że podany przez Ciebie kod służy tylko do pokazania, jak działa monada czytnika. Ale jeśli masz czas, czy możesz poprawić kod algorytmu Negamax? Ponieważ to interesujące, kiedy używasz monady czytelnika do rozwiązywania negamax.
chipbk10
4
Czy Readerjest więc funkcja z określoną implementacją klasy typu monad? Powiedzenie tego wcześniej pomogłoby mi w nieco mniejszym zdziwieniu. Po pierwsze, nie rozumiałem. W połowie pomyślałem: „Och, to pozwala Ci zwrócić coś, co da pożądany rezultat, gdy podasz brakującą wartość”. Pomyślałem, że to przydatne, ale nagle zdałem sobie sprawę, że funkcja robi dokładnie to.
ziggystar,
1
Po przeczytaniu tego większość rozumiem. localFunkcja wymaga trochę więcej wyjaśnień choć ..
Christophe De Troyer
@Philip Mam pytanie dotyczące instancji Monad. Czy nie możemy napisać funkcji bind jako (Reader f) >>= g = (g (f x))?
zeronone
@zeronone gdzie jest x?
Ashish Negi
56

Pamiętam, jak byłeś zdziwiony, dopóki sam nie odkryłem, że warianty monady Reader są wszędzie . Jak to odkryłem? Ponieważ ciągle pisałem kod, który okazał się być jego małymi wariacjami.

Na przykład w pewnym momencie pisałem kod dotyczący wartości historycznych ; wartości, które zmieniają się w czasie. Bardzo prostym modelem tego są funkcje od punktów w czasie do wartości w danym momencie:

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

Do Applicativeinstancji oznacza, że jeśli trzeba employees :: History Day [Person]i customers :: History Day [Person]można to zrobić:

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

To znaczy, Functori Applicativepozwalają nam dostosowywać regularne, niehistoryczne funkcje do pracy z historiami.

Instancję monady można najbardziej intuicyjnie zrozumieć, biorąc pod uwagę funkcję (>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c. Funkcja typu a -> History t bto funkcja, która odwzorowuje an ana historię bwartości; na przykład możesz mieć getSupervisor :: Person -> History Day Supervisori getVP :: Supervisor -> History Day VP. Tak więc instancja Monad for Historydotyczy tworzenia takich funkcji; na przykład getSupervisor >=> getVP :: Person -> History Day VPjest funkcją, która pobiera PersonhistorięVP , które mieli.

Cóż, ta Historymonada jest dokładnie taka sama jak Reader. History t ajest naprawdę taki sam jak Reader t a(czyli taki sam jak t -> a).

Inny przykład: ostatnio prototypowałem projekty OLAP w Haskell. Jednym z pomysłów jest „hipersześcian”, czyli odwzorowanie przecięć zestawu wymiarów na wartości. Znowu zaczynamy:

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

Jedną z typowych operacji na hipersześcianach jest zastosowanie wielomiejscowych funkcji skalarnych do odpowiednich punktów hipersześcianu. Możemy to uzyskać, definiując Applicativeinstancję dla 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)

Właśnie skopiowałem Historypowyższy kod i zmieniłem nazwy. Jak widać, Hypercubejest też sprawiedliwe Reader.

To trwa i trwa. Na przykład tłumacze języka również sprowadzają się do Reader, gdy zastosujesz ten model:

  • Wyrażenie = a Reader
  • Wolne zmienne = zastosowania ask
  • Środowisko ewaluacyjne = Readerśrodowisko wykonawcze.
  • Konstrukcje wiążące = local

Dobrą analogią jest to, że a Reader r areprezentuje an az "dziurami", które uniemożliwiają ci wiedzieć, o czym amówimy. Możesz otrzymać faktyczny atylko wtedy, gdy podasz an, raby wypełnić dziury. Takich rzeczy jest mnóstwo. W powyższych przykładach „historia” to wartość, której nie można obliczyć, dopóki nie określisz czasu, hipersześcian to wartość, której nie można obliczyć, dopóki nie określisz przecięcia, a wyrażenie językowe to wartość, która może nie będą obliczane, dopóki nie podasz wartości zmiennych. Daje też intuicję, dlaczego Reader r ajest to samo co r -> a, ponieważ taka funkcja jest również intuicyjnie abrakująca r.

Więc Functor , Applicativei Monadprzypadki Readersą bardzo użyteczne uogólnienie dla przypadków, gdy jesteś modelowania coś w tym rodzaju „To a, że brakuje r” i pozwala traktować te „niekompletnych” obiekty, jak gdyby były one kompletne.

Jeszcze innym sposobem na powiedzenie to samo: a Reader r ato coś, co zużywa ri produkuje a, a Functor, Applicativea Monadprzypadki są podstawowe wzory do pracy z Readers. Functor= zrobić, Readerktóry modyfikuje wyjście innego Reader; Applicative= podłącz dwa Readers do tego samego wejścia i połącz ich wyjścia; Monad= sprawdź wynik a Readeri użyj go do skonstruowania innego Reader. Funkcje locali withReader= make a, Readerktóre modyfikują dane wejściowe na inneReader .

Luis Casillas
źródło
5
Świetna odpowiedź. Można również skorzystać z GeneralizedNewtypeDerivingrozszerzenia do uzyskania Functor, Applicative, Monad, itd. Dla newtypes na podstawie ich typów bazowych.
Rein Henrichs
20

W Javie lub C ++ możesz bez problemu uzyskać dostęp do dowolnej zmiennej z dowolnego miejsca. Problemy pojawiają się, gdy kod staje się wielowątkowy.

W Haskell masz tylko dwa sposoby na przekazanie wartości z jednej funkcji do drugiej:

  • Wartość przekazuje się przez jeden z parametrów wejściowych funkcji wywoływalnej. Wady to: 1) nie możesz przekazać WSZYSTKICH zmiennych w ten sposób - lista parametrów wejściowych po prostu zaskakuje. 2) w sekwencji wywołań funkcji: fn1 -> fn2 -> fn3funkcja fn2może nie potrzebować parametru, z którego przekazuje się fn1do fn3.
  • Przekazujesz wartość w zakresie jakiejś monady. Wadą jest: musisz dobrze zrozumieć, czym jest koncepcja Monady. Przekazywanie wartości to tylko jedna z wielu aplikacji, w których można używać monad. W rzeczywistości koncepcja Monad jest niesamowicie potężna. Nie denerwuj się, jeśli nie masz wglądu od razu. Po prostu próbuj i czytaj różne samouczki. Wiedza, którą zdobędziesz, opłaci się.

Monada Reader po prostu przekazuje dane, które chcesz udostępniać między funkcjami. Funkcje mogą odczytywać te dane, ale nie mogą ich zmieniać. To wszystko, co robi monada Reader. Cóż, prawie wszyscy. Istnieje również wiele funkcji, takich jak local, ale po raz pierwszy możesz się trzymać askstylko.

Dmitrij Bespałow
źródło
3
Kolejną wadą używania monad do niejawnego przekazywania danych jest to, że bardzo łatwo jest napisać dużo kodu „imperatywnego” w doadnotacjach, które lepiej byłoby zrefaktoryzować w czystą funkcję.
Benjamin Hodgson
4
@BenjaminHodgson Pisanie „imperatywnego” kodu z monadami w notacji do nie jest konieczne, co oznacza pisanie kodu efektywnego (nieczystego). W rzeczywistości kod działający po stronie Haskell może być możliwy tylko w monadzie IO.
Dmitry Bespalov
Jeśli druga funkcja jest dołączona do tej przez whereklauzulę, czy zostanie zaakceptowana jako trzeci sposób przekazywania zmiennych?
Elmex80s,