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ąc na przekształcanie kodu źródłowego programu zanim dojdzie do jego ewaluacji. Dzięki makrom jesteśmy budować tzw. języki dziedzinowe, dostosowane do wyrażania specyficznych rozwiązań problemów w zwięzły i przejrzysty sposób.
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 Clojure
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 przeznaczonego do uruchamiania.
Powyższe może brzmieć nieco enigmatycznie, ale w praktyce chodzi o to, że mamy do czynienia z etapem interpretacji, w którym pewne konstrukcje programu (właśnie rzeczone makra) są w stanie zmienić jego kod. Jak? Otrzymując jako wejście (argumenty) S-wyrażenia, a następnie zwracając inne S-wyrażenia (nowe fragmenty kodu źródłowego, wygenerowane na podstawie wprowadzonych).
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:
- analiza leksykalna,
- analiza składniowa,
- analiza semantyczna,
- generowanie i optymalizacja kodu wynikowego,
- konsolidacja kodu wynikowego,
- 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):
-
- Wczytanie tekstu kodu źródłowego (analiza leksykalna).
- Zmiana S-wyrażeń w pamięciowe reprezentacje (analiza składniowa).
- Rozwijanie makr i aktualizacja drzewa składniowego.
-
- Rozpoznawanie form (analiza semantyczna).
- Generowanie kodu bajtowego.
- Uruchamianie kodu bajtowego i wartościowanie form.
Argumentami makr będą w Clojure wyrażenia, czyli elementy gramatyczne kodu źródłowego programu, 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 form specjalnych:
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 (i 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ę podprogramów (np. funkcji czy innych makr).
Tworzenie lispowych makr – w przeciwieństwie np. do makr znanych np. z języka C – polega na definiowaniu specyficznych konstrukcji przypominających funkcje, których zadanie polega na transformowaniu przekazanych w argumentach struktur danych reprezentujących kod źródłowy (po analizie składniowej). W przypadku Clojure strukturami tymi będą listy, wektory, mapy, zbiory lub wartości atomowe (napisy, liczby, symbole w formach stałych itp.). Będą one reprezentowały odpowiednie konstrukcje źródłowe: listowe S-wyrażenia, wektorowe S-wyrażenia, zbiorowe S-wyrażenia, a także wartości stałe reprezentowane danymi odpowiednich typów (łańcuchami znakowymi, symbolami, kluczami czy liczbami). Można wyobrażać sobie makra jako funkcje, których argumenty są automatycznie poddawane zacytowaniu, a zwracane struktury traktowane są jak kod źródłowy i wartościowane.
W traktowaniu kodu źródłowego jak danych, na których operuje się z użyciem standardowych konstrukcji językowych pomaga obecne w Lispach zapętlenie faz interpretowania programu wczytaj–oblicz i 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ą aranżację jak reprezentujące go abstrakcyjne drzewo składniowe.
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 argumenty, czyli symboliczne wyrażenia (fragmenty kodu źródłowego).
Pamiętajmy, że formy wywołania makr będą wartościowane zanim dojdzie do wartościowania pozostałych form wyrażonych w programie, w fazie tzw. rozwijania makr (ang. macro-expansion) lub inaczej makroekspansji (ang. macroexpansion).
Możemy więc zauważyć, że 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ł. Odpowiadające argumentom parametry zostaną powiązane z reprezentującymi S-wyrażenia strukturami danych.
Wartości parametrów będą formami stałymi, tak jakby przekazywane S-wyrażenia zostały tuż przed przekazaniem zacytowane. Jeżeli na przykład jako argument przekażemy niezacytowany symbol, to w makrze nie będzie on reprezentował formy symbolowej, która wymaga rozpoznania powiązanej wartości, lecz formę stałą wyrażającą obiekt tego symbolu. Podobnie z innymi atomowymi S-wyrażeniami, 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 makra czytnika 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ę 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, do którego dojdzie po zakończeniu makroekspansji i 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 (obiektah typu Var
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:
nazwa
– nazwa makra i reprezentującej go zmiennej globalnej (Symbol
),dok
– łańcuch dokumentujący (String
),metadane
– metadane zmiennej globalnej (mapowe S-wyrażenie),parametry
– wektor parametryczny,wyrażenie
– ciało makra składające się z wyrażeń.
Dokładne informacje dotyczące argumentów wywołania i konstruowania wektorów z parametrami znajdują się w sekcji „Argumenty funkcji” rozdziału VIII.
Pierwszym argumentem wywołania defmacro
powinna być nazwa makra wyrażona
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 formy 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.
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 AST (już rozpoznanymi wyrażeniami).
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 formy 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 formą 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ć formę specjalną do
.
Zobacz także:
- „Zmienne globalne”, rozdział VII,
- „Funkcje i domknięcia”, rozdział VIII.
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ą:
- wyłączać cytowanie (wymuszać wartościowanie),
- wyłączać cytowanie z wydobywaniem elementów (dla kolekcji),
- generować symbole o unikatowych nazwach.
Cytowanie składniowe działa dla S-wyrażeń, które w pamięci reprezentowane są z użyciem symboli, list, wektorów, zbiorów i map. 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 form 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, mapy i zbiory) 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ę.
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ń:
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 form 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.
Transponowanie 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.
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:
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
:
Rezultatem jest lista, której pierwszy element to symbol nazywający formę specjalną
let
(z ustawioną przestrzenią nazw clojure.core
), drugi to wektor
powiązań, 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?
Zgłoszony wyjątek informuje nas o tym, że nie można użyć symbolu z dookreśloną
przestrzenią nazw w wektorze powiązań. 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:
Inna metoda to użycie transponowania względem zacytowanych symboli:
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, gdy w trakcie wartościowania nazwa zostanie przesłonięta przez powiązanie z inną wartością. Popatrzmy:
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:
Uwzględniając leksykalne otoczenie, będziemy mieli do czynienia z następującym kodem:
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łonięte 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:
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:
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:
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ść.
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.
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 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.
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 formy
specjalnej let
, a drugim wektor powiązań. 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 formy
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 formę 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:
Możemy dla czytelności wyrazić go w bardziej konwencjonalnej postaci i wartościować:
Makra wbudowane
Niektóre konstrukcje języka Clojure, które w innych dialektach Lispu są formami 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 z funkcją dyspozycyjną;defmethod
– definiowanie metody przypisywanej do multimetody;memfn
– tworzenie funkcji wywołujących metody Javy;defn-
– definiowanie funkcji 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 typuVar
;..
– dostęp do zagnieżdżonych składowych klas Javy.
Pętle i rekurencja
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
with-precision
– określanie dokładności obliczeń.
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 typuDelay
;dosync
– tworzenie transakcji;future
– tworzenie obiektów typuFuture
;io!
– oznaczanie funkcji obsługujących wejście/wyjście;locking
– zakładanie blokad na obiekty;sync
– tworzenie transakcji (wersja rozszerzona);time
– obliczanie czasu wykonywania wyrażeń;vswap!
– podmiana wartości bieżącej obiektów typuVolatile
.