Haskell für Einsteiger, Teil 3
Mit dem heutigen Blogartikel möchte ich die Serie
„Haskell für Einsteiger“ fortsetzen.
Die Serie richtet sich an Leser mit Programmiererfahrung, die
Lust auf Haskell haben, bisher aber den Einstieg in die Sprache nicht richtig
geschafft haben. Im ersten Teil ging
es um eine abgespeckte Variante des Unix-Tools tail
und im
zweiten Teil haben wir ein
Programm zur Analyse von Textdateien mit verschiedenen Encodings
geschrieben. Heute werden wir
einen Pretty-Printer für JSON schreiben und
dabei Datentypen sowie eine Bibliothek zur JSON-Verarbeitung und zum
Formatieren von Texten kennenlernen.
Um loszulegen benötigen Sie lediglich eine Installation der Haskell Platform, sowohl die neue Version 2014.2.0.0 als auch die vorige Version 2013.2.0.0 wird unterstützt. Außerdem brauchen Sie ein Checkout des git-Repositories zu dieser Artikelserie.
In diesem Posting versuche ich, die Funktionsweise des Codes möglichst verständlich zu erläutern. Allerdings würde es den Rahmen dieses Blogs sprengen, auf jedes Detail einzugehen. Hierzu sei das Studium des einen oder anderen Haskell-Tutorials oder -Buchs empfohlen. Natürlich können Sie Rückfragen auch als Kommentar zu diesem Artikel stellen.
Im heutigen Artikel implementieren wir ein Tool names jsonpp
. Dieses
Programm liest JSON-Daten ein und gibt sie schön formatiert auf dem
Bildschirm aus. Hier ein Beispiel für eine Eingabe von jsonpp
:
// Datei sample.json
[{"name": {"first": "Stefan", "last": "Wehr"}, "age": 36}, {"name": "Max", "age": 23}]
Die Ausgabe sieht für sample.json
wie folgt aus:
$ jsonpp sample.json
[{"age": 36.0,
"name":
{"first": "Stefan",
"last": "Wehr"}},
{"age": 23.0,
"name": "Max"}]
Damit kann man in einer typischen Unix-Pipeline JSON-Daten viel besser
mittels grep
, sed
und Co bearbeiten. Als zusätzliches Feature möchten
wir noch einbauen, dass man die JSON-Daten mittels eines Pfadausdruck
filtern kann. Wenn wir z.B. als Pfadausdruck einfach name
wählen, werden
nur die Daten unterhalb von name
ausgegeben:
$ jsonpp --filter name test/sample.json
[{"first": "Stefan",
"last": "Wehr"},
"Max"]
Wir können auch kompliziertere Pfadausdrücke angeben:
$ jsonpp --filter name.first test/sample.json
["Stefan"]
So, jetzt wissen wir, was jsonpp
machen soll und können mit der
Implementierung beginnen. Wir starten mit 2
Pragmas.
Das erste sorgt dafür, dass der GHC vor
dem Kompilieren den htfpp
-Präprozessor über den Code laufen
lässt. Der Präprozessor gehört zum
HTF Paket und sorgt dafür, dass Testfälle
automatisch aufgesammelt werden. Wir haben HTF auch schon in einem
vorigen Artikel kennengelernt.
Mehr zum Testen aber später.
Das zweite Pragma schaltet die Spracherweiterung OverloadedStrings
ein. Um zu verstehen, warum diese Spracherweiterung sinnvoll ist, muss man
wissen, dass der String
-Typ in Haskell ein Synonym für [Char]
ist, d.h.
ein String in Haskell ist einfach als verkettete Liste von Characters
implementiert. Dies hat den Vorteil, dass alle Funktionen auf Listen auch
direkt auf Strings funktionieren (und es gibt in der
Standardbibliothek
sehr viele solcher Funktionen). Der Nachteil ist aber, dass daduch die
Speicherrepräsentation von Strings nicht sonderlich effizient ist. Daher
gibt es eine Reihen von Bibliotheken, die alternative String-Typen zur
Verfügung stellen. Die bekannteste Bibliothek ist
Data.Text,
welche den Typ Text
bereitstellt und Strings als Array von UTF-16 kodierten Zeichen repräsentiert. Die
Spracherweiterung OverloadedStrings
sorgt nun dafür, dass wir normale
Stringliterale auch an Stellen verwenden können, in denen ein solcher alternativer
String-Typ erwartet wird.
Als Nächstes folgen eine Reihen von Imports:
Einige der Imports sind qualifiziert (Schlüsselwort qualified
), d.h. Funktionen und Typen aus diesen Modulen
können nur durch Voranstellen des Modulnamens gefolgt von einem Punkt verwendet werden. Da die Modulnamen
relativ lang sind, geben wir mittels dem as
Teil noch ein Kürzel für den Modulnamen an. So definiert
Data.Vector
z.B. eine Funktion fromList
. Um diese in unserem Programm verwenden zu können, müssen
wir jetzt V.fromList
schreiben.
Wenn Sie selbst ein Haskell Programm schreiben, aber nicht wissen welche Funktionen in welchen Modulen zu finden sind, gibt es mindestens drei Möglichkeiten, dies herauszufinden:
- Sie benutzen hoogle oder hayoo, zwei Suchmaschine für Haskell-API-Dokumentation.
- Sie studieren die Übersicht der gängigen Haskell Module.
- Sie schauen sich auf hackage um.
Jetzt geht‘s richtig los. Wir widmen uns zunächst dem Filtern von
JSON-Daten. Wir haben oben gesehen, dass wir Pfadausdrücke der Form
name.first
angeben können, um bestimmte Teile der JSON-Daten zu
selektieren. Ein Pfadausdruck ist also eine Liste von Propertynamen:
Die eigentliche Funktion filterJson
zum Filtern nimmt einen solchen Pfadausdruck
und eine Repräsentation der JSON-Daten.
Um JSON-Daten zu repräsentieren,
zu parsen und zu serialisieren, benutzen wir die Bibliothek
aeson. Dort ist der
zentrale Datentyp
Value
definiert. Wir sehen den Value
-Typ sowohl im Argument- als auch im
Ergebnistyp von filterJson
. Im Ergebnis ist Value
noch in ein
Maybe
eingepackt. Der Maybe
-Typ kennt zwei Alternativen: Just x
, in diesem
Fall hat das Filtern das Ergebnis x
produziert. Die andere Alternative
ist Nothing
, dann hat das Filtern zu keinem Ergebnis geführt.
Der Value
-Datentyp erlaubt eine Fallunterscheidung über die
Art der JSON-Daten. Die Funktion filterJson
macht von dieser
Fallunterscheidung gebrauch, nämlich dann wenn der vorliegende
Pfadausdruck mit einem Propertynamen p
beginnt.
-
Liegt ein JSON-Objekt vor (also etwas von der Form
{"key1": value1, "key2": value2}
), dann landen wir im FallJ.Object m
, wobeim
eine Hash-Map mit den Properties ist. In diesem Fall schlagen wirp
in der Hash-Map nach. Fallsp
drin ist, machen wir mit dem restlichen Pfadausdruck und dem unterp
gespeicherten Wert weiter. Anderenfalls liefern wirNothing
als Ergebnis zurück. -
Liegt eine JSON-Array vor (also sowas wie
[1, "foobar", {"name": "Stefan"}]
), dann filtern wir mittels des kompletten Pfadausdrucks die Elemente des Arrays. DasmapMaybe
sorgt hier dafür, dass nur erfolgreich gefilterte JSON-Werte im Ergebnis landen. Interessant ist auch, wie hiermapMaybe
verwendet wird. Der Typ vonmapMaybe
ist(a -> Maybe b) -> [a] -> [b]
. Wir müssen also eine Funktion mit Typa -> Maybe b
und eine Liste vona
s übergeben, um eine Liste vonb
s zu erhalten. Das erste Argument vonmapMaybe
istfilterJson path
. Das mag zuerst mal ungewohnt erscheinen, dennfilterJson
nimmt ja eigentlich zwei Argumente. Allerdings unterstützt Haskell Currying, was bedeutet dass wirfilterJson
auch partiell anwenden können. Wenn wir nun ein statt zwei Argumente anfilterJson
übergeben, erhalten wir eine Funktion mit TypJ.Value -> Maybe J.Value
zurück. Diese Funktion übergeben wir dann als erstes Argument anmapMaybe
. -
In allen anderen Fällen liefert
filterJson
für einen nicht-leeren PfadausdruckNothing
zurück, als kein Ergebnis. -
Für einen leeren Pfadausdruck ist das Ergebnis die unveränderte Eingabe (eingepackt in ein
Just
).
So, jetzt können wir direkt mit dem Pretty-Printing weitermachen. Auch
hier verwenden wir eine Fallunterscheidung über die Form der JSON-Daten,
dieses Mal lernen wir aber nicht nur die Fälle Object
und Array
sondern auch die übrigen Fälle String
, Number
, Bool
und Null
kennen. Der Rückgabewert von prettyJson
ist Doc
. Dieser Typ
für Ausgabedokumente stammt aus der Library
pretty,
welche effiziente Operationen zum Layouten von textuellen Ausgaben
bereitstellt. Schauen wir uns nun prettyJson
an.
-
Bei einem JSON-Objekt formatieren wir zunächst die einzelnen Schlüssel-Wert-Paare mittels
prettyKv
. In dieser lokal definierten Funktion benutzen wir verschiedene Operatoren aus der pretty-Bibliothek:$$
hängt zweiDoc
s durch ein Newline getrennt aneinander.<+>
hängt zweiDoc
s durch ein Space getrennt aneinander.<>
hängt zweiDoc
s ohne Trennzeichen einander.
Interessant ist auch
nest
, was aus einem Dokument durch Vergrößern der Einrückungstiefe ein neues Dokument macht.Die Listen der formatierten Schlüssel-Wert-Paare (Variable
elem
) hat nun den Typ[Doc]
. Um daraus einDoc
zu machen, fügen wir zuerst mittelspuncuate
Kommata zwischen die einzelnenDoc
s und benutzten dannvcat
um dieDoc
s durch Newlines getrennt einanderzufügen. Zum Schluss packen wir das ganze mittelsbraces
noch in geschweifte Klammern{ }
ein. -
Die Formatierung eines JSON-Arrays erfolgt nach denselben Prinzipien.
-
Ein JSON-String wird mittels show formatiert, was in diesem Fall auch die Anführungszeichen und das Escaping umfasst.
-
Die restlichen Fälle sind einfach.
Die Hauptarbeit ist getan, wir brauchen aber noch eine Funktion, die eine einzelne Datei verarbeitet:
Falls der Dateiname -
ist, verwenden wir direkt das
Handle
für stdin
, ansonsten kümmert sich
withFile
um das Öffnen der Datei zu einem Handle
(und natürlich auch um das
Schließen). In der lokalen Methode action
lesen wir erst den Inhalt der
Datei mittels
hGetContents
,
parsen dann die JSON-Daten mit
decodeStrict
,
wenden dann filterJson
an und drucken schließlich den mittels
prettyJson
erzeugten String.
Jetzt bleibt uns noch die eigentliche main
-Funktion:
In der main
-Funktionen sehen wir auch, dass wir bei Angabe der
Kommandozeilenoption --test
unsere noch nicht vorhandenen Tests laufen
lassen. In der Variable htf_thisModulesTests
sammelt der Präprozessor
des HTF Pakets alle im Modul
definierten Tests. Die Namen solcher Tests müssen mit test_
beginnen,
damit sie automatisch gefunden werden. Hier sind die Definitionen der
Testfälle:
Wenn wir nun jsonpp
mit der Option --test
aufrufen, bekommen wir
folgende Ausgabe:
$ jsonpp --test
[TEST] Main:filterJson (src/JsonPretty.hs:111)
+++ OK (2ms)
[TEST] Main:prettyJson (src/JsonPretty.hs:130)
+++ OK (0ms)
* Tests: 2
* Passed: 2
* Pending: 0
* Failures: 0
* Errors: 0
* Timed out: 0
* Filtered: 0
Total execution time: 6ms
So, das war‘s für heute. Ich hoffe, das Lesen hat Ihnen Spaß gemacht. Ich freue mich über Feedback jeglicher Art!