Eingebaute Datenstrukturen in Clojure
Dieses Posting setzt unsere Clojure-Einführung (hier Teil 1) fort. Clojure war ein Pionier bei der Bereitstellung von effizienten rein funktionalen Datenstrukturen (siehe hier für eine Einführung). Dieses Posting erklärt, was es damit auf sich hat.
Übrigens …
Auf unserer Konferenz BOB 2015 gibt es ein Clojure-Tutorial!
Beispiele nachvollziehen
Um die Beispiele in diesem Posting nachzuvollziehen, reicht eine einfache Clojure-REPL. Entweder können Sie - wie in Teil 1 beschrieben - Nightcode hochfahren und die dortige REPL benutzen, oder Sie begeben sich in einem Kommandozeilen-Fenster in das Projektverzeichnis vom letzten Mal und tippen ein
lein repl
… und schon erscheint ein Prompt fp1.core==>
(oder so ähnlich), an
dem Sie Clojure-Ausdrücke eintippen können und sichten, was dabei
herauskommt.
Einen guten Referenz für die eingebauten Clojure-Funktionen - nach Datentyp sortiert - gibt es zu Beispiel bei Grimoire.
Primitive Datentypen
In diesem Abschnitt kümmern wir uns erst einmal um „primitive“
Datentypen, die nicht wesentlich strukturiert sind. Booleans zum
Beispiel sind true
und false
:
Booleans werden meist mit der if
-Form weiterverarbeitet:
Neben false
gibt es in Clojure noch einen weiteren Wert, der als
boolesch „falsch“ durchgeht, nil
:
nil
entspricht Javas null
und ist mit der gleichen Vorsicht zu
behandeln: Pragmatisch verwenden wir nil
als Platzhalter dafür, dass
etwas nicht da ist.
Außer false
und nil
zählen alle anderen Werte als „wahr“:
Die erste Überraschung zeigt, dass Clojure beliebig große ganze Zahlen verarbeiten kann:
Zeichenketten sind allerdings wie in Java:
Clojure ist ein Lisp-Dialekt, entsprechend gibt es dort auch Symbole. Diese kommen aber - anders als in anderen Lisps - fast ausschließlich bei der Programmierung von Makros zum Einsatz. Diese sparen wir uns deshalb für ein zukünftiges Posting auf.
Für den täglichen Einsatz hat Clojure stattdessen Keywords, die wir benutzen können, um Aufzählungen zum Beispiel für Status-Werte und ähnliches zu repräsentieren. Keyword-Literale sind durch den Doppelpunkt am Anfang zu erkennen:
Folgen
Für Folgen kennt Clojure gleich drei verschiedene Datentypen: Listen, Vektoren und sogenannte Seqs. Sie sind allesamt funktionale Datenstrukturen - es wird also niemals an eine Folge „in situ“ etwas hinzugefügt, wie etwa bei den Java-Collections üblich.
Die drei Folgentypen unterscheiden sich - grob - folgendermaßen:
-
Listen sind die klassischen einfach verketteten Listen, wie in Lisp und anderen funktionalen Sprachen. Der primitive Konstruktor
cons
hängt an eine bestehende Liste vorn ein neues Element dran. Hinten an eine Liste etwas dranhängen ist teuer. -
Vektoren bilden einen allgemeiner verwendbaren Datentyp. Insbesondere ist es billig, an einen Vektor hinten etwas dranzuhängen. (Vektoren sind durch hochverzweigte Bäume effizient repräsentiert.)
-
Seqs sind nicht-strikte („lazy“) Datenstrukturen, die erst wirklich konstruiert werden, wenn Elemente aus der Folge abgerufen werden.
Die folgenden Abschnitte gehen etwas mehr ins Detail:
Listen
Der Listentyp entspricht der klassichen Datendefinition:
Eine Liste ist eins der folgenden …
- die leere Liste (Literal
'()
) - eine Paar bestehend aus erstem Element und dem Rest, ebenfalls
eine Liste (konstruiert mit
cons
)
Vektoren
Vektoren entstehen komplementär zu Listen durch Anhängen hinten mit
der Funktion conj
. Der
leere Vektor wird durch das Literal []
gebildet:
Außerdem können wir Vektoren aus Elementen direkt mit den eckigen Klammern bilden:
Vektoren unterstützen außerdem noch sehr schnelles Umdrehen mit
rseq
und außerdem die
Extraktion von Teilfolgen mit
subvec
.
Wegen der praktischen []
-Notation und der effizienten
Repräsentation sind Vektoren in Clojure eher der Typ der Wahl für
Folgen, nicht Listen wie in anderen Lisps.
Seqs
Schließlich sind da noch die Seqs, die „lazy“ konstruiert werden.
Aus jedem Folgen-Datentyp wird ein Seq mit Hilfe der Funktion
seq
:
(Die runden Klammern sind also nicht - wie bei anderen Lisps - den Listen vorbehalten.)
Seqs entstehen im Alltag am häufigsten durch die Verwendung von
eingebauten Folgenkombinatoren wie
map
:
map
produziert also - obwohl die Eingabefolge ein Vektor ist - als
Ausgabe eine Seq, keinen Vektor.
Das ist praktisch, weil die Funktion, die bei map
benutzt wurde,
erst aufgerufen wird, wenn das entsprechende Element aus dem Ergebnis
herausgezogen ist. Das bedeutet aber auch, dass Seiteneffekte in
diesen Funktionen problematisch sind, weil sie möglicherweise erst
viel später nach der Anwendung von map
zur Ausführung kommen, wie
das folgende Beispiel zeigt:
Hier wird das println
erst dann angewendet, wenn der Inhalt von x
ausgedruckt wird, nicht schon bei der Definition von x
. (Clojure
druckt erst die öffnende Klammer, holt dann die drei Elemente heraus -
aus Effizienzgründen sind es meist mehrere auf einmal - diese werden
dabei ausgedruckt, und das Ergebnis ist dann die Liste aus den drei
nil
s, die println
zurückgegeben hat.)
Seqs merken sich die produzierten Elemente, so dass eine wiederholte
Auswertung von x
nicht dazu führt, dass alle Elemente erneut
berechnet werden.
Wer strikte Ausführung benötigt, ist oft mit spezialisierten
Operationen wie mapv
(map
auf Vektoren) besser bedient. Außerdem gibt es die Funktion
doall
, die alle Elemente
einer Seq auswertet.
Gemeinsame Operationen
Viele Operationen in Clojure funktionieren auf allen drei
Repräsentationen gleichermaßen. Zum Beispiel extrahiert
first
immer das erste
Element und rest
die
Restfolge nach dem ersten Element.
Ein paar dieser Funktionen sind allerdings auch etwas verwirrend:
conj
zum Beispiel fügt
ein Element einer beliebigen Folge hinzu, allerdings hängt es vom
Folgentyp ab, an welchem Ende:
Mengen
Clojure hat auch einen Typ für Mengen eingebaut. Mengenliterale sind
von #{...}
um umschlossen:
Aus anderen Folgen können wir Mengen mit der Funktion
set
konstruieren:
Elemente können zu Mengen mit
conj
hinzugefügt und mit
disj
entfernt werden:
Um festzustellen, ob ein Wert Element einer Menge ist, können wir das Mengenobjekt wie eine Funktion behandeltn:
Etwas lesbarer ist es aber vielleicht, die Funktion
contains?
zu
verwenden:
Maps
Schließlich gibt es in Clojure noch Maps, also Tabellen aus Schlüsseln
und Werten. Literale werden mit {...}
umschlossen, innen wechseln
sich Schlüssel und Werte ab. (Manche Clojure-Programmierer schreiben
noch ein Komma zwischen Schlüssel-/Wert-Paare.)
Maps können mit assoc
erweitert und mit
dissoc
verkleinert
werden:
Um an den Wert zu einem Schlüssel zu kommen, können Maps - wie Mengen
- als Funktion verwendet werden:
Verwirrenderweise können Schlüsselwörter auch als Zugriffsfunktion auf Maps dienen:
Auch hier ist es unter Umständen lesbarer, auf die explizite
get
-Funktion zurückzugreifen:
So weit, so gut …
Das war also der Turbo-Streifzug durch die eingebauten Clojure-Datenstrukturen. Ich hoffe, er war erhellend. Den nächsten Teil der Clojure-Einführung gibt es bald auf diesem Blog.