Clojure ist eine dynamische Programmiersprache, was unter anderem bedeutet, dass auch die Datentypen erst dynamisch zur Laufzeit feststehen. Clojure kennt zwar sogenannte type hints, die sollen aber dem Compiler helfen, effizienteren Code zu generien und sind nicht dafür da, Typeigenschaften von Datenstrukturen zu erzwingen. Die clojure-spec-Bibliothek füllt diese Lücke: Damit ist es möglich, die Struktur der Daten als Programmcode zu spezifizieren und so die zur Laufzeit vorhandenen Daten zu validieren. Das führt zu weniger unerwartetem Verhalten während der Programmlaufzeit und im Fehlerfall zu besseren Fehlermeldungen. Zusätzlich ist sogar möglich, Daten aus der Spezifikation zu generieren, zum Beispiel für randomisiertes Testen.

Dieser Artikel gibt eine Einführung in clojure.spec.

Mitmachen

Über die Programmiersprache Clojure haben wir in diesem Blog bereits viel geschrieben, zum Beispiel finden Sie den ersten Teil einer mehrteiligen Einführung hier.

Um die Beispiele in diesem Artikel nachzuvollziehen, reicht eine Clojure-REPL mit einer Clojure-Version, in der clojure.spec bereits enthalten ist, also 1.9.0-alpha10 oder neuer. Das kriegen Sie mit einer neuen Version der Clojure-IDE Nightcode oder mit Leiningen und einer Datei project.clj mit folgendem Inhalt:

(defproject specplay "0.1.0-SNAPSHOT"
:dependencies [[org.clojure/clojure "1.9.0-alpha10"]])

Dann können Sie aus dem Verzeichnis, in dem die project.clj-Datei liegt, die Clojure-REPL mit lein repl starten.

Um mit clojure.spec loszulegen, müssen Sie die Bibliothek einbinden:

