Makros in Clojure - 2
Dieser Blogpost ist eine Fortführung von Makros in Clojure. Wir werden weitere Makro-Begriffe, wie zum Beispiel das Syntax-Quote, kennenlernen und uns mit Makro-Hygiene beschäftigen. Dies wird es uns erleichtern, auch komplexere Makros fehlerfrei zu schreiben. Es empfiehlt sich, den vorherigen Beitrag gelesen zu haben.
Hinweis: Der komplette Code ist auf Github zu finden. Wir empfehlen, ihn während des Lesens Stück für Stück auszuführen.
Apostroph (Quote) und Syntax-Quote
Im vorherigen
Blogpost
dieser Reihe haben wir bereits den Apostroph '
kennengelernt. Dieser wurde
benutzt, um Symbole zu erzeugen. Zum Beispiel gibt 'if
nach Auswertung das
Symbol if
zurück. Doch '
kann noch mehr als bisher beschrieben: '
ist
syntaktischer Zucker für die special
form quote
. quote
liefert
eine Datenstruktur, die ausgedruckt so aussieht, wie das, was in dem Quote
steht. Damit kann es insbesondere dafür verwendet werden, eine Datenstruktur zu
bauen, die ausgedruckt wie Code aussieht.
(quote (+ 1 2))
gibt demnach die Liste (+ 1 2)
mit dem Symbol +
und den
beiden Zahlen 1
und 2
zurück, nicht den Wert 3
. Statt (list 'if true
"Hallo" nil)
hätten wir also im vorherigen Blogpost auch '(if true "Hallo"
nil)
schreiben können.
Doch unser my-when
-Makro würde mit Apostroph
(defmacro my-when
[pred then]
'(if pred
then
nil))
nicht funktionieren. Da quote
den übergebenen Parameter unausgewertet
zurückgibt, wird beispielsweise der Ausdruck (my-when (= 1 1) "Hallo")
nach
Makro-Expansion zu (if pred then nil)
. Dabei sind pred
und then
lediglich
Symbole und nicht die von uns übergebenen Werte (= 1 1)
und "Hallo"
. Nun
aber existieren für die Symbole pred
und then
zur Laufzeit (vermutlich)
keine Bindungen, weshalb die Laufzeitumgebung eine Fehlermeldung wirft. Wir
möchten also, dass im Rumpf des Makros pred
durch den übergebenen Wert ersetzt
wird. Dies ist in einem mit '
versehenen Ausdruck nicht möglich.
Dafür gibt es das Syntax-Quote `
. Innerhalb eines mit `
versehenen
Ausdrucks ist es möglich, einzelne Ausdrücke ersetzen zu lassen. Dies macht der
Ausdruck unquote
, für den es den syntaktischen Zucker ~
gibt. Hier ein
Beispiel:
;; Normal gequotet
'(1 2 (+ 1 2) (+ 2 2))
;; => (1 2 (+ 1 2) (+ 2 2))
;; syntax-gequotet mit unquote
`(1 2 ~(+ 1 2) (+ 2 2))
;; => '(1 2 3 (clojure.core/+ 2 2))
Wir sehen also, dass im Syntax-Quote-Beispiel der Ausdruck (+ 1 2)
ausgewertet
wurde.
Doch es ist noch mehr passiert: Syntax-Quote stellt die Bindung der
Symbole im aktuellen Kontext her und gibt ein voll-qualifiziertes Symbol zurück,
deshalb auch clojure.core/+
statt einfach nur +
. Mit Syntax-Quote können wir
nun das my-when
-Makro wie gewünscht schreiben:
(defmacro my-when
[pred then]
`(if ~pred
~then
nil))
Unquote-Splicing
Im Rumpf des in Clojure eingebauten when
dürfen mehrere Ausdrücke
hintereinander stehen. Um diese Funktionalität wollen wir nun unser
my-when
-Makro erweitern. Das heißt zunächst einmal, dass die Parameterliste
von my-when
angepasst werden muss: Statt zwei fixen Parametern pred
und
then
darf my-when
nun eine variable Anzahl an Parametern konsumieren: [pred
& thens]
. Wie schon im vorherigen Blogpost erwähnt, ist es sinnvoll, sich zu
überlegen, welcher Quellcode vom Makro erzeugt werden soll. Da wir in my-when
unterliegend if
benutzen, müssen wir die Elemente der thens
-Liste in einem
do
-Ausdruck platzieren. Der Code
(my-when (= 1 1)
(println "Ich will was ausgeben, bevor ich einen Wert zurückgebe")
"Hallo")
soll also folgenden Code
(if (= 1 1)
(do (println "Ich will was ausgeben, bevor ich einen Wert zurückgebe")
"Hallo")
nil)
produzieren. Im my-when
-Makro ~then
einfach durch (do ~thens)
zu ersetzen
wäre allerdings falsch, denn daraus würde
(do ((println "Ich will was ausgeben, bevor ich einen Wert zurückgebe")
"Hallo"))
werden, da thens
eine Liste ist. Der obige Ausdruck wirft einen Fehler, denn
das zusätzliche Klammernpaar verursacht einen Funktionsaufruf von nil
, dem
Rückgabewert von println
. Wir wollen die Elemente der thens
-Liste direkt in
den do
-Ausdruck einspleißen. Dies ermöglicht uns der
Unquote-Splicing-Operator ~@
. Zum besseren Verständnis von ~@
hier ein
Beispiel:
(let [thens (list 3 4 5)]
(println `(1 2 ~thens 6 7)) ; => (1 2 (3 4 5) 6 7)
(println `(1 2 ~@thens 6 7))) ; => (1 2 3 4 5 6 7)
Im zweiten Fall werden die Elemente der thens
-Liste direkt in die
andere Liste gesetzt.
Unser erweitertes my-when
-Makro sieht nun wie folgt aus:
(defmacro my-when+ [pred & thens]
`(if ~pred
(do ~@thens)
nil))
Makro-Hygiene
Nachdem wir nun alle essenziellen Makro-Befehle kennengelernt haben, kommen wir zu einem wichtigen, etwas technischen Thema: Makro-Hygiene.
Das Syntax-Quote ist dem einfachen Quote oft vorzuziehen, da es uns beim Makro-Schreiben etwas mehr unterstützt, Fehler zu vermeiden. Hier zunächst eine scheinbar harmlose Definition:
(defmacro my-first [lis]
(list 'first lis))
(my-first ["A" "B" "C"]) ;; => "A"
Doch nun benutzt ein anderer Ausdruck my-first
:
(let [first 1]
(my-first ["A" "B" "C"]))
Dies liefert die Exception java.lang.Long cannot be cast to clojure.lang.IFn
,
da im Rumpf von let
nun first
an 1
gebunden ist, und somit der Ausdruck
(1 ["A" "B" "C"])
entsteht. Wir können den Fehler beheben, indem wir statt des
Apostrophs das Syntax-Quote benutzen:
(defmacro my-first [lis]
`(first ~lis))
(let [first 1]
(my-first ["A" "B" "C"])) ;; => "A"
Warum evaluiert der let
-Ausdruck nun zu „A“? Wie oben erwähnt, qualifiziert
das Syntax-Quote Symbole. Damit wird das Symbol first
im Makro zur
Expansionszeit zu clojure.core/first
und unterscheidet sich somit zur Laufzeit
von dem im let
gebundenen first
. Andere Programmiersprachen, wie z.B. Common
Lisp, haben aber auch beim Syntax-Quote das obige Problem (da hier Symbole nicht
vollqualifiziert werden).
Noch ein weiteres Hygiene-Problem sehen wir bei folgendem Code:
(defmacro my-identity [a]
(list 'let ['x 0] a))
Die meisten Aufrufe unserer vermeintlichen Identitätsfunktion my-identity
tun
das Gewünschte, nämlich den übergebenen Ausdruck zurückzugeben. Doch was ist mit
(let [x "Hallo"]
(my-identity x))
Genau, es wird 0
zurückgegeben und nicht "Hallo"
, da das äußere x
vom
inneren überschattet wird. Dieses Phänomen nennt man im Englischen auch
accidental variable capture. Auch hier hilft uns Clojure weiter, wenn wir das
Syntax-Quote benutzen:
(defmacro my-identity [a]
`(let [x 0] ~a))
(let [x "Hallo"]
(my-identity x))
Auswerten des zweiten Ausdrucks resultiert in einer Exception:
Can't let qualified name: namespace.main/x
Clojures let
-Form akzeptiert in den Bindungen keine qualifizierten Symbole. Da
das Syntax-Quote alle Symbole vollqualifiziert, wird während der Expansion [x
0]
zu [namespace.core/x 0]
.
Doch was tun wir, wenn wir einen let
-Ausdruck in unserem Makro benutzen
wollen? Eine erste Idee wäre, sich im Makro ungewöhnliche Bezeichner für diese
Bindungen auszudenken, also statt x
x12915
zu wählen. Doch zufällig könnte
es dennoch zu Namenskollisionen kommen. Clojure (und andere Lisp-Dialekte) lösen
das mit der Funktion gensym
, die ein global eindeutiges Symbol zurückgibt:
(gensym) ;; => G__33881
(gensym "hallo") ;; => hallo33885
Damit können wir während der Makro-Expansionszeit ein eindeutiges Symbol
erzeugen und es in der let
-Form benutzen:
(defmacro my-identity-2 [a]
(let [sym (gensym)]
`(let [~sym 0] ~a)))
Da dieses Konstrukt doch häufig beim Makro-Schreiben vorkommt, hat Clojure eine Funktionalität eingebaut, die das abkürzt. Wir können statt Obigem auch schreiben:
(defmacro my-identity-2 [a]
`(let [sym# 0] ~a))
sym#
weist hier den Clojure-Compiler an, via gensym
ein neues, eindeutiges
Symbol zu generieren, auf das im weiteren Verlauf mit sym#
zugegriffen werden
kann.
Ungewollte mehrfache Auswertung
Warum wir unbedingt die let
-Bindung innerhalb eines Makros benötigen, zeigt
die dritte Fehlerquelle beim Makros-Schreiben: (ungewollte) mehrfache
Evaluation.
Das folgende Makro ist nicht unbedingt sinnvoll, aber zur Illustration gut geeignet:
(defmacro berechne [x]
`(if ~x
(str "Die Berechnungen haben geklappt! Das Ergebnis ist" ~x)
(str "Fehler bei der Berechnung.")))
Angenommen, wir haben einen Ausdruck, der einen Seiteneffekt ausführt (zum
Beispiel einen Datenbankschreibzugriff). Diesen Ausdruck dem berechne
-Makro zu
übergeben, würde dazu führen, dass der Seiteneffekt zweimal ausgeführt werden
würde! Um das zu vermeiden, müssen wir das Ergebnis von ~x
an ein Symbol
binden:
(defmacro berechne [x]
`(let [result# ~x]
(if result#
(str "Die Berechnungen haben geklappt! Das Ergebnis ist " result#)
(str "Fehler bei der Berechnung."))))
Somit wird die Berechnung nur einmal in der let
-Bindung durchgeführt und der
berechnete Wert kann durch die Bindung an das generierte Symbol mehrfach im Code
verwendet werden.
Fazit
In diesem Blogpost haben wir weitere, wichtige Makro-Befehle kennengelernt. Das Syntax-Quote erleichtert uns nicht nur die Schreibweise von Makros. Durch das Benutzen des Syntax-Quotes vermeiden wir einige Stolpersteine beim Makro-Schreiben.
Für die Zukunft dürfen wir uns auf einen weiteren Blogpost freuen, welcher Anwendungsbeispiele von Makros in Clojure behandeln wird.