Um ein (verschachteltes) Objekt komfortabel untersuchen zu können, wird eine
gute Darstellung desselben in Form von Text benötigt. Pretty-Printer versuchen
genau das: Dinge so auf den Bildschirm zu drucken, dass die interne Struktur auf
einen Blick ersichtlich ist. Wir schauen uns heute den Pretty-Printer aus
Philip Wadlers Paper „A prettier
printer“
(1997) an, dessen Besonderheit die Anwedung von Algebra auf Design und
Implementierung ist. Daran werden wir ein weiteres Mal sehen, dass
Datenmodellierung und Abstraktion der funktionalen Programmierung zu wunderbar
einfachem und prägnantem Quellcode führen, der komplexe Domänen elegant
beschreibt.
*Hinweis: Der komplette Code des Pretty-Printers ist auf
Github
zu finden. Viel Spaß beim Ausprobieren!
The Good, the Bad and the Ugly
Wir werden im Folgenden LISP-Ausdrücke betrachten. Bei ihnen ist es von
besonderer Wichtigkeit, die interne Struktur ersichtlich zu machen und damit den
Sinn von guten Pretty-Printern zu motivieren. Hier ein gelungenes, ein nicht
ganz gelungenes und ein ganz und gar nicht gelungenes (Pretty-)Printing der
Fakultätsfunktion:
(DEFUN FACTORIAL (X)
(COND ((= X 0) 1)
((* (FACTORIAL (- X 1))
X))))
(DEFUN
FACTORIAL
(X)
(COND
((= X 0) 1)
((* (FACTORIAL
(- X 1))
X))))
(DEFUN FACTORIAL (X) (COND ((=
X 0) 1) ((* (FACTORIAL (- X 1)
) X))))
Fangen wir unten an: Es wurde kein Pretty-Printer verwendet, sondern der Code
einfach auf die verfügbare Breite von 30 Zeichen gedruckt. Sehr ungeschickt ist,
dass wegen fehlender Einrückung nicht ersichtlich ist, welcher Ausdruck Teil
eines anderen Ausdrucks ist. Im zweiten Beispiel ist dies schon wesentlich
besser, da verschachtelte Ausdrücke jeweils um ein Zeichen eingerückt sind.
Zudem erzeugen die Zeilenumbrüche noch mehr Struktur. Hier ist jedoch zu
bemängeln, dass zu „aggressiv“ umgebrochen wird. Das hat schlechtere Lesbarkeit
und mehr Zeilen im Pretty-Print zur Folge. Das erste Beispiel lässt den Kopf und
Rumpf einer Funktionsdefinition besonders gut erkennen.
Als weiteres Beispiel XML-Code in drei geprinteten Varianten (dieses Mal mit 30
Zeichen Maximalbreite):
<p
color="red" font="Times"
size="10"
>
Here is some
<em> emphasized </em> text.
Here is a
<a href="http://ww.eg.com" >
link
</a>
elsewhere
</p>
<p
color=
"red"
font=
"Times"
size=
"10"
>
Here is some
<em>
emphasized
</em>
text. Here is a
<a
href="http://www.eg.com/"
>
link
</a>
elsewhere.
</p>
<p color="red" font="Times"
size="10" > Here is some <em>
emphasized </em> text. Here is
a <a href="http://www.eg.com/"
> link </a> elsewhere. </p>
Das erste Beispiel zeigt wieder den Idealfall: Der gesamte em-Ausdruck wird in
eine Zeile in den Fließtext eingebettet. Es ist kein Umbruch nötig, da die
Maximalbreite nicht überschritten wird. Die Attribute des p-Elements werden
zunächst nebeneinander, so lang es Platz hat, und dann untereinander sortiert.
Hier wird also nicht nur stur ein „Vorgehen“ befolgt („breche pro
Attribut-Wert-Paar um“), sondern der vorhandene Platz sinnvoll ausgenutzt. Die
beiden anderen Beispiele sind aus obigen Gründen keine gelungenen Layouts.
Obwohl die Beispiele aus zwei unterschiedlichen Domänen kommen
(Programmiersprache Common Lisp und Auszeichnungssprache XML), können beide mit
dem hier vorgestellten Pretty-Printer behandelt werden. Das wird dadurch
ermöglicht, dass eine Metasprache für Dokumente, die schön ausgedruckt werden
sollen, entwickelt wird. Dann erst wird für den Anwendungsfall bestimmt, wie man
Objekte in diese Metasprache übersetzt. Die Vorgehensweise, eine beschreibende
Zwischensprache zu entwerfen – eine sogenannte domänenspezifische
Sprache (domain specific language im Englischen, kurz DSL), ist in der
funktionalen Programmierung gang und gäbe. Im Blogpost
Systematisch eingebettete DSLs entwickeln in Clojure
werden DSLs ausführlich beschrieben.
Strategie des Prettier Printers
Der Pretty-Printer nimmt eine Beschreibung eines Dokuments entgegen und erzeugt
daraus viele Layouts. Diese unterscheiden sich im Format bzgl. Umbrüchen und
Einrückungen. Dann wählt der Pretty-Printer aus dieser Menge von Layouts das
beste aus. Das beste hierbei ist das, das jede Zeile möglichst gut ausnutzt,
aber dennoch die vorgegebene Breite nicht überschreitet.
In diesem Blogpost wollen wir unser Augenmerk auf die Verwendung der
Pretty-Printer-DSL setzen. Wie die eigentliche DSL implementiert ist – besonders
in Hinblick auf Effizienz (es könnten einige Tausend bis Millionen Layouts
entstehen) – sehen wir in einem folgenden Blogpost.
Die Pretty-Printer-Dokumentensprache
Unsere Pretty-Printer-DSL besteht aus Dokumenten und Kombinatoren, das heißt
Operatoren, die auf diesen Dokumenten arbeiten. Wir benutzen ab jetzt
Haskell-Syntax, um die Funktionssignaturen und Typen zu beschreiben.
Sechs Operatoren für das Glück
Folgende fünf Operatoren stehen zur Verfügung, um ein Dokument in unserer
Pretty-Printer-Sprache zu beschreiben:
(<>) :: Doc -> Doc -> Doc
nil :: Doc
text :: String -> Doc
line :: Doc
nest :: Int -> Doc -> Doc
Dabei fügt <> zwei Dokumente aneinander (man sagt auch es „konkateniert“ sie).
Das Resultat ist wieder ein Dokument. nil ist das sogenannte leere Dokument.
Beliebige Dokumente bleiben bei Konkatenation mit nil unverändert (etwas
mathematischer ausgedrückt bildet also die Menge aller Dokumente zusammen mit
der assoziativen Verknüpfung <> und dem neutralen Element nil ein Monoid).
text wandelt eine Zeichenkette in ein Dokument um, line beschreibt einen
Zeilenumbruch und nest rückt ein Dokument ein.
Hinzu kommt ein sechster Operator, layout, der ein als String gerendertes
Dokument zurückgibt:
An den fünf Operatoren und layout ist die in der funktionalen Programmierung
häufig vorkommende Trennung von Beschreibung und Ausführung wunderbar
ersichtlich.
Das folgende, mit den obigen Operatoren beschriebene Dokument
doc =
text "<p>" <>
nest 2 (
line <> text "<a>" <> text "This is a link" <> text "</a>" <>
line <> text "Some text"
) <>
line <> text "</p>"
wird mit Hilfe von layout so ausgedruckt:
<p>
<a>This is a link</a>
Some text
</p>
Erst die Masse, dann die Klasse
Oben war die Rede von mehreren Layouts, aus denen das beste ausgewählt wird.
Hier ist bisher nichts davon zu sehen. Die Sprache benötigt dafür noch eine
Erweiterung des Begriffs „Dokument“ und eine weitere Operation: Wenn ab jetzt
„Dokument“ gesagt wird, meint dies eine Sammlung mehrerer möglicher Layouts
(desselben Dokuments). Diese müssen jedoch von derselben Struktur sein und
dürfen sich nur in Zeilenumbrüchen und Einrückungen unterscheiden. Doch wie
kommen wir überhaupt zu verschiedenen Variationen eines Layouts? group ist
hier der Schlüssel:
group nimmt ein Dokument (Sammlung mehrerer Layouts) und fügt dieser Sammlung
ein neues Layout hinzu, in welchem jeder Zeilenumbruch durch ein Leerzeichen
ersetzt wird. Wenn wir also group auf unser oben definiertes Dokument doc
anwenden, käme das folgende Layout hinzu (nach Anwendung von layout):
<p> <a>This is a link</a> Some text </p>
Unser Pretty-Printer stellt zusätzlich die Funktion pretty bereit:
pretty :: Int -> Doc -> Doc
Diese wählt aus der übergebenen Sammlung von Layouts das beste für die ebenfalls
übergebene Maximalbreite aus. Hier zur Verdeutlichung obiges Dokument im
Zusammenspiel mit pretty und verschiedenen Maximalbreiten:
twoLayouts = group doc
layout $ pretty 30 twoLayouts
-- -->
-- <p>
-- <a>This is a link</a>
-- Some text
-- </p>
layout $ pretty 60 twoLayouts
-- -->
-- <p> <a>This is a link</a> Some text </p>
Das einzeilige Layout ist für obigen Aufruf zu lang. Im unteren Aufruf
unterschreiten beide Layouts die geforderte Maximalbreite. Somit wird hier das
einzeilige gewählt, da dieses die (erste) Zeile größtmöglich ausnutzt.
XML aufgehübscht!
Zum Schluss des Blogposts wollen wir uns noch anschauen, wie solch eine
Übersetzung von Domänencode, hier im speziellen XML-Code, in die
Pretty-Printer-DSL aussehen könnte. Wenn wir tatsächlichen XML-Code benutzen
wollten, müssten wir diesen zunächst parsen, um ihn in einer für uns
handhabbaren Form zu haben. Wir nehmen der Einfachheit halber an, dies sei
bereits geschehen und die Repräsentation von XML-Code sähe wie folgt aus:
data XML = Element String [Attribute] [XML]
| Txt String
data Attribute = Attribute String String
Ein XML-Element ist ein gemischter Datentyp. Entweder es ist ein tatsächliches
Element, das aus einer Zeichenkette (das ist der Name des Elements), einer Liste
an Attributen und einer Liste an weiteren XML-Elementen besteht. Oder es ist nur
eine einfache Zeichenkette. Ein Attribut besteht aus zwei Zeichenketten: dem
Namen des Attributs und dessen Wert. Die Repräsentation von <p color="red">
Hallo </p> ist demnach:
Element "p" [Attribute "color" "\"red\""] [Txt "Hallo"]
Fangen wir mit den Attributen an. Ein Attributpaar soll immer in eine Zeile
gedruckt werden:
attToDOC :: Attribute -> DOC
attToDOC (Attribute key value) = text key <> text "=\"" <> text value <> text "\""
Ein Tag wollen wir so drucken lassen, dass es nur dann auf eine Zeile gedruckt
wird, falls alle Attribute auf diese passen. Ansonsten soll nach dem Tag-Namen
umgebrochen und um zwei Zeichen eingerückt werden. Zudem soll dann auch nach
jedem Attributpaar umgebrochen werden:
tagToDOC :: String -> [Attribute] -> DOC
tagToDOC name [] = text "<" <> text name <> text ">"
tagToDOC name attributes = group
(text "<" <> text name <>
(nest 2 (line <> concatDOCs (map attToDOC attributes))) <>
line <> text ">")
group um den gesamten Ausdruck macht die Wahl zwischen „alles auf eine Zeile“
vs „Umbrechen nach jedem Attributpaar“ möglich, da group, wie oben
beschrieben, den Layouts ein weiteres hinzufügt, bei welchem alle
Zeichenumbrüche entfernt sind. concatDOCs nimmt eine Liste von Dokumenten und
fügt diese mit einem Zeilenumbruch dazwischen zu einem Dokument zusammen:
concatDOCs :: [DOC] -> DOC
concatDOCs [] = nil
concatDOCs [x] = x
concatDOCs (x:xs) = x </> concatDOCs xs
</> ist eine Hilfsfunktion, die zwei Dokumente mit einem line-Dokument
dazwischen konkateniert. Jetzt nehmen wir obige Funktionen zusammen, um ein
ganzes Element auszudrucken:
xmlToDOC :: XML -> DOC
xmlToDOC (Txt string) = text string
xmlToDOC (Element name attributes xmls) = group
(tagToDOC name attributes </>
nest 2 (group line <>
concatDOCs (map xmlToDOC xmls)) </>
text "</" <> text name <> text ">")
Im einfachen Textfall erzeugen wir nur ein TEXT-Dokument. Wenn wir ein Element
vorliegen haben, lassen wir das Tag drucken, dann ggf. umbrechen und um zwei
Zeichen einrücken, um dann die Kinder-Elemente entsprechend rekursiv drucken zu
lassen.
Das Dokument
xml = Element "p" [
Attribute "color" "red",
Attribute "font" "Times",
Attribute "size" "10"
] [
Txt "Here is some",
Element "em" [] [Txt "emphasized"],
Txt "Text. Here is a",
Element "a" [Attribute "href" "http://example.org"]
[Txt "link"],
Txt "elsewhere."
]
wird mit layout $ pretty 30 xml bzw. layout $ pretty 60 xml so ausgedruckt:
<p
color="red"
font="Times"
size="10"
>
Here is some
<em> emphasized </em>
text. Here is a
<a
href="http://example.org"
>
link
</a>
elsewhere.
</p>
<p color="red" font="Times" size="10" >
Here is some
<em> emphasized </em>
text. Here is a
<a href="http://example.org" > link </a>
elsewhere.
</p>
Wie wir sehen, kommt diese Implementierung noch nicht ganz an die Beispiele von
oben heran: Dort wurden mehrere Attributpaare (wenn möglich) sogar auf eine
Zeile gedruckt. Zudem gibt es unschöne Leerzeichen an gewissen Stellen. Wie man
das elegant verbessern kann, werden wir im nächsten Blogpost sehen, wenn wir uns
dort Hilfs- und Komfortfunktionen für unseren Pretty-Printer schreiben.
Fazit
Der heutige Blogpost zeigt, dass es nur wenige Operatoren braucht, um eine
mächtige Sprache zu entwickeln. Das besonders Schöne an der Sprache ist, dass,
wie so oft in der funktionalen Programmierung, kleinere Dinge zusammengefügt
werden zu größeren. In einem nächsten Blogpost schauen wir uns an, wie der
Pretty-Printer genau implementiert ist und optimieren unser XML-Beispiel.