05 października 2010

Monady w Clojure - wstęp do maybe-m

Kto śledzi moje poczynania na twitterze (kanał @jaceklaskowski) już wie, że przynajmniej monada maybe-m w Clojure jest rozpoznana (z dokładnością do metod m-return i m-bind).

Kiedy kolejny raz przesłuchiwałem nagrania z prezentacji Josha Grahama podczas konferencji QCon i jednocześnie próbowałem się z moimi "wymyślnymi" (czytaj: trywialnymi do bólu) przykładami w Eclipse z CounterClockWise (CCW), wszystko stało się jasne. Zgoda. Może nie wszystko, ale więcej i chociażby monadę maybe-m mogę zaliczyć do tych trywialniejszych.

Nie jestem w stanie wyrazić tego błogiego stanu uniesienia, kiedy w końcu wysiłek zrozumienia monad w Clojure nie idzie na marne i po przynajmniej miesiącu zagłębiania się w różnego rodzaju materiały w Sieci i poza nią, maybe-m zaczyna funkcjonować zgodnie z oczekiwaniami. Teraz powinno być mi łatwiej wyjaśnić, cóż magicznego jest w monadach, co powoduje chęć ich zrozumienia u wielu, acz niewielu niestety ma wystarczająco dużo motywacji, aby przebrnąć przez dostępny materiał i przetrawić go. Wierzę, że seria wpisów, które zaplanowałem na ten temat wypełni choć po części tę lukę (i nie przyczynię się jednocześnie do jeszcze większego zaciemnienia tematu, a wręcz przeciwnie).

Jako, że rozpiera mnie, aby zgłębić więcej o monadach (prawdopodobnie będę musiał przeczytać jeszcze raz artykuły, które odznaczyłem jako przeczytane - patrz moje konto na Delicious), teraz przedstawię jedynie zrąb informacji, aby w kolejnych odsłonach przymierzyć się do artykułu o monadach z przykładami. Gdyby ktoś zechciał mi pomóc, proszę o pobranie źródeł z repozytorium monady-artykuł na GitHubie i przesyłanie łatek. Pisanie artykułu w ten sposób, to zrealizowanie nauki git i Clojure, i monad, i pewnie kilku innych rzeczy za jednym zamachem, więc...czekam na aktywny odzew. Niech nawet będzie na poziomie sugestii, bo bez tego materiał będzie tak zrozumiały, jak osoby przedstawiające.

Tyle w ramach rozgrzewki. Pora na wyjawienie "prawdy oczywistej" o monadach w krótkim wprowadzeniu do wprowadzenia do monady maybe-m w Clojure. Przypomnę, że tym razem nie będzie kodu w Javie - on będzie wynikiem pracy kolektywu :) Za to będzie Clojure. Ostrzegałem.

Mała rozgrzewka w postaci kodu w Clojure. Co będzie jego wynikiem?

(ns
  #^{:author "Jacek Laskowski"
     :doc "Examples with monads"}
  monads
  (:use [clojure.contrib monads]))

(domonad maybe-m
  [m (do (println 1) :m)
   n (do (println 2) nil)
   o (do (println 3) :o)
   p (do (println 4) nil)
   r (do (println 5) :r)]
    (println m n o p r))
Najważniejsza w tym przykładzie jest konstrukcja (domonad maybe-m ...) (linia 7), której zadaniem jest wykonanie serii obliczeń (ang. computations) w "środowisku" monady maybe-m. Jej działanie w skrócie można przedstawić jako - jeśli w jakimkolwiek kroku obliczeń, jedno z nich zwróci nil, kolejne nie będą wykonane, jak i końcowe, podsumowujące wyrażenie - w naszym przypadku (println ...) na linii 13.

A zatem jaki będzie wynik?

Przeanalizujmy konstrukcje występujące w ciele (domonad maybe-m ...) zaczynające się nawiasem kwadratowym (symbolizującym tablice w wielu językach programowania, np. Javie) - linie 8-12. Konstrukcja tablicowa [] wymaga parzystej liczby form i lewej stronie przypisuje wartość z prawej. Tym samym staną się stałymi lokalnymi (nie zmiennymi!). I tak kolejno dla każdej pary (przecinek jest opcjonalny i spacja wystarczy jako separator elementów w tablicy). W naszym przypadku, litery od m do r zostaną zainicjowane kolejno typem kluczowym :m, później nil i tak dalej. Dodatkowo dla poprawy zrozumienia, co się dzieje w każdym kroku dodałem wykonanie (println ...), aby wykonanie inicjowania zostawiło jakikolwiek ślad na konsoli (i abym mógł poznać działanie kodu bez uruchamiania debuggera). Jako, że w jednym kroku przypisania wykonujemy dwie funkcje - (println ...) oraz zwrócenie wartości - konieczne było opakowanie ich funkcją (do ...). To kończy wyjaśnianie sekcji inicjującej.

Ostatnia linia 13. z (println m n o p r) odpowiada wypisaniu wartości stałych m, n, o, p, r na ekran. To odpowiada wywołaniu System.out.println w Javie.

