Poczytaj mi Clojure, cz. 14: Makra

Makra to jeden z mechanizmów metaprogramowania – zbioru technik umożliwiających odczytywanie, tworzenie i modyfikowanie programów przez inne programy lub przez nie same. Są one jedną z charakterystycznych cech dialektów języka Lisp. Pozwalają przekształcać kod źródłowy programu zanim dojdzie do jego ewaluacji i stwarzać nowe konstrukcje składniowe. Dzięki nim jesteśmy w stanie nie tylko rozszerzać możliwości języka, ale nawet budować tzw. języki dziedzinowe, dostosowane do wyrażania specyficznych problemów i ich rozwiązań.

Makra

Makro (ang. macro, z gr. makros – długi, obszerny, wielki) to skrócona nazwa makroinstrukcji (ang. macroinstruction). Terminu tego użyto po raz pierwszy w wydanej w Nowym Jorku w roku 1959 publikacji zatytułowanej “The Share 709 System: Programming and Modification”. Opisuje ona m.in. specjalne instrukcje assemblera, które nie były typowymi mnemonikami, dającymi bezpośrednio przełożyć się na kod maszynowy, lecz służyły do generowania zestawów innych instrukcji. Gdyby nie makroinstrukcje, programista musiałby wielokrotnie wprowadzać długie sekwencje rozkazów, aby wyrażać często przeprowadzane operacje na danych.

Makra to konstrukcje języków programowania, które służą do generowania lub modyfikowania kodu źródłowego. Mogą być implementowane jako zestawy reguł lub wzorców, które określają jak proces ten powinien przebiegać, tzn. w jaki sposób podany na wejście kod źródłowy ma być przekształcany, aby zastąpić istniejący. W językach kompilowanych makra uruchamiane są zanim dojdzie do właściwej kompilacji i konsolidacji.

Makra języka Clojure operują na składni programu i w użytkowaniu przypominają funkcje: można je definiować, wywoływać, przyjmują argumenty i zwracają wartości. Od funkcji różnią się m.in. tym, że ich podprogramy są realizowane podczas pierwszego etapu kompilacji, zanim dojdzie do generowania kodu bajtowego.

Przypomnijmy, że proces generowania wynikowej postaci programu i jego późniejszego uruchamiania w większości kompilatorów przebiega wg. następujących kroków:

  1. Analiza leksykalna.
  2. Analiza syntaktyczna.
  3. Analiza semantyczna.
  4. Generowanie i optymalizacja kodu wynikowego.
  5. Konsolidacja kodu wynikowego.
  6. Uruchamianie kodu wynikowego.

W przypadku Clojure będzie to przekładało się na następujące etapy (z podziałem na lispowe komponenty uczestniczące w procesie):

  1. Czytnik:

    1. Wczytanie tekstu kodu źródłowego (analiza leksykalna).
    2. Zmiana S-wyrażeń w wyrażenia (analiza syntaktyczna).
    3. Rozwijanie makr i aktualizacja drzewa wyrażeń.
  2. Ewaluator:

    1. Rozpoznawanie formuł (analiza semantyczna).
    2. Generowanie kodu bajtowego.
    3. Uruchamianie kodu bajtowego i wartościowanie formuł.

Argumentami makr będą w Clojure wyrażenia, a dokładniej pamięciowe reprezentacje S-wyrażeń, które powstały w efekcie analizy składniowej. Wartościami zwracanymi również będą wyrażenia, czyli struktury danych, które zostaną włączone do istniejącego zestawu wyrażeń znajdującego się w pamięci.

Gdy spojrzymy na wywołania niektórych makr, to na pierwszy rzut oka nie można rozróżnić ich od zwykłych funkcji czy formuł specjalnych:

Przykłady wywoływania wbudowanych makr języka Clojure
1
2
3
4
(and     (= 2 2) (< 1 2) "prawda")  ; => "prawda"
(if-let  [x 5] x)                   ; => 5
(dotimes [x 2] (println x))         ; => nil  >> 0 1
(doto    1 println)                 ; => 1    >> 1

Wiele języków wyposażonych jest w systemy makr, których definiowanie wymaga korzystania ze specyficznej składni. Mamy tam do czynienia z tzw. językami makrowymi (ang. macro languages), zwanymi też makrojęzykami. W przypadku Clojure (oraz innych Lispów) makra korzystają z tej samej składni co język, a w dodatku mogą odwoływać się do wbudowanych i tworzonych przez programistę formuł (np. funkcji czy innych makr). Dlatego w powyższym przykładzie nie możemy rozpoznać, czy mamy do czynienia z formułami funkcyjnymi czy makrowymi.

Tworzenie lispowych makr – w przeciwieństwie np. do makr znanych z języka C – polega na definiowaniu specyficznych konstrukcji bardzo przypominających funkcje, których zadanie polega na transformowaniu przekazanych w argumentach struktur danych reprezentujących kod źródłowy po analizie syntaktycznej. W przypadku Clojure będą to listy, wektory, mapy, zbiory lub wartości atomowe (napisy, liczby, symbole w formach stałych itp.).

W traktowaniu kodu źródłowego jak danych pomaga obecne w Lispach zapętlenie faz interpretowania programu wczytaj–oblicz oraz cecha zwana jednoznacznością lub homoikonicznością (ang. homoiconicity). Oznacza ona, że wewnętrzna reprezentacja źródła programu korzysta z tych samych struktur danych, jakie są powszechnie używane w języku, a kod źródłowy ma taką samą strukturę jak reprezentujące go abstrakcyjne drzewo składniowe (ang. Abstract Syntax Tree, skr. AST). Można też powiedzieć, że AST i składnia języka są izomorficzne.

