Die Einführung der Sprache Clojure in diesem Buch beginnt mit dem Satz: „Clojure ist eine
Programmiersprache, deren Sprachkern in Java implementiert ist.“ Gerade die Lisp-Familie
der Programmiersprachen hat aber eine stolze Tradition von self-hosted Implementationen. In
diesen Implementationen ist die Sprache in sich selbst geschrieben: Lisp in Lisp. In ähnlicher
Weise strebt auch Clojure eine Implementation in Clojure an. Die Clojure-Gemeinde spricht
in diesem Zusammenhang von „Clojure in Clojure“, mittlerweile häufig verkürzt zu „CinC“
oder auch „cinc“.
Warum Clojure in Clojure?
Dieses Ziel ist jedoch kein Selbstzweck; es braucht eine
gute Begründung. Warum sollte Clojure in Clojure geschrieben werden? Inwiefern
unterscheidet sich Clojure von den zahlreichen guten Bibliotheken, die in Java entwickelt
werden?
Im Gegensatz zu diesen Bibliotheken ist Clojure vor allem eine Programmiersprache. Die
Entwicklung einer neuen Programmiersprache entsteht meist aus der Unzufriedenheit mit den
vorhandenen Sprachen. Clojure strebt also an, auch eine bessere Programmiersprache als Java
zu sein. Die Zielplattform ist die Java Virtual Machine, doch der Weg dorthin führt nicht über
den Java-Compiler. Clojures Kombination von Eigenschaften führt zu besser wartbaren und
stabileren Programmen, zumindest nach Ansicht der Entwickler von Clojure. Diese
Eigenschaften würden einer Implementation in Clojure zugute kommen. CinC würde auch die
Möglichkeit der Unterstützung weiterer Host-Plattformen – etwa Microsofts CLR oder Perls
Parrot – mit verringertem Aufwand eröffnen. Zu guter Letzt ist der Compilerbau eine
klassische Anwendung für funktionale Programmierung.
Als Version 1.1 von Clojure aktuell war, fragten sich die Entwickler von Clojure,
was nötig sei, um dieses Ziel zu erreichen. Die Möglichkeit der Implementation von
Java-Klassen in Clojure – wie in Abschnitt 4.2.2 beschrieben – reichte ihnen nicht aus. Für
Clojure und Rich Hickey sind Abstraktionen von großer Bedeutung. Clojure sollte es erlauben,
eigene Abstraktionen zu definieren. Vererbung, potenziell sogar mit schon vorhandenen
Basisimplementationen, gehört dabei aus Sicht der Entwickler von Clojure nicht zu den
sauberen Konzepten. Javas Interfaces sind einer erstrebenswerten Lösung näher,
lösen aber vor allem das Expression Problem nicht, auf das im folgenden Abschnitt
näher eingegangen wird. Offensichtlich brauchte Clojure in dieser Hinsicht etwas
Neues.
Parallel zu Abstraktionen, deren Aufgabe es ist, Verhalten zu beschreiben, existieren
Typen, deren Aufgabe es ist, Daten zu halten. Sobald Abstraktionen in Clojure definiert
werden, besteht die Notwendigkeit, dazu passende Konkretisierungen programmieren zu
können. Für diese Anforderung sind die Entwickler von Clojure ebenfalls zu dem Schluss
gekommen, dass das Vorhandene – das abstrakte Konzept der Klassen – nicht ihrer
Vorstellung entspricht und sie eine eigene Lösung wünschen. Klassen haben aber den großen
Vorteil, von der Plattform optimal unterstützt zu werden. Vor allem die Performance und die
Möglichkeiten der Optimierung durch HotSpot sind erstrebenswert. Clojure muss unter der
Haube auf das Klassensystem der Host-Plattform aufsetzen; für seine Anwender
kann es aber sich davon unterscheidende Konzepte anbieten. Auf diese Weise kann
die auf der Plattform größtmögliche Geschwindigkeit erreicht werden. Eine hohe
Verarbeitungsgeschwindigkeit ist bei Kernfunktionen einer Programmiersprache absolut
notwendig.
Die notwendigen Erweiterungen an der Sprache kommen nicht nur den
Entwicklern von Clojure zugute, sondern auch denjenigen, die Clojure einsetzen. Sie stehen als
eine weitere Ausdrucksmöglichkeit bei der Programmierung zur Verfügung.
Im Jahre 1998 gab Philip Wadler, der unter anderem für seine Arbeiten an Haskell und
XQuery bekannt ist, einem damals schon bekannten Problem einen neuen Namen, der bis
heute Bestand hat: das Expression Problem [74]. Dieser Name ist sowohl eine Anspielung auf
fehlende Ausdrucksmöglichkeiten als auch auf Probleme mit den Ausdrücken – im Sinne von
Anweisungen, die einen Rückgabewert haben – von Programmiersprachen. Inhaltlich handelt
es sich dabei aber eher um ein Erweiterungsproblem.
Erweitern von Bibliotheken
Gute Bibliotheken sind in Abstraktionen und Konkretisierungen
geschrieben. Java-Programmierer können diese Begriffe gedanklich durch Klassen und
Interfaces ersetzen. Eine Bibliothek definiert einige abstrakte Konzepte sowie verschiedene
Datentypen, die diese Konzepte umsetzen. Wenn jetzt ein Entwickler eine solche Bibliothek
verwenden möchte, wird er sich häufig in der Situation wiederfinden, entweder die
Abstraktionen der Bibliothek in eigenen Typen unterstützen zu müssen oder aber
die Datentypen der Bibliothek in eigene Abstraktionen einzubetten. Dabei gibt es
aber häufig die Einschränkung, dass die Bibliothek selbst nicht verändert werden
darf.
Beide Ziele gleichzeitig zu erreichen, stellt häufig ein Problem dar.
Tendenziell haben objektorientierte Programmiersprachen ein Problem mit der Unterstützung
neuer Abstraktionen, während funktionale Sprachen eher dazu neigen, bei der Einführung
neuer Datentypen auf Schwierigkeiten zu stoßen.
Verständlich wird das Problem an einem simplen Beispiel. Eine Bibliothek stelle
Funktionen für die Verwaltung von Farben bereit. Als Abstraktion definiert sie die Ermittlung
des RGB-Werts einer Farbe, als Konkretisierung enthält die sparsame Bibliothek lediglich
Rot:
Package: FarbenLib
Abstraktion: RGBWert
Konkretisierung: Rot
Der weitere Verlauf dieses Beispiels hängt davon ab, wie die Bibliothek
geschrieben ist. Wenn sie dem Entwurfsmuster Kompositum folgt, wird die Konkretisierung
eine Methode zur Implementation der Abstraktion „RGBWert“ enthalten:
Package: FarbenLib
Konkretisierung: Rot
Methode: getRGBWert: return F,0,0
Für ein Programm, das diese Bibliothek verwenden möchte, ist es nun leicht, eine neue
Klasse hinzuzufügen: Es muss lediglich darauf geachtet werden, ebenfalls die notwendige
Methode bereitzustellen:
Package: MeinProgramm
Konkretisierung: Blau
Methode: getRGBWert: return 0,0,F
Diese Konkretisierung kann in einem anderen Teil des Quelltexts, in einem anderen
Paket erfolgen, was hier durch ein anderes Package angedeutet wird; die Bibliothek
muss dafür nicht angepasst werden. Allerdings führt die Einführung einer neuen
Abstraktion zu einem Problem: Die Konkretisierungen aus der Bibliothek unterstützen
sie nicht. Alle Konkretisierungen – auch die der Bibliothek – müssten angepasst
werden.
Umgekehrt verhält es sich bei Verwendung des Entwurfsmusters Besucher. Hier
muss die Bibliothek zuvor etwas mehr Infrastruktur zur Verfügung stellen. Alle
Konkretisierungen verfügen über eine Methode, die einen Besucher akzeptiert und dessen
Methode „visit“ aufruft. Somit findet eine Verzweigung in den Besucher statt. Dieser kann nun
anhand des Typs der Konkretisierung entscheiden, was zu tun ist. Das kann implizit durch
Überladen oder explizit durch Typprüfungen geschehen. In der Bibliothek findet sich also etwa
folgende Situation:
Package: FarbenLib
Konkretisierung: Rot
Methode: accept(besucher):
besucher.visit(this)
Konkretisierung: RGBWertBesucher
Methode: visit:
Verzweige auf Konkretisierung
Wenn Rot: return F,0,0
Das Hinzufügen einer neuen Methode ist in diesem Falle einfach: Es muss lediglich ein neuer
Besucher geschrieben werden, der dann die notwendigen Aktionen auslöst. Beispielsweise
könnte die Assoziation, die eine Farbe hervorruft, gefragt sein. Das Programm kann dann
folgenden Besucher schreiben:
Package: MeinProgramm
Konkretisierung: AssoziationBesucher
Methode: visit:
Verzweige auf Konkretisierung
Wenn Rot: return "Gefahr"
In diesem Falle ist jedoch die Unterstützung neuer Datentypen aufwendig. Wenn das
Programm eine neue Farbe verwenden soll, müssen auch die Besucher, die die Bibliothek
enthält, angepasst werden, da sie den neuen Typen nicht kennen.
Mats Torgersen beschreibt in seinem Paper „The Expression Problem
Revisited“ [70] sowohl das Problem als auch einige mögliche Lösungen aus dem
Java- und C#-Umfeld. Clojure hingegen ist auch ein Nachkomme von Common
Lisp, und dort wurde bereits eine mögliche Lösung geschaffen. Dazu wurden die
Methoden gewissermaßen aus den Klassen geholt und an ihrer Stelle generische
Funktionen eingeführt. Diese erlauben jederzeit neue Implementationen je nach
Konkretisierung und Abstraktion. Auch Clojure verfügt über diese Möglichkeit mit
den Mehrfachmethoden, die in Abschnitt 2.12.4 beschrieben wurden. Diese haben
allerdings das Problem, dass sie nicht so schnell sind, wie die Plattform es erlauben
würde.
Die Definition einer Abstraktion umfasst im einfachsten Falle eine Menge von Funktionen.
Sowohl die einzelnen Funktionen als auch ihre Gesamtheit benötigen ferner einen Namen und
sollen dokumentierbar sein. Das leisten Protocols in Clojure. Wir verwenden die englische
Bezeichnung „Protocols“ als Fachterminus im Zusammenhang mit Clojure. Im Gegensatz zu
etwa Interfaces in Java sind sie aber ausdrücklich nicht Bestandteil einer hierarchischen
Ordnung.
Protocols werden mit dem Befehl defprotocol angelegt. Dessen formale Syntax
lautet:
(defprotocol name docstring?
(function-name [args*]? function-docstring?)*)
Somit lässt sich beispielhaft eine einfache Abstraktion definieren, die verschiedene Zeichen
zählen kann:
(defprotocol ZeichenZaehler
"Zaehle verschiedene Zeichen"
(zaehle-leerzeichen [this] "Zaehle Leerzeichen")
(zaehle-ziffern [this] "Zaehle Ziffern 0-9"))
Auf den Namen folgt der Docstring des Protocol. Alle weiteren Ausdrücke bestehen aus
Deklarationen von Methoden. Auch diese können einen Docstring enthalten. Die
Argumentenliste deutet durch die Verwendung von „this“ bereits an, dass den Methoden ein
geeignetes Objekt übergeben wird.
Nach diesem Schritt sind im aktuellen Namensraum drei neue Vars zu finden. Es ist
wichtig zu beachten, dass die Funktionen eines Protocol zwar vollständig mit Namespace
qualifiziert sind, aber innerhalb eines Namespace eindeutig sein müssen. Es können
also nicht zwei Protocols in einem Namensraum die gleiche Methode enthalten.
Die neuen Vars sind einerseits das definierte Protocol und andererseits die dort
enthaltenen zwei Funktionen. Das Protocol verrät mit Hilfe von type Details seiner
Implementation als PersistentArrayMap und kann mit print an der REPL ausgegeben
werden:
user> (resolve ’ZeichenZaehler)
#’user/ZeichenZaehler
user> (type ZeichenZaehler)
clojure.lang.PersistentArrayMap
user> ZeichenZaehler
{:on-interface user.ZeichenZaehler,
:on user.ZeichenZaehler,
:doc "Zaehle verschiedene Zeichen",
;; Ausgabe gekuerzt
#<user$eval2320$fn__2332 user$eval2320$fn__2332@7ad3f189>
}
Auch die Funktionen lassen sich auflösen und ihre Dokumentation wie gewohnt mit doc
nachschlagen. Sie werfen aber beim Aufruf noch eine Exception, wie das folgende Beispiel
zeigt:
user> (resolve ’zaehle-leerzeichen)
#’user/zaehle-leerzeichen
user> (type zaehle-leerzeichen)
user$eval2320$fn__2321$G__2311__2326
user> (doc zaehle-leerzeichen)
-------------------------
user/zaehle-leerzeichen
([this])
Zaehle Leerzeichen
nil
user> (zaehle-leerzeichen)
java.lang.IllegalArgumentException:
No single method: zaehle_leerzeichen of interface:
user.ZeichenZaehler found for function: zaehle-leerzeichen
of protocol: ZeichenZaehler
Diesem Protocol fehlt bislang eine Konkretisierung. Der folgende
Abschnitt 5.3 behandelt die Erzeugung eigener Typen, aber eine Motivation von Clojures
Protocols ist es ja, das Expression Problem zu lösen. Auf der JVM bedeutet das, dass es
möglich sein muss, Java-Klassen um die Unterstützung eines Protocol zu erweitern.
Wäre dies nicht gegeben, so ließe sich das Expression Problem nur für in Clojure
definierte Typen lösen, aber die Vielfalt in Java geschriebener Bibliotheken bliebe außen
vor.
Die Unterstützung eines Protocol für einen Datentyp übernimmt das
Makro extend-type. Es stellt einen leichten Weg dar, einen Datentyp zu erweitern, und
expandiert zu einem Aufruf von extend, das hier nicht weiter beschrieben wird.
(defn zaehle-ziffern-in-string [s]
(count (filter (set "0123456789") s)))
(extend-type
java.lang.String
ZeichenZaehler
(zaehle-ziffern [s]
(zaehle-ziffern-in-string s))
(zaehle-leerzeichen [s]
(count (filter #(= % \space) s))))
user> (zaehle-ziffern "C3P0")
2
user> (zaehle-leerzeichen "Landkreis Leer")
1
Zunächst definiert das Beispiel eine Hilfsfunktion, die die Anzahl von Ziffern in einem String
ermittelt. Als Filterfunktion kommt dabei ein Set zum Einsatz, das – als Funktion
verwendet – darauf prüft, ob ein Wert im Set enthalten ist. Der Aufruf von extend-type
erwartet als erstes Argument den Namen des Typs, hier java.lang.String, gefolgt vom Namen
des Protocol, ZeichenZaehler. Darauf werden alle Funktionen definiert, die das Protocol
vorgibt. Die einzelnen Funktionsdefinitionen bestehen aus dem Namen der Funktion, einer
Argumentenliste und der Implementation selbst. Sollen weitere Protocols implementiert
werden, können diese im Anschluss an das im Beispiel gezeigte Protocol in gleicher Form
geschrieben werden: der Name des Protocol gefolgt von den Implementationen. Die letzten
beiden Aufrufe des Beispiels zeigen, dass die Funktionen des Protocol nun für Strings
verfügbar sind, und entlarven einen Tippfehler, bei dem eine Null statt des Buchstabens „O“
eingegeben wurde.
Mit extend-protocol existiert ein verwandtes Makro, das es erlaubt, zu einem Protocol in
einem Aufruf die Unterstützung mehrerer Typen anzugeben.
Ein solchermaßen erweiterter Datentyp wird im Protocol selbst vermerkt.
Somit ist es dem Protocol möglich, eine Liste von unterstützenden Datentypen zu liefern.
Diese Aufgabe erfüllt die Funktion extenders:
user> (extenders ZeichenZaehler)
(java.lang.String)
Protocols erlauben eine einfache Definition von Schnittstellen in Form
von mehreren Funktionen. Sie ähneln den Interfaces von Java, führen aber unter
anderem nicht zu einer hierarchischen Ordnung der implementierenden Typen. Die
erzeugten Funktionen sind anhand des Typs des Arguments überladen, was die am
häufigsten verwendete Form der in Abschnitt 2.12.4 beschriebenen Mehrfachmethoden
ist.
Die Definition einer Abstraktion ist ohne eine konkrete Implementation wirkungslos. Der
vorige Abschnitt hat gezeigt, dass Javas Typen in die Lage versetzt werden können, Clojures
Protocols zu unterstützen. Damit kann ein Entwickler in Clojure neue Abstraktionen
definieren und deren Unterstützung für Java-Klassen implementieren. Zur Erzeugung neuer
Datentypen hat er bislang nur die Entwicklung von kompilierten Java-Klassen zur Hand. Aber
dieses auf Kompilieren ausgerichtete Entwicklungsmodell ist nicht so dynamisch, wie es ein
Clojure-Entwickler erwartet.
Um diese Lücke zu füllen, treten drei verschiedene Mechanismen an, die in diesem
Abschnitt beschrieben werden: Types, Records sowie ad hoc erzeugte, anonyme Klassen. Wir
verwenden das englische „Datatypes“ als Sammelbegriff für diese drei Varianten sowie die
englischen Formen „Type“ und „Record“ in ihrer speziellen Bedeutung in dem hier
geschilderten Zusammenhang. Die deutsche Form „Datentyp“ steht nach wie vor für die
Gesamtheit aller Typen, also auch für Maps, ganze Zahlen und andere. Das Ziel ist in jedem
Falle ein von der Host-Plattform ideal unterstützter Datentyp, der ein oder mehrere Protocols
implementieren kann.
In seiner einfachsten Form ist ein Datatype eine Kollektion von Werten, die beim Erzeugen
angegeben werden können; in der Datenmodellierung oft als „Entität“ bezeichnet. Diese Werte
stehen wie gewöhnliche Felder zur Verfügung.
Die Definition eines solchen Types erfolgt mit Hilfe des Makros deftype, das hier
zunächst in der Form
;; noch nicht vollstaendig
(deftype name [felder*])
zur Anwendung kommt. Nach der Definition kann eine Instanz eines solchen Types angelegt
werden, indem der Name wie der Name einer Java-Klasse verwendet wird. Es steht genau ein
Konstruktor zur Verfügung, der die definierten Felder in der angegebenen Reihenfolge
akzeptiert. Das folgende Beispiel zeigt die Definition und Erzeugung eines Types zusammen
mit dem Zugriff auf die Felder.
user> (deftype Planet [name position])
user.Planet
user> (def venus (Planet. "Venus" 2))
#’user/venus
user> (.name venus)
"Venus"
user> (.position venus)
2
Wie gewohnt sind die Feldinhalte unveränderlich. Lediglich am Rande sei bemerkt, dass es
auch möglich ist, veränderliche Felder anzulegen. Davon ist jedoch dringend abzuraten, wie
auch die Dokumentation von deftype erklärt. Daher wird dieser Fall hier nicht
behandelt.
Unterstützung von Protocols
Ein solchermaßen definierter Type kann wie zuvor mit extend-type
um die Unterstützung eines Protocol erweitert werden. Dazu definiert das folgende Beispiel
zunächst ein sehr einfaches Protocol und verwendet dann extend-type für die schon bekannte
Erweiterung des Types.
user> (defprotocol Entfernung
(sonnen-entfernung [this]))
user> (extend-type Planet
Entfernung
(sonnen-entfernung [this]
(condp = (.name this)
"Venus" "108e6 km")))
nil
user> (sonnen-entfernung venus)
"108e6 km"
Da bei Verwendung von deftype jedoch der Entwickler direkten Zugriff auf
den neuen Type hat, liegt es nahe, die Unterstützung für ein Protocol oder ein Interface von
Java gleich in der Definition des Types vorzunehmen. Dazu versteht deftype auch weitere
Argumente:
(deftype name [felder*] specs*)
Die specs bestehen aus den Namen von Protocols oder Interfaces, jeweils gefolgt von den
Definitionen der Methoden.
(defprotocol FormatiereAdresse
(fmt-email [this])
(fmt-anschrift [this]))
(deftype LoginUser [vname nname alter strasse plz stadt]
FormatiereAdresse
(fmt-email [this]
(str "<" (.vname this) " " (.nname this) ">"
" " (.vname this) "." (.nname this) "@"
"clojure-buch.de"))
(fmt-anschrift [this]
(str (.vname this) " " (.nname this) "\n"
(.strasse this) "\n"
(.plz this) " " (.stadt this) "\n"))
java.lang.Comparable
(compareTo [this other]
(compare (.alter this) (.alter other))))
Nach dieser Definition, die sowohl das Protocol FormatiereAdresse als auch das Interface
java.lang.Comparable mit Implementationen versorgt, steht der Type wie eine Java-Klasse mit
dem Konstruktor zur Verfügung. Die Funktionen des Protocol können mit diesem Type
verwendet werden. Die Funktion compare aus Clojures Kern kann Objekte vergleichen, die
java.lang.Comparable implementieren. Somit lassen sich Instanzen von LoginUser miteinander
vergleichen.
user> (def emil (LoginUser. "Emil" "Egal" 21
"Einsteinstr. 1" 111 "Eselei"))
#’user/emil
user> (fmt-email emil)
"<Emil Egal> Emil.Egal@clojure-buch.de"
user> (fmt-anschrift emil)
"Emil Egal\nEinsteinstr. 1\n111 Eselei\n"
user> (def anja (LoginUser. "Anja" "Auchegal" 28
"An der Ampel" 8 "Achso"))
#’user/anja
user> (compare anja emil)
1
user> (fmt-email anja)
"<Anja Auchegal> Anja.Auchegal@clojure-buch.de"
Mit den Funktionen satisfies? und extends? stehen zwei Mittel bereit, um zur
Laufzeit zu ermitteln, ob ein Objekt oder ein Type ein Protocol oder ein Interface
unterstützt:
user> (satisfies? FormatiereAdresse emil)
true
user> (extends? FormatiereAdresse LoginUser)
true
user> (extends? FormatiereAdresse java.lang.String)
false
Nach dieser Erweiterung von Clojure sind sowohl Clojures Anwender als auch Clojures
Entwickler in der Lage, zur Laufzeit Datentypen zu erzeugen und mit ihnen die Methoden
eines Protocol zu implementieren. Diese in Clojure definierten Abstraktionen und
Konkretisierungen stehen in Form von Klassen und Interface auch Java-Programmierern zur
Verfügung.
Anwendungsentwickler in einer objektorientierten Programmiersprache verwenden Klassen
für zwei unterschiedliche Aufgaben. Auf der einen Seite stehen die Klassen, die die
Programmiersprache und ihre Standardbibliothek mitbringen. Sie sind oft genereller Natur
und dienen als Grundlage eigener Klassen oder Typen in der Anwendung. Die eigenen Typen
hingegen dienen der Umsetzung des Domänenmodells der Anwendung, repräsentieren also
Elemente der spezifischen Aufgabenstellung.
In schwach typisierten Programmiersprachen – wie auch in
Clojure – ist es üblich, für solche Informationsträger Datenstrukturen wie Maps oder
Vektoren zu verwenden. Das erlaubt vor allem eine schnellere Entwicklung. Die
Vorteile, die sich durch eine stärkere Formalisierung der Daten ergeben, rechtfertigen
oft nicht den zusätzlichen Aufwand, der durch dedizierte Klassen entsteht. Diese
Vorgehensweise hat noch einen weiteren günstigen Aspekt, der gerade in der Domäne der
funktionalen Programmierung zum Tragen kommt: Clojure kann Funktionen mitliefern,
die auf die Datenstrukturen spezialisiert sind. Wäre jeder Informationscontainer
ein separater Typ, wie es in objektorientierten Sprachen die Regel ist, kann keine
Bibliothek für die Datentypen des Anwendungsprogrammierers bereitgestellt werden, da
deren Zugriff bei jedem Typen unterschiedlich ausfallen kann. Beispielsweise macht
sich die Funktion get-in die Eigenschaft von Maps zunutze, dass der Zugriff auf
die Elemente mit Keywords gewährleistet wird. Sollte ein ähnliches Ziel in einer
objektorientierten Sprache erreicht werden, so müsste eine Abstraktion – in der Regel in
Interface – den Zugriff regeln. Dies würde etwa eine Methode getElementByKeyword
vorschreiben, die jeder Datentyp, der mit get-in funktionieren soll, implementieren
muss.
Performance, Polymorphismus
Andererseits sprechen aber auch Fakten für die Definition eigener
Datentypen: Sie werden vom Host mit bestmöglicher Performance verarbeitet und erlauben
Polymorphismus.
Clojure erlaubt die Definition von Records, die beide Vorteile vereinen: Sie führen in
der JVM zu einer eigenen Klasse und implementieren zudem das Protocol von Clojures
Maps.
Records gewähren Keyword-basierten Zugriff auf ihre Elemente und sind somit
für alle Funktionen geeignet, die Maps verarbeiten können. Zusätzlich erhalten Records auch
Unterstützung für Metadaten. Allerdings sind sie im Gegensatz zu Maps keine Funktionen
ihrer Schlüssel.
Das folgende Beispiel beginnt mit der Definition eines einfachen Protocol
und eines Records. Die Definition von Records unterscheidet sich nicht von der Definition von
Types, es kommt lediglich das Makro defrecord zum Einsatz.
(defprotocol TypischerSatz
(sag-deinen-satz [this]))
(defrecord BuchCharakter
[name typ-satz autor]
TypischerSatz
(sag-deinen-satz [this]
(println (:typ-satz this))))
Nach diesen Definitionen steht der Konstruktor für BuchCharakter zur Verfügung, und
Instanzen von diesem Typ erlauben den Zugriff auf ihre Elemente mit Schlüsselwörtern. Die
Instanz kann jedoch nicht als Funktion ihrer Schlüsselwörter verwendet werden, wie es bei
Maps sonst der Fall ist.
user> (def generalticktack
(BuchCharakter.
"General Ticktack"
(str "Mein Name [tic] ist General Ticktack\n"
"Und dies sind [tac] meine Kupfernen Kerle")
"Moers"))
#’user/generalticktack
user> (def gaunab
(BuchCharakter.
"Gaunab der 99."
"Ich lefehbe dir, mir zu chenhorge"
"Moers"))
#’user/gaunab
user> (:autor gaunab)
"Moers"
user> (generalticktack :name)
java.lang.ClassCastException:
user.BuchCharakter cannot be cast to clojure.lang.IFn
Die Funktionen des Interface werden von Records unterstützt:
user> (sag-deinen-satz gaunab)
Ich lefehbe dir, mir zu chenhorge
nil
user> (sag-deinen-satz generalticktack)
Mein Name [tic] ist General Ticktack
Und dies sind [tac] meine Kupfernen Kerle
Mit assoc wird eine neue Instanz erzeugt, bei der einer der Werte überschrieben
wird:
user> (assoc gaunab :name "Gaunab der Letzte")
#:user.BuchCharakter{:name "Gaunab der Letzte",
:typ-satz "Ich lefehbe dir, mir zu chenhorge",
:autor "Moers"}
Zerlegende Variablenbindung
Durch ihre Eigenschaften als Map haben Records den Vorteil, dass
sie zerlegende Variablenbindung unterstützen. Diese Form scheint sich durchaus zu etablieren.
Vorteilhaft dabei ist, dass die Funktionssignatur signalisiert, welche Elemente verwendet
werden. Das folgende Beispiel demonstriert diese Verwendung anhand einer separaten
Funktion.
user> (defn print-name [{n :name}]
(println "Name: " n))
#’user/print-name
user> (print-name gaunab)
Name: Gaunab der 99.
nil
Da beim Aufruf einer Funktion aus einem Protocol das jeweilige Objekt immer als
erstes Argument übergeben wird, kann die Definition einer Methode des Protocol
gleich in der Definition der Argumente die benötigten Informationen aus dem Typ
extrahieren. Diese Form kann dann auch in der Definition mit defrecord verwendet
werden.
Implementierte Interfaces
Welche Interfaces aus Clojures Bereich Records genau unterstützen,
lässt sich mit Hilfe von supers ermitteln:
user> (doseq [k (supers BuchCharakter)]
(when (re-find #"clojure" (str k))
(println k)))
clojure.lang.IObj
clojure.lang.IMeta
clojure.lang.ILookup
clojure.lang.Counted
clojure.lang.IKeywordLookup
clojure.lang.Seqable
clojure.lang.ILookupHost
clojure.lang.IPersistentCollection
clojure.lang.Associative
clojure.lang.IPersistentMap
Dazu gesellen sich noch einige Schnittstellen aus dem Sprachumfang von Java:
user> (doseq [k (supers BuchCharakter)]
(when (re-find #"java" (str k))
(println k)))
java.lang.Iterable
java.io.Serializable
java.util.Map
java.lang.Object
Gelegentlich sieht sich ein Programmierer mit einer Situation konfrontiert, in der er einen
Datatype benötigt, der ein bestimmtes Protocol implementiert, aber nur lokal relevant ist.
Ähnlich wie der funktionale Programmierstil häufig anonyme Funktionen verlangt, scheint in
solchen Fällen eine anonyme Ad-hoc-Definition einer Klasse, die an keiner anderen
Stelle wieder benötigt wird, angebracht. Solche anonymen Typen haben auch eine
gewisse Ähnlichkeit zu Proxy-Objekten, wie sie in Clojure mit proxy erzeugt werden
(vgl. Abschnitt 4.2.1).
In Clojure bewirkt der Befehl reify die Erzeugung eines solchen dynamischen
Konstrukts. In der Informatik ist im deutschen Sprachraum der Begriff „Reifikation“ oder
„Reifizierung“ bekannt, er wird allerdings selten gebraucht. Die direkte Übersetzung
„Vergegenständlichung“ deutet jedoch darauf hin, dass es um die Konkretisierung eines
mentalen oder abstrakten Konzeptes geht.
Beispielsweise könnte eine Programmentwicklung ein Objekt verlangen, das
Runnable implementiert. Würde dieses Objekt aber nur lokal und vielleicht auch nur
einmal verwendet werden, wäre die explizite Definition einer eigenen Klasse für diesen
Anwendungsfall unnötig aufwendig. Die Verwendung von reify erlaubt die Definition und
gleichzeitige Instantiierung solcher Objekte. Der Aufruf von reify gleicht dabei dem von
deftype und defrecord bis auf den fehlenden Namen.
Das folgende Beispiel erzeugt eine Klasse und eine Instanz, die Runnable implementiert. Auf
diesem Objekt kann die Methode run aufgerufen werden oder das Objekt kann an einen
Thread übergeben werden, der auch für den Aufruf von run sorgt.
(def Walker (reify Runnable
(run [this] (println "I’m walking."))))
user> (.run Walker)
I’m walking.
nil
user> (.start (Thread. Walker))
nil
I’m walking.
Diese Methode unterscheidet sich im Detail von proxy. Erstens beschränkt sich reify auf
die Implementation von Interfaces und erlaubt nicht das Ableiten von anderen Klassen.
Zweitens ist das Resultat etwas performanter, weil es von der Host-Plattform direkt
unterstützt wird.
Die Einführung von Protocols, Types, Records und Ad-hoc-Typen erfolgte erst mit
Clojure 1.2. Das Konzept erscheint zu diesem Zeitpunkt schlüssig, und im Sprachkern werden
diese Konstrukte auch bereits verwendet. Die Eigenschaften dieser Technologien reflektieren
aktuell vor allem die Meinung von Rich Hickey zu den Themen Polymorphismus, Vererbung
und Kapseln von Informationen. Diese sind in der Dokumentation auf der Webseite von
Clojure [27] wiedergegeben.
Die Akzeptanz bei den Clojure-Entwicklern bleibt abzuwarten. Erste Erfahrungen deuten
bereits darauf hin, dass dieses Thema erfolgreich sein könnte. Wenn es sich so entwickelt, ist
davon auszugehen, dass die hier noch in einem separaten Kapitel vorgestellten Konstrukte
eine zentralere Rolle spielen werden. Die meisten Verwendungen von proxy werden
durch reify ersetzt, StructMaps werden weitestgehend durch Records abgelöst
werden.