A zatem jaki będzie wynik?

Potrzeba więcej? Zgoda.

Konstrukcja (domonad maybe-m ...) jest tak na prawdę makrem w Clojure, co oznacza, że podczas kompilacji zostanie zamieniona na odpowiadające jemu wywołania funkcji. Można się o tym przekonać korzystając z funkcji (clojure.walk/macroexpand-all). Wynikiem jej działania jest "rozwinięcie" makra na odpowiednie wywołania funkcji w Clojure. Nie zapomnijmy o wykluczeniu wykonania formy będącej argumentem dla (macroexpand-all) apostrofem albo funkcją (quote)!

user=> (macroexpand-all '(domonad maybe-m
  [m (do (println 1) :m)
   n (do (println 2) nil)
   o (do (println 3) :o)
   p (do (println 4) nil)
   r (do (println 5) :r)]
    (println m n o p r)))

(let* 
  [name__518__auto__ maybe-m
   m-bind (:m-bind name__518__auto__)
   m-result (:m-result name__518__auto__)
   m-zero (:m-zero name__518__auto__)
   m-plus (:m-plus name__518__auto__)]
  (do 
    (m-bind (do (println 1) :m) (fn* ([m] 
    (m-bind (do (println 2) nil) (fn* ([n]
    (m-bind (do (println 3) :o) (fn* ([o] 
    (m-bind (do (println 4) nil) (fn* ([p] 
    (m-bind (do (println 5) :r) (fn* ([r] 
    (m-result (println m n o p r)))))))))))))))))))
Pewnie te ostatnie nawiasy najbardziej intrygujące, co? ;-) Idzie się do nich przyzwyczaić (jak do klepania klamrowych w Javie).

Pozostaje zrozumieć, co robią metody m-bind oraz m-result. Pochodzą one z monady, w ramach której działamy, co w naszym przypadku będzie monadą maybe-m. Poniżej jej definicja.

(defmonad maybe-m
   "Monad describing computations with possible failures. Failure is
    represented by nil, any other value is considered valid. As soon as
    a step returns nil, the whole computation will yield nil as well."
   [m-zero   nil
    m-result (fn m-result-maybe [v] v)
    m-bind   (fn m-bind-maybe [mv f]
               (if (nil? mv) nil (f mv)))
    m-plus   (fn m-plus-maybe [& mvs]
        (first (drop-while nil? mvs)))
    ])
Funkcja m-result oczekuje na wejściu pojedynczego parametru i po prostu zwraca go. Innymi słowy, przekazuje wejście na wyjście bez zmian. Tym samym, opakowanie formy przez m-result nie zmienia wyniku formy (bo m-result nic nie zmienia). Jedynym "cudem" jest działanie m-bind, które występuje, aż 5 razy w naszym przykładzie. Odpowiada to liczbie wykonywanych obliczeń. Funkcja m-bind dostaje na wejściu parametr (wartość monadyczną) i sprawdza, czy jest nil. Jeśli tak, zwraca nil. Zwrócenie nil, efektywnie wyłącza wykonanie kolejnych obliczeń, które mogłyby być niezwykle kosztowne - czasowo i/lub finansowo (!)

A zatem jaki będzie wynik?

Teraz wszystko powinno być jaśniejsze. W wyniku działania monady maybe-m otrzymamy wynik ostatniego wyrażenia tylko wtedy, gdy każde z obliczeń (może ich być dowolna liczba) *nie* zwróci nil. W przypadku zwrócenia nil, przetwarzanie się zakończy. Stąd możemy zobaczyć na konsoli jedynie 1 i 2 z przypisania m i n, które "dotknie" nil, co zakończy wykonywanie kolejnych instrukcji.

user=> (domonad maybe-m
  [m (do (println 1) :m)
   n (do (println 2) nil)
   o (do (println 3) :o)
   p (do (println 4) nil)
   r (do (println 5) :r)]
    (println m n o p r))
1
2
nil
Czy teraz jest już łatwiej zrozumieć, dlaczego warto stosować monady i dlaczego nazywa się je kontenerami, podobnie jak kontener servletów, czy EJB znanych z serwerów JEE? Monady, podobnie jak kontenery JEE, udostępniają środowisko dla naszych bytów - konstrukcji obliczeniowych. Obliczenia w monadach mogą być tak proste jak w naszym przykładzie, w którym sprowadziłem do przypisania z poprzedzającym je wypisaniem na ekran, ale równie dobrze mogą być tak skomplikowane jak pobranie danych z bazy danych, albo ekranu prezentowanego użytkownikowi i tylko w przypadku zwrócenia wartości innej niż nil (albo wybranej przez nas, kiedy stworzymy własną monadę), pozwala się im na kontynuowanie wykonania kolejnych obliczeń - na ich uruchomienie.

Do zapamiętania: zamiast serii if'ów, wystarczy jedynie opakować serię obliczeń w monadę maybe-m i oczekiwać zatrzymania przetwarzania, w momencie pojawienia się niedozwolonej wartości - w naszym przypadku nil. Co było do okazania :-)