Makra w Clojure nie operują więc na tekstowej reprezentacji kodu, lecz na elementach drzewa składniowego, które składa się ze struktur danych obsługiwanych przez język. Możemy przetwarzać je, korzystając z wielu wbudowanych funkcji.

Korzystanie z makr

Korzystanie z makr polega na ich tworzeniu i wywoływaniu. Tworzenie wymaga użycia konstrukcji służących do definiowania makr, a wywoływanie odbywa się tak samo, jak wywoływanie funkcji: korzystając z listowego S-wyrażenia, podajemy nazwę makra, a następnie przyjmowane przez nie wyrażenia.

Istotna różnica polega na tym, że formuły makrowe będą wartościowane zanim dojdzie do wartościowania pozostałych formuł wyrażonych w programie, w fazie tzw. rozwijania makr (ang. macro-expansion) lub inaczej makroekspansji (ang. macroexpansion).

Jak mogliśmy zauważyć wcześniej w Clojure rozwijanie makr następuje po wczytaniu kodu źródłowego do pamięci (odzwierciedleniu w AST), ale przed jego wartościowaniem.

Tworzenie makr

Tworzenie makr przypomina tworzenie funkcji i polega na ich definiowaniu – z określaniem argumentowości oraz ciał. W przeciwieństwie do funkcji podawane jako argumenty makr S-wyrażenia nie będą wartościowane przed ich przekazaniem, lecz odpowiadające argumentom parametry zostaną powiązane z reprezentującymi S-wyrażenia strukturami danych.

Wartości parametrów będą formułami stałymi, tak jakby przekazywane S-wyrażenia zostały tuż przed przekazaniem zacytowane, aby nie dopuścić do ich wartościowania. Jeżeli na przykład jako argument przekażemy niezacytowany symbol, to w makrze nie będzie on reprezentował powiązanej z nim wartości, lecz formułę stałą wyrażającą obiekt tego symbolu. Podobnie z innymi atomami, jak również wyrażonymi symbolicznie złożonymi strukturami: listą, mapą, wektorem czy zbiorem. Jeżeli sobie tego zażyczymy, będziemy mogli dla wybranych parametrów dokonać w ciele makra ich wartościowania – służą do tego odpowiednie elementy składniowe i funkcje.

Również obsługa wartości zwracanych przez makra różni się od tego, z czym mamy do czynienia w funkcjach. Oczekuje się, że wartością ostatniego wyrażenia obliczanego w ciele makra będzie struktura danych zawierająca reprezentację poprawnego kodu źródłowego – może nią być np. lista, mapa, wektor, zbiór czy pojedyncza wartość. Tak stworzone wyrażenie nie będzie wartościowane, lecz umieszczone w odpowiednim miejscu drzewa składniowego (tam, gdzie doszło do wywołania makra). Będziemy mieli do czynienia z procesem analogicznym do wstawienia S-wyrażenia w odpowiednie miejsce tekstu programu. Dopiero rezultat wartościowania tak wygenerowanego wyrażenia, do którego dojdzie po zakończeniu makroekspansji i po późniejszym uruchomieniu programu, stanie się widoczną w programie wartością zwracaną.

Definiowanie makr, defmacro

Makro defmacro pozwala definiować makra w podobny sposób, w jaki defn umożliwia tworzenie nazwanych funkcji. Do makr również można odwoływać się z użyciem symbolicznych identyfikatorów, a odwołania te przechowywane są w zmiennych globalnych internalizowanych w przestrzeniach nazw.

Użycie:

  • (defmacro nazwa dok? metadane?  parametry & wyrażenie…),
  • (defmacro nazwa dok? metadane? (parametry & wyrażenie…)… metadane?);

gdzie poszczególne etykiety mają następujące znaczenia:

(Dokładne informacje dotyczące argumentów wywołania i konstruowania wektorów z parametrami znajdują się w sekcji “Argumenty funkcji” rozdziału VII).

Pierwszym argumentem wywołania defmacro powinna być nazwa makra wyrażona niezacytowanym symbolem. Następnym, opcjonalnym argumentem może być łańcuch dokumentujący, a kolejnym (również nieobowiązkowym) mapa metadanych, które zostaną ustawione dla zmiennej globalnej odwołującej się do obiektu makra. Nic nie stoi też na przeszkodzie, aby metadane zostały ustawione w symbolu określającym nazwę makra, zamiast w osobnej mapie – zostaną wtedy skopiowane do obiektu typu Var odnoszącego się do makra.

Po nazwie i opcjonalnym łańcuchu dokumentującym i/lub metadanych należy podać wektor parametryczny, który określa przyjmowane argumenty. W wektorze możemy korzystać z destrukturyzacji.

Kolejne przekazywane do defmacro argumenty to wyrażenia składające się na ciało makra.

Podczas realizowania makra podane argumenty nie będą wartościowane, lecz odpowiadające im parametry zostaną powiązane z reprezentującymi je w drzewie składniowym strukturami pamięciowymi. Jeżeli na przykład jako argument makra podamy S-wyrażenie (+ 2 2), to wartością, z którą powiązany zostanie parametr, będzie lista dająca się symbolicznie wyrazić jako '(clojure.core/+ 2 2). Gdybyśmy taki sam argument przekazali do funkcji, wartością parametru byłaby liczba 4, bo przed przekazaniem obliczona zostałaby wartość wyrażenia.

Wartość ostatnio obliczonego w ciele makra wyrażenia powinna być strukturą danych, którą da się zapisać jako S-wyrażenie, czyli wyrażająca kod źródłowy. Zostanie on umieszczony w drzewie składniowym, w miejscu wywołania makra, a w fazie wartościowania wyrażeń obliczony. Rezultat tego wartościowania możemy nazwać wartością zwracaną przez makro.

