Die Java Platform, Standard Edition 6 API Specification listet über 3700 Klassen auf, und im
Internet finden sich unzählige qualitativ hochwertige Programme und Bibliotheken, die nicht
nur in Java geschrieben, sondern auch unter freien Lizenzen verfügbar sind. In den meisten
Fällen erlauben die Lizenzen auch eine kommerzielle Verwendung. Dieses reichliche Angebot
hat dazu geführt, dass kommerzielle Softwareentwicklung heute oft darin besteht,
die passenden Bibliotheken zu finden, zu konfigurieren und sie dann mit dem noch
fehlenden eigenen Code zusammenzufügen. Clojure integriert sich so nahtlos in die
Java-Landschaft, dass die Verwendung beider Sprachen in einem Programm in fast allen
Fällen möglich ist, was ein großer Vorteil ist. Manch neue Sprache scheitert an ihrer zu
kleinen Standardbibliothek, aber auch Sprachen mit langer Geschichte, gerade in der
Lisp-Familie, haben oft eine zerklüftete Bibliotheken-Landschaft. Für Common Lisp
finden sich entgegen der landläufigen Meinung zwar zahlreiche Bibliotheken für
diverse Anforderungen, doch ist deren Integration oft unzulänglich, und auch die
Qualität der Dokumentation oder die Vollständigkeit der Implementation lassen
häufig zu wünschen übrig. Selbst auf der JVM laufende Sprachen, die von ihrer
kanonischen Implementation portiert wurden, wie Jython oder JRuby, fügen sich
nicht vollkommen nahtlos ein, da sie durch ihre primäre Plattform nicht immer mit
Javas Typsystem harmonieren. Harmonie mit der JVMPython beispielsweise verfügt
über keinen Typen für einen Character und kennt nur Doubles, die allerdings in
Python „float“ heißen. Deutlich schwieriger wird es aber in den Fällen, in denen eine
Bibliothek, egal ob Teil des Standards oder extern, weitere Abhängigkeiten hat.
Die C-Implementation von Python beispielsweise unterstützt die Entwicklung von
Terminal-Interfaces mit der Curses-Bibliothek, Jython hingegen nicht. Nichtsdestotrotz sind
diese Sprachen natürlich der JVM sehr nah und integrieren sich recht gut in die
Java-Infrastruktur.
Im allgemeinen Fall ist für die Interaktion zwischen zwei Sprachen eine
Bindungsschicht unerlässlich; ohne sie sind die in einer Sprache deklarierten Funktionen und
Klassen in der anderen nicht verfügbar. Die reflektiven Fähigkeiten von Java ermöglichen es,
zur Laufzeit deklarierte Typen zu inspizieren, und gestatten Clojure somit ohne zusätzlichen
Aufwand den Zugriff auf existierenden Code.
Dieses Kapitel beschreibt die Verwandtschaft von Clojure und Java. Dabei richten wir unser
Augenmerk nicht ausschließlich auf den üblichen Fall, dass Java-Klassen aus Clojure
verwendet werden sollen, was bereits in Abschnitt 2.5 grundlegend beschrieben wurde. Auch
die Gegenrichtung, Clojure aus Java zu verwenden, findet Beachtung. Letzteres ist auf zwei
Ebenen möglich.
Java-Klassen und Methoden direkt aus Clojure zu verwenden, gilt in der Clojure-Welt nicht
als unfein oder Lösung zweiter Klasse. Es ist vollkommen idiomatischer Code. Tatsächlich rät
Rich Hickey sogar von der Entwicklung von Wrapper-Bibliotheken, die lediglich den Zugriff
auf bestimmte Klassen vereinfachen sollen, ab. Java bietet bereits in seiner Standard Edition
ein so umfangreiches Ensemble von Lösungen, dass ein pragmatischer Ansatz, der versucht, in
möglichst kurzer Zeit qualitativ möglichst hochwertige Programme zu schaffen, dieses
Angebot nicht ignorieren darf. Einfache Syntax: PunktWenn aber dieser Zugriff als
so relevant betrachtet wird, muss er einfach erfolgen können. Das ist in Clojure
der Fall: Vereinfacht gesagt ist Clojures Syntax für den Griff in die Java-Welt der
Punkt.
Einer der ersten Schritte ist die Instantiierung von Klassen. dies wurde bereits in der
Einleitung in Abschnitt 2.5 verwendet. Die ausführliche Form ist die Verwendung von
new:
Obwohl diese Form gerade für Clojure-Einsteiger und Java-Programmierer
sicherlich vertrauter ist, taucht in realem Clojure-Code die kürzere Form deutlich häufiger auf.
Diese sieht als Clojure-Code so aus, als wäre der Klassenname mit einem Punkt als Suffix ein
Funktionsaufruf:
Eventuelle Argumente an den Konstruktor werden in diesen Aufruf mit eingeschlossen:
user> (String. "Hallo Welt")
"Hallo Welt"
user> (Integer. 99)
99
user> (StringBuffer.)
#<StringBuffer >
user> (StringBuffer. 100)
#<StringBuffer >
user> (StringBuffer. "Komm an Bord")
#<StringBuffer Komm an Bord>
Diese Beispiele konnten den unqualifizierten Klassennamen verwenden, da
sie aus dem Paket java.lang stammen, das in der REPL-Sitzung bereits geladen ist. Falls ein
Paket noch nicht importiert wurde (siehe Abschnitt 2.18.1), muss der vollständige Name
qualifiziert werden.
user> (java.io.File. "/etc/hosts")
#<File /etc/hosts>
user> (import ’[java.io File])
java.io.File
user> (File. "/etc/hosts")
#<File /etc/hosts>
Nachdem eine Instanz vorliegt, sollen in der Regel auf dieser
Instanz Methoden aufgerufen werden. Erneut spielt der Punkt bei der Syntax eine
entscheidende Rolle, denn der Methodenname mit einem führenden Punkt wird hier wie eine
Clojure-Funktion verwendet. Genau genommen existieren für das Aufrufen von Methoden drei
alternative Formen.
(.methode KlasseOderInstanz parameter*)
(. KlasseOderInstanz (methode parameter*))
(. KlasseOderInstanz methode Parameter*)
Der erste Ausdruck, bei dem der Name der Methode die Position des Funktionsaufrufs
einnimmt, entspricht der Syntax eines normalen Clojure-Aufrufs. Diese Variante ist wohl auch
die derzeit am häufigsten auftretende, was eine rekursive Suche nach „(\. “ und „(\.[a-zA-Z]“
im Wurzelverzeichnis der Contrib-Bibliothek mit ca. 700:30 andeutet. Für die meisten
Java-Programmierer hingegen wird die alternative Reihenfolge vertrauter erscheinen. Das
zusätzliche Paar Klammern, das im ersten Ausdruck den Methodennamen und die
übergebenen Parameter umfasst, ist optional. Es kann jedoch einerseits die Lesbarkeit
verbessern, bietet andererseits aber auch eine Alternative, die bei der Codeerzeugung durch
Makros hilfreich sein kann.
Statische Felder und Methoden
Die letzten beiden Ausdrücke sind auch für das Aufrufen von
statischen Methoden sowie den Zugriff auf statische Felder verwendbar, allerdings ist in
diesem Fall eine weitere Syntax üblicher, die den Schrägstrich (Slash) als Separator
verwendet:
(Klasse/Feld-oder-Methode)
Die Berechnung der Fläche eines Kreises mit zufälligem Radius demonstriert die
Instantiierung eines Objekts der Klasse Random, einen Methodenaufruf auf diesem Objekt
und die Verwendung des statischen Felds PI aus dem Math-Paket.
user> (import ’[java.util Random])
java.util.Random
user> (def rnd (Random.))
#’user/rnd
user> (defn kreisflaeche [radius]
(* 2 Math/PI radius))
#’user/circle-area
user> (let [radius (. rnd nextInt 20)
flaech (kreisflaeche radius)]
(println "radius:" radius
"Flaeche:" flaech))
radius: 8 Flaeche: 50.26548245743669
nil
Locale, Kalender, Formatierung
Das folgende Beispiel zeigt einen Ausschnitt aus einer
REPL-Sitzung, in der zunächst kurze Experimente mit Locale und TimeZone erfolgen, bis
dann eine Instanz von GregorianCalendar erzeugt wird, auf der die Methode set mit der
Signatur für das Setzen von Datum und Uhrzeit (sechs Argumente vom Typ int) aufgerufen
wird. Dieses Datum wird dann mit format und Javas Formatierungsanweisung für eine
Zeitangabe unter Berücksichtigung der Locale und Zeitzone (%tc) formatiert und als
Rückgabewert an der REPL sichtbar.
user> (import ’[java.util TimeZone
GregorianCalendar
Locale])
java.util.Locale
user> (Locale/US)
#<Locale en_US>
user> (doseq [id (filter
#(re-find #"America/L" %)
(TimeZone/getAvailableIDs))]
(println id))
America/Los_Angeles
America/Lima
America/Louisville
America/La_Paz
nil
user> (let [greg (GregorianCalendar.
(TimeZone/getTimeZone
"America/Los_Angeles")
(Locale/US))]
(.set greg 1993 11 4 6 0 0)
(format "his final tour %tc" greg))
"his final tour Sa Dez 04 06:00:00 PST 1993"
Häufig müssen auf einer Instanz mehrere Methoden aufgerufen werden.
In Java führt das zu einer immerwährenden Wiederholung der Instanz für jeden Aufruf. Auch
in Clojure-Code kann diese Form gewählt werden. Das folgende Beispiel zeigt das
anhand einer etwas unsinnigen Methode, einen String „Hallo Welt“ zu erzeugen,
indem es einen StringBuilder instantiiert und verschiedene Methoden auf diesem
aufruft.
user> (let [sb (StringBuilder.)]
(.append sb "Hello")
(.append sb " ")
(.append sb "World")
(.setCharAt sb 1 \a)
(.replace sb 6 9 "Welt")
(.deleteCharAt sb 11)
(.deleteCharAt sb 10))
#<StringBuilder Hallo Welt>
Dieses sicherlich künstlich konstruierte Beispiel dient hier der Demonstration: Es kann
deutlich eleganter durch die Verwendung des Makros doto geschrieben werden. Dieses Makro
nimmt ein erstes Argument, häufig eine instantiierte Java-Klasse, entgegen und fügt dieses in
alle darauf folgenden Ausdrücke als zweites Argument ein. Das obige Beispiel kann somit
kompakter geschrieben werden:
user> (doto (StringBuilder.)
(.append "Hello")
(.append " ")
(.append "World")
(.setCharAt 1 \a)
(.replace 6 9 "Welt")
(.deleteCharAt 11)
(.deleteCharAt 10))
#<StringBuilder Hallo Welt>
Beachtenswert bei doto ist der Rückgabewert. Es wird das im ersten Ausdruck
erzeugte Objekt als Resultat des gesamten Ausdrucks zurückgeliefert. Somit sind die folgenden
Schreibweisen äquivalent:
user> (let [ra (java.util.Random.)]
(.setSeed ra 100)
(.nextFloat ra))
0.7220096
user> (.nextFloat (doto (java.util.Random.)
(.setSeed 100)))
0.7220096
Hier kann der Aufruf von nextFloat nicht innerhalb des Rumpfes von doto erfolgen, da der
Rückgabewert von doto das erzeugte Objekt ist. Gewünscht wird in diesem Fall aber die mit
dem Zufallszahlengenerator erzeugte Zahl.
Ein weiteres, unvollständiges Beispiel verdeutlicht, was hier ausgedrückt werden
soll:
(let [aus-knopf (JButton. "Aus")
an-knopf (doto (JButton. "An")
(.setEnabled false))])
In diesem Beispiel werden zwei Buttons erzeugt, die in einem anderen Codeabschnitt
gezeichnet werden können. Dem Status der Drückbarkeit folgend, ist das dahinter liegende
System aktuell „an“, kann aber durch Drücken des aktiven Buttons mit der Beschriftung
„Aus“ ausgeschaltet werden. Der Button „An“ ist nicht aktiv, und genau diese Eigenschaft
wurde durch das doto-Makro direkt im Zusammenhang mit der Instantiierung festgelegt,
indem im Body von doto (.setEnabled false) aufgerufen wurde. Das Resultat ist eine
deutlich „funktionalere“ Schreibweise eines an sich nicht funktionalen Vorgangs, nämlich der
Erzeugung eines Objekts einer bestimmten Klasse und der darauf folgenden Manipulation
dieses Objekts.
In Abschnitt 2.12.3 haben wir die Nützlichkeit von Makros unter anderem mit
der Wiederholung einer Instanz von JFrame begründet und eine Lösung angekündigt.
Diese Lösung ist doto, und das Beispiel aus jenem Abschnitt lässt sich wie folgt
komprimieren:
user> (import ’[javax.swing JFrame JLabel])
user> (doto (JFrame.)
(.add (JLabel. "leWidget"))
(.pack)
(.setVisible true))
Der Swing-ThreadBeispiele wie das eben gezeigte erzeugen leicht von der REPL aus Swing-Anwendungen. Bei einem Aufruf direkt von der REPL aus läuft der Programmteil, der die Oberfläche zeichnet, aber im gleichen Thread ab wie die REPL auch. Native Java-Anwendungen tragen Sorge, dass der für das Zeichnen der Oberfläche und das Bearbeiten der verschiedenen Events verantwortliche Code in einem einzigen, dedizierten Thread abläuft
[67]. Der Beitrag von Graham Hamilton
[26] beschreibt detailliert die Hintergründe dieser Entscheidung. Kurz zusammengefasst handelt es sich um Probleme beim Locking, wenn einige Programmteile aus hohen Abstraktionsschichten, andere aus tief liegenden, betriebssystemnahen Schichten stammen. Korrekt wäre daher der Aufruf des UI-Codes mit Hilfe der statischen Methode
invokeLater aus
SwingUtilities.
InMethodenaufrufe auf Methodenrückgabewerten vielen Fällen wird auf dem Rückgabewert einer
Methode erneut eine Methode aufgerufen. In Clojure-Code existiert auch für diesen
Anwendungsfall eine alternative Ausdrucksform. Als Beispiel verwenden wir hier
eine XSL-Transformation, der das folgende, minimale DocBook-Beispiel zugrunde
liegt.
Dies wird durch ein ganz simples XSL-Stylesheet konvertiert, das eine Kopie des Originals
erzeugt.
Für dieses XSL-Stylesheet wird eine Instanz von Transformer aus einer TransformerFactory
erzeugt. Auf dem Transformer erfolgt dann ein Aufruf von der Methode transformer, der das
XML-Dokument als Argument übergeben wird. Das Resultat – hier inklusive der Imports und
der Contrib-Bibliothek java-utils, aus der as-file importiert wird – ist durch die
Schachtelung der vielen Aufrufe nicht für jeden leicht lesbar.
user> (import ’[javax.xml.transform
Transformer TransformerFactory])
javax.xml.transform.TransformerFactory
user> (import ’[javax.xml.transform.stream
StreamSource StreamResult])
javax.xml.transform.stream.StreamResult
user> (use ’[clojure.contrib.java-utils
:only (as-file)])
nil
user> (.transform
(.newTransformer
(TransformerFactory/newInstance)
(StreamSource. (as-file "listings/db.xsl")))
(StreamSource. (as-file "listings/source.xml"))
(StreamResult. *out*))
<?xml version="1.0" encoding="UTF-8"?>
<book>
<bookinfo>
<title>Beispiel-XML</title>
</bookinfo>
<chapter>
<title>Beschreibung</title>
<para>
Dieses Dokument dient als XML-Beispiel im Clojure-Buch.
</para>
</chapter>
</book>
nil
Abhilfe verschafft der Dot-Dot-Operator, genauer das Dot-Dot-Makro.
Dieses signalisiert durch seinen Namen, dass es an die Stelle von mehreren Punkten tritt. In
diesem Falle handelt es sich um die Punkte, die im Java-Code durch das Aneinanderfügen –
Chaining – von Methodenaufrufen entstehen. Das Resultat ist leichter lesbar als die vorige
Variante in Clojure und als die Java-Version, die sich vermutlich über mehrere Zeilen
erstrecken würde.
user> (.. (TransformerFactory/newInstance)
(newTransformer
(StreamSource. (as-file "listings/db.xsl")))
(transform
(StreamSource. (as-file "listings/source.xml"))
(StreamResult. *out*)))
;; Ausgabe wie oben
Mit einem einfachen Makro (oder einer ähnlichen Funktion) lassen sich
noch die Wiederholungen von StreamSource enfernen:
(defmacro stream-file [file]
‘(StreamSource. (as-file ~file)))
(.. (TransformerFactory/newInstance)
(newTransformer (stream-file "listings/db.xsl"))
(transform (stream-file "listings/source.xml")
(StreamResult. *out*)))
;; Ausgabe wie oben
Dot-Dot macht den Ausdruck nicht notwendigerweise kürzer, aber – vor allem
durch die Einrückung des Quelltexts – vermutlich für die meisten Anwender besser
lesbar.
Java kennt das Schlüsselwort instanceOf , mit dem zur Laufzeit überprüft werden kann, ob ein
Objekt einer bestimmten Klasse angehört. In Clojure ist dies mit dem Prädikat instance?
möglich:
user> (instance? Number (Integer. 1))
true
user> (instance? String (Integer. 1))
false
user> (instance? String "Hallo Welt")
true
user> (instance? Float 1.23)
false
user> (type 1.23)
java.lang.Double
user> (instance? Double 1.23)
true
Java-Methoden als Funktionen
Bei der Verwendung von Funktionen höherer Ordnung, wie
filter oder map, im Zusammenhang mit Java-Objekten fällt ein häufig auftretendes Muster
auf: Es kommt eine anonyme Funktion zum Einsatz, die eine Methode auf dem übergebenen
Argument aufruft. Diese etwas ausführlichere Form ist notwendig, weil Java-Methoden keine
Clojure-Funktionen sind und somit nicht in der gleichen Art und Weise übergeben werden
können. Für dieses Muster bietet Clojure mit dem Makro memfn eine Variante, mit
der es möglich wird, auch Java-Methoden fast wie echte Clojure-Funktionen zu
verwenden.
user> (map #(.toUpperCase %) ["hello" "world"])
("HELLO" "WORLD")
user> (map (memfn toUpperCase) ["hello" "world"])
("HELLO" "WORLD")
user> (map (memfn getClass) ["Die" 2 :Trauben])
(java.lang.String java.lang.Integer clojure.lang.Keyword)
Allerdings scheint sich die Clojure-Gemeinde gegen die Verwendung dieser Funktion
entschieden zu haben. Die Verwendung ist letztlich Geschmackssache.
Eine JavaBean ist eine Klasse, die bestimmten Konventionen unterliegt. Insbesondere
existieren für alle Instanzvariablen (in diesem Zusammenhang als „Properties“ bezeichnet)
Get- und Set-Methoden, deren Namen mit der jeweiligen Variablen übereinstimmen. Die
Funktion bean erzeugt aus einer Bean-Instanz eine Map, deren Schlüssel den Namen der
Properties entsprechen, aber Keywords sind. Somit ergibt sich ein lesender Zugriff auf die
Properties einer JavaBean.
user> (use ’(clojure.contrib [repl-utils :only (show)]))
nil
user> (import ’[java.util Locale])
java.util.Locale
user> (show Locale)
...
[33] getCountry : String ()
...
[36] getDisplayLanguage : String ()
...
user> (select-keys (bean (Locale/getDefault))
[:country :displayLanguage])
{:displayLanguage "Deutsch", :country "DE"}
Schreibender Zugriff auf Klassen- und Instanzvariablen ist mit dem
Operator set! möglich, der auch auf Vars operieren kann. Er tauchte bereits in
Abschnitt 2.12.2 auf. Dieser Operator erwartet als erstes Argument die Information, was
geschrieben werden soll, und danach den zu schreibenden Wert. Im ersten Argument können
sowohl statische Variablen als auch Instanzvariablen auftauchen.
;; existiere diese Klasse:
;; public class Foo {
;; public static String bar;
;; public String baz;
;; }
user> (import ’Foo)
Foo
user> (set! Foo/bar "test")
"test"
user> (let [F (Foo.)] (set! (. F baz) "test"))
"test"
In vielen bestehenden Java-Projekten und -Bibliotheken werden Java-Arrays von primitiven
Datentypen wie int, long, etc. verwendet. Diese integrieren sich nicht so nahtlos in Clojure wie
Javas Objekte. Ihre Verbreitung verlangt auch von Clojure die Behandlung von
Java-Arrays.
Die gute Nachricht zuerst: Arrays können als Grundlage für Sequences dienen. Daher ist
lesender Zugriff mit den bekannten Funktionen möglich, wie am Ende dieses Abschnitts
demonstriert wird. Allerdings sind spezielle Funktionen für das Erzeugen sowie für
indexbasierten Zugriff notwendig.
user> (let [a (make-array String 8)]
(dotimes [i 8]
(aset a i (str "entry " i)))
(dotimes [i (alength a)]
(println i ":" (aget a i))))
0 : entry 0
1 : entry 1
2 : entry 2
3 : entry 3
4 : entry 4
5 : entry 5
6 : entry 6
7 : entry 7
nil
Die Funktion make-array erwartet mindestens den Objekttyp und die Länge des Arrays als
Parameter. Die Verwendung der Funktionen alength, aget und aset sollte anhand des obigen
Beispiels deutlich werden.
Arrays von primitiven Datentypen können erzeugt werden, indem als
Objekttyp die statische Variable TYPE der entsprechenden Klasse verwendet wird, zum Beispiel
Integer/TYPE.
Es ist aus Performancegründen jedoch empfehlenswert, nicht mit den generischen Methoden
auf Arrays von primitiven Typen zu operieren, sondern die typspezifischen Methoden wie
int-array, byte-array, char-array usw. zum Erzeugen und Setzen von Werten zu
verwenden.
(defn t1 [anz]
(let [a (make-array Integer/TYPE anz)]
(dotimes [i anz]
(aset a i i))))
(defn t2 [anz]
(let [a (int-array anz)]
(dotimes [i anz]
(aset-int a i i))))
user> (time (t1 10000))
"Elapsed time: 560.231 msecs"
nil
user> (time (t2 10000))
"Elapsed time: 4.976 msecs"
nil
Vektoren von Grunddatentypen
Eine Alternative zu Arrays von Grunddatentypen ist das seit
Clojure 1.2 verfügbare vector-of, das einen Vektor von Grunddatentypen erzeugt, der sich
wie ein Clojure-Vektor verhält. Sofern nicht Anforderungen von verwendeten Bibliotheken die
Verwendung von Arrays verlangen, ist der Vektor vermutlich die bessere Wahl, da
er sich nahtlos in Clojure integriert, wohingegen Arrays eine Sonderbehandlung
verlangen.
Die Funktion into-array erlaubt das Erzeugen eines Java-Arrays aus einer
Sequence. Optional kann der Typ angegeben werden, andernfalls ermittelt Clojure ihn aus
dem ersten Element.
user> (into-array Integer [1 2 3])
#<Integer[] [Ljava.lang.Integer;@706d756b>
user> (into-array ’("Dies" "wird" "ein" "String[]"))
#<String[] [Ljava.lang.String;@44be6435>
Die Webseite von Clojure beschreibt noch weitere Funktionen, die auf
Java-Arrays spezialisiert sind. Die Erzeugung von Arrays aus Datenstrukturen und das
Kopieren von Array-Werten sind darunter. Auch die funktionale Verwendung kann mit amap
und areduce erreicht werden.
Im Folgenden demonstrieren wir die Verwendung von areduce. Dessen formale
Syntax lautet:
(areduce a idx ret init expr)
Die Funktion iteriert über alle Elemente des Arrays a und bindet bei jedem Durchlauf
den aktuellen Index an die Variable idx. Zusätzlich wird die Variable ret an das
bisherige Ergebnis gebunden, was beim ersten Element des Arrays mit dem Inhalt
von init vorbelegt ist. Mit diesen Variablenbindungen führt areduce dann den
Ausdruck expr aus, dessen Rückgabewert im nächsten Schritt in ret zu finden sein
wird.
Für die Demonstration dieser Funktion werden zunächst zwei Vars angelegt, die mit Hilfe
von int-array an ein Array von ints gebunden werden.
user> (def a1 (int-array [1 3 2 4]))
#’user/a1
user> a1
#<int[] [I@235e9d>
user> (def a2 (int-array [2 7 3 6]))
#’user/a2
Der nächste Schritt ist Definition und Aufruf der Funktion alle>1?, deren Aufgabe es ist,
zu testen, ob alle Elemente eines Arrays größer als 1 sind. In dieser Funktion findet areduce
Verwendung.
(defn alle>1? [a]
(areduce a i ret true
(and (> (aget a i) 1)
ret)))
user> (alle>1? a1)
false
user> (alle>1? a2)
true
Auf ähnliche Weise versucht amap das Konzept von map auf Arrays zu übertragen. Auch
hier wird das Array selbst mit einer Laufvariablen verwendet. Das Array, das als Rückgabe
dient, wird mit einer Kopie des Ursprungsarrays vorbelegt und die einzelnen Elemente werden
mit dem Ergebnis der Evaluation des Ausdrucks übergeben. Dabei ist zu beachten, dass der
Typ des Arrays sich nicht ändern darf. Es ist nicht möglich, aus einem Array von Characters
eines von Floats zu machen.
user> (def a (into-array "hallo"))
user> (aget (amap a i ret
(Character/toUpperCase (aget a i)))
2)
\L
Da mit Hilfe von seq auch aus einem Array eine Sequence erstellt werden kann, ist
es meist einfacher und eleganter, diesen Weg zu gehen. Das Beispiel zu areduce würde dann
mit every? implementiert werden können.
user> (every? #(> % 1) (seq a1))
false
user> (every? #(> % 1) (seq a2))
true
Clojures Zusammenarbeit mit Java beschränkt sich nicht auf die Verwendung von Javas
Klassen in Clojure. Die Integration mit verschiedenen Bibliotheken verlangt das Ableiten von
Klassen, das Implementieren von Interfaces, und manchmal beginnt ein Programm sein Leben
nicht an der REPL, sondern in einer Main-Funktion von Java, die dafür sorgt, dass benötigte
Klassen nachgeladen werden. Auch diese Anforderungen deckt Clojure ab. Clojure-Code kann
zu Klassen kompiliert werden, die von anderen abgeleitet sind oder Interfaces implementieren
oder auch einfach so, wie sie sind, verwendet werden. Auch ohne kompilierte Klassen können
mit Clojure Interfaces implementiert und Klassen abgeleitet werden: mit Hilfe eines
Proxy-Objekts.
Eine sehr einfache Form der Integration mit fremden Bibliotheken besteht darin,
ein Proxy-Objekt mit Hilfe des Makros proxy zu erstellen. Dessen formale Syntax
lautet:
(proxy [KlassenUndInterfaces+] [args*] & fns)
Interfaces und Basisklassen
Das erste Argument ist ein Vektor. Er enthält Interfaces, die
implementiert werden sollen, sowie Klassen, von denen abgeleitet werden soll. Der zweite
Vektor nimmt Argumente für die Konstruktoren auf. Darauf folgen die Definitionen der
notwendigen Funktionen.
Diese Möglichkeit, ein Interface zu implementieren, bringt Clojure seit langem mit. Vor
dem Einsatz von proxy sollte jedoch die Möglichkeit, reify zu verwenden, geprüft
werden. Diese Möglichkeit besteht seit Clojure 1.2. Eine Beschreibung erfolgt in
Abschnitt 5.3.3.
Ein Anwendungsfall ist ein ActionListener, der in einer Swing-Oberfläche an
verschiedene Komponenten übergeben werden kann und, etwa im Fall eines JButton den nach
einem Klick auszuführenden Code kapselt. Das folgende Beispiel erzeugt ein solches
Proxy-Objekt, das das Interface ActionListener implementiert. Mit diesem Objekt kann ein
einfacher Java-Frame erstellt werden, der einen Knopf beinhaltet, auf dessen Druck das
Proxy-Objekt reagiert.
Close-ButtonViele Swing-Beispiele erzeugen einen neuen
JFrame und setzen mit Hilfe der Methode
setDefaultCloseOperation die Aktion, die beim Schließen des Swing-Fensters durchgeführt werden soll, auf die statische Konstante
JFrame/EXIT_ON_CLOSE. Das empfiehlt sich jedoch nicht für Swing-Fenster, die aus Clojure erzeugt werden, da damit auch die Clojure-Laufzeitumgebung beim Schließen des Fensters beendet wird.
user> (import [java.awt.event ActionListener]
[javax.swing JFrame JButton])
javax.swing.JButton
user> (doto (JFrame.)
(.add (doto (JButton. "Picard")
(.addActionListener
(proxy [ActionListener] []
(actionPerformed [e]
(println "Energie!"))))))
(.pack)
(.setVisible true))
#<JFrame javax.swing.JFrame[
;; Ausgabe gekürzt...
rootPaneCheckingEnabled=true]>
user> ;; Button druecken: Energie!
Eine angenehme Eigenschaft dieser Form der Implementation eines
Interface ist, dass Clojure erlaubt, nur die Funktionen zu implementieren, die gebraucht
werden. Zwar verlangt Java die vollständige Implementation, doch Clojure belegt nicht
implementierte Methoden mit einem Default, der im Falle der Verwendung eine
UnsupportedOperationException wirft.
Im Umfeld von Java ist die Klasse die Lingua Franca. Für Clojure ist es unabdingbar, Klassen
erzeugen zu können, wenn es sich in diesem Umfeld platzieren möchte. Hinter den Kulissen
geschieht das oft für den Clojure-Programmierer unbemerkt. Gleichzeitig ist es aber auch
möglich, Klassen gezielt zu erzeugen. Diese können danach sowohl von Clojure- als auch von
Java-Programmen verwendet werden. Auf diese Weise lassen sich auch Klassen in Clojure
erstellen, die ein Interface implementieren oder von einer anderen – möglicherweise
abstrakten – Klasse ableiten.
Ein erstes einfaches Beispiel zeigt das Vorgehen beim Erzeugen solcher in Clojure
geschriebenen Klassen. Zudem behandelt es die bereits in Abschnitt 2.18.1 angesprochene
Problematik der unterschiedlichen Auffassung von gültigen Identifiern von Clojure und Java:
Der Namensraum enthält in Clojure ein Minuszeichen, das im Java-Umfeld durch einen
Unterstrich ersetzt wird.
Zunächst werden zwei Verzeichnisse benötigt:
shell> mkdir -p de/clojure_buch/
shell> mkdir classes
Das erste Verzeichnis beinhaltet eine Datei mit Namen hildegard.clj und folgendem
Inhalt:
(ns de.clojure-buch.hildegard
(:gen-class))
(defn -main []
(println "Bitte sagen Sie jetzt nichts, Hildegard"))
Das sind lediglich zwei einfache Ausdrücke. Der erste definiert einen
Namespace, dessen Name ein Minuszeichen enthält, der zweite eine Funktion mit
dem speziellen Namen -main. Auffällig ist das neue Schlüsselwort :gen-class in der
Namespace-Definition. Dieses sorgt dafür, dass eine Klasse kompiliert wird. Die Kompilation
wird durch den Befehl compile angestoßen. Dieser Aufruf kann von der REPL erfolgen, aber
auch die Verwendung auf der Kommandozeile ist möglich, wie im folgenden Beispiel
gezeigt:
shell> java -cp ./:classes/:clojure.jar clojure.main \
-e "(compile ’de.clojure-buch.hildegard)"
Die resultierenden Klassen werden im Verzeichnis classes abgelegt.
Genau genommen gibt die Variable *compile-path* den Ort an. Dieser muss sich im
Classpath befinden. Bei Angabe dieses Verzeichnisses im Classpath und der aufzurufenden
Klasse kann die durch -main definierte Main-Methode von der Kommandozeile aufgerufen
werden:
shell> java -cp ./:classes/:clojure.jar \
de.clojure_buch.hildegard
Bitte sagen Sie jetzt nichts, Hildegard
Davon, dass Clojures Compiler im Hintergrund aktiv war, zeugt das passende
Verzeichnis-Listing im classes-Unterordner:
shell> ls classes/de/clojure_buch/
classes/de/clojure_buch/hildegard$_main.class
classes/de/clojure_buch/hildegard$loading__4403__auto__.class
classes/de/clojure_buch/hildegard.class
classes/de/clojure_buch/hildegard__init.class
Wenn Clojure einerseits Klassen aus Java verwenden, andererseits aber auch Klassen für Java
erzeugen kann, liegt es nahe, dass sich in Clojure Klassen schreiben lassen, die sowohl aus
Clojure- als auch aus Java-Code verwendet werden können.
Erweiterung des Namespace
Das folgende Beispiel ähnelt dem zur Demonstration eines
Proxy-Objekts aus Abschnitt 4.2.1. Es erzeugt eine Klasse, die das Interface ActionListener
implementiert. Dazu verwendet es neben dem bereits gesehenen Schlüsselwort :gen-class
weitere Schlüsselwörter in der Definition des Namespace.
(ns de.clojure-buch.GenCl
(:import [javax.swing JFrame JButton])
(:gen-class
:implements [java.awt.event.ActionListener]
:init init
:constructors {[String] []}
:state state))
Diese Definition weist Clojures Compiler an, eine Klasse mit Namen
GenCl im Namespace de.clojure-buch zu erzeugen. Der Name richtet sich hier
nach dem Namen des Namespace, kann aber optional auch durch Verwendung des
Schlüsselwortes :name explizit angegeben werden. Die Angabe von :implements weist
darauf hin, dass die resultierende Klasse das angegebene Interface implementieren
wird.
Um das Beispiel etwas zu erweitern, hält diese Klasse interne
Statusinformationen vor. Bei aus Clojure erzeugten Klassen gibt es exakt eine interne
Statusvariable. Der Name dieser Variablen wird mit dem Schlüsselwort :state angegeben.
Damit dieser Status beim Aufruf des Konstruktors gesetzt wird, definiert das Beispiel eine
spezielle InitInit-Funktion, deren Name durch das Schlüsselwort :init angegeben wird. Die
Implementation dieser Funktion erfolgt dann in einer Funktion mit dem angegebenen Namen,
versehen mit einem Präfix „-“.
(defn -init [s]
[[] (atom s)])
Beachtenswert ist hier der Rückgabewert. Diese Form der Init-Funktionen verlangt, dass ein
Vektor als Resultat geliefert wird. Spezieller RückgabewertDas erste Element dieses Vektors gibt
die Argumente an den Konstruktor der abzuleitenden Klasse an. Das zweite Argument liefert
die interne Statusvariable. In diesem Falle verwenden wir ein Atom. Das ist hier eigentlich
überflüssig, da diese Klasse keine Änderung am Status vorsieht. Diese Form demonstriert
jedoch ein übliches Verfahren.
Implementation des Interface
Abschließend folgt die Implementation der Interface-Funktion
actionPerformed, ebenfalls mit einem Präfix versehen. Diese Funktion greift durch
Dereferenzieren auf den internen Status zu. Das erste Argument von -actionPerformed ist
das Objekt selbst, per Konvention mit „this“ bezeichnet.
(defn- -actionPerformed [this e]
(println "Hello" @(.state this)))
Alternativer Aufruf des Compilers
Das Kompilieren dieser Klasse übernimmt dieses Mal ein
alternativer Aufruf. Statt den Clojure-Code zum Kompilieren per -e zu übergeben, wird die
Klasse clojure.lang.Compile als Hauptprogramm verwendet. Ihr kann durch Definition der
Property clojure.compile.path auf der Kommandozeile der Pfad für die erzeugten
Klassen übergeben werden, die in Clojure zur Laufzeit als *compile-path* verfügbar
ist.
shell> java -cp clojure.jar:./:./classes/ \
-Dclojure.compile.path=classes \
clojure.lang.Compile \
de.clojure-buch.GenCl
Compiling de.clojure-buch.GenCl to classes
Im Unterordner classes/de/clojure_buch findet sich darauf die
Klassendatei GenCl.class zusammen mit den Hilfsklassen, die auch beim vorigen Beispiel
schon auftauchten. Die so erzeugte Klasse kann nun direkt von einer REPL ebenso wie aus
einem Clojure-Namespace verwendet werden. Das folgende Beispiel importiert die
notwendigen Klassen und verwendet GenCl dann als ActionListener.
shell> java -cp classes/:clojure.jar clojure.main
Clojure 1.2.0
user=> (import [de.clojure_buch GenCl])
de.clojure_buch.GenCl
user=> (import [javax.swing JFrame JButton])
javax.swing.JButton
user=> (doto (JFrame.)
(.add (doto (JButton. "Hello")
(.addActionListener (GenCl. "Clojure"))))
(.pack)
(.setVisible true))
#<JFrame javax.swing.JFrame[ ;; ... Ausgabe gekuerzt
;; Druecke Button im neuen JFrame
Hello Clojure
Nachdem diese Klasse, die den Druck auf einen JButton abfängt, für
Clojure zur Verfügung steht, zeigt das folgende Beispiel, dass auch die Verfügbarkeit aus
einem Java-Programm gegeben ist. Dazu braucht Java eine Klasse, die als Container für
die Main-Funktion fungiert. Dazu dient die Datei ProgGenCl.java im Verzeichnis
de/clojure_buch mit folgendem Inhalt.
Der Java-Compiler kann diese Datei kompilieren:
shell> javac -cp classes/ de/clojure_buch/ProgGenCl.java
Die so erzeugte Klasse wird – wie es von Java-Klassen bekannt ist – von der
Kommandozeile aus aufgerufen. In diesem Falle wird „Clojure“ als Argument übergeben, das
schlussendlich dem Konstruktor von GenCl übergeben wird und in der Ausgabe nach dem
Drücken des Buttons wieder auftaucht.
shell> java -cp ./:classes/:clojure.jar \
de.clojure_buch.ProgGenCl Clojure
# Button drücken
Hello Clojure
# Programm mit Strg-c beenden
Wie das Beispiel zeigt, benötigt dieses Programm auch die JAR-Datei von Clojure in seinem
Classpath, um zu laufen. Da dieses einfache Programm kein Event an den Close-Button des
Fensters gebunden hat, muss es mit „Strg-c“ beendet werden.
Dieses Beispiel hat gezeigt, dass sich in Clojure geschriebene Klassen nicht nur für den
Einsatz in Clojure-Programmen eignen, sondern auch für Java-Programme zur Verfügung
stehen.
Die logistische Abbildung ist eine einfache, iterierte Abbildung, die für manche Zahlenwerte zu
recht stabilen Zuständen führt, aber bei anderen chaotisches, nicht vorhersagbares Verhalten
an den Tag legt. Sie wurde bereits im 19. Jahrhundert aus der Betrachtung der Entwicklung
von Populationen entwickelt. An dieser Abbildung lässt sich der Effekt der Bifurkation, also
des Aufspaltens in zwei Werte, beobachten.
 | (4.1) |
Gleichung (4.1) zeigt die iterierende Vorschrift. Einführendes Material zu den Hintergründen
findet sich in einer Präsentation von Jakob Nawrath von der Uni Freiburg [49] und auf der
deutschen Wikipedia [79].
Trennen von Anzeige und Berechnung
Eine Implementation in Clojure trennt sinnvollerweise das
Modell zur Berechnung von der Anzeige; eine Aufgabe, die mit nativer Java-Entwicklung auch
möglich ist, aber durch den erhöhten Aufwand oft nicht stattfindet. Das große Problem dabei
ist, dass die Berechnung, je nach Ausstattung des Rechners und Parametern für die logistische
Abbildung, zu lange dauert, um im Thread für die Swing-Aktivitäten zu laufen. Dort
sollen alle Events zügig verarbeitet werden, damit die Anwendung weiterhin flüssig
läuft.
Die Berechnung soll auf mehreren Threads erfolgen, der Zahlenbereich für
μ wird dafür zerteilt. Das ist möglich, da die Berechnungen für verschiedene Werte von μ
voneinander unabhängig sind. Wir betrachten die einzelnen Punkte, die später abgebildet
werden sollen, als Koordinaten und wählen einen Vektor von Vektoren als Datenstruktur.
Damit die verschiedenen Threads ihre Ergebnisse dort speichern können, verwenden wir ein
Atom. Hintergrund dieser Entscheidung ist, dass die Threads konfliktfrei ihre Daten ablegen
sollen sowie dass wir aber nur eine Datenstruktur haben und kein Ensemble, das den Einsatz
von STM erfordern würde. Den Anfang macht die Definition des Namensraums und des
datenhaltenden Atom.
(ns de.clojure-buch.java.bifurkation
(:import [javax.swing JFrame JButton
SwingUtilities BorderFactory
JPanel Timer]
[java.awt Color Dimension]
[java.awt.event ActionListener]
[java.awt.image BufferedImage]))
(def main-data (atom []))
Eine iterative Abbildung wie die logistische Abbildung wird rekursiv implementiert.
Im Falle von Clojure also mit loop und recur. Um die interessanten Werte der logistischen
Abbildung einzufangen, muss die Abbildung zunächst einige Male iteriert werden, ohne die
Ergebnisse einzufangen, danach wird eine gewisse Anzahl von Werten aufgenommen und als
Resultat zurückgegeben.
(defn logistische-iteriert [mu spring nimm]
(let [logabb #(* mu % (- 1.0 %))]
(loop [x 0.5
step 0
acc []]
(if (> step (+ spring nimm))
acc
(if (< step spring)
(recur (logabb x) (inc step) acc)
(recur (logabb x) (inc step)
(conj acc x)))))))
Zu beachten ist hier auch die lokale Definition einer kleinen Hilfsfunktion, die letztlich einen
Rechenschritt der Iteration implementiert. Diese Definition liegt in Form einer anonymen
Funktion mit Read-Syntax vor, die lokal im let gespeichert wird. Alles andere in
dieser Funktion dient zur Koordination der Iteration. Die Iteration ist am Ende,
wenn die Anzahl der erfolgten Schritte die Summe aus zu überspringenden und zu
erfassenden Schritten übersteigt. Dann wird der Akkumulator als Resultat präsentiert.
Davor sind die Fälle zu unterscheiden, ob es sich noch um die Einschwingphase
handelt oder um die Aufzeichnungsphase. Im ersten Falle wird der Akkumulator
zwischen den Schritten nicht verändert, im zweiten Falle wird der aktuelle Wert dort
hineingeschrieben.
Diese Berechnung verteilen wir durch die Verwendung von pmap anstelle von map einfach
über die verfügbaren Prozessoren. Das Resultat der Iteration, die 5000 Werte verwirft und
danach 100 aufzeichnet, wird noch in die korrekte Form für die zentrale Datenstruktur
gebracht und dann mit Hilfe von swap! und into in das Atom geschrieben.
(defn starte-berechnung []
(dorun
(pmap
(fn [mu]
(let [xs (logistische-iteriert mu 5000 100)
points (vec (map (fn [x] [mu x]) xs))]
(swap! main-data into points)))
(range 2.90 4 0.001))))
Zusammenfassung des funktionalen Modells
Damit ist das funktionale Modell für die Berechnung
fertig. Die Verteilung der Arbeit auf Threads findet nur in diesem Bereich statt und ist durch
die Verwendung eines Atom abgesichert. Die Verteilung selbst übernimmt pmap auf
unspektakuläre Weise. Wer eine Maschine mit vielen Kernen sein Eigen nennt, kann
durch Experimente mit map und pmap einen Eindruck von der Leistungssteigerung
bekommen.
Für die Darstellung ist weitere Berechnung notwendig. Der Wertebereich der
logistischen Abbildung ist [0 : 1], der Parameter μ wird aus dem Bereich [2, 9 : 4, 0] gewählt.
Die Darstellung in einem Fenster oder Bild muss diese Bereiche auf Pixelkoordinaten
umrechnen. Dazu ist es notwendig, die Minima und Maxima zu kennen. Wenn wir die Minima
und Maxima der Koordinaten gemeinsam als einen Wert betrachten, bietet sich die Funktion
reduce an. Diese ist oft die richtige Wahl, wenn eine Sequence auf einen Wert abgebildet
werden soll. Die Extrema werden gemeinsam in einer Map mit selbsterklärenden Schlüsseln
gespeichert. Diese Map dient auch als Akkumulator. Mit der folgenden Hilfsfunktion stehen
die Voraussetzungen dann bereit.
(defn minmax [acc neu]
{:xmin (min (:xmin acc) (nth neu 0))
:xmax (max (:xmax acc) (nth neu 0))
:ymin (min (:ymin acc) (nth neu 1))
:ymax (max (:ymax acc) (nth neu 1))})
Ein kurzer Ausflug an die REPL zeigt den Algorithmus hierfür.
bifurkation> (def test-daten
[[0 1] [1 2] [0 0]])
bifurkation> (let [[x1 y1] (first test-daten)]
(reduce minmax
{:xmin x1 :xmax x1
:ymin y1 :ymax y1}
test-daten))
{:xmin 0, :xmax 1, :ymin 0, :ymax 2}
Aufbauend auf der Lösung für das Auffinden von Minima und Maxima in einem
eindimensionalen Datensatz aus Abschnitt 2.17.2 hätte unter Verwendung von juxt eine
alternative Implementation gefunden werden können. Das hier gewählte Verfahren
demonstriert hingegen, wie komplexere Datenstrukturen als Akkumulator in Rekursionen
verwendet werden.
Die vollstände Umrechnung der Ergebnismenge in Pixelkoordinaten berechnet zunächst auf
diese Weise die Extremwerte, definiert sich kürzere Namen und skaliert diese Werte dann auf
den durch die Abmessungen des Pixelraums gegebenen Bereich.
(defn normalize [data sx sy]
(let [[x1 y1] (first data)
minmax (reduce minmax
{:xmin x1 :xmax x1
:ymin y1 :ymax y1}
data)
xmin (float (:xmin minmax))
ymin (float (:ymin minmax))
xmax (float (:xmax minmax))
ymax (float (:ymax minmax))]
(map (fn [[x y]]
[(int (* sx (/ (- x xmin)
(- xmax xmin))))
(int (- sy
(* sy (/ (- y ymin)
(- ymax ymin)))))])
data)))
Mit dieser Funktion ist es nun möglich, die berechneten Daten in einen Grafikkontext zu
zeichnen. Dafür erzeugt die folgende Funktion (paint-data-img-dots) zunächst einen
Snapshot der Daten durch Dereferenzieren und danach, sofern bereits Daten vorliegen, ein
BufferedImage, in dem die Pixel durch Aufrufen von setRGB schwarz ((Color/black))
gezeichnet werden. Das resultierende Bild wird in das als g übergebene Graphics-Objekt
gezeichnet.
(defn paint-data-img-dots [g sx sy]
(let [data @main-data]
(if (< 0 (count data))
(let [img (BufferedImage. (inc sx) (inc sy)
BufferedImage/TYPE_INT_ARGB)
d (normalize data sx sy)
rgb (.getRGB (Color/black))]
(doseq [[x y] d]
(.setRGB img x y rgb))
(.drawImage g img 0 0 nil))
(println "Warte auf Daten ..."))))
Diese Methode ist dafür entwickelt, im Callback paintComponent einer JComponent zum
Einsatz zu koemmen. Diese Verwendung zeigt die Funktion swing-setup.
(defn swing-setup [timer sizex sizey]
(let [p (doto (proxy [JPanel] []
(paintComponent
[g]
(println "Repaint")
(proxy-super paintComponent g)
(paint-data-img-dots g sizex sizey)))
(.setBorder
(BorderFactory/createLineBorder (Color/black)))
(.setPreferredSize (Dimension. sizex sizey)))
w (doto (JFrame. "CLJ Bifurkation")
(.add p)
(.pack)
(.setVisible true))]
(.addActionListener
timer
(proxy [java.awt.event.ActionListener] []
(actionPerformed [e]
(println "Timer rennt")
(.repaint w)))))
(.start timer))
Es wird ein JPanel erzeugt, dessen paintComponent-Methode mit Hilfe von proxy
implementiert wird. Die Verwendung von doto erlaubt im weiteren Verlauf den Aufruf diverser
Methoden direkt bei der Erzeugung des Objekts. In ähnlicher Form wird ein JFrame erzeugt,
der das Panel aufnimmt. Kernstück der Darstellung ist ein Timer-Objekt, das dieser
Funktion übergeben wird. Wann immer dieser Timer feuert, wird der aktuelle Stand der
Berechnung gezeichnet. Das wird durch das Registrieren eines ActionListener mit der
Methode addActionListener erreicht. In guter Swing-Manier löst dieses Event lediglich
einen Repaint aus, was dazu führt, dass die paintComponent-Methode aufgerufen
wird.
Abschließend benötigt dieses Beispiel eine Funktion, die das Ganze koordiniert:
(defn bifurkation []
(let [timer (Timer. 250 nil)]
(SwingUtilities/invokeLater
#(swing-setup timer 1000 500))
(dosync
(reset! main-data []))
(starte-berechnung)
(Thread/sleep 5000)
(.stop timer)
timer))
Ein Timer-Objekt, das viermal pro Sekunde feuert, wird erzeugt, und dem Swing-Thread
wird der Code für die Oberfläche übergeben. Da diese Funktion sicherlich öfter aufgerufen
wird, empfiehlt es sich, die gespeicherten Daten zuvor zu löschen und danach die Berechnung
zu starten. Im Ganzen wartet diese Funktion dann fünf Sekunden, bis sie den Timer wieder
stoppt, womit die kleine Applikation beendet ist. Abbildung 4.1 zeigt das Resultat des
Aufrufs
bifurkation> (bifurkation)
#<Timer javax.swing.Timer@556d8a64>
Die Verwendung existierender Java-Bibliotheken ist die vermutlich häufigste Form der
Java-Clojure-Interaktion; Clojure liefert jedoch auch Werkzeuge mit, um eine bestehende – in
Java geschriebene Anwendung – durch Clojure zu erweitern. Übliche Motivationen für den
Einsatz dieser Variante sind in der Regel die höhere Entwicklungsgeschwindigkeit im Vergleich
zum Erstellen vergleichbarer Funktionalität in Java selbst oder die Bereitstellung einer
Schnittstelle für die dynamische Erweiterung des Java-Programms, eine Lösung, die auch
häufig mit Groovy realisiert wird.
Seit Java 6 gibt es die Scripting API (JSR 223); die Verwendung dieser Abstraktion
verbirgt die Implementationsdetails der Skriptsprache vor dem Java-Code und ermöglicht
zumindest in der Theorie ein problemloses Austauschen der Skript-Implementation.
Tatsächlich gibt es zumindest eine JSR 223 Implementation auf der Grundlage von
Clojure [7], die für diese Zwecke einsetzbar wäre und aktiv entwickelt wird.
Im Folgenden zeigen wir grundlegend die Verwendung von in Clojure definierten Funktionen
aus einem Java-Programm.
Die Klasse clojure.lang.RT ist eines der Kernstücke von Clojure. Hier findet
die Initialisierung der Sprache, das Bootstrapping, statt. So wird zum Beispiel der
Namensraum clojure.core erzeugt und mit Variablen sowie Funktionen gefüllt. Hier
finden sich auch viele der Funktionen, die für die Interaktion mit Clojure notwendig
sind.
Mit Hilfe dieser Klasse kann von Java auf in Clojure definierte Funktionen zugegriffen
werden. In einer Clojure-Datei test1.clj sei eine einfache Funktion definiert:
(ns test1)
(defn func-in-file [in]
(apply str (reverse in)))
Diese Datei muss sich im Classpath befinden, um gefunden zu werden, wobei die Regeln für
Namensräume und Dateinamen gelten, die in Abschnitt 2.7.2 beschrieben sind. Die so
definierte Funktion kann nun aus Java verwendet werden. Dazu lädt das Java-Programm
zunächst mit der statischen Methode load aus RT die Datei, ermittelt dann die
passende Var anhand des Funktionsnamens und ruft auf dieser die Methode invoke
auf.
RT.load("test1"); // .clj wird angehaengt
Var func1 = RT.var("test1", "func-in-file");
System.out.println(func1.invoke("hello"));
Dieses Beispiel zeigt nur die wichtigen Abschnitte des Java-Codes. Die notwendigen
Bestandteile eines vollwertigen Programms sind hier nicht abgebildet.
Auf diese Weise ließen sich auch Hooks mit vorgegebenen Namen definieren, die vom
Java-Programm an definierten und dokumentierten Stellen aufgerufen werden. Im
Clojure-Code können dann nahezu beliebige Funktionen an die Hooks gehängt werden und so
die Funktion des statischen Java-Programms dynamisch erweitern. Benutzer von Emacs als
Texteditor kennen dieses Verfahren von zahlreichen Beispielen.
Die Methode var kann auch mit drei Parametern aufgerufen werden,
um eine neue Variable anzulegen. Das dritte Argument gibt dann das Root-Binding an. Unter
Verwendung der Klasse clojure.lang.Compiler können so neue Funktionen nach Belieben
angelegt werden, die sich mit Hilfe von invoke aufrufen lassen. Das folgende Beispiel zeigt
einen Codeabschnitt aus einem Java-Programm, das in der Runtime zunächst eine Variable
my-global im Namespace my-ns registriert und ihr ein Root-Binding „hello „ gibt. Darauf
folgt eine weitere Var, deren Root-Binding jedoch vom Compiler erzeugt wird, der einen String
mit Clojure-Code evaluiert.
RT.var("my-ns", "my-global", "hello ");
Var my_func = RT.var("my-ns", "my-func",
Compiler.load(new java.io.StringReader(
"(fn [in] (str my-ns/my-global in))")));
System.out.println(my_func.invoke("world"));
In obigem Beispiel muss die Variable „my-global“ mit ihren vollständig
qualifizierten Namen verwendet werden, da das Erzeugen der anonymen Funktion nicht im
Namespace „my-ns“ geschieht.
Clojure-Objekte in Interfaces
Clojure-Datenstrukturen können Java-Interfaces implementieren,
daher ist es ebenfalls denkbar, den Inhalt einer Clojure-Variablen an entsprechenden
Java-Code zu übergeben.
Wie bereits in den Abschnitten 2.7.4 und 2.12.1 erwähnt wurde,
implementieren Clojures Funktionen das Interface Runnable. Damit lässt sich das folgende
Beispiel nachvollziehen, das eine simple Funktion vom Compiler erzeugen lässt und danach
damit einen separaten Thread startet.
Var f = RT.var("my-ns", "my-func",
Compiler.load(new java.io.StringReader(
"(fn [] (doseq [i (range 10)] (println i)))")));
Thread t = new Thread((Runnable)f.get());
t.start();
Rückgabewert ist nur Object.
Ein Wermutstropfen ist, dass der Typ des Rückgabewertes vieler
Funktionen – etwa Var.get – java.lang.Object ist. Daher ist in der Regel eine manuelle
Wandlung des Typs durch einen geeigneten Cast notwendig, und Programmierfehler werden
erst zur Laufzeit auffallen, da der Java-Compiler keine Typenprüfungen durchführen
kann.
Clojure wird als eine JAR-Datei ausgeliefert. In dieser findet sich eine Vielzahl von Klassen,
manche mehr, manche weniger interessant. Vor allem die persistenten Datenstrukturen und die
Sequence-Funktionalitäten können auch für eigentlich in Java geschriebene Programme
interessant sein. Für diese Programme ist es leicht möglich, die JAR-Datei in ihre Sammlung
von Bibliotheken aufzunehmen und die dortigen Klassen zu verwenden. Allerdings verlangen
die meisten Klassen eine laufende Runtime von Clojure, die beim Starten einen erheblichen
Aufwand bedeutet. Das dürfte für Karl Krukow der Anlass gewesen sein, eine Bibliothek zu
entwickeln und zu pflegen, die Clojures Datenstrukturen enthält, aber nicht auf die komplette
Runtime angewiesen ist [42].
Das erste Beispiel in diesem Abschnitt zeigt die Verwendung der Sequence-Funktionen aus
einem Java-Programm.
Die Zeilen 1–7 importieren die notwendigen Pakete aus dem Sprachumfang von Clojure. Die
statische innere Klasse XmlFilter, deren Definition ab Zeile 18 erfolgt, enthält relativ viel
gleichartige Wiederholungen zur Definition einiger Keywords. Zudem implementiert sie eine
Filtermethode auf einer Java Map.
Ab Zeile 68 beginnt mit der Main-Methode der interessantere Teil. Mit der Methode load
der Runtime RT wird die Contrib-Bibliothek clojure.contrib.lazy-xml geladen. Auf diese
Weise stehen alle in Clojure implementierten Bibliotheken zur Verfügung. Aus diesem Paket
extrahiert der Ausdruck in Zeile 74 eine Var – seqVar – die darauf im Java-Programm zur
Verfügung steht. In Clojure werden Funktionen wie andere Typen auch durch Vars
dargestellt, und die soeben importierte Var verweist auf die Funktion parse-seq. Diese
Funktion ruft der Ausdruck ab Zeile 77 mit Hilfe von invoke auf. Dabei dient der
RSS-Feed zum Thema Clojure auf Reddit als Quelle. Das Resultat ist eine ISeq. Ab
Zeile 82 iteriert das Programm nun unter Verwendung der Interface-Funktionen
von Clojure durch die Seq und wendet den XmlFilter auf jedes einzelne Element
an.
Dieses Programm demonstriert exemplarisch die Verwendung der Runtime und der
Sequences von Clojure. Nebenbei zeigt das Programm aber auch, wie kompakt in Clojure
geschriebener Code ist. Dieser einfache XML-Parser benötigt beinahe 100 Zeilen
Quelltext.
Die Implementation der persistenten Datenstrukturen ist ohne Zweifel ein zentrales Element
der Sprache (in Version 1.2 machen sie beinahe ein Fünftel der Java-Quellen aus). Auch in
Java-Programmen wird es, ein entsprechendes Design der Algorithmen vorausgesetzt, diverse
Anwendungsmöglichkeiten geben.
Ein denkbares Beispiel ist eine Situation, in der sich die Inhalte verschiedener Instanzen
eines Container-Objekts teilweise überschneiden. Eine simple Implementation würde die
Duplikate mehrfach im Speicher halten, andernfalls müsste man selbst einen Container mit
einem Mechanismus zur geteilten Datennutzung erstellen; Letzteres wird durch die
Verwendung eines Clojure-Datentyps sehr einfach.
In obigem Beispiel wird das Grundkonstrukt für einen Algorithmus zur Textklassifikation
dargestellt: Einer Kategorie werden Stichwörter zugeordnet, und Unterkategorien „erben“ die
Begriffe ihrer Eltern.
Selbstverständlich könnte man einen vergleichbaren Effekt auch durch Java-Referenzen
erreichen (eine „shallow copy“ eines Containers speichert Referenzen auf die enthaltenen
Objekte anstatt Kopien anzulegen) und wäre insbesondere mit interniert gespeicherten Strings
ebenfalls speichereffizient. Allerdings eröffnen persistente Container interessante
Möglichkeiten; im Zusammenspiel mit Software-Transaktionen (siehe nächster Abschnitt)
könnten beispielsweise neue Testdaten versuchsweise trainiert werden, wobei die Laufzeit- und
Speicher-kosten für das Anlegen der Kopien minimal wären (und inbesondere nicht linear mit
der Anzahl der enthaltenen Elemente skalieren).
Auch Clojures Implementation von Software Transactional Memory lenkt häufig
die Aufmerksamkeit von Entwicklern auf Clojure. Auch diese Rosine kann sich ein
Java-Programmierer herauspicken. Das folgende Beispiel demonstriert, wie aus Java-Code eine
clojure.lang.Ref erzeugt und in einer Transaktion manipuliert werden kann. Die Erzeugung
findet im statischen Block ab Zeile 11 statt, in dem zwei Refs a und b auf den String „hello“
und dessen Länge gesetzt werden.
Die Transaktion verlangt eine Klasse, die Callable implementiert als Funktion zum
Verändern der Daten. Im Beispiel erledigt das die innere Klasse StmSetter ab Zeile 25. Diese
setzt zunächst a auf den übergebenen neuen String und erneut b auf dessen Länge.
Die Methode run ab Zeile 40 verwendet die statische Methode runInTransaction
aus der Klasse LockingTransaction, der sie die Callable implementierende Instanz
übergibt.
Die Main-Methode schließlich setzt per Transaktion die Werte zunächst auf „foo“ und 3,
erzeugt dann mit Hilfe einer Exception, die bei der Längenberechnung von null auftritt, einen
Konflikt. Dieser steht hier stellvertretend für einen komplexeren Konflikt, der in einem
Kontext mit mehreren Threads auftreten könnte. Die zwischenzeitlich erfolgende Ausgabe
zeigt, dass die Werte in dieser Transaktion nicht verändert werden.
Auf der Kommandozeile kompiliert mit javac und ausgeführt, ergibt sich folgende
Sitzung:
shell> javac -cp clojure.jar stm.java
shell> java -cp clojure.jar:./ stm
a = hello, b = 5
a = foo, b = 3
java.lang.NullPointerException
a = foo, b = 3
a = world, b = 5
Ein wirklich effizienter Einsatz der vorgestellten Komponenten wird vermutlich erst dann
möglich, wenn man sich zumindest konzeptionell von einer rein imperativ geprägten
Denkweise löst. Andernfalls wird man wahrscheinlich immer wieder vertraute Lösungsansätze
verwenden und keinen echten Nutzeffekt erzielen.
Abschließend verbleibt hinsichtlich der Verwendung in Java ein wesentlicher Kritikpunkt:
Die Rückgabewerte vieler Funktionen sind schwach typisiert (Object). Dieser Umstand wird
durch die fehlende Parametrisierung der persistenten Container verstärkt; man fühlt sich in
Zeiten vor der Einführung von Generics zurückversetzt.
Diese Kritik soll in keinem Fall die Sprache oder ihre Implementation herabsetzen. Das
Design vieler Klassen macht es offensichtlich, dass eine unabhängige Nutzung als Bibliothek
nie der Zielsetzung entsprach, so dass eine Übergabe von Werten als Objects sinnvoll
erscheint.
Die JVM liefert mit HotSpot [68] eine Maschine für die Ausführung von Bytecode, die zur
Laufzeit Optimierungen vornehmen kann. Der Anspruch ist, in rechenaufwendigen
Anwendungen die Performance von nativem C- oder C++-Code zu erreichen. Für
Anwender, die bislang keine Erfahrungen mit virtuellen Bytecode-Maschinen haben, wirkt
es ungewohnt, dass sich der erzeugte Code nach einer Weile wie von Geisterhand
beschleunigt.
Ein einfaches Beispiel demonstriert die Wirksamkeit von HotSpots
Optimierungen. Dazu berechnet die folgende Funktion die Standardabweichung von Werten,
die in einer Sequence übergeben werden.
(defn std-abw [xs]
(let [n (count xs)
mw (/ (reduce + xs) n)
quads (map #(Math/pow (- % mw) 2) xs)]
(Math/sqrt (/ (reduce + quads) (dec n)))))
Diese Funktion enthält keine Optimierungen und verschwendet zudem noch Zeit mit der
Berechnung der Länge durch Aufruf von count. Ist count bei Datentypen wie Vector oder List
noch
(1), so wird daraus im Falle einer Sequence eine Operation der Ordnung
(N). Zudem
wurde an keiner Stelle irgendein Typ spezifiziert.
Eine Funktion zum Testen der Performance zeigt der folgende Codeabschnitt. Hier
werden 10 000 ganze Zahlen zufällig zwischen 0 und 100 gezogen, aus denen dann die
Standardabweichung berechnet wird. Mit Hilfe des Makros time wird ganz einfach die Zeit
gemessen.
(defn viele-berechnungen [wie-oft]
(dotimes [_ wie-oft]
(time
(std-abw
(vec (take 10000 (repeatedly #(rand-int 100))))))))
user> (viele-berechnungen 20)
"Elapsed time: 1155.321966 msecs"
"Elapsed time: 362.805691 msecs"
"Elapsed time: 472.227636 msecs"
"Elapsed time: 358.231114 msecs"
"Elapsed time: 170.326634 msecs"
"Elapsed time: 120.865899 msecs"
"Elapsed time: 118.956424 msecs"
"Elapsed time: 118.087103 msecs"
"Elapsed time: 120.922323 msecs"
"Elapsed time: 118.793614 msecs"
"Elapsed time: 111.64418 msecs"
"Elapsed time: 113.938075 msecs"
"Elapsed time: 111.807744 msecs"
"Elapsed time: 110.866336 msecs"
"Elapsed time: 117.818978 msecs"
"Elapsed time: 115.045278 msecs"
"Elapsed time: 112.953086 msecs"
"Elapsed time: 114.27616 msecs"
"Elapsed time: 124.001107 msecs"
"Elapsed time: 108.299311 msecs"
nil
Im Laufe von nur 20 Funktionsaufrufen hat sich die notwendige Zeit auf ein Zehntel
reduziert. Diese Messung wurde auf einem DualCore-Notebook unter Ubuntu Linux gemacht.
Das ist allerdings unerheblich, da hier nur die relativen Zeiten relevant sind. Weitere Aufrufe
zeigen keine signifikante Leistungssteigerung mehr.
user> (viele-berechnungen 10)
"Elapsed time: 113.921454 msecs"
;; ...
"Elapsed time: 115.417321 msecs"
"Elapsed time: 116.979618 msecs"
nil
Da die jeweilige Berechnung sehr schnell erfolgt, bringt auch eine Parallelisierung in
diesem Falle keinen Geschwindigkeitszuwachs. Im Gegenteil, der zusätzliche Aufwand
verlangsamt die Berechnung wieder deutlich, wie das folgende Beispiel mit pmap
zeigt.
(defn std-abw [xs]
(let [n (count xs)
mw (/ (reduce + xs) n)
quads (pmap #(Math/pow (- % mw) 2) xs)]
(Math/sqrt (/ (reduce + quads) (dec n)))))
user> (viele-berechnungen 90)
"Elapsed time: 921.151366 msecs"
"Elapsed time: 517.00259 msecs"
;; ...
"Elapsed time: 368.08286 msecs"
"Elapsed time: 380.962187 msecs"
nil
Eine native Implementation in Java zeigt eine deutlich bessere
Performance:
shell> java StdAbw
Elapsed time: 32,912659 msecs
Elapsed time: 16,886312 msecs
Elapsed time: 21,772243 msecs
# gekuerzt, auch hier der Effekt von HotSpot sichtbar
Elapsed time: 4,732631 msecs
Der Quelltext des Programms ist auf der Webseite zu diesem Buch einsehbar:
http://clojure-buch.de/listings/StdAbw.java. Jenes Listing implementiert ein ähnliches
Verfahren, die Standardabweichung zu berechnen, wie die Clojure-Variante. Es ist uns
bewusst, dass sich die Implementationen im Detail unterscheiden, aber für die Ermittlung
einer ungefähren Größenordnung halten wir es für ausreichend.
In manchen Fällen verwendet Clojure Reflection, um die richtigen Methoden zu
finden, ein Verfahren, das als langsam zu betrachten ist. Solche Fälle können sichtbar gemacht
werden, indem die Var *warn-on-reflection* auf true gesetzt wird. Leider bringt das in
diesem Falle keine Verbesserung:
;; wieder die urspruengliche Version
user> (set! *warn-on-reflection* true)
true
user> (viele-berechnungen 1)
"Elapsed time: 118.648741 msecs"
nil
Eine wichtige Optimierung ist die Verwendung von Javas Grunddatentypen,
die mit Hilfe einer der Funktionen int, long, float, double, char, boolean, short
oder byte erreicht werden kann. Zudem kann der Einsatz von Typhinweisen, die
in Abschnitt 2.15 beschrieben wurden, hilfreich sein. Primitive Typen können in
Clojure nicht an Funktionen übergeben werden, da die Übergabe immer als Object
erfolgt. Lokale Variablenbindungen hingegen können solche Typen verwenden. Es
zeigt sich, dass es in diesem Beispiel reicht, lediglich einen primitiven Typen zu
erzwingen:
(defn std-abw [xs]
(let [n (count xs)
mw (float (/ (reduce + xs) n))
quads (map #(Math/pow (- % mw) 2) xs)]
(Math/sqrt (/ (reduce + quads) (dec n)))))
user> (viele-berechnungen 4)
"Elapsed time: 28.747785 msecs"
"Elapsed time: 14.277022 msecs"
"Elapsed time: 8.10453 msecs"
"Elapsed time: 8.002072 msecs"
Es ist zu beachten, dass Clojure bei Verwendung von / mit ganzzahligen Werten den
Typ Ratio verwendet. Eine vollständige explizite Typisierung dieses Beispiels brachte keine
wesentliche Verbesserung und wird daher hier ausgelassen. Das Resultat ist eine
Performance, die von der gleichen Größenordnung wie die native Implementation in Java
ist.
Die Auslieferung und Installation von in Clojure geschriebenen Programmen, im Englischen
als Deployment bezeichnet, kann auf verschiedene Weisen erfolgen.
Zunächst sind die Methoden zu nennen, die sich aus der Verwandtschaft zu Java
ergeben. Abschnitt 4.2.2 hat beschrieben, wie mit Clojure eine Klasse inklusive einer
Main-Methode definiert und kompiliert werden kann. Das Resultat lässt sich wie
jedes andere Java-Programm auch ausliefern. In ähnlicher Weise könnte eine in
Java geschriebene Klasse die Main-Funktion beinhalten und Clojure-Code wie in
Abschnitt 4.4 beschrieben nachladen. Diese Main-Methode hätte auch die Wahl, Clojure
als Bibliothek aufzufassen und gezielt Funktionalitäten zu entnehmen, wie es der
Abschnitt 4.5 beschreibt. Die letztgenannte Methode kommt vermutlich eher selten zum
Einsatz.
Zusätzlich zu diesen Methoden kann auch die Main-Methode von Clojure als
Einstiegspunkt in ein Programm verwendet werden. In Abschnitt 2.18 wurden bereits die
Konventionen für die Installation von Bibliotheken sowie die Möglichkeit, ein ausführbares
Clojure-Programm zu erzeugen, beschrieben. Eng verwandt mit dieser Methode ist auch das
explizite Starten eines Programms über die REPL. Da dies aber jederzeit einen manuellen
Eingriff erfordert, ist bei produktiven und unternehmenskritischen Anwendungen davon
abzusehen. Das Programm sollte beispielsweise bei einem unerwarteten Reboot automatisch
wieder starten. Ein solches Programm kann aber auch jederzeit ein Framework wie Swank
nachladen. Dieses Werkzeug arbeitet zwischen Emacs und Clojure und erlaubt einem
Entwickler, sich mit dem laufenden System nach Belieben zu verbinden. Somit sind
zumindest kleinere Deployments wie Bugfixes einzelner Funktionen ohne Neustart des
Programms möglich. Es ist offensichtlich, dass dieser Zugang gut abgesichert sein
sollte!
Die Clojure-Gemeinschaft setzt zurzeit verstärkt auf die Entwicklung eines
Werkzeugs mit Namen „Leiningen“ [24], bei dem unter der Haube Maven [2] seine Arbeit
verrichtet. Leiningen unterstützt den Clojure-Entwickler beim Auflösen von Abhängigkeiten
wie auch beim Starten einer Clojure-Runtime, die zusätzlich einen Swank-Server starten
kann.
Auch das Ausliefern der benötigten Clojure-Bibliotheken zusammen mit dem
Hauptprogramm als eine JAR-Datei ist möglich. Dem Aufruf von Java kann der Name des im
JAR befindlichen Clojure-Programms mit einem vorangestellten @ übergeben werden. Die
folgende Sitzung in einer Shell (Bash) verdeutlicht das Verfahren.
shell> mkdir cljar-test
shell> cd cljar-test
# requires the files in ..
shell> unzip -q ../clojure.jar
shell> cat >> mein-main.clj
(println "Hello World")
shell> ls
clojure/ mein-main.clj META-INF/
shell> zip -rq ../cljar-test.jar *
shell> cd ..
shell> ls
cljar-test/ cljar-test.jar
shell> java -cp cljar-test.jar clojure.main \
@mein-main.clj
Hello World
Anwender mit Erfahrung im Ausliefern von Java-Software können ihr Wissen auf
Clojure-Software weitestgehend übertragen. Wichtig ist, was auf dem Classpath zu finden
ist.
Clojure ist eine Programmiersprache, deren Sprachkern in Java implementiert ist. Der gesamte
Quelltext von Clojure 1.2 besteht aus etwa 130 Dateien mit etwa 20 000 Zeilen Java-Code
(generated using David A. Wheeler’s „SLOCCount“[77]) und knapp 13 000 Zeilen
Clojure-Code (generated using find, grep and wc). Das ist ein durchaus bemerkenswert kleiner
Kern. Zum Vergleich: Das gleiche Werkzeug liefert für die Quellen von Ruby 1.8.6 etwa
144 000 Zeilen C-Code und etwa 215 000 Zeilen Rubycode, für die Quellen von Python 2.6.4
knapp 390 000 Zeilen C-Code und 370 000 Zeilen Pythoncode. Der Vergleich hinkt
natürlich insofern, als beide eine Standardbibliothek mit sich bringen, die bei Clojure
zumindest teilweise durch die Standardbibliothek von Java gestellt wird. Der kleine
Sprachkern erlaubt es, mit relativ wenig Aufwand einen Blick hinter die Kulissen
zu werfen und zum Verständnis der Aspekte der Sprache die Implementation zu
konsultieren. Gerade Entwickler mit einem Java-Hintergrund werden hier wenige
Überraschungen erleben, da die Implementation von Clojure gängigen Java-Standards
entspricht.
Dieser Abschnitt soll einen oberflächlichen Eindruck der Implementation von Clojure
vermitteln. Die Beschreibung bezieht sich auf Clojure 1.2; es ist sehr wahrscheinlich, dass sich
viele Details im Verlauf der Weiterentwicklung der Sprache ändern werden.
Die Implementation von Clojure umfasst grob zwei Teile: den grundlegenden Java-Teil sowie
darauf aufbauend die bereits in Clojure implementierten Bestandteile des Sprachumfangs. Der
Java-Teil gliedert sich wiederum in die Bytcodemanipulation und die Implementation von
clojure.lang. NamenskonventionenLetztere hält aktuell alle Dateien in einem Verzeichnis vor.
Dort gelten folgende Namenskonventionen:
- Dateien, deren Name mit „A“ und einem weiteren Großbuchstaben beginnt,
enthalten abstrakte Klassen.
- In ähnlicher Weise enthalten Dateien mit einem „I“ am Anfang ihres Namens
gefolgt von einem Großbuchstaben ein Interface.
- Dateien, deren Name auf ein Adjektiv oder ein Partizip hinweist, enthalten
ebenfalls Interface-Definitionen. Zum Beispiel Settable.java, Associative.java
oder Sorted.java.
- Alle anderen Dateien enthalten die konkrete Implementation von Klassen.
Die folgende Liste benennt einige der wichtigeren Klassen von Clojures
Implementation.
-
RT.java
- Die Laufzeitumgebung von Clojure. Nicht nur notwendig für den Betrieb von
Clojure, sondern auch für die Verwendung aus Java-Programmen heraus. Enthält
Symbole ebenso wie wichtige Funktionen beispielsweise auch für Listen und die
Sequence-Abstraktion.
-
Util.java
- Kleine Sammlung von Werkzeugen wie die Erzeugung von Hashcodes oder
den Vergleich von Objekten
-
LispReader.java
- Implementation des Readers, der die in Abschnitt 2.18.2
beschriebenen Aufgaben wahrnimmt
-
LockingTransaction.java
- Kernstück der Software Transactional Memory
Implementation
-
Compiler.java
- Der Clojure-Compiler. Eine große Datei, die viel der unter der Haube
stattfindenden Magie von Clojure enthält. Verwendet die ASM-Bibliothek zur
Erzeugung des Bytecodes.
-
Numbers.java
- Implementiert die grundlegenden mathematischen Funktionen für
Clojures Zahlentypen.
-
MultiFn.java
- Implementation der Mehrfachmethoden, aus Abschnitt 2.12.4
Der Graph in Abbildung 4.2 zeigt die Vererbungshierarchie der Klassen
AFunction sowie Atom, Ref und Var. Zusätzlich enthält er einen nicht unbeträchtlichen Teil
der zentralen Interfaces und Klassen des Sprachkerns.
Die eigentliche Codeerzeugung geschieht in der Klasse Compiler unter Verwendung
der ASM-Bytecode-Bibliothek [10]. Genau genommen kann man Clojure also als
MetaprogrammierungMetaprogrammiersprache auffassen, mit der nicht nur Clojure-Code (siehe
Abschnitt 2.12.3), sondern auch JVM-Bytecode erzeugt werden kann.
Zusammenspiel der Klassen
Die Motivation für diesen Abschnitt ist jedoch, das Zusammenspiel
der Klassen im Namensraum clojure.lang zu demonstrieren. Ein solches Verständnis kann
nützlich sein, um zum Beispiel Clojure als Bibliothek zu verwenden oder in ein existierendes
Java-Programm einzubetten.
Als einfaches Beispiel sei in einer Datei demo.clj folgender Inhalt gespeichert:
(ns demo)
(def meaning-of-life 42)
(defn say-hello [user]
(println (str "Hello " user ": "
meaning-of-life)))
Um diesen Code kompilieren zu können, muss der Classpath entsprechend angepasst
werden: Einerseits muss die clj-Datei erreichbar sein, andererseits wird ein Verzeichnis für die
generierten Klassen benötigt, das ebenfalls im Classpath enthalten sein muss. Dieses
Verzeichnis lässt sich durch die globale Variable *compile-path* konfigurieren. Der
voreingestellte Wert ist „classes“. Wenn dieses Verzeichnis existiert, kann mit der Funktion
compile ein vollständiger Namespace in Bytecode übersetzt werden. Dieses Beispiel kompiliert
den Namespace durch Starten der REPL und interaktiven Aufruf der Funktion
compile.
$ mkdir classes
$ java -cp clojure.jar:.:classes clojure.main
user> (compile ’demo)
demo
In diesem Beispiel entstehen drei Klassen mit teilweise recht umständlichen
Namen:
- „demo__init“,
- „demo$loading__6309__auto__“ sowie
- „demo$say_hello“.
Schon auf den ersten Blick sind Zusammenhänge zum Quelltext ersichtlich wie
der Name des Namespace sowie die innere Klasse, erkennbar am trennenden $, die
offensichtlich aus der Funktion say-hello hervorgegangen ist. Die verbleibende Klasse
enthält Clojure-spezifischen Initialisierungscode und kann hier weitgehend ignoriert
werden.
Der Bytecode in den erzeugten Klassen entspricht in etwa folgendem Java-Code, der
durch einen Decompiler zurückübersetzt und zur besseren Übersicht verkürzt wurde, da diese
Darstellung vermutlich verständlicher ist als die Ausgabe von javap.
public class demo__init {
// ...
public static final Var c_mol =
(Var)RT.var("demo", "meaning-of-life");
public static final Object c_42 =
Integer.valueOf(42);
public static final Var c_hello =
(Var)RT.var("demo", "say-hello");
public static void load() {
new demo.loading__6309__auto__();
Var tmp35_32 = c_mol;
tmp35_32.bindRoot(c_42);
tmp35_32.setMeta(...);
Var tmp82_79 = c_hello;
tmp82_79.bindRoot(new demo.say_hello());
tmp82_79.setMeta(...);
}
// ...
}
Diese Klasse enthält die Deklaration und Initialisierung der Vars und des Namespace
„demo“, der implizit durch das Erzeugen der ersten Var angelegt wird. Die Methode load wird
in einem statischen Initialisierungsblock, der hier nicht gezeigt wird, aufgerufen, wenn die
Klasse durch den Java-Classloader geladen wird.
Von Interesse sind hier die verwendeten Klassen und Methoden der Sprachimplementation;
so wird durch die Methode var der Klasse RT ein Symbol mit dem gewünschten Namen
angelegt und die entsprechende Variable zurückgegeben. Anschließend werden in den
Var-Instanzen mittels bindRoot die initial zugeordneten Werte gespeichert sowie Metadaten
wie der Name und die Zeile der ursprünglichen Quelldatei hinzugefügt.
Die zweite Klasse enthält die Implementation der Funktion. Auch hier werden Objekte vom
Typ Var verwendet, um auf die Inhalte von verschiedenen Symbolen zugreifen zu können.
Zudem erweitert die Klasse die Clojure-interne Klasse AFunction und überschreibt die durch
die Parameteranzahl identifizierte Methode invoke.
public class demo$say_hello extends AFunction {
public static final Var v_println =
(Var)RT.var("clojure.core", "println");
public static final Var v_str =
(Var)RT.var("clojure.core", "str");
public static final Var v_mol =
(Var)RT.var("demo", "meaning-of-life");
// ...
public Object invoke(Object user)
throws Exception {
return ((IFn)v_println.get()).invoke(
((IFn)v_str.get()).invoke(
"Hello ", user, ": ", v_mol.get()));
}
}
Der Inhalt des Methodenrumpfes besteht lediglich aus dem Zugriff auf die Variableninhalte
(get), einigen Casts sowie den dadurch ermöglichten Aufrufen der invoke-Methoden. Auch hier
ist sichtbar, dass mit einer relativ kleinen Anzahl an Klassen verhältnismäßig viel
Funktionalität erreicht wird.