(require '[clojure.spec :as s])

Spezifikationen

Jede Spezifikation beschreibt die Menge von erlaubten Werten. Eine Spezifikation ist zum Beispiel das Prädikat odd?, das bestimmt, ob ein Wert ungerade ist. Ob ein Wert auf eine Spezifikation passt, sagt s/valid?:

(s/valid? odd? 23)
;; => true

s/valid? akzeptiert eine Spezifikation und einen Wert. s/valid? gibt zurück, ob der Wert mit der Spezifikation übereinstimmt.

(s/valid? odd? 42)
;; => false

Als Spezifikationen können alle Funktionen verwendet werden, die einen Wahrheitswert zurückgeben, insbesondere alle Konstrukte, die es bereits in Clojure gibt, wie zum Beispiel eingebaute Prädikate, anonyme Funktionen, Funktionalität aus importierten Java-Bibliotheken und Mengenprädikate:

(s/valid? string? "Hello, World!")
;; => true
(s/valid? #(> % 5) 10)
;; => true
(s/valid? #(> % 5) 0)
;; => false
(import java.util.Date)
(s/valid? inst? (Date.))
;; => true
(s/valid? #{"Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"} "Tue")
;; => true
(s/valid? #{"Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"} 23)
;; => false

Der Ausdruck #(> % 5) im vorigen Beispiel ist eine kürzere Notation für anonyme Funktionen, hier mit einem Parameter, der mit % referenziert wird. #{...} ist eine Notation für Mengen.

Namen geben

Spezifikationen können mit s/def auch an globale Namen gebunden werden, um sie einfach wiederverwenden zu können und auch um bessere Fehlermeldungen zu erhalten. Um konfliktfreie Spezifikationen über mehrere Bibliotheken und Namespaces zu verwenden, sollten die Namen der Spezifikation namespaced keywords sein, also qualifizierte Schlüsselwörter mit Namespace-Präfix. In Clojure erzeugt man qualifizierte Schlüsselworter mit zwei Doppelpunkten:

(s/def ::day-of-week #{"Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"})

Das obige Beispiel bindet die Spezifikation in Form eines Mengenprädikats an das qualifizierte Schlüsselwort ::day-of-week. Die benannte Spezifikation kann dann so benutzt werden:

(s/valid? ::day-of-week "Tue")
;; => true

Zusammengesetzte Daten validieren

Auch für zusammengesetzte Datentypen können wir Spezifikationen schreiben, hier am Beispiel mit Gürteltieren und Papageien aus einem früheren Blogeintrag:

(defrecord Dillo
[weight alive?])

(defrecord Parrot
[weight sentence])

Ein Gürteltier hat ein Gewicht und ist lebendig oder tod, als Spezifikation wie folgt ausgedrückt:

(s/def ::weight number?)

(s/def ::alive? boolean?)

(s/def ::dillo
(s/and
#(instance? Dillo %)
(s/keys :req-un [::weight ::alive?])))

Um Records zu spezifizieren, nutzen wir, dass Spezifikationen komponierbar sind, sie können also nach belieben zusammengesetzt werden. Ein Record ist gültig, wenn der Typ des Records stimmt und wenn die Bestandteile des Records den entsprechenden Spezifikationen entsprechen. Da beide Vorgaben gelten müssen, verknüpfen wir beide Bedingungen mit s/and. Die erste Bedingung überprüfen wir mit einer anonymen Funktion, die die eingebaute Funktion instance? nutzt. Für die zweite Bedingungen nutzen wir aus, dass Clojure-Records auch als Map funktionieren (siehe unsere Einführung in Datentypen). Die obige Spezifikation für Gürteltiere ist also tatsächlich eine Spezifikation für eine Map, in der die Schlüsselwörter ::weight und ::alive? als unqualifizierte Schlüssel enthalten sein müssen. Das Argument :req-un für s/keys legt fest, dass die Spezifikation unqualifizerte Schlüssel akzeptieren soll, da Clojure Records mit unqualifizierten Schlüsselwörtern implementiert (Gegenstück wäre das Argument :req, das qualifizerte Schlüssel erwartet, die von clojure.spec grundsätzlich empfohlen werden, um Namenskonflikte zu vermeiden — die Clojure-Records passen aber nicht dazu).

Damit können wir Gürteltiere validieren:

(s/valid? ::dillo (->Dillo 23 true))
;; => true
(s/valid? ::dillo "Gürteltier")
;; => false
(s/valid? ::dillo (->Dillo :male true))
;; => false

Da die Datenstrukturen jetzt schon komplizierter sind, ist es auf den ersten Blick gar nicht mehr so einfach, herauszufinden, warum die letzte Validierung fehl schlägt. clojure.spec kann uns bei der Fehlersuche helfen:

(s/explain ::dillo (->Dillo :male true))
;; => In: [:weight] val: :male fails spec: :user/weight at: [:weight] predicate: number?

Die Funktion s/explain liefert eine hilfreiche Fehlerbeschreibung, aus der sofort klar wird, dass :male keine Zahl ist, die an der Stelle eigentlich erwartet wird.

Gemischte Daten validieren

Wir können auch gemischte Daten spezifizeren, indem wir die Spezifikationen anders zusammensetzen. Um Tiere zu repräsentieren, die entweder ein Gürteltier oder ein Papagei sind, liefern wir zunächst die Spezifikation für Papageien nach, die ein Gewicht haben und einen Satz sagen können:

(s/def ::sentence string?)

(s/def ::parrot
(s/and
#(instance? Parrot %)
(s/keys :req-un [::weight ::sentence])))

Um die Spezifikation für Tiere zu erhalten, können wir die Spezifikationen von Gürteltieren und Papageien mit s/or verknüpfen:

(s/def ::animal
(s/or :dillo ::dillo :parrot ::parrot))

Die s/or-Spezifikation erfordert eine Auswahl beim validieren. Jede Möglichkeit wird mit einem Namen versehen um den Zweigen Namen zu geben, hier :dillo und :parrot, damit im Fehlerfall die Fehlerbeschreibung zielgerichtet helfen kann.

(s/valid? ::animal (->Parrot 5 "I like cookies"))
;; => true
(s/valid? ::animal (->Parrot 5 true))
;; => false
(s/explain ::animal (->Parrot 5 true))
;; val: #user.Parrot{:weight 5, :sentence true} fails spec: :user/dillo at: [:dillo] predicate: (instance? user.Dillo %)
;; In: [:sentence] val: true fails spec: :user/sentence at: [:parrot :sentence] predicate: string?

Der letze Aufruf erläutert, dass der Wert kein Tier ist, weil es kein Gürteltier ist und auch kein gültiger Papagei, da der Wert des Satzes keine Zeichenkette ist, wie in der Spezifikation gefordert. Diese detaillierten Fehlerbeschreibungen helfen weiter.

Nutzung zur Laufzeit

Wir haben inzwischen gesehen, wie Hilfreich clojure.spec beim interaktiven Entwickeln und Debuggen von Programmen sein kann. Sinnvoll ist aber auch, die Fähigkeiten von clojure.spec zur Laufzeit des Programms zu nutzen, um zur Laufzeit die Gültigkeit der Daten zu überprüfen. Dazu kann man zum Beispiel Prädikate mit Hilfe des bereits bekannten s/valid? implementieren:

(defn animal?
[thing]
(s/valid? ::animal thing))

Eine weitere Möglichkeit ist, mit s/assert sicherzustellen, dass ein Wert eine Spezifikation erfüllt. s/assert akzeptiert wie s/valid? eine Spezifikation und einen Wert. Passt die Spezifikation, gibt s/assert den Wert zurück, ansonsten wirft es eine Exception:

(s/check-asserts true)
(s/assert ::animal "Dog")
;; => ExceptionInfo Spec assertion failed...

Das Überprüfen der Assertions ist standardmäßig deaktiviert, die Funktion s/check-asserts kann Assertions aktivieren.

Noch viel mehr…

In diesem Artikel haben wir nur einen kleinen Teil der Fähigkeiten von clojure.spec abgedeckt. clojure.spec kann noch viel mehr: mit Hilfe von Spezifikationen Eingaben parsen und destrukturieren, Collections und Funktionen spezifizieren; die Code-Dokumentation erleichtern und automatische Tests erzeugen. Mehr davon in einem späteren Artikel.