zurück zum Artikel

Clojure: Ein pragmatisches Lisp für die JVM

Stefan Tilkov

Man kann das Gefühl bekommen, Programmiersprachen auf Basis der Java Virtual Machine gebe es im Dutzend billiger.

Man kann das Gefühl bekommen, Programmiersprachen auf Basis der Java Virtual Machine (JVM) gebe es im Dutzend billiger: JRuby, Groovy, Scala, Jython und das gute alte Java – alle ringen um des Programmierers Gunst. Vielen erschien Scala als der bereits gesetzte "Gewinner", aber in den letzten Monaten konkurriert zumindest in Sachen öffentlicher Aufmerksamkeit deutlich eine andere Sprache – Clojure.

Clojure ist ein speziell für die JVM entwickelter Lisp-Dialekt, der insbesondere durch seine Unterstützung für die Entwicklung von Anwendungen für Multicore-Plattformen glänzt. War Lisp nicht diese in der Praxis völlig irrelevante Sprache mit den unsäglich vielen Klammern, mit der man im Informatik-Studium gequält wurde oder wird? Unter dem Ruf leidet Lisp seit einigen Jahrzehnten, und die recht zersplitterte und teilweise nicht eben demütige Lisp-Gemeinde hat es bislang nicht geschafft, das zu ändern. Clojure ist anders, denn es ist vor allem eines: praktisch. Es lohnt sich daher, eine eventuelle initiale Scheu vor der Syntax zu überwinden: Es gibt einen guten Grund dafür, und zumindest der Autor dieser Zeilen hat sich mittlerweile nicht nur daran gewöhnt, sondern empfindet sie als die eleganteste denkbare Variante.

Neben Literalen für Zahlen, Strings und reguläre Ausdrücke unterstützt Clojure als wichtigste Datentypen Listen, Vektoren, Mengen und assoziative Arrays (Maps). Listen sind in allen Lisp-Sprachen das wichtigste Element, und darin unterscheidet sich Clojure nicht von seinen Ahnen (obwohl auch Vektoren eine große Rolle spielen, wie der Artikel gleich zeigt). Sie werden als geklammerter Ausdruck dargestellt, können Elemente beliebiger Art enthalten und lassen sich ineinander verschachteln:

'(1 2 3)
'(1 "Ein String" 2.45)
'(1 2 ("a" :b "c") ("d" "e" (:x "y")))

Vektoren unterstützen einen effizienten Indexzugriff und werden mit eckigen Klammern notiert. Maps werden mit {...} dargestellt, Sets (Mengen) mit #{...}:

[1 2 3]
[1 2 [4 5 6] ["a" "b"]]
{1 "Eins", 2 "Zwei"}
#{1 2 3 4}
Mehr Infos

Clojure

Clojure 1.0 wurde im Mai 2009 freigegeben, die aktuell stabile Version 1.1 stammt aus dem Dezember 2009. Die Version 1.2 steht unmittelbar vor ihrer Freigabe. Allen Versionen ist gemeinsam, dass sie vor allem Erweiterungen mit sich brachten beziehungsweise bringen: Bis auf die Änderung einiger Namensräume waren in der Regel kaum Auswirkungen auf bestehenden Code zu befürchten. In Clojure 1.2 sind vor allem Performanceoptimierungen und Ergänzungen enthalten, die mittelfristig eine vollständige Implementierung von Clojure in Clojure ermöglichen sollen.

Clojure-Vater Rich Hickey legt in seinen Präsentationen nach der Folie zu Datentypen gerne als Nächstes eine mit dem Titel "Syntax" auf und sagt dazu "You've just seen it" – und tatsächlich hat die Sprache fast keine anderen Elemente. Clojure-Code wird in den Datenstrukturen abgelegt, vor allem in Listen und Vektoren: Code und Daten sind letztlich das Gleiche. Diese besondere  Eigenschaft spielt eine große Rolle, wenn es darum geht, wiederkehrende Muster zu automatisieren: Lisp braucht hierzu keinen externen Mechanismus wie einen Code-Generator, sondern bringt ihn als Bestandteil der Sprache mit – dazu später mehr.

