Nachdem wir in den beiden Blogposts Makros in
Clojure
und Makros in Clojure -
2 die
für die Praxis relevanten Makro-Befehle kennengelernt haben, widmen wir uns in
diesem Blogpost einem umfangreichen Beispiel: Wir werden unser eigenes
Record-Makro erstellen! Es empfiehlt sich, die vorherigen Beiträge 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.
Records für zusammengesetzte Daten
Immer dann, wenn ein Objekt aus mehreren Teilen besteht, haben wir es mit
zusammengesetzten Daten zu tun. Diese modellieren wir in Clojure mit sogenannten
Records. Näheres dazu gibt es im Blogpost Zusammengesetzte Daten in
Clojure.
Manche Dinge an den Clojure-Records stören jedoch und verleiten zu Fehlern,
weshalb wir heute eine eigene Version schreiben wollen.
Clojure-Records und die bereits bezahlte Rechnung
Wir modellieren eine Rechnung mit gewöhnlichen Clojure-Records: Eine Rechnung
besteht aus einer ID, einer IBAN, einem zu bezahlenden Betrag und einer
Markierung, die anzeigt, ob eine Rechnung schon beglichen wurde oder nicht:
(defrecord Bill [id iban amount paid?])
(def restaurant-bill (->Bill 1 "DEXY XYXY XYXY ..." 48.00 true))
(def hospital-bill (->Bill 2 "DEYX YXYX YXYX ..." 10.00 false))
Mit defrecord lässt sich der neue Recordtyp Bill definieren. Ein
Bill-Record wird mit dem Konstruktor ->Bill und den jeweiligen Werten für
die Felder erzeugt. Auf die einzelnen Bestandteile eines Records kann via
Keywords zugegriffen werden: (:id restaurant-bill) und (:amount
hospital-bill) liefern die Werte 1 und 10.00.
Um die Unzulänglichkeiten der Clojure-eigenen Records zu demonstrieren,
betrachten wir eine Funktion bills-to-pay, die aus einer Liste von Rechnungen
diejenigen entfernt, die schon beglichen worden sind (remove ist eine
eingebaute Funktion, die ein Prädikat und eine Liste konsumiert und alle
Elemente der Liste, die das Prädikat erfüllen, aus der Liste entfernt):
(defn bills-to-pay
[bills]
(remove :paid bills))
(bills-to-pay [restaurant-bill hospital-bill])
;; => (#Bill{:id 1, :iban "DEXY XYXY XYXY ...", :amount 48.0, :paid? true}
;; #Bill{:id 2, :iban "DEYX YXYX YXYX ...", :amount 10.0, :paid? false})
Huch! (oder auch „Ja Holla, die Waldfee!!“) Wieso wurde die Restaurant-Rechnung
nicht herausgefiltert? Die Rechnung wurde doch schon beglichen! Sie sehen es
bestimmt: Ein Schreibfehler hat sich eingeschlichen, statt :paid? wird :paid
benutzt. Ein Keywordzugriff auf eine Hashmap, in welcher das Keyword nicht
vorhanden ist, liefert nil (näheres dazu z. B.
hier).
Das ist ärgerlich. Solche Fehler fallen unter Umständen lange Zeit nicht auf, da
keine Fehlermeldung geworfen, sondern ein valider, aber falscher Wert geliefert
wird.
Clojure-Records implementieren das Hashmap-Interface, was zu einer weiteren
Eigenheit führt: Einem Record kann man problemlos weitere Paare mit assoc
anfügen. Das allein ist noch nicht fragwürdig, aber dass das resultierende
Ergebnis trotzdem noch denselben Typ von zuvor hat, schon:
(def rb-2 (assoc restaurant-bill :velocity 100))
rb-2
;; => #...Bill{:id 1, :iban "DEXY XYXY XYXY ...",
;; :amount 48.0, :paid? true, :velocity 100}
(instance? Bill rb-2) ; => true
rb-2 ist immer noch ein Bill-Record! Wir wollen robustere Records und
bauen uns deshalb selbst welche!
Das Record-Makro
Dazu benötigen wir als erstes einen Recordtypkonstruktor. Der in clojure.core
enthaltene Konstruktor heißt defrecord, wir wählen def-record-type. Folgende
Funktionen müssen von ihm erzeugt werden:
- ein Recordkonstruktor
- Feld-Selektoren
- Typ-Prädikat
Als Argumente konsumiert er einen Recordtyp-Namen und Recordtypfelder-Namen. Das
Skelett des Makros sieht wie folgt aus:
(defmacro def-record-type
[type-name & field-names]
...)
Im Rumpf werden die oben beschriebenen Funktionen erzeugt.
Recordkonstruktor
Wir fangen mit der Implementierung des Recordkonstruktors an. Dieser soll nach
Anwendung auf Feldwert-Argumente einen Record zurückgeben. Als unterliegende
Struktur eines Records verwenden wir der Einfachheit halber eine
Clojure-Hashmap. Statt ->type-name wählen wir make-type-name als Namen.
Ein nicht automatisch generierter Konstruktor für Bill-Records könnte so
aussehen:
(defn make-bill
[id iban amount paid?]
{:id id
:iban iban
:amount amount
:paid? paid?})
Damit könnten wir eine Restaurant-Rechnung via
(make-bill 1 "DEXY XYXY XYXY ..." 48.00 true)
erzeugen.
Wir müssen also ein Makro schreiben, der uns obigen Ausdruck als Clojure-Liste
zurückgibt. Den Namen der Funktion erhalten wir durch Konkatenation von
"make-" und type-name. Der Parametervektor ist einfach der übergebene
Feldnamen-Vektor. Schlussendlich erzeugen wir die Hashmap, indem wir über die
Feldnamen-Liste iterieren und Tupel erzeugen:
(defmacro def-record-type
[type-name & field-names]
`(defn ~(symbol (str "make-" type-name))
~(vec field-names)
~(into {}
(map (fn [field-name]
[(keyword field-name) field-name])
field-names))))
Wir können mithilfe von macroexpand-1 überprüfen, ob tatsächlich der
gewünschte Code erzeugt wird:
(macroexpand-1 '(def-record-type bill [id iban amount paid?]))
;; => (clojure.core/defn make-bill
;; [id iban amount paid?]
;; {:id id, :iban iban, :amount amount, :paid? paid?})
Da das Recordtypkonstruktor-Makro noch weitere Funktionen erzeugen wird, lagern
wir, bevor wir fortfahren, das Erzeugen des Recordkonstruktors in eine eigene
Funktion aus:
(defn create-record-constructor
[type-name field-names]
`(defn ~(symbol (str "make-" type-name))
~field-names
~(into {}
(map (fn [field-name]
[(keyword field-name) field-name])
field-names))))
(defmacro def-record-type
[type-name field-names]
`(do
~(create-record-constructor type-name field-names)))
Beim Testen der neuen Recordkonstruktor-Erzeugerfunktion müssen wir darauf
achten, dass wir Symbole übergeben, da es sich um eine Funktion und nicht um ein
Makro handelt:
(create-record-constructor 'bill ['id 'iban 'amount 'paid?])
Selektoren
Wir können nun eigene Recordtypen und dazugehörige Records erzeugen. Auf die
einzelnen Feldwerte der Records ist es, wie bei den eingebauten Clojure-Records
auch, möglich, mit Keywords zuzugreifen:
(:amount (make-bill 1 "DEXY XYXY XYXY ..." 48.00 true))
;; => 48.00
Wie oben erläutert, ist der Keywordzugriff etwas problematisch und deshalb
erzeugen wir Selektorfunktionen. Diese könnten so aussehen:
(defn bill-amount
[bill]
(:amount bill))
Für jeden Feldnamen muss solch eine Selektorfunktion erzeugt werden. Dazu lagern
wir die Recordselektoren-Erzeugerfunktion wieder aus dem Makro aus:
(defn create-record-accessors
[type-name field-names]
(map (fn [field-name]
`(defn ~(symbol (str type-name "-" field-name))
[~type-name]
(~(keyword field-name) ~type-name)))
field-names))
In den Recordtypkonstruktor übernommen ergibt das:
(defmacro def-record-type
[type-name field-names]
`(do
~(create-record-constructor type-name field-names)
~@(create-record-accessors type-name field-names)))
(def-record-type bill [id iban amount paid?])
(def the-bill (make-bill 1 "DEXY XYXY XYXY ..." 48.00 true))
(bill-paid? the-bill)
;; => true
Der ~@-Operator ist nötig, da eine Liste von Formen zurückgegeben wird, die an
dieser Stelle eingebettet werden muss.
Typ-Prädikat
Um die Records auch sinnvoll benutzbar zu machen, benötigen wir noch eine
Möglichkeit zur Überprüfung, ob ein Record eine Instanz eines bestimmten Typs
ist. Clojure-Records machen das über (instance? Bill my-bill). Unsere
Implementierung ermöglicht uns das bis jetzt nicht; wir erzeugen schließlich nur
eine einfache Hashmap ohne Typinformation. Deshalb fügen wir den Metadaten der
Hashmap nun das Paar (:__type__ : type-name) hinzu.
In create-record-constructor müssen wir dazu den (into {} ...)-Ausdruck um
einen vary-meta-Aufruf erweitern:
(vary-meta (into {}
(map (fn [field-name]
[(keyword field-name) field-name])
field-names))
(fn [m] (assoc m :__type__ `'~type-name)))
Das kryptisch anmutende `'~type-name wird weiter unten
erläutert.
Nun zum Typ-Prädikat. Das soll eine Funktion bill? sein, sodass (bill?
thing) im Falle eines Bill-Records true und ansonsten false zurückgibt. Ein
Objekt ist ein Bill-Record, wenn zwei Bedingungen erfüllt sind:
- der Typname ist in den Metainformationen vorhanden
- Genau die (und nur die) Felder des Recordtypkonstruktors finden sich als
Keywords in der unterliegenden Hashmap wieder
Punkt 2 zieht eine weitere Bedingung mit sich: Das Objekt muss eine Hashmap
sein. Ein nicht generisches Prädikat für einen Bill-Record könnte so aussehen:
(defn bill?
[thing]
(and
(= 'bill (:__type__ (meta thing)))
(map? thing)
(= (set (map keyword field-names))
(set (keys thing)))))
Wir schreiben also eine Erzeugerfunktion, die zu gegebenen Typnamen eine
Funktion wie oben zurückgibt:
(defn create-predicate
[type-name field-names]
`(defn ~(symbol (str type-name "?"))
[~'thing]
(and
(= '~type-name (:__type__ (meta ~'thing)))
(map? ~'thing)
(= ~(set (map keyword field-names))
(set (keys ~'thing))))))
Etwas befremdlich könnten ~'thing und '~type-name wirken. Warum nicht
einfach thing statt ~'thing? Wie im vorherigen
Blogpost
erläutert, qualifiziert das Syntax-Quote ` Symbole. defn akzeptiert in
der Parameterliste jedoch keine qualifizierten Symbole. '~ sorgt dafür, dass
tatsächlich das übergebene Symbol (und nicht type-name) dasteht, zur Laufzeit
aber nicht weiter evaluiert wird. Gleiche Begründung gilt bei `'~type-name
aus create-record-constructor von oben. Hier ist noch zusätzlich das `
nötig, da zuvor unquotet wurde.
Im Ausdruck ~(set (map keyword field-names)) könnte das Unquote ~ auch vor
field-names stehen. Dann aber würde das Mappen und Konvertieren in eine Menge
zur Laufzeit geschehen; so wie es jetzt ist, geschieht das bereits zur
Makro-Expansionszeit und es ist damit etwas schneller zur Laufzeit.
Nun haben wir alles beisammen, um unsere Records sinnvoll nutzen zu können!
(def-record-type car [color])
(def-record-type bill [id iban amount paid?])
(def fire-truck (make-car "red"))
(car-color fire-truck) ; => "red"
(car? fire-truck) ; => true
(bill? fire-truck) ; => false
(car? (assoc fire-truck :amount 23)) ; => false
Wie zu sehen, ist fire-truck nach dem Hinzufügen eines weiteren Paares kein
car-Record mehr. Auch das in der Motivation angesprochene Vertippen wird
bereits vom Compiler verhindert:
(bill-paid the-bill)
;; => Unable to resolve symbol: bill-paid in this context
Fazit und Ausblick
Das Record-Makro nimmt der Entwicklerin nicht nur Schreibarbeit einzelner
Funktionen ab, sondern ermöglicht ein erweitertes, datenflussorientiertes
Programmieren. Zwar ist es erfreulich, dass Clojure überhaupt Records zur
Verfügung stellt, aber wie wir gesehen haben, stören einige
Implementierungsentscheidungen. Clojure ermöglicht es uns mithilfe von Makros,
bestehende Funktionalität zu erweitern oder ganz zu ersetzen. Das eigentlich
Hervorragende ist, dass unsere Implementierung ohne Clojure-Records
funktioniert!
Natürlich sind die hier entworfenen Records nur beispielhaft zu sehen: Um den
Rahmen des Blogposts nicht zu sprengen, sind ein paar fragwürdige Entscheidungen
(z. B. für die unterliegende Struktur des Records eine Hashmap zu wählen)
getroffen worden. Eine wirklich gute Alternative entwickelt und benutzt die
Active Group GmbH täglich in der Entwicklung von
Clojure-Programmen:
active-clojure-Records.
Neben zusammengesetzten Daten kommen gemischte Daten in der Modellierung von
Programmen vor. In einem nächsten Blogpost werden wir mit Makros Summentypen in
Clojure entwickeln und damit die Sprache noch weiter erweitern und an unsere
Vorstellungen anpassen.