10 listopada 2010

Monady maybe odsłona kolejna - rozwiązanie problemu nr 1

Logo ClojurePamiętamy 2 problemy z wpisu "O warsjawie i monadach w Clojure - nauka wspólnie jako sposób własnego rozwoju"? Daniel zaproponował rozwiązanie w Clojure (nota bene, tam dowiedziałem się o możliwości nazywania bytów w Clojure tak nieszablonowo jak "kraj->waluta"!), a Grzesiek w Scali. Grzesiek postawił kolejny problem: "Nie mam pojecia jaki zwiazek maja zaproponowane przez Ciebie problemy z monadami", na który postaram się dać odpowiedź w tym wpisie.

Rozwiązanie do problemu 1. polega na wychwyceniu sytuacji, w której poszukiwana wartość nie istnieje, której wystąpienie powinno zatrzymać kolejne obliczenia. Pozwoliłem sobie na użycie terminu obliczenie jako zastępstwa dla "funkcja", aby przywyczajać do pewnej abstrakcji, którą wprowadzają monady, z którymi są one nierozerwalnie skojarzone.

W Javie obsłużylibyśmy taką sytuację przez zastosowanie "wzorca"
if (nieZnaleziono) return null;
zanim wykona się kolejne obliczenie. To nakłada pewną dyscyplinę na programistę, aby pamiętał o takiej konstrukcji lub innym, właściwym obsłużeniu oraz (co bardziej istotne) niepotrzebnie zaciemnia treść metody. Są inne, ładniejsze rozwiązania i liczę na ich prezentacje w postaci komentarzy do tego wpisu (które wykorzystam do kolejnych odsłon "monadycznych").

Rozwiązanie Daniela, jakkolwiek bardzo twórcze i wiele się można z niego nauczyć, jest jednak obarczone problemem ignorowania sytuacji wyjątkowej, w której nieistnienie elementu w zbiorze nie powoduje zatrzymania kolejnych obliczeń, a więc w którymś momencie może doprowadzić do NPE (!) Można się o tym przekonać modyfikując nieznacznie zaproponowane rozwiązanie.
user=> (defn printlnX
  [v]
  (do
    (println "Wykonano z:" v) ; wyświetl wartość
    v                         ; zwróć ją
    ))
user=> (defn pracownik->waluta [p] (-> p printlnX pracownik->departament printlnX departament->kraj printlnX kraj->waluta printlnX))
#'user/pracownik->waluta
user=> (pracownik->waluta "Daniel")
Wykonano z: Daniel
Wykonano z: nil
Wykonano z: nil
Wykonano z: nil
nil
Co oznacza ni mniej ni więcej, że każde z kolejnych obliczeń było wykonane.

A co powiecie o takim rozwiązaniu?
user=> (defn pracownik->waluta
  [p]
  (domonad maybe-m
    [dept (pracownik->departament p)
     kraj (departament->kraj dept)
     waluta (kraj->waluta kraj)]
    waluta))
Tym razem opieram się na wykorzystaniu monady maybe, której działanie można streścić tak: "Jeśli dowolne obliczenie w serii obliczeń zwróci nil/null/wartość nieporządaną, kończymy". Sprawdźmy (musimy wcześniej dodać do niej "magiczne" printlnX).
user=> (defn pracownik->waluta
  [p]
  (domonad maybe-m
    [_ (printlnX p)
     dept (pracownik->departament p)
     _ (printlnX dept)
     kraj (departament->kraj dept)
     _ (printlnX kraj)
     waluta (kraj->waluta kraj)]
    waluta))
user=> (pracownik->waluta "Daniel")
Wykonano z: Daniel
nil
Konwencją w Clojure jest oznaczenie wartości przez podkreślnik "_", jeśli nie interesuje nas.

Czy teraz widać różnicę? Dzięki monadzie maybe kolejne obliczenia nie są wyliczane, co często nazywa się "szybkim przerwaniem" - jeśli już wystąpi błąd, to nie ma sensu wykonywać kolejnych kroków w ciągu obliczeń. Podobnie działa LinQ.

Na zakończenie jeszcze jeden przykład, który może uzmysłowić znaczenie monady maybe i transformat monadycznych, czyli możliwości składania lub modyfikowania monad.

Załóżmy, że nasza aplikacja zawiera serię ekranów. Jeśli użytkownik wciśnie "Zatrzymaj" albo "Anuluj", kolejne - z oczywistych względów - nie powinny być wyświetlone. Dobrze byłoby móc zająć się jedynie wyświetlaną treścią bez konieczności dbania o detale obsługi zdarzenia zatrzymaj czy anuluj. To pozostawiamy pewnemu, bardziej generycznemu rozwiązaniu, które co najwyżej konfigurujemy dopasowując do naszych potrzeb. I tutaj właśnie widzę zastosowanie dla monady maybe. Tym razem wartością szczególną będzie 1.
user=> (def screen-maybe-m (maybe-t maybe-m 1))

user=> (import 'javax.swing.JOptionPane)

user=> (defn showConfirmDialog
  [step]
  (JOptionPane/showConfirmDialog nil (str "Step" step ": Continue?") "Monads" JOptionPane/YES_NO_OPTION))
  
user=> (defn screen1
  []
  (showConfirmDialog 1))

user=> (defn screen2
  []
  (showConfirmDialog 2))

user=> (defn screen3
  []
  (showConfirmDialog 3))

user=> (domonad screen-maybe-m
  [_ (screen1)
   _ (screen2)
   _ (screen3)]
    "Wykonano całość")
Tylko, jeśli użytkownik zaakceptuje wszystkie z wyświetlonych ekranów, nastąpi wyświetlenie "Wykonano całość". Sprawdzenie prawdziwości tego stwierdzenia pozostawiam Tobie.

5 komentarzy:

  1. Rozwiązanie w Scali

    val m1 = Map("zero" -> 0, "jeden" -> 1, "dwa" -> 2)
    val m2 = Map(0 -> "a", 1 -> "b")
    val m3 = Map("a" -> "+")
    def f1 = (x:String) => m1.get(x) flatMap (m2.get(_)) flatMap (m3.get(_))

    Zastosowana jest tutaj monada Option zwracana przez Map.get, a dokładnie jeden z jej podtypów Some(value) gdy mapa zawiera dany klucz (value to wartość dla podanego klucza) lub None gdy nie można znaleźć klucza. Funkcja przekazana do flatMap wykonywana jest tylko gdy Option jest niepuste (podtyp Some).

    Funkcję showConfirmDialog można zastosować w ten sam sposób, wystarczy aby zwracała Some gdy użytkownik naciśnie YES, a None w pozostałych przypadkach.

    OdpowiedzUsuń
  2. Mozna tez wykorzystac for-comprehension, ktore przypomina notacje do z haskella
    def f1 = (x: String) => for (v1 <- m1.get(x); v2 <- m2.get(v1); v3 <- m3.get(v2)) yield v3

    To jest syntactic sugar dla tego co napisal aptu.

    OdpowiedzUsuń
  3. Na monadach jeszcze mało się znam, ale problem wygląda, jakby mógł zostać rozwiązany z -?>

    http://clojuredocs.org/clojure_contrib/clojure.contrib.core/-_q%3E

    OdpowiedzUsuń
  4. @Konrad, wspaniałe! Ciekawe jak wygląda implementacja. Jutro tym zacznę dzień i pewnie pojawi się wzmianka na blogu. Wielkie, wielkie dzięki za wskazanie.

    OdpowiedzUsuń