Podobnie jak w przypadku formuły specjalnej fn możemy podać wiele argumentowości i wiele ciał makr. Każde z nich powinno być w takim przypadku zapisane jako listowe S-wyrażenie, którego pierwszym elementem jest wektor parametryczny. Będziemy wtedy mieć do czynienia z tzw. makrem wieloczłonowym, które przypomina funkcję wieloczłonową (zwaną też wieloargumentowościową). Podczas wywołania makra wartościowane będzie to ciało, do którego wektora parametrycznego pasuje przekazany podczas wywołania zestaw argumentów.

Makro defmacro zwraca obiekt typu funkcyjnego, a jego efektem ubocznym jest stworzenie identyfikowanej symbolem zmiennej globalnej w bieżącej przestrzeni nazw, która będzie powiązana z tym obiektem.

Przykłady użycia makra defmacro
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(defmacro do-tekstu
  "Zwraca tekstową reprezentację podanego wyrażenia."
  [wyrażenie]
  (str "WYWOŁANO: " wyrażenie))

(do-tekstu (+ 1 2 3))    ; => "WYWOŁANO: (+ 1 2 3)"
(do-tekstu (+ 1, 2, 3))  ; => "WYWOŁANO: (+ 1 2 3)"
(do-tekstu "test")       ; => "WYWOŁANO: test"