Da es sich bei Clojure um eine funktionale Programmiersprache handelt, ist das wichtigste Code-Konstrukt der Funktionsaufruf. Dazu interpretiert ein Lisp das jeweils erste Element einer Liste als eine Funktion (genauer: etwas, das zu einer Funktion evaluiert) und die restlichen Elemente als deren Parameter. Die Funktion str zum Beispiel verkettet ihre Parameter zu einem String. Ein Aufruf sieht wie folgt aus (das Ergebnis steht hinter dem ->-Symbol in der nächsten Zeile):

(str "a" "b" "c")
->  "abc"

Will man die Auswertung (Evaluation) des Listenausdrucks verhindern, kann man die Funktion "quote" oder ihre Abkürzung ' (ein einfaches Hochkomma) benutzen:

'(str "a" "b" "c")
->  (str "a" "b" "c")

Funktionsnamen können aus praktisch beliebigen Zeichen bestehen, bekommen in vielen Fällen eine variable Anzahl von Parametern und lassen sich beliebig schachteln. Sonderregeln für Operatoren erübrigen sich damit. Das folgende Beispiel zeigt die Funktionen '*', '+' und '-' im Einsatz:

(str "Das Ergebnis ist: " (* (+ 2 4) (- 10 8)))
->  "Das Ergebnis ist: 12"

Die Syntax von Funktionsdefinitionen zeigt, wie eng die Datenstrukturen und der Code zusammenhängen: Eine Funktionsdefinition ist eine Liste, die aus dem Schlüsselwort defn, einem Namen, einem Vektor mit den Parametern besteht, gefolgt von der Implementierung selbst (die wiederum eine Liste ist, die den oben genannten Regeln folgt):

(defn multiply [a b] (* a b))
(multiply 3 6)
->  18

defn als Schlüsselwort zu bezeichnen ist genau genommen falsch: Es handelt sich um ein Makro, eine weitere Besonderheit von Lisp-Sprachen. Die Funktion multiply von oben lässt sich auch als Zuweisung einer Funktion an eine Variable definieren:

(def multiply (fn [a b] (* a b)))

In Lisp sind zwar fast alle Sprachkonstrukte selbst Funktionen. Aber es gib eine Grenze, einen kleinen Sprachkern, den die sogenannten "Special Forms" bilden. Zu ihnen zählen auch def und fn: Ersteres definiert eine neue Variable, Letzteres eine neue (anonyme) Funktion.

Wie der Name defn suggeriert, werden damit die Schritte def und fn zusammengefasst – man definiert eine Funktion und gibt ihr im selben Schritt einen Namen. Dass der Entwickler einer Programmiersprache eine solche Konstruktion einbauen kann, verwundert nicht. Das Besondere an Lisp-Dialekten wie Clojure ist jedoch, dass sich solche Vereinfachungen nicht nur durch eine Änderung des Sprachkerns, sondern auch mit den Mitteln der Sprache erstellen lassen. Genau dazu verwendet man Makros: Ein solches Makro ist ein Stück Code, das Code schreibt. Auch defn ist als ebensolches implementiert. Das Ergebnis des Makros kann man sich mit der Funktion macroexpand ansehen:

