Clojure: Ein pragmatisches Lisp für die JVM

Seite 2: Benefits

Inhaltsverzeichnis

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 der Sprache selbst. Die Clojure-Community hat eine beachtliche Größe, ist sehr aktiv und auch Neueinsteigern gegenüber  freundlich. Sowohl in der Mailingliste 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"-Seite verwiesen, an Büchern ist "Programming Clojure" von Stuart Halloway zu empfehlen. Einen kompakten Online-Einstieg bietet auch das Tutorial von Mark Volkmann. (ane)