blog Daniela Janusa
Leniwa wersja makra ->
11 listopada 2010
Jacek Laskowski podaje ciekawy przykład wykorzystania monad w Clojure -- aplikowanie kolejnych funkcji do wyrażenia, dopóki ma ono wartość nie będącą nil
. Przykład mi się podoba, bo jest prosty, ale nie trywialny – wykorzystuje monadę maybe
do eleganckiego rozwiązania rzeczywistego problemu. Jest to w dodatku problem, z którym borykają się czasem programiści piszący w Javie, co widać choćby w tym wątku.
Nie mogę powiedzieć, że rozumiem monady. Owszem, znam definicję i potrafię napisać program w Haskellu wykorzystujący monadę IO
, ale każdy monadyczny kod, jaki dotychczas widziałem, dawał się zapisać równie zwięźle bez użycia monad w językach, które mniej religijnie podchodzą do niewpływania funkcji na stan świata zewnętrznego. Tak jest również w tym przypadku.
Jacek tak postawił oryginalny problem:
Napisać metodę, która zwraca walutę, dla pracownika z danego departamentu międzynarodowej korporacji. Pracownik jest przypisany do departamentu (np. poprzez mapę – pracownik-departament), departament do kraju, a kraj do waluty. Funkcja na wejściu dostaje nazwę, identyfikator, lub cokolwiek jednoznacznie reprezentującego pracownika, a na wyjściu symbol waluty, np. dla „Jacek” powinno być „PLN”, a dla „John” „USD”, a „Tomek” i „Mateusz” dawaliby „CHF”.
Moje pierwotne rozwiązanie jest po prostu złożeniem trzech funkcji:
(defn pracownik->waluta [p]
(-> p pracownik->departament departament->kraj kraj->waluta))
O ile wszystkie z nich są mapami (pamiętamy, że w Clojure mapy implementują interfejs IFn
, można je więc uważać za funkcje i wołać jak funkcje), to dla żadnej wartości wejściowej nie zostanie rzucony NullPointerException
, ponieważ wywołanie mapy dla nieistniejącego klucza zwraca nil
.
Jacek słusznie zauważa jednak, że wykonujemy w ten sposób więcej pracy niż trzeba. Jeżeli już w mapie pracownik->departament
nie ma departamentu dla danego pracownika, to otrzymany nil
zostanie „przepchnięty” przez pozostałe dwie mapy, zanim będzie zwrócony. Można kontrargumentować, że taka implementacja pracownik->waluta
jest bardzo czytelna i mała strata wydajności jest niewielką ceną do zapłacenia za tę czytelność. Co jednak, gdy nie możemy lub nie chcemy zgodzić się na taką stratę, a jednocześnie nie chcemy stracić czytelności?
Odpowiedzią jest makro, które nazwałem and->
. Działa ono tak samo, jak ->
, z tą różnicą, że kolejne wartości oblicza tylko jeśli po drodze nie pojawiło się false
lub nil
, podobnie jak and
(stąd nazwa). Oto ono:
(defmacro and->
([x] x)
([x form] `(when-let [y# ~x] (~form y#)))
([x form & more] `(when-let [y# (and-> ~x ~form)]
(and-> y# ~@more))))
Implementacja jest bardzo zbliżona do zwykłego ->
, którego kod w Clojure 1.2 wygląda tak. W stosunku do ->
moje makro jest dla większej poglądowości trochę uproszczone i nie obsługuje konstrukcji typu (and-> mapa (get :klucz))
, które normalnie rozwijane są do (get mapa :klucz)
.
Warto zwrócić uwagę na to, że argument makra nigdy nie jest wyliczany w jego rozwinięciu więcej niż raz; zawsze jest wiązany do lokalnego symbolu o unikatowej nazwie za pomocą when-let
. Jest to jedna z reguł pozwalających unikać błędów w makrach. Szerszy opis tego problemu, występującego też w Common Lispie, można znaleźć w rozdziale 8 książki „Practical Common Lisp”.
Aktualizacja (7.12.2010)
Jak się okazuje, clojure-contrib zawiera już takie makro! Nazywa się -?>
i można je znaleźć w przestrzeni nazw clojure.contrib.core
. To już któryś raz, kiedy okazuje się, że jakieś użyteczne makro lub funkcja już jest w contrib i nie trzeba jej było pisać.