(macroexpand '(defn multiply [a b] (* a b)))
->  (def multiply
 (.withMeta (fn multiply ([a b] (* a b))) (.meta #'multiply)))

Das clojure.core/ vor dem fn ist der Namespace, in dem die Form definiert ist. In anderen Programmiersprachen würde man für eine derartige Automatisierung einen
zusätzlichen Code-Generator benötigen, bei Lisp ist dieser Mechanismus eingebaut. Eine (stark vereinfachte) Version des defn-Makros ließe sich wie folgt definieren:

(defmacro defn2 [name & fdecl] (list 'def name (cons 'fn fdecl)))

Die Funktion list erzeugt eine neue Liste aus einzelnen Elementen, cons fügt ein Element am Anfang einer Liste ein – das Makro generiert also aus seinen Argumenten eine neue Datenstruktur, die den zu erzeugenden Code enthält. (In der Praxis würde man syntax-quote, eine Art Templating-Mechanismus verwenden.) Ein Makro verhält sich ähnlich wie eine Funktion, wird allerdings während der Kompilierung und nicht erst zur Laufzeit des Programms ausgewertet. Zur Überprüfung lässt sich wieder macroexpand verwenden:

(macroexpand '(defn2 multiply [a b] (* a b)))
->  (def multiply (fn [a b] (* a b)))

Der Zusammenhang zwischen Makros, Funktionen und Special Forms lässt sich zudem am Beispiel von Kontrollstrukturen zeigen. Andere Sprachen haben für Aspekte wie eine Bedingung ein eigenes Schlüsselwort, bei Clojure und anderen Lisp-Dialekten sieht auch eine Bedingung aus wie ein Funktionsaufruf. Das illustriert die folgende einfache Funktion:

(defn gleich3 [v]
  (if (== v 3)
    (str v " ist durch 3 teilbar")
    (str v " ist nicht durch 3 teilbar")))
(gleich3 3)
 "3 ist durch 3 teilbar"
(gleich3 4)
 "4 ist nicht durch 3 teilbar"

Makros sind eines der interessantesten Merkmale von Lisp-basierten und -inspirierten Sprachen. Steht etwas Vergleichbares nicht zur Verfügung (wie bei Java), greifen Entwickler häufig auf externe Code-Generatoren zurück.

Eine weitere wesentliche Zutat für ein Verständnis der Lisp-Besonderheiten sind Funktionen höherer Ordnung, also Funktionen, bei denen ein oder mehrere Parameter selbst wieder Funktionen sein können. Ein Beispiel für eine solche Funktion ist map:. Diese Funktion transformiert eine Liste in eine neue, indem sie auf jedes Element eine als Parameter übergebene Funktion anwendet:

(defn double-number [v] (* 2 v))
(map double-number '(1 2 3 4 5))
->  (2 4 6 8 10)

Eine etwas komplizierte Funktion ist die folgende, die für durch 3 und 5 teilbare Zahlen "FizzBuzz", für durch 3 teilbare Zahlen "Fizz", für durch 5 teilbare Zahlen "Buzz" und für alle anderen die Zahl selbst zurückliefert:

(defn fizzbuzz [n]
  (cond
    (and (= (rem n 3) 0) (= (rem n 5) 0)) "FizzBuzz"
    (= (rem n 3) 0) "Fizz"
    (= (rem n 5) 0) "Buzz"
    :else n))

An fizzbuzz lässt sich ein weiterer Clojure-Mechanismus erläutern: unendliche Sequenzen, die sich erst verspätet ("lazy") evaluieren lassen. So liefert (iterate inc 1) eine Sequenz zurück, die in jedem Schritt die Funktion inc (inkrementieren) aufruft und dabei den jeweils letzten Wert übergibt (mit 1 als Startwert).

Das Ergebnis ist die Menge der natürlichen Zahlen. Diese komplett zu verarbeiten wäre keine sonderlich gute Idee; daher hilft die Funktion take, die ersten n Elemente aus einer solchen Sequenz abzurufen. In Kombination mit map lässt sich damit elegant die fizzbuzz-Folge für die Zahlen von 1 bis 25 generieren:

(map fizzbuzz (take 25 (iterate inc 1)))
->  (1
  2
  "Fizz"
  4
  "Buzz"
  "Fizz"
  7
  8
  "Fizz"
  "Buzz"
  11
  "Fizz"
  13
  14
  "FizzBuzz"
  16
  17
  "Fizz"
  19
  "Buzz"
  "Fizz"
  22
  23
  "Fizz"
  "Buzz")

Clojure ist keine Portierung eines Standard-Lisp-Dialekts auf die JVM, sondern wurde von vornherein so entworfen, dass eine Integration mit den zugrunde liegenden Java-Bibliotheken und anderem Java-Code leichtfällt. Aus einem Clojure-Programm heraus lassen sich Objekte anlegen, Instanz- und Klassenmethoden aufrufen, aber auch neue Klassen erstellen, die beliebige Java-Interfaces implementieren. Es ist nicht nur erlaubt, sondern sogar völlig idiomatisch für Clojure, auf bestehende Java-Bibliotheken zuzugreifen. Auf der anderen Seite lässt sich Clojure-Code einfach in Java-Programme einbetten.

In Kombination mit JDBC (Java Database Connectivity), Servlet-Engines (und anderen HTTP-APIs), Swing und Eclipse SWT et cetera wird Clojure zu einer äußerst praktischen Sprache. Eine mittlerweile umfangreiche Menge von Bibliotheken, zum Teil vollständig in Clojure implementiert, zum Teil als Wrapper um bestehende Java-Bibliotheken, ergänzen das.

Clojure wird in Bytecode kompiliert, der zur Laufzeit die Clojure-Ablaufbibliotheken benötigt. Das clojure.jar-Archiv ist ungefähr 1,5 MByte groß. Darin sind nicht nur Clojure-Compiler und REPL (Read-Eval-Print Loop), sondern auch die persistenten Datenstrukturen und die umfangreiche Standardbibliothek enthalten. Einer Nutzung in einem bestehenden Java-Projekt steht dank Maven- und Ant-Integration sowie IDE-Unterstützung nicht nur im Emacs, sondern auch in Eclipse, NetBeans und IntelliJ IDEA nichts im Wege.

Besonderes Interesse findet Clojure durch die Unterstützung für Parallelverarbeitung auf Multi-Core-Plattformen – das ist das Merkmal, mit dem die Sprache im Moment die Aufmerksamkeit vor allem auf sich zieht. Dem Modell liegt die Idee zugrunde, dass das populäre objektorientierte Programmiermodell aus einer Zeit stammt, in der von Multithreading noch keine Rede war; die Unterstützung für Parallelverarbeitung in Form von Locking-Strategien ist daher schlecht integriert und aufgepfropft. Die Entwickler haben Clojure daher von vornherein explizit für eine Multi-Core-Multithreading-Umgebung entworfen. Das äußert sich vor allem in einer expliziten Trennung von Identität und Werten, die in den Objekten der OOP-Welt miteinander verknüpft werden.

Auch in der Java-Welt wird das Konzept von Unveränderbarkeit (Immutability) als Entwurfsmuster populär, bei Clojure ist es die Voreinstellung: Alle Clojure-Werte sind unveränderlich; Funktionen ändern Werte niemals, sondern erzeugen neue Werte. Ein Beispiel ist die Funktion reverse, die eine beliebige Sequenz (zum Beispiel eine Liste, einen Vektor oder auch eine Zeichenkette) umkehrt:

(reverse '(1 2 3 4 5))
->  (5 4 3 2 1)

Das Ergebnis liefert zwar die erwartete Liste, allerdings wird das Ausgangsobjekt dabei nicht verändert:

(def v1 '(1 2 3 4 5))
->  #'clojure.core/v1

Der gleiche Mechanismus greift, wenn Listen, Vektoren oder andere Datenstrukturen nicht fünf, sondern ein paar Millionen Elemente enthalten – logisch gesehen wird dieser Wert niemals geändert, sondern eine Kopie erzeugt. Das wäre gänzlich inperformant und praxisuntauglich, wenn es wirklich so implementiert wäre. Tatsächlich teilen sich die alte und die neue Datenstruktur die gemeinsamen Elemente. Das ist als Structural Sharing bekannt, die verwendeten Datenstrukturen bezeichnet man Persistent Data Structures.

Diese Datenstrukturen sind das Herz von Clojure und sorgen dafür, Unveränderbarkeit konsequent umzusetzen. Das wiederum führt dazu, dass parallele Threads in der Regel nicht zu koordinieren sind: Wenn sich Daten nie ändern, muss der Zugriff darauf auch nicht gesperrt werden. Allerdings beziehen sich die Aussagen nur auf Werte. Was jedoch, wenn man Informationen von mehreren Threads gemeinsam aufbereitet aggregiert, zum Beispiel in Form einer Liste, in die parallel Elemente eingefügt werden? Die Lösung besteht darin, dass sich zwar die Werte nie ändern, wohl aber Referenzen. Das ändert sich, indem die Referenz statt auf den alten nun auf den neuen, geänderten Wert zeigt. Für Referenzen wiederum bietet Clojure eine Reihe unterschiedlicher Synchronisierungs- und Isolationsmechanismen.

Was wie Zutaten zu einem Thriller aus dem "Kalten Krieg" klingt, sind in Wirklichkeit Koordinationsmechanismen für die parallele Verarbeitung in Clojure: Ein Atom ist eine Referenz, die sich nur atomar ändern lässt. Es koordiniert den Zugriff aus mehreren parallel laufenden Threads. Das Atom wird mit der Funktion atom und einem initialen Wert erzeugt und danach durch die Funktion swap! geändert:

(defn add-element [collection element] (conj collection element))
(def my-atom (atom []))
(swap! my-atom add-element 1)
(swap! my-atom add-element 2)
(swap! my-atom add-element 3)
->  [1 2 3]

Die Funktion swap! ist wiederum eine Higher-Order-Funktion, das heißt, sie bekommt als ersten Parameter eine Funktion. Diese ruft sie mit dem aktuellen Wert des Atoms sowie eventuellen weiteren übergebenen Argumenten auf, und das atomar. Das Lesen der Collections kann parallel erfolgen. Damit können mehrere Threads parallel aus der Datenstruktur, auf die die Referenz zeigt, neue Werte erzeugen, die sich dann wiederum der Referenz zuweisen lassen. Die Funktion deref beziehungsweise ihre Abkürzung @ dereferenziert das Atom.

Man benutzt Agenten, um eine asynchrone Verarbeitung durchzuführen. Unterschiedliche Threads können demselben Agenten Nachrichten zusenden, die sich dann in einem separaten Thread abarbeiten lassen. (Dazu wird ein ThreadPoolExecutor aus dem java.util.concurrent-Package verwendet.) Zunächst implementiert man eine Hilfsfunktionen, um eine bestimmte Zeit zu warten, indem man die statische Methode sleep der Java-Klasse java.lang.Thread aufruft. Die Zeichenkette nach dem Funktionsnamen ist eine Dokumentation, die den Entwickler bei der Benutzung unterstützt:

(defn sleep [time]
  "Sleeps for the number of milliseconds defined by time"
  (Thread/sleep time))

Das Hinzufügen eines Elements lässt sich mit der extra dafür vorgesehenen Funktion conj implementieren. Mit sleep verlangsamt man den Vorgang künstlich:

(defn slow-add-element [collection element]
  "Slowly add element to collection"
  (sleep 100)
  (conj collection element))

Nun folgt der Auftritt der Agenten:

(def my-agent (agent []))
(send my-agent slow-add-element 1)
(send my-agent slow-add-element 2)
(send my-agent slow-add-element 3)
(sleep 150)
@my-agent
->  [1 2 3]
(sleep 250)
@my-agent
->  [1 2 3]

Atoms und Agents sind immer dann die richtige Wahl, wenn man den Zugriff auf einzelne Datenstrukturen koordinieren soll – Atoms für den synchronen, Agents für den asynchronen Zugriff. Die letzte wesentliche Synchronisierungsmöglichkeit betrifft den koordinierten parallelen Zugriff auf mehrere Referenzen. Für den Zweck enthält Clojure eine Implementierung des "Software Transactional Memory"-Ansatzes (STM): Ähnlich wie bei Datenbanktransaktionen lassen sich damit Änderungen atomar (ganz oder gar nicht), isoliert (erst am Ende für andere sichtbar) und konsistent durchführen (allerdings nicht dauerhaft, das heißt, von den "ACID"-Eigenschaften sind die ersten drei umgesetzt). Der Nutzen von STM ist umstritten, allerdings bezieht sich das vor allem auf den Ansatz, eine solche Mechanik einer bestehenden Programmiersprache (etwa C oder C++) hinzuzufügen. Bei Clojure ist die Lage ein wenig anders, da es durch die Unveränderlichkeit von vornherein auf Concurrency ausgelegt ist.

Zur Nutzung des Clojure-STM verwendet man ref-Objekte. Sie werden ähnlich wie Atome angelegt und geändert. Allerdings muss das im Rahmen einer durch das dosync-Makro definierten Transaktion erfolgen. Am einfachsten lässt sich das anhand des althergebrachten Beispiels einer Überweisung von einem Konto auf ein anderes illustrieren. Dazu sei zunächst eine weitere Hilfsfunktion definiert:

(defn run-thread-fn [f]
  "Runs function f in a new thread"
  (.start (new Thread f)))

run-thread-fn nutzt die Tatsache, dass alle Clojure-Funktionen das Runnable-Interface implementieren. Für die Verwaltung der Konten definiert man drei einfache Funktionen (assoc "ändert" einen Wert in einer Map):

(defn make-account [balance owner] {:balance balance, :owner owner})
(defn withdraw [account amount]
  (assoc account :balance (- (account :balance) amount)))
(defn deposit [account amount]
  (assoc account :balance (+ (account :balance) amount)))

Mit OOP-Hintergrund erwartet man die Definition einer Klasse Konto, die Methoden enthält; bei Clojure und anderen "Lisps" treten Funktionen in den Vordergrund, die auf einfachen Datenstrukturen operieren. Es ist üblich, viele Dinge, die man zum Beispiel in Java mit Klassen realisieren würde, auf Maps, Vektoren oder andere Clojure-Datenstrukturen abzubilden.

Mit der make-account-Funktion sei nun eine Reihe Konten angelegt, die ein Ref-Objekt kapselt

(defn init-accounts []
  (def acc1 (ref (make-account 1000 "alice")))
  (def acc2 (ref (make-account 1000 "bob")))
  (def acc3 (ref (make-account 1000 "charles"))))

Änderungen von Refs erfolgen – ähnlich wie bei Atoms – nur über eine spezielle Funktion. Bei Ref-Objekten heißt sie alter:

(defn transfer [from to amount]
  (dosync (alter from withdraw amount) (alter to deposit amount)))

Die so definierte Funktion überträgt transaktional abgesichert einen Geldbetrag von einem Konto auf ein anderes. Parallel laufende Threads "sehen" die Änderungen erst am Ende der Transaktion. Tritt ein Fehler auf, wird die Transaktion zurückgerollt. Beim konkurrierenden schreibenden Zugriff aus zwei Transaktionen wird korrekt serialisiert (und gegebenenfalls eine der Transaktionen wiederholt):

(init-accounts)
->  #'clojure.core/acc3
(run-thread-fn #(transfer acc1 acc2 100))
(run-thread-fn #(transfer acc3 acc1 400))
(list acc1 acc2 acc3)
->  (#<Ref {:balance 1300, :owner "alice"}>
  #<Ref {:balance 1100, :owner "bob"}>
  #<Ref {:balance 600, :owner "charles"}>)
(run-thread-fn #(transfer acc3 acc2 500))
(run-thread-fn #(transfer acc2 acc1 200))
(list acc1 acc2 acc3)
->  (#<Ref {:balance 1500, :owner "alice"}>
  #<Ref {:balance 1400, :owner "bob"}>
  #<Ref {:balance 100, :owner "charles"}>)

Ein kurzer Artikel kann keine vollständige Einführung in eine mächtige Programmiersprache wie Clojure liefern, aber vielleicht Interesse wecken: Keine andere Sprache, zumindest auf JVM-Basis, ist konzeptionell ähnlich und thematisiert insbesondere die Concurrency-Aspekte so konsequent. Eine Lisp-Sprache oder Scheme zu erlernen lohnt sich allein wegen der Erweiterung des Horizonts. Mit Clojure und seiner perfekten Java-Integration muss es aber dabei nicht bleiben – die Sprache ist pragmatisch und für einen Einsatz in der Praxis absolut geeignet.

Stefan Tilkov
ist Autor des Buchs "REST und HTTP" (dpunkt.verlag), Autor zahlreicher Fachartikel und häufiger Sprecher auf internationalen Konferenzen.

Haupteinstiegspunkt für Clojure ist die Website [1] der Sprache selbst. Die Clojure-Community hat eine beachtliche Größe, ist sehr aktiv und auch Neueinsteigern gegenüber  freundlich. Sowohl in der Mailingliste [2] als auch im IRC-Chat erhält man schnell Antworten auf Fragen, durchaus auch noch von Rich Hickey selbst. Als Startpunkt für eigene Experimente mit verschiedenen IDEs sei auf die "Getting Started [3]"-Seite verwiesen, an Büchern ist "Programming Clojure [4]" von Stuart Halloway zu empfehlen. Einen kompakten Online-Einstieg bietet auch das Tutorial von Mark Volkmann [5]. (ane [6])


URL dieses Artikels:
https://www.heise.de/-1030144

Links in diesem Artikel:
[1] http://clojure.org/
[2] http://groups.google.com/group/clojure
[3] http://www.assembla.com/wiki/show/clojure/Getting_Started
[4] http://www.pragprog.com/titles/shcloj
[5] http://java.ociweb.com/mark/clojure/article.html
[6] mailto:ane@heise.de