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.