DSLs ganz einfach mit Clojure
Clojure hat sich als alternative Programmiersprache auf der JVM fest etabliert: Die Sprache ist ausgereift, das Ökosystem enthält viele nützliche Libraries und Frameworks, und die kleine aber rege Community ist freundlich und stellt viele Konferenzen und Meetups auf die Beine.
Nicht nur Clojures Nische ist klein: die Programmiersprache selbst ist es auch, und damit schnell und leicht erlernbar. Was ist die Anziehungskraft dieser winzigen Sprache gegen den Goliath Java?
Zwei Dinge sind da besonders relevant: Clojure-Programme sind kompakter als ihre Java-Pendants und ermöglichen damit die Konzentration auf das Wesentliche. Wichtiger noch: Clojure ist eine funktionale Sprache und ermöglicht damit oft eine andere Sicht auf die Domäne als das klassisch objektorientierte Java. Dieser Artikel demonstriert das anhand einer kleinen domänenspezifischen Sprache für Bilder. Vorkenntnisse in Clojure sind für die Lektüre nicht notwendig, die verwendeten Konstrukte werden allesamt erläutert.
Bilder als Objekte
Dieser Abschnitt erläutert den Übergang von der üblichen objektorientierten zur funktionalen Programmierung. Es soll also um Bilder gehen. Aus Sicht der objektorientierten Programmierung sollte aus jedem Substantiv in einer Domänenbeschreibung ein Objekt werden: Ein Bild sollte demnach durch ein Objekt repräsentiert werden.
Exemplarisch ist das zum Beispiel in Java-AWT so, wo es ein Interface
java.awt.image.Image
gibt, mit der relevanten
Implementierung BufferedImage
. Diese Klasse hat zum
Beispiel diese Methode:
void setRGB(int x, int y, int rgb)
Sie setzt also in einem Raster aus Pixeln einen Pixel auf eine bestimmte
Farbe. Ein BufferedImage
ist also zunächst noch gar
nicht das gewünschte Bild: das entsteht erst noch durch eine Folge von
Methodenaufrufen, welche die Farbe bestimmter Pixel ändert. Die Idee
„ein Bild besteht aus rechteckig angeordneten Pixeln jeweils bestimmter
Farbe“ ist sehr technisch.
Die funktionale Programmierung beantwortet die Frage „Was ist ein Bild?“ auf höherer Ebene. Wenn Menschen ein Bild betrachten, so sehen sie an jeder Stelle des Bildes eine bestimmte Farbe. Das Konzept des „Pixels“ nehmen Menschen nicht direkt wahr. Die Idee „an jeder Stelle des Bildes eine bestimmte Farbe“ lässt sich aber auch ganz ohne Pixel formulieren, nämlich als eine Funktion von „Stelle“ zu „Farbe“. Diese Idee verfolgt das System Pan, das der prominente funktionale Programmierer Conal Elliott 2003 veröffentlichte. Dieser Artikel zeichnet seine Idee vereinfacht in Clojure nach.
Den Anfang macht die Datenmodellierung: Die Begriffe „Stelle“ und
„Farbe“ müssen modelliert werden, bevor es so richtig losgeht. Eine
„Stelle“ wird als kartesische Koordinaten mit x
- und
y
-Feldern modelliert. In Clojure sieht das so aus:
(defrecord Point [x y])
Man kann schon sehen, dass Clojure ein Lisp-Dialekt ist, das heißt
zusammengehörende Konstrukte immer von Klammern umschlossen sind. Das
defrecord
kennzeichnet die Definition eines Records, also
eines Typs für zusammengesetzte Daten mit x
- und
y
-Feld. In Java wäre das ein „Plain Old Java Object“, und
in der Tat erzeugt Clojure für die obige Deklaration eine POJO-Klasse
dafür namens Point
mit x
- und
y
-Feldern.
Die Record-Deklaration zeigt, dass Clojure eine dynamisch getypte
Sprache ist: Man muss bei x
und y
nicht
angeben, welche Typen sie haben, die werden erst zur Laufzeit
entschieden. Für Koordinaten werden natürlich hier immer Zahlen
verwendet.
Der Konstruktor von Point
heißt in Clojure
->Point
(also mit Pfeil vor dem Namen), und entsprechend
sieht die Konstruktion eines Punkts zum Beispiel so aus:
(->Point 1 2)
Zwei Beispielpunkte namens point1
und point2
werden folgendermaßen definiert:
(def point1 (->Point 1 2))
(def point2 (->Point 0.5 4))
Für Point
sind außerdem automatisch zwei Getter definiert
mit den Namen :x
und :y
. Anders als in Java
sind dies aber keine Methoden (die sind in Clojure eher verpönt),
sondern Funktionen. Aufgerufen werden auch mit Klammern drum, wie
folgt zum Beispiel:
(:x point1)
liefert 1(:y point2)
liefert 4
Eine Farbe ist in diesem Artikel ein Boolean true
oder
false
für schwarz respektive weiß. (Das ist leicht zu
verallgemeinern auf beliebige Farben und Transparenz, macht aber diesen
Artikel kürzer.)
Koordinaten und Farben sind also definiert: Ein Bild ist eine Funktion,
die für Koordinaten - ein Point
-Objekt eine Farbe liefert -
true
oder false
. Hier ist ein Beispiel:
(defn vstrip
[p]
(<= (Math/abs (:x p)) 0.5))
defn
definiert in Clojure eine Funktion, in diesem Fall
eine, die vstrip
heißt und - in eckigen Klammern - einen
Parameter namens p
hat. In Clojure - da es eine funktionale
Sprache ist - liefert jede Funktion einen Wert, darum wäre
return
redundant: Die Funktion liefert also das Ergebnis
eines Vergleichs mit <=
. Hier sieht man, dass auch
<=
nur eine Funktion in Clojure ist und entsprechend vor
die Argumente und mit Klammern drum geschrieben wird.
Math/abs
ist die statische Java-Methode aus der
Math
-Klasse. Die Funktion liefert also true
,
wenn die X-Koordinate von p
zwischen -0,5 und +0,5 liegt,
sonst false
.
Um herauszubekommen, welche Farbe an bestimmten Koordinaten sitzt,
kann vstrip
einfach aufgerufen werden:
(vstrip (->Point 0 0))
lieferttrue
(schwarz)(vstrip (->Point 0.6 0))
liefertfalse
(weiß)
Das Bild sieht entsprechend so aus:
… also genauer gesagt, ein Ausschnitt des Bildes von jeweils -2 bis +2, denn das Bild ist zumindest konzeptuell unendlich groß.
vstrip
ist ziemlich langweilig. Marginal interessanter ist
das hier:
(defn vstripes
[p]
(even? (int (Math/floor (:x p)))))
Die Funktion int
macht ein Integer aus dem
double
, das bei Math/floor
herausgekommen ist,
und die Funktion even?
testet, ob ein Integer gerade ist
oder nicht. Das Fragezeichen gehört in Clojure zum Namen und ist
Konvention für Funktionen, die ein Boolean liefern - in Java würde die
Funktion isEven
heißen.
Die gleiche Idee lässt sich auch in der Horizontalen verwirklichen:
(defn hstripes
[p]
(even? (int (Math/floor (:y p)))))
Weg vom Gefängnismuster geht es mit einer Tischdecke:
(defn checker
[p]
(even? (+ (int (Math/floor (:x p))) (int (Math/floor (:y p))))))
So ein bißchen kann man schon sehen, wo der Unterschied zwischen der
objektorientierten Herangehensweise von
java.awt.image.Image
und diesem funktionalen Modell liegt:
java.awt.image.Image
ist geprägt durch eine
Implementierungsidee (Pixel), während die funktionale Sicht auf der
„mathematischen Essenz“ der Idee eines Bildes beruht. Zur
Implementierung - wie also aus der Darstellung ein Bild auf dem
Bildschirm wird - ist bisher noch gar nichts bekannt. (Aber keine Sorge,
das kommt noch.)
Kombinatormodelle
Das Bild checker
mit dem Schachbrettmuster wurde oben
„direkt“ definiert. Aber checkers
kann auch mit Hilfe von
vstripes
und hstripes
definiert werden:
(defn checker
[p]
(not= (vstripes p) (hstripes p)))
Die Funktion not=
testet auf „nicht gleich“, also
effektiv ein Exklusiv-Oder auf den Farben von vstripes
und
hstripes
. Das p
wird von checker
an vstripes
und hstripes
durchgeschleift: Die
beiden Bilder werden also quasi als ganzes xor-kombiniert.
Diese Idee - zwei Bilder xor-kombinieren - kann man aus
checker
herausabstrahieren. Das sieht dann so aus:
(defn img-xor
[img1 img2]
(fn [p]
(not= (img1 p) (img2 p))))
(Der Bindestrich gehört zum Namen - in Java würde man einen Underscore
_
schreiben.)
Diese Funktion nimmt zwei Bilder - wie zum Beispiel
vstripes
oder hstripes
als Argumente und
liefert wieder ein Bild - also eine Funktion. Das fn
ist
das Clojure-Pendant zum Lambda-Ausdruck in Java und stellt eine Funktion
her, die in diesem Fall ein Point
-Objekt akzeptiert und
eine Farbe liefert, mit dem gleichen Rumpf wie schon zuletzt bei
checker
. Das kann jetzt so definiert werden:
(def checker
(img-xor hstripes vstripes))
… und damit ist sofort die Essenz von checker
klar
(hoffentlich), die bei der ersten Definition doch schwieriger zu sehen
war.
Die Implementierung von Funktionen wie img-xor
, die auf
Bildern als ganzes operieren (sogenannte Kombinatoren), macht aus
Clojure zusammen mit einer Library dieser Funktionen effektiv eine
domänenspezifische Sprache.
Koordinatentransformation
Hier ist ein weiteres Bild:
Intuitiv kann man sehen, dass es eine ähnliche Idee wie
checker
umsetzt - nur irgendwie im Kreis statt im Quadrat.
Ideal wäre, wenn gerade das Konezpt „im Kreis statt im Quadrat“ als
Code ausgedrückt werden könnte. Und tatsächlich gibt es ja Koordinaten,
die im Kreis statt im Quadrat funktionieren, sogenannte
Polarkoordinaten. Die bestehen nicht aus X- und Y-Koordinate.
Stattdessen stellt man sich vor, dass zu einem Punkt ein Strahl vom
Ursprung gezeichnet wird. Die Polarkoordinaten sind dann die Länge des
Strahls (auch der Radius, meist r) und der Winkel des Strahls zur
X-Achse (meist ρ).
Das heißt, die kartesischen Quadratkoordinaten müssten nur in Polarkoordinaten umgerechnet werden. Das macht folgende Funktion, deren Formel man aus einer handelsüblichen Formelsammlung beziehen kann:
(defn to-polar
[p]
(->Point (distance-from-origin (:x p) (:y p))
(Math/atan2 (:x p) (:y p))))
Die Hilfsfunktion distance-from-origin
berechnet den
Abstand vom Ursprung, ebenfalls mit einer Standardformel:
(defn distance-from-origin
[x y]
(Math/sqrt (+ (* x x) (* y y))))
Für das obige Bild wechselt die Farbe bei einer Kreisumdrehung insgesamt
20mal - also 10mal hin und zurück. Entsprechend muss der Winkel in den
Polarkoordinaten angepasst werden, so dass eine Kreisumdrehung - zweimal
Pi - gerade 20 entspricht. Das verallgemeinert folgende Hilfsfunktion
turn
, die bei Polarkoordinaten den Winkel (der im
y
-Feld steht) entsprechend skaliert:
(defn turn
[n]
(fn [p]
(->Point (:x p)
(* (:y p)
(/ n Math/PI)))))
Die turn
-Funktion akzeptiert also als Parameter
n
die Anzahl der Farbwechsel pro Umdrehung und liefert eine
Funktion, die ein Point
-Objekt entsprechend transformiert.
Um jetzt das Schachbrettmuster im Kreis zu bilden, müssen die
Koordinaten zunächst mal mit to-polar
in Polarkoordinaten
umgewandelt und dann mit turn
der Winkel skaliert werden,
damit das ursprüngliche checker
dann die Farbe ausrechnen
kann. Die Koordinaten werden also durch eine kleine Pipeline geleitet,
die aus drei Funktionen besteht. Mathematisch gesehen werden die
Funktionen komponiert, darum heißt die eingebaute Funktion in Clojure
dafür auch comp
und wird so benutzt, um
polar-checker
zu definieren:
(defn polar-checker
[n]
(comp checker (turn n) to-polar))
Auch hier sieht man schön, wie die funktionale Sichtweise die Essenz der Idee dieses Bildes herausstreicht, und wie Clojure es erlaubt, die Idee in nur wenigen Zeilen Code zum Ausdruck zu bringen.
Mit etwas Sinn für Formelspielereien kann man so richtig hübsche Bilder machen. Zum Beispiel kann man auch umgekehrt von Polarkoordinaten in kartesische Koordinaten zurückrechnen:
(defn from-polar
[p]
(->Point (* (:x p) (Math/cos (:y p)))
(* (:x p) (Math/sin (:y p)))))
(Auch diese Formel liefert die Formelsammlung.)
Das kann man benutzen, um auf den Polarkoordinaten Transformationen
durchzuführen, indem die Koordinaten erst ins Polarformat überführt
werden, dann transformiert, und dann in kartesische Koordinaten
zurückgerechnet werden. Auch das ist eine Pipeline mit
comp
:
(defn from-polar-transformation
[trafo]
(comp from-polar trafo to-polar))
Die folgende Funktion bildet so den Kehrwert des Polarradius, stülpt also quasi innen nach außen:
(def invert-polar-radius
(from-polar-transformation
(fn [p]
(->Point (if (zero? (:x p))
0.0
(/ 1.0 (:x p)))
(:y p)))))
Das folgende Bild schaltet checker
und ìnvert-polar-radius
hintereinander:
(def rad-invert-checker
(comp checker invert-polar-radius))
Der Aufsatz von Conal Elliott iefert noch mehr und schönere Beispiele, inklusive psychedelische Animationen.
Bilder auf den Bildschirm!
Die Essenz der Bilder ist schön und gut, aber trotzdem würde man sie natürlich gern sehen: Entsprechend geht es in diesem Abschnitt darum, zum Konzept die Implementierung zu liefern und demonstriert damit die andere Seite von Clojure, nämlich die Interoperatibilität mit Java.
Für die Darstellung der Bilder werden einige AWT- und Swing-Klassen benötigt, die in den Namespace importiert werden. Der Namespace ist das Clojure-Pendant zum Java-Package, und er wird am Beginn einer Dateil deklariert:
(ns javapro.functional-images
(:import (java.awt Color Graphics Image BorderLayout)
(javax.swing JFrame JLabel ImageIcon)
java.awt.image.BufferedImage))
Damit kann die Funktion image->bitmap
aus dem Clojure-Image
eine handfeste Bitmap machen. Hier ist der Header dieser Funktion:
(defn image->bitmap
[image width height x-min x-max y-min y-max]
Der Pfeil im Namen ist Konvention für Funktionen, die ein Objekt in ein
anderes konvertieren. Bei den Parametern ist image
das
Clojure-Bild, width
und height
sind Höhe und
Breite des Bildes in Pixel, und x-min
, x-max
,
y-min
und y-max
sind der
Koordinaten-Ausschnitt aus dem Bild, der angezeigt werden soll.
Als nächstes werden einige lokale Variablen gebunden, das geht in
Clojure mit dem Konstrukt let
: Dort stehen in eckigen
Klammern die Namen lokaler Variablen und Ausdrücke für deren Werte,
danach können die lokalen Variablen verwendet werden:
(let [buffered-image (BufferedImage.
width height
BufferedImage/TYPE_INT_ARGB)
graphics (.getGraphics buffered-image)
xinc (/ (- x-max x-min)
(double width))
yinc (/ (- y-max y-min)
(double height))]
Für die erste Variable buffered-image
erzeugt die Funktion
ein BufferedImage
-Objekt, in das die Funktion das Bild
hineinmalen wird. Wie bei den Records ist BufferedImage.
der Name des Konstruktors für diese Klasse. Danach wird aus
buffered-image
der Grafik-Context extrahiert -
BufferedImage
hat dafür die Methode
getGraphics
. Der Java-Aufruf dieser Methode
buffered-image.getGraphics()
wird in Clojure als
Funktionsaufruf mit .getGraphics
geschrieben.
Die beiden Bindungen für xinc
und yinc
rechnen
schließlich aus, wie breit und wie hoch ein Pixel im Koordinatensystem
des Bildes sind. (Die Funktion double
macht aus den
Integern width
und height
jeweils Doubles.)
Jetzt muss die Funktion über die Pixel des Bildes extrahieren, jeweils
die Farbe ermitteln, diese im Grafik-Context als kleine Rechtecke malen
und schließlich am Ende das BufferedImage
-Objekt
zurückliefern. Das doseq
-Konstrukt ist das Pendant zum
„foreach“ und Java und führt hier zwei geschachtelte Schleifen über
alle Pixel des Bildes aus:
(doseq [x (range 0 width)
y (range 0 height)]
(let [black? (image (->Point (+ x-min (* xinc x)) (+ y-min (* yinc y))))
color (if black?
Color/BLACK
Color/WHITE)]
(.setColor graphics color)
(.fillRect graphics x y 1 1)))
buffered-image))
Das Image-Objekt, das image->bitmap
liefert, kann nun zum
Beispiel in einem Fenster angezeigt werden mit folgender Funktion, die
nahezu auschließlich aus Aufrufen von Java-Methoden besteht:
(defn display-image!
[image width height x-min x-max y-min y-max]
(let [bitmap (image->bitmap image width height x-min x-max y-min y-max)
frame (JFrame. "Image")
label (JLabel.)]
(.setIcon label (ImageIcon. bitmap))
(.setSize frame width height)
(.setDefaultCloseOperation frame JFrame/EXIT_ON_CLOSE)
(.add (.getContentPane frame) label BorderLayout/CENTER)
(.pack frame)
(.setVisible frame true)))
Fazit
Dieser Artikel konnte hoffentlich einen kleinen Einblick geben in die beiden Seiten von Clojure: Die eine Seite, die Domänenkonzepte in Reinform modelliert, und die andere Seite, die ihre Implementierung ermöglicht.
Natürlich ginge das alles auch in Java - allerdings mit einigen kleinen aber wesentlichen Abstrichen, die damit zu tun haben, dass Java zwar inzwischen Lambda-Ausdrücke aus der funktionalen Programmierung importiert hat, aber immer noch zwischen Methoden und Funktionen unterscheidet.
In Clojure hingegen kann die Idee des Domänenmodells in Reinform ausgedrückt werden, wobei ganz natürlich eine domänenspezifische Sprache entsteht. Dieser Prozess ist damit ein natürlicher Bestandteil der Arbeit mit Clojure, und Clojure kann dann auch die Implementierung des Konzepts und Anbindung an Betriebssystem und UI begleiten.
Die Sprache hat natürlich noch mehr zu bieten, ist aber insgesamt deutlich kleiner als Java und damit einfach zu beherrschen. Und, vor allem: Es macht viel mehr Spaß!