None

Clojure erklärt – für Java-Entwickler

Clojure bietet einen durchdachten konzeptuellen und praktischen Ansatz, um moderne Software zu entwickeln.DC Studio | shutterstock.com



Clojure ist einer der faszinierendsten Zweige der Programmierarbeit und vereint das hochwertige Design von Lisp mit praktischen, modernen Funktionen. Für Java-Entwickler kann Clojure eine hervorragende Erweiterung darstellen. Schließlich eröffnet es eine völlig andere Art, Probleme zu verstehen und zu lösen, läuft dabei aber weiterhin auf der JVM und ist mit Java interoperabel.



Um Clojure zu verstehen, sind die folgenden vier Konzepte von entscheidender Bedeutung:




Code als Daten,



funktionale Programmierung,



Unveränderlichkeit, und



Parallelität.




In diesem Artikel werfen wir einen eingehenden Blick auf diese Konzepte.



Code als Daten



Wenn Sie von Java zu Clojure wechseln, dürfte Ihnen zuallererst auffallen, dass Letzteres homoikonisch ist – der Code ist in Form der Datenstrukturen der Sprache geschrieben. Diese Praxis, auch bekannt als “Code as Data”, führt zu einer sehr konsistenten Syntax mit einer begrenzten Anzahl von Keywords und Konstrukten. Zudem entsteht dadurch ein Meta-Programmiermodell, das “syntaxbewusste” Code Templates (sogenannte Macros) verwendet.



Die Idee dahinter: Jedes Clojure-Code-Snippet ist auch eine Instanz von Clojure-Daten. Am deutlichsten zeigt sich in der Listensyntax von Clojure. Hier eine einfache “Hello, InfoWorld”-Funktion:



(defn hello-infoworld []
(println "Hello, InfoWorld"))



Klammern weisen in Clojure auf eine Liste hin. Die Definition dieser Funktion ist also im Wesentlichen nur die Definition einer Liste mit einem Keyword (defn), einem Funktionsnamen (hello-infoworld), einem leeren Argumentvektor ([]) sowie dem Funktionskörper. Der Funktionskörper ist ebenfalls eine Liste, die einen Funktions-Call (println) und das Argument der Funktion („Hello, InfoWorld”) enthält.



Funktionen in Clojure werden mit einem Listenliteral (definiert mit ()) definiert. Der []-Vektor ähnelt dem aus Java bekannten ArrayList und wird spezifisch für Daten verwendet – nicht, um Code auszuführen.



Funktionale Programmierung



Java hat sich im Laufe der Jahre zu einer vielseitigeren funktionalen Programmiersprache entwickelt. Clojure wurde hingegen von Grund auf unter Berücksichtigung funktionaler Programmierprinzipien entwickelt. In Java ist das Kernkonzept das Objekt, in Clojure hingegen – wie in anderen funktionalen Sprachen – die Funktion. Sie ist der grundlegende Baustein für wiederverwendbare Funktionalität und ein “First-Class-Citizen”, der mit Variablen referenziert, als Wert zurückgeben und als Argument übergeben werden kann (auch bekannt als Funktion höherer Ordnung).



In der objektorientierten Programmierung werden hingegen State und Behavior zu Objekten kombiniert. Das ist ein intuitives Modell, das leicht zu verstehen ist. Allerdings kann es zu unerwarteten Schwierigkeiten kommen, sobald ein Softwaresystem komplexer wird. Auch das hat dazu geführt, dass funktionale Programmierung immer populärer wird. Die Konzepte dahinter zu durchdringen, kann anfangs zwar schwierig sein, weil kein Objektmodell vorhanden ist – aber die Mühen lohnen sich.



Und Clojure bietet eine gute Gelegenheit, um sich der funktionalen Programmierung anzunähern. Dem folgenden (synthetischen) Beispiel entnehmen Sie, wie eine Funktion eine andere als Argument nutzt:



(defn formal-greeting [name]
(str "Greetings, " name "."))

(defn informal-greeting [name]
(str "Hey, " name "!"))

;; A "higher-order" function:
argument
(defn greet [greeting-fn name]
(greeting-fn name))

;; --- Using it ---

(greet formal-greeting "Dr. Smith")
;=> "Greetings, Dr. Smith."

(greet informal-greeting "Alex")
;=> "Hey, Alex!"



In Clojure wird alles, was nach einem Semikolon steht, nicht geparst – sondern für Kommentare verwendet. ;=> stellt lediglich eine konventionelle Methode dar, um die Rückgabe des Funktionsaufrufs zu kennzeichnen. Wichtig zu wissen ist außerdem, dass am Ende einer Funktion der letzte Ausdruck als Rückgabewert ausgewertet wird (es wird also kein return-Keyword verwendet). Mit diesen beiden Punkten im Hinterkopf ist der obenstehende Code relativ leicht zu verstehen.



Unveränderlichkeit



Die Clojure-Dokumentation spiegelt wider, dass die Programmierphilosophie dahinter sorgfältig durchdacht ist. Folgendes ist hier zum Aspekt der Unveränderlichkeit (Immutability) zu lesen:



“Wir müssen uns von der Vorstellung des State als ‚Inhalt dieses Memory-Blocks‘ lösen und ihn als Wert wahrnehmen, der derzeit mit der jeweiligen Identität verbunden ist. Somit kann eine Identität zu unterschiedlichen Zeitpunkten unterschiedliche Zustände haben, aber der State selbst ändert sich nicht. Folglich ist eine Identität kein State, sondern hat einen.”