(defmacro odwróć-argumenty
  "Odwraca argumenty podanych wyrażeń."
  [& wyrażenia]
  (cons 'do (map #(cons (first %1) (reverse (rest %1))) wyrażenia)))

(odwróć-argumenty
 (println 1 2 3)
 (vector  1 2 3))

; >> 3 2 1
; => [3 2 1]

Zauważmy, że w linii nr 7 przekazujemy do wywołania makra listowe S-wyrażenie (+ 1, 2, 3), którego elementy oddzielone zostały separatorem w postaci przecinka. Kiedy jednak makro zwraca wartość w postaci łańcucha znakowego (rezultat wywołania funkcji str na strukturze wejściowej), przecinków nie ma: (+ 1 2 3).

Dzieje się tak dlatego, że argumenty lispowych makr nie są po prostu tekstową reprezentacją fragmentów kodu źródłowego, ale elementami struktury drzewa składniowego.

Ponieważ dialekty języka Lisp cechuje jednoznaczność (homoikoniczność), więc odzwierciedlająca przekazane S-wyrażenie struktura (na przykład lista) będzie wewnątrz makra potraktowana jak dane, na których się operuje (np. zmienia miejscami pewne elementy, dodaje nowe czy usuwa istniejące).

W naszym przykładzie listowe S-wyrażenie przekazane jako argument jest w pamięci reprezentowane w postaci listy, a dokładnie obiektu typu clojure.lang.PersistentList. Na liście tej możemy dokonywać różnych przekształceń, a także odczytywać jej zawartość, korzystając z rozmaitych funkcji języka.

Rezultatem (ostatnim obliczanym wyrażeniem makra) musi być taka struktura danych, której elementy wyrażają kod źródłowy. Wartością zwracaną przez makro będzie rezultat wartościowania tej struktury.

W przypadku listy możemy na jej pierwszym miejscu umieścić nazwę symbolu odnoszącego się do jakiejś funkcji, makra lub formuły specjalnej, a pozostałe elementy potraktować jak przekazywane argumenty. Gdy będzie ona potem wartościowana, dojdzie do wywołania odpowiedniego podprogramu – zupełnie tak samo, jak przy listowym S-wyrażeniu umieszczonym w danym miejscu kodu.

W naszym konkretnym przykładzie (linia nr 4) nie tworzymy listy, ale atomową wartość: łańcuch znakowy, w którym umieściliśmy tekstową reprezentację struktury danych wyrażającej kod podany jako argument makra. Po przeliczeniu wartością będzie więc ten sam łańcuch znakowy, ponieważ mamy do czynienia z formułą stałą.

Zwróćmy jeszcze uwagę na znacznik cytowania w linii nr 13. Budujemy tam listę, która po zakończeniu pracy makra stanie się kodem źródłowym. Będzie ona strukturą pochodną względem podanej jako argument przy czym kolejność wszystkich oprócz ostatnich elementów w każdej z list wejściowych zostanie odwrócona. Problemem jest jednak to, że mamy tam do czynienia z wieloma wyrażeniami, które trzeba jakoś zgrupować. W tym celu do nadrzędnej listy jako pierwszy element wstawiamy (z użyciem conj) symbol do. Musimy go zacytować, bo w przeciwnym razie zostałby on przeliczony, zamiast reprezentować formułę specjalną do.

Zobacz także:

Cytowanie składniowe, `⁣

Pojedynczy symbol `⁣ czyli grawis (ang. grave accent), zwany też symbolem odwróconego pojedynczego cudzysłowu (ang. backquote) lub odwróconej fajki (ang. backtick), jest nazwą makra czytnika, które sprawia, że umieszczone po nim S-wyrażenie będzie rekurencyjnie zacytowane z użyciem tzw. cytowania składniowego.

Użycie:

  • `wyrażenie.

Cytowanie składniowe (ang. syntax-quote) różni się od konwencjonalnego cytowania (wyrażanego znakiem pojedynczego apostrofu) tym, że możemy w nim korzystać ze specjalnych znaczników, które dla wybranych S-wyrażeń, wchodzących w skład zacytowanego S-wyrażenia, pozwalają:

Cytowanie składniowe działa dla S-wyrażeń, które w pamięci reprezentowane są z użyciem symboli, list, wektorów, zbiorówmap. Wszystkie inne konstrukcje językowe będą pozostawione w postaci niezacytowanej (potraktowane jak wartości stałe).

Poddane cytowaniu składniowemu symbole będą przekształcane do formuł stałych z dookreślonymi przestrzeniami nazw. Wyjątek stanowiły będą symbole, do których dodano (opisany dalej) specjalny znacznik # – w ich przypadku dojdzie do wygenerowania w danych miejscach symboli o unikatowych nazwach.

Poddane cytowaniu składniowemu kolekcje reprezentujące złożone S-wyrażenia (listy, wektory, mapyzbiory) zostaną rekurencyjnie zacytowane, przy czym w procesie tego cytowania zastosowanie będą mogły znaleźć wspomniane wcześniej specjalne znaczniki wyłączające cytowanie dla pewnych elementów.

Z cytowania składniowego skorzystamy zazwyczaj w makrach, ale nic nie stoi na przeszkodzie, aby używać go w dowolnym innym miejscu programu, jeżeli mamy taką potrzebę.

Przykłady użycia cytowania składniowego
1
2
3
4
5
6
(def a :a) (def b :b) (def c :c)

`(1 2 3)               ; => (1 2 3)
`(vector a b)          ; => (clojure.core/vector user/a user/b)
`(vector 1 2 3 [a b])  ; => (clojure.core/vector 1 2 3 [user/a user/b])
`[a b 1 2 3]           ; => [user/a user/b 1 2 3]

Zauważmy, że zastosowanie cytowania składniowego w odniesieniu do symboli prowadzi do wygenerowania ich stałych reprezentacji z dookreślonymi przestrzeniami nazw. Dzięki temu unikamy konfliktów identyfikatorów w fazie obliczania wartości wyrażenia wygenerowanego przez makro lub podczas wartościowania wybranych wyrażeń z użyciem wspomnianych wcześniej, specjalnych znaczników.

Sprawdźmy jeszcze z jakimi typami danych mamy dokładnie do czynienia, gdy stosujemy składniowe cytowanie S-wyrażeń:

Przykłady użycia cytowania składniowego
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
(defn jaki-typ [wyr]
  (list
   (symbol (last (clojure.string/split (str (type wyr)) #"\.")))
   (if (coll? wyr) (map jaki-typ wyr) wyr)))

(jaki-typ `(1 2 3))
; => (Cons                           komórki Cons
; =>  ((Long 1)                      liczba całkowita
; =>   (Long 2)                      liczba całkowita
; =>   (Long 3)))                    liczba całkowita

(jaki-typ `(vector a b))
; => (Cons                           komórki Cons
; =>  ((Symbol clojure.core/vector)  symbol
; =>   (Symbol user/a)               symbol
; =>   (Symbol user/b)))             symbol

(jaki-typ `(vector 1 2 3 [a b]))
; => (Cons                           komórki Cons
; =>  ((Symbol clojure.core/vector)  symbol
; =>   (Long 1)                      liczba całkowita
; =>   (Long 2)                      liczba całkowita
; =>   (Long 3)                      liczba całkowita
; =>   (PersistentVector             wektor
; =>    ((Symbol user/a)             symbol
; =>     (Symbol user/b)))))         symbol

(jaki-typ `[a b 1 2 3])
; => (PersistentVector               wektor          
; =>  ((Symbol user/a)               symbol
; =>   (Symbol user/b)               symbol
; =>   (Long 1)                      liczba całkowita
; =>   (Long 2)                      liczba całkowita
; =>   (Long 3)))                    liczba całkowita

Widzimy jakie struktury i typy danych odpowiadają poszczególnym elementom S-wyrażeń, które zostały zacytowane. Zauważmy, że z takimi samymi strukturami będziemy mieć do czynienia podczas przetwarzania argumentów makr, ponieważ wyrażenia są w nich przekazywane w postaci odpowiadających im formuł stałych.

Transponowanie S-wyrażeń, ~

Znacznik ~ (tylda) to makro czytnika, które w cytowaniu składniowym wyraża operację transponowania lub odwrotnego cytowania (ang. unquote). Służy do wskazywania tych S-wyrażeń, których wartość ma zostać obliczona, mimo że zawierające je S-wyrażenia zostały zacytowane.

Użycie:

  • ~ wyrażenie.

Znacznik należy umieścić przed S-wyrażeniami, których ma dotyczyć (znak spacji można pominąć). Użycie go sprawi, że ich pamięciowe reprezentacje zostaną wyłączone z procesu cytowania składniowego i w ich miejscu pojawią się wartości będące efektem obliczenia wyrażeń. Dokładniej rzecz ujmując, wyrażenia te zostaną na etapie makroekspansji odpowiednio oznaczone i potem, w fazie ewaluacji zastąpione konkretnymi wartościami.

Przypomina to mechanizm szablonów (ang. templates) znany z generatorów stron WWW czy skryptów do automatycznego odpowiadania na e-maile. Możemy określać pewne fragmenty, które będą wartościowane.

Przykłady użycia cytowania składniowego z transponowaniem S-wyrażeń
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
;; definiujemy zmienne globalne identyfikowane symbolami a, b i c:

(def a :a) (def b :b) (def c :c)

;; transponujemy zagnieżdżone wektorowe S-wyrażenie:

`(vector 1 2 3 ~[a b c])
; => (clojure.core/vector 1 2 3 [:a :b :c])

;; transponujemy całe listowe S-wyrażenie:

`~(list a b c)
; => (:a :b :c)

;; transponujemy dwa pierwsze symbole wektorowego S-wyrażenia:

`[~a ~b c 1 2 3]
; => [:a :b user/c 1 2 3]

;; transponujemy wybrane symbole zagnieżdżonego, listowego S-wyrażenia:

(defmacro sumuj [a b] `(+ ~a ~b))
(sumuj 1 2)
; => 3

Transponowanie S-wyrażeń z rozplataniem, ~@

Znacznik ~@ (tylda i małpka) to makro czytnika, które w cytowaniu składniowym wyraża operację transponowania z rozplataniem, zwaną też transponowaniem ze spłaszczaniem lub odwrotnym cytowaniem z rozplataniem (ang. unquote-splice).

Ten rodzaj transponowania działa podobnie do omówionego wcześniej, lecz zastosować go możemy tylko w odniesieniu do struktur złożonych (reprezentowanych z użyciem listy, wektora, mapy lub zbioru), które same również są elementami złożonych wyrażeń.

Użycie:

  • ~@ wyrażenie-złożone.

Elementy wyrażenia poprzedzonego znacznikiem transponowania z rozplataniem będą najpierw wartościowane, a następnie uzyskana kolekcja zostanie przetworzona w taki sposób, że każdy z jej elementów zostanie dodany do wyrażenia nadrzędnego (obejmującego), stając się jego elementem.

Przydaje się to na przykład w obsłudze makr, które operują na parametrze wariadycznym.

Przykłady użycia cytowania składniowego i transponowania z rozplataniem
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(def a :a) (def b :b) (def c :c)

`(vector 1 2 3 ~@[a b c])
; => (clojure.core/vector 1 2 3 :a :b :c)

`(~@(list a b c))
; => (:a :b :c)

`(a b c ~@[1 2 3])
; => (user/a user/b user/c 1 2 3)

`(1 2 3 ~@(list (+ 2 2)))
; => (1 2 3 4)

(defmacro sumuj [& args] `(+ ~@args))
(sumuj 1 2 3 4)
; => 10

Spójrzmy jeszcze na przykład makra, w którym transponowanie z rozplataniem pomaga przetwarzać parametr wariadyczny, zawierający wiele reprezentacji S-wyrażeń przekazanych jako argumenty:

Przykład transponowania z rozplataniem w definicji makra
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(defmacro rób-parzyste
  "Oblicza wartości parzystych wyrażeń podanych jako argumenty
  i zwraca wartość ostatniego."
  [& wyrażenia]
  `(do
     ~@(take-nth 2 (rest wyrażenia))))

(rób-parzyste
 (println "pierwsze")
 (println "drugie")
 (+ 2 2)
 "koniuszek")

; >> drugie
; => "koniuszek"

Generowanie unikatowych symboli, #

Jeżeli wewnątrz zacytowanego składniowo S-wyrażenia pojawi się symboliczna nazwa zakończona znakiem # (kratka) i nie będzie miała ona dookreślonej przestrzeni nazw, to taki zapis nie zostanie potraktowany jak reprezentacja symbolu i zacytowany, lecz w jego miejsce podstawiony będzie rezultat jednokrotnego (dla podanej nazwy) wywołania funkcji gensym, która służy do tworzenia unikatowych symboli.

Użycie:

  • symbol#.

Znacznik generatora symbolu umieszczamy za jego nazwą (dołączając go do niej). Sprawi on, że wygenerowany zostanie symbol o unikatowej nazwie stworzonej przez dodanie do podanej łańcuchów znakowych i losowo wybranych cyfr.

Generowanie symboli w makrach pozwana na bezpieczne korzystanie z nich w konstruowanych S-wyrażeniach, ponieważ pomaga unikać nieoczekiwanych przesłonięć identyfikatorów pochodzących z obejmujących wyrażeń.

Spójrzmy na prosty przykład zacytowanego wywołania konstrukcji let:

1
2
`(let [a 2] a)
; => (clojure.core/let [user/a 2] user/a)

Rezultatem jest lista, której pierwszy element to symbol nazywający formułę specjalną let (z ustawioną przestrzenią nazw clojure.core), drugi to wektor powiązaniowy, a ostatnim jest symbol a z przestrzenią nazw user.

Co by się stało, gdybyśmy chcieli wartościować powyższe wyrażenie, bo byłoby ono rezultatem pracy makra?

1
2
3
4
5
6
(defmacro pisz-dwa []
  `(let [a 2]
     a))

(pisz-dwa)
; >> java.lang.RuntimeException: Can't let qualified name: user/a

Zgłoszony wyjątek informuje nas o tym, że nie można użyć symbolu z dookreśloną przestrzenią nazw w wektorze powiązaniowym. Musimy więc znaleźć sposób na tworzenie symboli na użytek powiązań leksykalnych w generowanych wyrażeniach. Możemy to zrobić na przykład z wykorzystaniem funkcji symbol, która na podstawie łańcucha znakowego wygeneruje symbol w momencie wartościowania wyrażenia:

1
2
3
4
5
6
(defmacro pisz-dwa []
  `(let [~(symbol "a") 2]
     ~(symbol "a")))

(pisz-dwa)
; => 2

Inna metoda to użycie transponowania względem zacytowanych symboli:

1
2
3
4
5
6
(defmacro pisz-dwa []
  `(let [~'a 2]
     ~'a))

(pisz-dwa)
; => 2

Sposób ten zadziała, jednak po pierwsze zapis będzie mniej czytelny, a po drugie: jeżeli gdziekolwiek w wyrażeniu wprowadzanym do makra (zakładając, że argumenty makra są aktywnie używane) znajdzie się symbol o takiej samej nazwie, jak użyta przez nas, możemy mieć kłopoty, bo w trakcie wartościowania któraś wartość zostanie przesłonięta. Popatrzmy:

1
2
3
4
5
6
7
8
9
(defmacro dodaj-dwa [x]
  `(let [~'a 2]
     (+ ~'a ~x)))

(dodaj-dwa (inc 1))
; => 4

(let [a 10] (dodaj-dwa a))
; => 4             ???!

Spodziewaliśmy się, że ostatnie wywołanie zwróci wartość 12, a uzyskaliśmy 4. Dlaczego? Wartością rozwinięcia ostatniego wywołania makra (linia nr 9) było wyrażenie, którego symboliczna postać wygląda następująco:

1
2
(let* [a 2]
  (clojure.core/+ a a))

Uwzględniając kontekst, będziemy mieli do czynienia z następującym kodem:

1
2
3
(let [a 10]
  (let* [a 2]
    (clojure.core/+ a a)))

Podany w wywołaniu (dodaj-dwa a) symbol a został zgodnie z oczekiwaniami podstawiony w miejsce ~x. Pamiętajmy, że mamy do czynienia z makrem, więc argumentem wejściowym (parametr x) nie będzie wartość, z którą przekazywany symbol powiązano, lecz jego własna reprezentacja, ponieważ wartościowanie wyrażeń kodu źródłowego jeszcze się nie rozpoczęło.

Efektem pracy makra będzie więc wygenerowanie wyrażenia, w którym najpierw dochodzi do powiązania symbolu a z wartością 2, a następnie do sumowania wartości tego symbolu z wartością symbolu przekazanego z zewnątrz: (clojure.core/+ a a).

Ponieważ jednak generowany przez makro symbol ma taką samą nazwę, jak nazwa symbolu przekazana do makra, w momencie wartościowania dojdzie do konfliktu. Generowane przez makro wyrażenie dokonało powiązania symbolu a z wartością 2 na samym początku, a tym samym przesłonione zostało wcześniejsze powiązanie go z wartością 10. W rezultacie po wartościowaniu pojawia się nieoczekiwany wynik.

Musimy więc znaleźć sposób na tworzenie symboli, których nazwy byłyby różne od nazw symboli potencjalnie przekazywanych do makr. Taką pewność daje funkcja gensym, która stwarza symbole o niepowtarzalnych nazwach:

1
2
3
4
5
6
7
8
9
10
(defmacro dodaj-dwa [x]
  (let [s (gensym 'a)]
    `(let [~s 2]
       (+ ~s ~x))))

(dodaj-dwa (inc 1))
; => 4

(let [a 10] (dodaj-dwa a))
; => 12

Udało się, jednak w ciele makra musieliśmy wprowadzić dodatkowe powiązanie leksykalne (s), aby pamiętać stworzoną nazwę symbolu. Wywoływanie gensym za każdym razem sprawiłoby, że w każdym miejscu nazwy byłyby inne.

Z pomocą przychodzi tu makro czytnika służące go generowania symboli o unikatowych nazwach:

Przykład generowania symboli w cytowaniu składniowym
1
2
3
4
5
6
7
8
9
(defmacro dodaj-dwa [x]
  `(let [a# 2]
     (+ a# ~x)))

(dodaj-dwa (inc 1))
; => 4

(let [a 10] (dodaj-dwa a))
; => 12

Pierwsze użycie generatora symboli wewnętrznie skorzysta z funkcji gensym, aby stworzyć niepowtarzalną nazwę symbolu, która zastąpi zapis jego wywołania. Każde kolejne zastosowanie tego makra czytnika w odniesieniu do symbolicznej nazwy, wobec której już wcześniej zostało użyte, sprawi, że podstawiona zostanie wcześniej wytworzona nazwa.

Spójrzmy jeszcze, jak będzie wyglądało rozwinięcie ostatniej wersji makra wraz z kontekstem jego wywołania:

1
2
3
(let [a 10]
  (let* [a__10986__auto__ 2]
    (clojure.core/+ a__10986__auto__ a)))

Widzimy, że zapis a# został we wszystkich miejscach zamieniony w a__10986__auto__ i nie dochodzi już do konfliktu symbolicznych nazw w powiązaniach leksykalnych.

Diagnozowanie makr

Diagnostyka makr jest potrzebna, ponieważ w metaprogramowaniu bazującym na przekształceniach syntaktycznych z wykorzystaniem mechanizmów szablonowych (interpolacja, cytowanie, transpozycja) łatwo o przeoczenia – struktura danych reprezentująca kod źródłowy bywa często składniowo przemieszana z wyrażeniami, które będą wartościowane.

Rozwijanie makra, macroexpand-1

Funkcja macroexpand-1 służy do generowania rozwinięć makr.

Użycie:

  • (macroexpand-1 konstrukcja).

Jeżeli podana konstrukcja, wyrażona strukturą danych (np. zacytowanym S-wyrażeniem lub zbudowana w inny sposób), jest makrem, dokonane zostanie jego rozwinięcie, a wartością zwracaną będzie struktura danych zawierająca reprezentację S-wyrażenia.

Jeżeli podana konstrukcja nie jest makrem, zwrócona będzie jej własna wartość.

Przykłady użycia funkcji macroexpand-1
1
2
3
4
5
6
7
8
9
10
11
12
(defmacro razy       [& args] `(* ~@args))
(defmacro razy-2     [& args] `(razy 2 ~@args))
(defmacro razy-lista [x]      `(list 2 (razy-2 ~x)))

(razy-lista 2)
; => (2 4)

(macroexpand-1 '(razy-2 2))
; => (user/razy 2 2)

(macroexpand-1 '(razy-lista 2))
; => (clojure.core/list 2 (user/razy-2 2))

Rozwijanie makr, macroexpand

Funkcja macroexpand działa podobnie do macroexpand-1, ale jeżeli rezultatem rozwinięcia podanej konstrukcji jest znowu makro, powtarza operację, do momentu, aż uzyskaną wartością nie będzie makro.

Użycie:

  • (macroexpand konstrukcja).

Funkcja wielokrotnie wywołuje macroexpand-1 na podanej konstrukcji, dopóki zwracana wartość jest makrem. Jeżeli zwracana wartość nie jest makrem, wywoływanie jest przerywane, a wartość zwracana.

Przykład użycia funkcji macroexpand
1
2
3
4
5
6
7
8
9
10
11
12
(defmacro razy       [& args] `(* ~@args))
(defmacro razy-2     [& args] `(razy 2 ~@args))
(defmacro razy-lista [x]      `(list 2 (razy-2 ~x)))

(razy-lista 2)
; => (2 4)

(macroexpand '(razy-2 2))
; => (clojure.core/* 2 2)

(macroexpand '(razy-lista 2))
; => (clojure.core/list 2 (user/razy-2 2))

Rekurencyjne rozwijanie makr, macroexpand-all

Funkcja macroexpand-all z przestrzeni nazw clojure.walk działa podobnie do macroexpand, ale dokonuje rekursywnego rozwinięcia wszelkich makr zagnieżdżonych w podanej konstrukcji.

Użycie:

  • (clojure.walk/macroexpand-all konstrukcja).

Funkcja wielokrotnie wywołuje macroexpand-1 na podanej konstrukcji (wyrażonej strukturą danych), a także na wszystkich zagnieżdżonych konstrukcjach, dopóki zwracane wartości są makrami.

Wartością zwracaną jest struktura danych reprezentująca S-wyrażenie, które jest podaną jako argument konstrukcją z rekursywnie rozwiniętymi wszystkimi zawartymi makrami.

Przykład użycia funkcji clojure.walk/macroexpand-all
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(require 'clojure.walk)

(defmacro razy       [& args] `(* ~@args))
(defmacro razy-2     [& args] `(razy 2 ~@args))
(defmacro razy-lista [x]      `(list 2 (razy-2 ~x)))

(razy-lista 2)
; => (2 4)

(clojure.walk/macroexpand-all '(razy-2 2))
; => (clojure.core/* 2 2)

(clojure.walk/macroexpand-all '(razy-lista 2))
; => (clojure.core/list 2 (clojure.core/* 2 2))

Przykład implementacji pętli

Spróbujmy użyć wiedzy, którą mamy o makrach do stworzenia implementacji prostej pętli. Powinna ona przyjmować liczbę całkowitą oraz zero lub więcej wyrażeń powtórzyć wykonywanie ich wszystkich tyle razy, ile wynosi podana wcześniej wartość. Wartością zwracaną powinna być wartość ostatnio obliczonego wyrażenia w ostatnim przebiegu pętli.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(defmacro powtarzaj
  "Powtarza wywołanie podanych wyrażeń określoną liczbę razy.
  Zwraca rezultat ostatniego wywołania ostatniego wyrażenia."
  [powtórzenia & wyrażenia]
  `(let [p# ~powtórzenia]
     (loop [x# 0 l# nil]
       (if (< x# p#)
         (recur (inc x#) (do ~@wyrażenia))
         l#))))

(powtarzaj
 3
 (println "wow")
 (+ 2 2))

; >> wow
; >> wow
; >> wow
; => 4

W wektorze parametrycznym makra określamy, że przyjmuje ono jeden obowiązkowy argument (powtórzenia) i zero lub więcej argumentów opcjonalnych, które zostaną zgrupowane w wartości parametru wariadycznego wyrażenia.

Od linii nr 5 do końca ciała makra mamy zwracaną przez nie strukturę danych. Będzie to składniowo zacytowana lista. Jej pierwszym elementem jest odwołanie do formuły specjalnej let, a drugim wektor powiązaniowy. Dokonujemy w nim wygenerowania symbolu o unikatowej nazwie (p#), który zostanie powiązany z wartością powtórzeń (powtórzenia). Aby ją uzyskać, dokonujemy transpozycji z użyciem znacznika ~.

Dlaczego pobieramy wartość powtórzeń i dokonujemy jej leksykalnego powiązania z symbolem? Argumenty makr nie są przekazywane przez wartość, więc wartością parametru powtórzenia będzie struktura danych reprezentująca S-wyrażenie. Dopóki będzie to wartość stała (np. literał liczbowy), nic złego się nie stanie. Jeżeli jednak jako argument przekażemy zapis (+ 1 2 3), to każdorazowe odwołanie się do wartości parametru powtórzenia będzie powodowało przeliczanie wyrażenia. Jeżeli przypadkiem wyrażenie generuje efekty uboczne lub wykonuje bardziej czasochłonne obliczenia możemy mieć kłopoty, gdy np. będziemy wielokrotnie wartościowali je w pętli. Z tego względu dokonujemy obliczenia wartości tylko raz, a rezultat zapamiętujemy przez powiązanie z nim symbolu.

Kolejna linia (nr 6) to zagnieżdżone S-wyrażenie, które jest wywołaniem formuły specjalnej loop. Dzięki niej możemy powiązać pewne symbole z wartościami początkowymi: pełniący funkcję licznika symbol x# z wartością 0, a symbol l# z wartością ostatnio zwracanego wyrażenia (początkowo nil).

(Dla czytelności używamy tu sformułowań symbol x# czy symbol l#, mając na myśli symbole o unikatowych nazwach, które powstaną w rezultacie przetworzenia wspomnianych wywołań makr czytnika).

W linii nr 7 mamy formułę specjalną if, która sprawdza czy spełniony jest warunek zakończenia rekurencji (czy licznik x# powiązany jest z wartością taką, jak wartość powiązana z symbolem p#, czyli czy osiągnięto już właściwą liczbę powtórzeń). Jeżeli jeszcze tak nie jest, wywoływana jest konstrukcja specjalna recur. Dokonuje ona skoku do określonego przez loop miejsca, a dodatkowo zmiany powiązań symboli: x# zostanie powiązany z wartością o 1 większą niż poprzednia, a l# z wartością ostatnio realizowanego wyrażenia.

Warto zauważyć, że podane wyrażenia, które będą wywoływane w pętli, zostaną wydobyte ze struktury, w której zostały przekazane i umieszczone wewnątrz listy rozpoczynającej się symbolem do. W ten sposób grupujemy je w jeden zestaw, którego wartością zwracaną będzie rezultat wartościowania ostatniego umieszczonego wyrażenia.

W ostatniej linii przykładu mamy zwracanie wartości powiązanej z symbolem #l, czyli ostatnio zapamiętanej wartości przeliczanego wyrażenia. Jest to przypadek bazowy rekurencji, po którym pętla kończy pracę.

Spójrzmy jeszcze na rezultat rozwinięcia naszego makra:

Rezultat wywołania macroexpand
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(macroexpand
 '(powtarzaj
   3
   (println "wow")
   (+ 2 2)))

; => (let*
; =>  [p__10611__auto__ 3]
; =>  (clojure.core/loop
; =>   [x__10612__auto__ 0 l__10613__auto__ nil]
; =>   (if
; =>    (clojure.core/< x__10612__auto__ p__10611__auto__)
; =>    (recur (clojure.core/inc x__10612__auto__) (do (println "wow") (+ 2 2)))
; =>    l__10613__auto__)))

Możemy dla czytelności wyrazić go w bardziej konwencjonalnej postaci i wartościować:

1
2
3
4
5
6
7
8
9
10
(let [p 3]
  (loop [x 0 l nil]
    (if (< x p)
      (recur (inc x) (do (println "wow") (+ 2 2)))
      l)))

; >> wow
; >> wow
; >> wow
; => 4

Makra wbudowane

Niektóre konstrukcje języka Clojure, które w innych dialektach Lispa są formułami specjalnymi a w wielu językach programowania wbudowanymi instrukcjami, zostały zaimplementowane jako makra.

Wyrażenia warunkowe

  • assert – warunkowe generowanie wyjątków;
  • and – suma logiczna;
  • case – obsługa przypadków warunkowych;
  • cond – obsługa wyrażeń warunkowych;
  • condp – zaawansowana obsługa wyrażeń warunkowych;
  • if-not – odwrotna konstrukcja warunkowa;
  • or – alternatywa logiczna;
  • when – wykonywanie warunkowe bez alternatywy;
  • when-not – odwrotne wykonywanie warunkowe bez alternatywy;
  • when-some – wykonywanie warunkowe wartościowych bez alternatywy.

Sterowanie wartościowaniem

  • comment – tworzenie komentarzy;
  • doto – przyłączanie argumentu;
  • -> – przewlekanie S-wyrażeń;
  • ->> – przewlekanie S-wyrażeń od końca;
  • cond-> – warunkowe przewlekanie S-wyrażeń;
  • cond->> – warunkowe przewlekanie S-wyrażeń od końca;
  • some-> – przewlekanie wartościowych S-wyrażeń;
  • some->> – przewlekanie wartościowych S-wyrażeń od końca.

Funkcje i multimetody

  • fn – definiowanie funkcji;
  • defmulti – definiowanie multimetody;
  • defmethod – definiowanie metody;
  • memfn – tworzenie funkcji wywołujących metody Javy;
  • defn- – definiowanie zmiennych prywatnych.

Makra

Kolekcje i sekwencje

  • amap – odwzorowywanie tablic Javy;
  • areduce – skracanie tablic Javy;
  • defstruct – definiowanie map strukturalizowanych;
  • doseq – wymuszanie wartościowania leniwych sekwencji;
  • for – iterowanie po elementach sekwencji;
  • lazy-cat – złączanie leniwych sekwencji;
  • lazy-seq – generowanie leniwych sekwencji;
  • pvalues – współbieżne generowanie leniwej sekwencji z wartości wyrażeń.

Powiązania leksykalne

  • if-let – powiązania warunkowe;
  • if-some – warunkowe powiązania wartościowych;
  • let – powiązania leksykalne;
  • letfn – powiązania leksykalne funkcji;
  • when-first – powiązania niepustego elementu sekwencji;
  • when-let – powiązania z warunkiem;
  • when-some – powiązania wartościowych.

Zmienne globalne i typ Var

  • binding – przesłanianie zmiennej dynamicznej;
  • defonce – jednokrotne definiowanie zmiennych globalnych;
  • with-bindings – przesłanianie zmiennych dynamicznych;
  • with-local-vars – obsługa zmiennych lokalnych;
  • with-redefs – obsługa redefinicji zmiennych globalnych.

Przestrzenie nazw

  • declare – deklarowanie zmiennych;
  • import – importowanie bibliotek;
  • ns – ustawianie bieżącej przestrzeni nazw;
  • refer-clojure – dodawanie odniesień do obiektów typu Var;
  • .. – dostęp do zagnieżdżonych składowych klas Javy.

Pętle i rekurencja

  • dotimes – pętla powtórzeniowa;
  • loop – punkt startowy rekurencji ogonowej;
  • while – pętla warunkowa.

Obsługa łańcuchów znakowych

  • with-in-str – łańcuch znakowy ze standardowego wejścia,
  • with-out-str – łańcuch znakowy ze standardowego wyjścia.

Obsługa typów numerycznych

Obsługa wejścia/wyjścia

  • with-open – operowanie na otwartych plikach.

Wykonywanie współbieżne

  • bound-fn – zachowywanie wątkowych powiązań funkcji;
  • delay – tworzenie obiektów typu Delay;
  • dosync – tworzenie transakcji;
  • future – tworzenie obiektów typu Future;
  • io! – oznaczanie funkcji obsługujących wejście/wyjście;
  • locking – zakładanie blokad na obiekty;
  • sync – tworzenie transakcji;
  • time – obliczanie czasu wykonywania wyrażeń;
  • vswap! – podmiana wartości bieżącej obiektów typu Volatile.

Ten materiał jest częścią większej całości, którą można zobaczyć odwiedzając poniższy odnośnik:

Komentarze