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:

  1. der Typname ist in den Metainformationen vorhanden
  2. 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.