Etwas konkreter ausgedrückt: Funktionen sind so konzipiert, dass sie Werte akzeptieren und zurückgeben, nicht Identitäten. Das ist ein wesentlicher Unterschied zu objektorientierten Sprachen wie Java, in denen Objekte oft per Referenz übergeben werden. In der Folge kann sich die Funktion auf den Zustand des Objekts auswirken und damit Nebenwirkungen oder Auswirkungen auf etwas nach sich ziehen, das außerhalb des Rückgabewerts der Funktion liegt.



Weil der Zustand des Objekts unterschiedlich sein kann, ist es möglich, dass dieselbe Funktion mit demselben Input (dem Objektreferenzargument) in Java unterschiedliche Ergebnisse liefert. Das ist in Clojure nicht möglich, weil Funktionen nur Werte oder andere Funktionen entgegennehmen oder zurückgeben. Clojure verwendet außerdem unveränderliche Operationen, bei denen Datenstrukturen neu erstellt werden, um Änderungen zu erzeugen – im Gegensatz zu Inline-Mutationen, wie sie in Java häufig zum Einsatz kommen.



Hier ist ein Beispiel, um etwas zu einer Collection hinzuzufügen:



;; `original-numbers` is an identity with the value `[1 2]`
(def original-numbers [1 2])

;; The `conj` function returns a NEW vector.
(def new-numbers (conj original-numbers 3))


original-numbers
;=> [1 2] -- Unchanged original collection

new-numbers
;=> [1 2 3] -- A new collection with the joined values



Neu ist hier die Funktion conj. Dabei handelt es sich um den universellen Mechanismus von Clojure, um Elemente zu einer Sammlung hinzuzufügen. Er funktioniert für Listen, Sets und Maps. In diesem Beispiel haben wir die bestehenden Elemente [1, 2] um 3 ergänzt.



Aufgrund des Unveränderlichkeitsprinzips, das in Clojure zur Anwendung kommt, erstellt dieser Code einen neuen Vector. Das liegt daran, dass es sich bei der ursprünglichen numbers-Variable um eine Identität handelt. Diese können Sie zwar nutzen, um darauf basierend neue Identitäten mit neuen Werten zu erstellen – aber Sie können sie nicht verändern.



Eine direkte Neuzuweisung von Variablen wie in Java gibt es in Clojure nicht. Variablen werden mit einem Anfangswert erstellt oder durch Funktionsergebnisse erzeugt.



Parallelität



Parallelität (Concurrency) ist eines der Hauptziele von Clojure. Unveränderlichkeit trägt dazu bei, das zu vereinfachen. Aber letztendlich führt kein Weg daran vorbei, sich mit Shared State auseinanderzusetzen, bei dem viele Threads gleichzeitig auf denselben Speicher zugreifen können. Um die gemeinsam genutzten Zustände zu managen, enthält Clojure mehrere leistungsstarke Konzepte.




Atoms bilden einen einfachen Mechanismus ab, über den mehrere Threads sicher auf einen einzelnen Wert zugreifen können. Der Entwickler definiert einfach eine normale Funktion, Clojure übernimmt den Rest im Hintergrund (technisch gesehen handelt es sich dabei um “Compare-and-Swap” oder CAS).



Refs ist ein ausgeklügelte, transaktionale Software Memory, die Transaktionen mit gemeinsam genutzten Werten ermöglicht – inklusive Commit- und Rollback-Operationen, wie man sie aus Datenbanken kennt.



Futures bieten einen simplen Mechanismus, um einen Teil der Arbeit an einen anderen Thread auszulagern und den Wert zu einem späteren Zeitpunkt abzurufen.



Promises sind eine Möglichkeit, auf die Fertigstellung einer asynchronen Aufgabe zu warten, die auf einem anderen Thread ausgeführt wird.




Clojure eignet sich für alle Arten von paralleler Programmierung, von sehr einfachen bis hin zu hochkomplexen Projekten. Nachfolgend ein einfaches Beispiel dafür, wie Atoms genutzt werden können, um einen Multithread-Zählerwert zu verarbeiten:



;; Create an atom to hold the counter (initially 0)
(def counter (atom 0))

;; Define a function for one thread's worth of work
;; `swap!` atomically applies the `inc` function to the atom's value.
(defn do-work []
(dotimes [_ 1000]
(swap! counter inc)))

;; Use `future` to run our work on two separate threads
(def thread-1 (future (do-work)))
(def thread-2 (future (do-work)))

;; Dereferencing them with @
@thread-1
@thread-2

;; Read the final value of the atom
(println "Final count:" @counter)
;=> Final count: 2000



In diesem Snippet stellt @ die Syntax dar, um Threads zu “dereferenzieren”. Das bedeutet im Wesentlichen, dass diese blockiert werden und auf ihre Fertigstellung (beziehungsweise die der Futures) gewartet wird. Die integrierte swap!-Funktion und die atom-Variable arbeiten zusammen, um eine sichere Änderung des Zähler-Status über Threads hinweg zu ermöglichen. Die swap!– und auch die zugehörige reset!-Funktion haben die Aufgabe, den threadsicheren Zugriff auf den Atom-Container zu verwalten. Mit der future-Funktion lässt sich die Arbeit in einem Worker-Thread ausführen. (fm)



Dieser Artikel ist im Original bei unserer Schwesterpublikation Infoworld.com erschienen.