Material zur Vorlesung “Funktionale Programmierung”

Werbung
Material zur Vorlesung “Funktionale
Programmierung”
Priv.-Doz. Dr. Frank Huch
Dr. Sebastian Fischer
Björn Peemöller
Wintersemester 2011/2012
Inhaltsverzeichnis
1 Input / Output
1.1 do-Notation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2 Lazy IO . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.3 Programmieren mit IO . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2
3
5
6
2 Typkonstruktor-Klassen
8
3 Abschließende Bemerkungen zur MonadPlus Typklasse
11
3.1 Ein Kommentar zu den MonadPlus-Gesetzen . . . . . . . . . . . . . . . . 12
4 Zustandsmonaden
13
5 Ausgewählte Programmiertechniken
20
5.1 Differenzlisten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
5.2 Continuations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
5.3 Continuation-basierte Listen . . . . . . . . . . . . . . . . . . . . . . . . . . 32
6 Parserkombinatoren
6.1 Verwendung . . . . . .
6.2 Implementierung . . .
6.3 Monadische Parser . .
6.4 Applikative Funktoren
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
7 Automatisiertes Testen
7.1 Eigenschaftsbasiertes Testen .
7.2 Klassifikation der Testeingabe
7.3 Eingabe-Generatoren . . . . .
7.4 Implementierung . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
1
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
37
37
44
51
53
.
.
.
.
55
55
59
61
63
7.5
Quelltextüberdeckung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
8 Debugging
8.1 Debuggen mit Observationen (Hood) . . . . . .
8.2 Implementierung von Observationen für Daten
8.3 Observieren von Funktionen . . . . . . . . . . .
8.4 Andere Ansätze . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
69
71
72
75
77
9 Funktionale Datenstrukturen
9.1 Queues . . . . . . . . . .
9.2 Arrays . . . . . . . . . .
9.3 Array-Listen . . . . . .
9.4 Tries . . . . . . . . . . .
9.5 Verallgemeinerte Tries .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
79
80
84
87
100
107
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
10 Graphen
113
11 Generische Programmierung
117
1 Input / Output
Hello World in Haskell:
main :: IO ()
main = putStrLn "Hello World!"
Dieses Programm kann man mit runhaskell ausführen. Die main Funktion muss den
Typ IO () haben (dieser kann aber inferiert werden). Sie dient als Startpunkt zur
Ausführung des Programms.
bash# runhaskell helloworld.hs
Hello World!
putStrLn erzeugt eine IO-Aktion:
ghci> :t putStrLn
putStrLn :: String -> IO ()
Der Ergebnistyp IO () steht für eine IO-Aktion, die ein Ergebnis vom Typ () liefert,
wenn sie ausgeführt wird. IO-Aktionen werden ausgeführt, wenn sie Teil des Hauptprogramms (definiert durch main) sind (oder wenn sie in GHCi eingegeben werden).
getLine liest eine Zeile von der Standardeingabe:
2
ghci> :t getLine
getLine :: IO String
IO-Aktion, die einen String liefert, wenn sie ausgeführt wird.
1.1 do-Notation
Mehrere IO-Aktionen können mit do-Notation kombiniert werden.
main = do
putStrLn "Wie heißt Du?"
name <- getLine
putStrLn ("Hello " ++ name ++ "!")
Ausführen:
bash# runhaskell hello.hs
Wie heißt Du?
World
Hello World!
Der Linkspfeil holt das Ergebnis aus einer IO-Aktion heraus und bindet es an eine
Variable. name hat den Typ String und kann in reinen Funktionen (d.h. solchen ohne
IO Typ) verwendet werden.
Was ist das Ergebnis von getLine ++ getLine?
In einem do-Block können Variablen auch mit einer let-Anweisung gebunden werden.
Im Gegensatz zum let-Ausdruck hat die Anweisung kein in sondern die Bindungen sind
in den folgenden Anweisungen sichtbar:
main = do
let name = "World"
putStrLn ("Hello " ++ name ++ "!")
Man bindet Variablen in do-Blöcken mit let an Ergebnisse von reinen Funktionen und
mit dem Linkspfeil an Ergebnisse von IO-Aktionen. Wenn man eine Variable mit let an
eine IO-Aktion bindet, ist der Wert der Variablen die IO-Aktion selbst:
main = do
let gl = getLine
a <- gl
b <- gl
putStrLn (a ++ b)
3
Die IO-Aktion gl kann mehrfach ausgeführt werden und dabei unterschiedliche Ergebnisse liefern. Sie ist eine Abkürzung für die IO-Aktion getLine selbst, nicht für deren
Ergebnis.
IO-Aktionen können rekursiv definiert werden. Als Beispiel definieren wir unsere eigene
getLine Aktion:
getLine’ :: IO String
getLine’ = do
c <- getChar
if c == ’\n’ then
return ""
else do
cs <- getLine’
return (c:cs)
Die IO-Aktion getChar liefert ein Zeichen von der Standardeingabe. Wir vergleichen dieses Zeichen mit ’\n’ um zu entscheiden, ob wir weiterlesen müssen. In do-Blöcken können
wir if-then-else Ausdrücke verwenden, deren then und else Zweige IO-Aktionen
(vom gleichen Typ) sind.
return :: a -> IO a erzeugt aus einem beliebigen Wert eine IO-Aktion, die diesen
Wert zurück liefert. Wir verwenden return um den leeren String zu liefern, wenn das
’\n’-Zeichen gelesen wurde und um im rekursiven Fall die gesamte Zeile aus erstem
Zeichen c und restlicher Zeile cs zurück zu liefern.
return verhält sich anders als in imperativen Sprachen:
main = do
a <- return "a"
b <- return "b"
putStrLn (a++b)
return "c"
return ()
Es bricht die Ausführung eines do-Blocks nicht ab sondern verpackt das Argument lediglich in einer IO-Aktion ohne Seiteneffekt. Das obige Programm gibt ab aus und könnte
kürzer so geschrieben werden:
main = do
let a = "a"
b = "b"
putStrLn (a++b)
4
Da wir das Ergebnis der beiden ersten mit return erzeugten Aktionen sofort wieder mit
dem Linkspfeil heraus holen, können wir auch let verwenden. Die Ergebnisse der beiden
letzten Aktionen werden nicht verwendet. Wir können die Aktionen also weglassen (da
return keinen Seiteneffekt hat).
IO-Aktionen können auch (potentiell) unendlich lange laufen.
import Data.Char ( toUpper )
main = do
c <- getChar
putChar (toUpper c)
main
Dieses Programm liest immer wieder ein Zeichen von der Standardeingabe und gibt es
groß aus. Bei Eingabe von hello ergibt sich folgende Ausgabe:
bash# runhaskell echo-char.hs
hHeElLlLoO
1.2 Lazy IO
Man kann die Standardeingabe in Haskell auch lazy einlesen, d.h. erst wenn sie gebraucht
wird. Die IO-Aktion getContents :: IO String liefert die Standardeingabe als lazy
String.
main = do
s <- getContents
putStr (map toUpper s)
Dieses Programm liest genau wie das obige die Eingabe zeichenweise ein und gibt sie
groß wieder aus:
ghci> main
hHeElLlLoO
Obwohl mit map toUpper konzeptuell die gesamte Eingabe auf einmal verarbeitet wird,
verarbeitet das Programm die Eingabe zeichenweise: jedes Zeichen wird erst eingelesen,
wenn der entsprechende Großbuchstabe ausgegeben werden soll.
Die Pufferung der Eingabe wird davon beeinflusst, wie man das Programm ausführt. Im
GHCi ist die Pufferung standardmäßig zeichenweise, bei der Ausführung mit runhaskell
zeilenweise:
5
bash# runhaskell lazy-echo-char.hs
hello
HELLO
world
WORLD
Die Art der Pufferung kann man mit Funktionen aus dem System.IO Modul beeinflussen.
Das obige Programm verhält sich, als würde es in einer Schleife Zeilen einlesen, ist aber
im Gegensatz zum vorher gezeigten Programm nicht rekursiv definiert. Lazy IO wird
häufig für Programme verwendet, die die Benutzereingabe zeilenweise verarbeiten, da es
erlaubt solche Programme ohne Rekursion zu definieren.
Auch der Inhalt von Dateien wird in Haskell lazy eingelesen. Die Funktion readFile ::
String -> IO String erwartet als Parameter einen Dateinamen und liefert eine IOAktion, die den Dateiinhalt zurück gibt. Wie bei getContents wird die Datei erst gelesen, wenn der Inhalt von der Berechnung gebraucht wird. Die Funktion writeFile ::
String -> String -> IO () nimmt einen Dateinamen und einen String und liefert
eine IO-Aktion, die die angegebene Datei mit dem gegebenen String überschreibt. Zum
Anhängen eines Strings an eine bestehende Datei, kann man die Funktion appendFile
:: String -> String -> IO () verwenden.
Variante der Uppercase-Konvertierung mit Dateien:
main = do
s <- readFile "input.txt"
writeFile "output.txt" (map toUpper s)
Der Inhalt von input.txt wird erst beim Schreiben in output.txt gelesen. Obwohl
die map Funktion konzeptuell die komplette Eingabe konvertiert, ist weder die Eingabe
noch die Ausgabe jemals komplett im Speicher. Laziness ermöglicht die Verwendung von
Zwischenergebnissen, ohne dass diese komplett erzeugt werden.
1.3 Programmieren mit IO
Statt Haskell-Programme mit runhaskell auszuführen, kann man sie auch kompilieren.
Zum Beispiel können wir mit dem Kommando
bash# ghc --make helloworld
aus der Datei helloworld.hs die Datei helloworld erzeugen und diese dann ausführen.
bash# ./helloworld
Hello World!
6
Als etwas komplizierteres Beispiel schreiben wir ein Programm, das eine Zahl n vom
Benutzer einliest und die ersten n Fakultäten ausgibt:
import System ( getArgs )
main = do
a:_ <- getArgs
printFactorials (read a)
return ()
printFactorials :: Int -> IO Int
printFactorials 1 = do
print 1
return 1
printFactorials n = do
facNm1 <- printFactorials (n-1)
let facN = n * facNm1
print facN
return facN
Die IO-Aktion getArgs :: IO [String] liefert die Liste aller KommandozeilenParameter, deren erstes Element wir mit einem Pattern an die Variable a binden.
printFactorials berechnet die Fakultätsfunktion und gibt gleichzeitig alle Zwischenergebnisse aus.
Ein Nachteil dieser Implementierung ist die Verzahnung der Berechnung von Fakultäten
und deren Ausgabe. Besser ist es die Berechnung und die Ausgabe im Programm voneinander zu trennen:
main = do
a:_ <- getArgs
sequence $ map (print.factorial) [1..read a]
return ()
factorial :: Int -> Int
factorial n = product [1..n]
Dieses Programm berechnet die auszugebenden Fakultäten mit der Funktion factorial
ohne Seiteneffekte und gibt diese dann mit der print Funktion aus.
Die Funktion sequence :: [IO a] -> IO [a] nimmt eine Liste von IO-Aktionen als
Argument, die wir mit der map Funktion erzeugen. Das Ergebnis von sequence ist eine
IO-Aktion, die die gegebenen Aktionen der Reihe nach ausführt und die Ergebnisse der
7
Ausführungen in einer Liste zurück gibt. Wir ignorieren diese Ergebnisse und liefern
stattdessen () als Ergebnis von main.
Haskell-Programme sollten in der Regel dem Muster des zweiten Programms folgen und
• als erstes die Eingabe einlesen,
• dann mit einem rein funktionalen Programm ein Ergebnis berechnen und
• dieses dann ausgeben oder in eine Datei schreiben.
Dadurch wird der imperative Anteil eines Programms auf die Ein- und Ausgabe
beschränkt. Das eigentliche Programm bleibt seiteneffektfrei und dadurch einfacher
verständlich und besser wartbar.
Anders als in imperativen Programmiersprachen sind seiteneffektbehaftete Berechnungen in Haskell sogenannte Bürger erster Klasse. IO-Aktionen können, wie oben gesehen,
Argumente und Ergebnisse von Funktionen sein und in Datenstrukturen, zum Beispiel
in Listen, stecken ohne ausgeführt zu werden.
2 Typkonstruktor-Klassen
Bisher haben wir Klassen eingesetzt um Funktionen für unterschiedliche Typen zu
überladen. Diese Idee lässt sich auf Typkonstruktoren fortsetzen. Wir haben zum
Beispiel zwei map-Funktionen kennen gelernt. Eine für Listen:
map :: (a -> b) -> [a] -> [b]
map _ []
= []
map f (x:xs) = f x : map f xs
und eine für Bäume:
data Tree a = Empty | Node (Tree a) a (Tree a)
mapTree :: (a -> b) -> Tree a -> Tree b
mapTree _ Empty = Empty
mapTree f (Node l x r) =
Node (mapTree f l) (f x) (mapTree f r)
Einstellige Typkonstruktoren, für die man eine solche map-Funktion definieren kann,
heißen Funktoren:
class Functor f where
fmap :: (a -> b) -> f a -> f b
8
Die Variable f ist hier eine Typkonstruktor-Variable, d.h. sie abstrahiert von einstelligen
Typkonstruktoren.
Wir können nun folgende Functor-Instanzen angeben:
instance Functor [] where
fmap = map
instance Functor Tree where
fmap = mapTree
Auch für Maybe können wir eine Instanz angeben:
instance Functor Maybe where
fmap _ Nothing = Nothing
fmap f (Just x) = Just (f x)
Die Instanz für den Typkonstruktor Maybe wendet die gegebene Funktion auf den optionalen Wert an, wenn einer vorhanden ist.
Die Klasse Functor erlaubt es Funktionen wie die folgende zu schreiben, die man sowohl
auf Listen als auch auf Bäumen oder Maybe-Werten oder beliebigen anderen FunctorInstanzen anwenden kann:
inc :: Functor f => f Int -> f Int
inc = fmap (+1)
Auch IO ist ein einstelliger Typkonstruktor. Können wir für ihn auch eine FunctorInstanz angeben? Ja:
instance Functor IO where
fmap f a = do x <- a
return (f x)
Die Functor-Klasse und die gezeigten Instanzen (bis auf die für Tree) sind in Haskell
vordefiniert, Sie können sie also verwenden, ohne sie selbst zu definieren.
Mit der Functor-Instanz für IO können wir das folgende Programm schreiben:
main = do x <- fmap length getLine
print x
Wenn wir es ausführen, müssen wir eine Zeile eingeben und bekommen dann deren Länge
angezeigt:
9
ghci> main
abc
3
Hier ein weiteres Beispiel, das zeigt, wie man den ersten Kommandozeilen-Parameter
ausgeben kann:
main = do x <- fmap head getArgs
print x
Speichert man dieses Programm in der Datei print-first-arg.hs, kann man es wie
folgt ausführen:
bash# runhaskell print-first-arg.hs 42 43 44
42
Instanzen der Klasse Functor müssen die folgenden sogenannten Funktor-Gesetze
erfüllen:
fmap id
= id
fmap (f . g) = fmap f . fmap g
Das heißt, fmap ist ein Homomorphismus. Wir überprüfen die Gesetze beispielhaft für
die Maybe-Instanz:
fmap id Nothing = Nothing
= id Nothing
fmap id (Just x) = Just (id x) = id (Just x)
fmap (f . g) Nothing
= Nothing
= fmap f (fmap g Nothing)
= (fmap f . fmap g) Nothing
fmap (f . g) (Just x)
= Just ((f . g) x)
= Just (f (g x))
= fmap f (fmap g (Just x))
= (fmap f . fmap g) (Just x)
Für Listen und Bäume zeigt man die Gesetze per struktureller Induktion. Um zu zeigen, dass die IO-Instanz die Funktor-Gesetze erfüllt, benötigt man Gesetze für die doNotation, die wir bisher noch nicht kennen.
10
3 Abschließende Bemerkungen zur MonadPlus Typklasse
Die Funktion guard wird häufig zur Einschränkung des Suchraums verwendet. Für Listen
könnte man sie so definieren:
guard :: Bool -> [()]
guard False = []
guard True = [()]
Diese Definition erscheint zunächst wenig sinnvoll, ist es aber in Kombination mit dem
bind Operator. Das folgende Beispiel zeigt dies. Der Ausdruck
return False >>= guard >> return 42
wird zu
guard False >> return 42
= [] >> return 42
= []
ausgewertet, der Ausdruck
return True >>= guard >> return 42
aber zu
guard True >> return 42
= [()] >> return 42
= return 42
= [42]
Mit guard kann man also Ergebnisse anhand eines Prädikats verwerfen. Das erinnert
nicht zufallig an die filter-Funktion, die man mit guard definieren kann:
filter :: (a -> Bool) -> [a] -> [a]
filter p xs = do x <- xs
guard (p x)
return x
Üblicherweise verwendet man guard um zulässige Ergebnisse aus einem größeren Suchraum auszuwählen. Zum Beispiel könnten wir das Programm zur Berechnung Pythagoräischer Tripel aus einer früheren Vorlesung auch so schreiben:
11
pytriples :: Int -> [(Int,Int,Int)]
pytriples max = do a <- [1..max]
b <- [a..max]
c <- [b..max]
guard (a*a + b*b == c*c)
return (a,b,c)
Der Aufruf pytriples 10 ergibt dann [(3,4,5),(6,8,10)]. Tatsächlich kann man beliebige List-Comprehensions in do-Notation übersetzen (siehe Übung).
Die guard-Funktion ist für beliebige Instanzen der Klasse MonadPlus wie folgt vordefiniert:
guard :: MonadPlus m => Bool -> m ()
guard False = mzero
guard True = return ()
Man könnte also eine (leicht angepasste) Version der pytriples-Funktion auch in anderen MonadPlus-Instanzen ausführen.
3.1 Ein Kommentar zu den MonadPlus-Gesetzen
Neben der Forderung, dass mzero und mplus ein Monoid formen, ist noch eine weitere
Eigeschaft von MonadPlus-Instanzen wünschenswert. Für jede Funktion f sollte (>>=
f) verknüpfungstreu bezüglich mzero und mplus (also gewissermaßen ein MonadPlusHomomorphismus) sein:
mzero >>= f
(a ‘mplus‘ b) >>= f
=
=
mzero
(a >>= f) ‘mplus‘ (b >>= f)
Diese Gesetze stellen sicher, dass während einer Berechnung keine Ergebnisse verloren
gehen, sind aber nicht für jede vordefinierte MonadPlus-Instanz erfüllt. Zum Beispiel
erfüllt die Instanz für Maybe das zweite, sogenannte Distributivgesetz für MonadPlus,
nicht, denn es gilt für a = return False, b = return True und f = guard:
=
=
=
=
(return False ‘mplus‘ return True) >>= guard
(Just False ‘mplus‘ Just True) >>= guard
Just False >>= guard
guard False
Nothing
aber
12
(return False >>= guard) ‘mplus‘ (return True >>= guard)
= guard False ‘mplus‘ guard True
= Nothing ‘mplus‘ Just ()
= Just ()
Die Maybe-Monade ist daher nur bedingt zum Lösen von Suchproblemen geeignet, da
Ergebnisse verloren gehen können. Wenn man zum Beispiel, wie oben beschrieben, Pythagoräische Tripel in der Maybe-Monade berechnen möchte, erhält man als Ergebnis
Nothing statt Just (3,4,5).
4 Zustandsmonaden
Wir betrachten noch einmal den Datentyp für beschriftete Binärbäume
data Tree a = Empty | Node (Tree a) a (Tree a)
und wollen eine Funktion definieren, die die Knoten so eines Baums von links nach rechts
durchnummeriert.
numberTree :: Tree a -> Tree (Int,a)
Beispiel (verkürzt):
ghci> numberTree (N (N E ’a’ E) ’b’ (N E ’c’ E))
N (N E (1,’a’) E) (2,’b’) (N E (3,’c’) E)
Zur rekursiven Definition dieser Funktion über die Struktur von Binärbäumen benötigen
wir einen zusätzlichen Parameter, der angibt, welche die nächste verfügbare Nummer ist.
Wir könnten daher versuchen, die Funktion wie folgt zu definieren
numberTree t = numberTreeWithNum t 1
wobei numberTreeWithNum den Typ Tree a -> Int -> Tree (Int,a) hat. Beim Versuch, den rekursiven Fall von numberTreeWithNum zu definieren, bekommen wir aber das
Problem, dass wir die Größe des linken Teilbaums kennen müssen um die nächste freie
Nummer für den rechten zu berechnen. Statt den linken Teilbaum zweimal zu durchlaufen (einmal zur Nummerierung und einmal zur Berechnung der Größe) ist es besser,
wenn unsere Hilfsfunktion nicht nur den nummerierten Baum sondern auch die nächste
freie Nummer zurückgibt. Die nächste freie Nummer wird dadurch zu einer Art Zustand,
der durch die Berechnung durchgereicht wird.
13
numberTreeWithState
:: Tree a -> Int -> (Tree (Int,a), Int)
Mit dieser Funktion können wir numberTree definieren und müssen nur das erste Element
des Ergebnisses selektieren, also die letzte freie Nummer ignorieren.
numberTree t = fst (numberTreeWithState t 1)
Unsere Hilfsfunktion definieren wir wie folgt. Einen leeren Baum braucht man nicht zu
nummerieren und die nächste freie Nummer bleibt unverändert:
numberTreeWithState Empty n = (Empty,n)
Bei einem inneren Knoten nummerieren wir erst den linken Teilbaum, ergänzen die
Beschriftung durch die dann nächste freie Nummer und nummerieren dann den rechten
Teilbaum mit einer um 1 größeren Nummer.
numberTreeWithState (Node l x r) n =
let (l’,n1) = numberTreeWithState l n
(r’,n2) = numberTreeWithState r (n1+1)
in (Node l’ (n1,x) r’, n2)
Definitionen wie diese sind fehleranfällig, da man Variablen wie n1 und n2 leicht verwechseln kann. Das manuelle Auspacken und Weiterreichen des Zustands wird bei größeren
Programmen schnell unübersichtlich.
Die sequentielle Struktur dieses Programms (erst den linken Teilbaum nummerieren,
dann den rechten), wirft die Frage auf, ob wir es nicht eleganter mit do-Notation implementieren können. Es folgt eine wünschenswerte monadische Variante unserer Hilfsfunktion, die einen Zustand mit Hilfe von Funktionen get und put manipuliert.
numberTreeState Empty = return Empty
numberTreeState (Node l x r) =
do l’ <- numberTreeState l
n <- get
put (n+1)
r’ <- numberTreeState r
return (Node l’ n r’)
Können wir eine Monade definieren, die diese Definition erlaubt? Angenommen
numberTreeState soll denselben Typ haben wie numberTreeWithState, dann müsste
die return-Funktion den Typ a -> Int -> (a,Int) haben. Den Typkonstruktor
IntState für die zugehörige Monade müssten wir dann so definieren:
14
type IntState a = Int -> (a,Int)
Der bind Operator hätte den Typ IntState a -> (a -> IntState b) -> IntState
b. Außerdem haben wir Funktionen get und put verwendet, die in dem Fall folgende
Typen haben müssten:
get :: IntState Int
put :: Int -> IntState ()
Zur Implementierung dieser und der monadischen Funktionen ist der konkrete Typ des
Zustands (hier Int) unerheblich, wir können von diesem also abstrahieren. Außerdem
definieren wir statt eines Typsynonyms einen neuen Typ mit newtype um Berechnungen in Zustandsmonaden von anderen Funktionen zu unterscheiden, die zufällig einen
passenden Typ haben.
newtype State s a = State (s -> (a,s))
Zu diesem Typ definieren wir eine Funktion
runState :: State s a -> s -> (a,s)
runState (State f) = f
mit der man Berechnungen in Zustandsmonaden ausführen kann. Es gilt offensichtlich
runState (State f) = f und für alle a :: State s a auch State (runState a) =
a.
Wir geben nun eine Monad-Instanz für den Typkonstruktor State s an. Die returnFunktion lässt den Zustand unverändert und der bind-Operator reicht ihn durch sein
erstes Argument in die Berechnung des zweiten.
instance Monad (State s) where
return x = State (\s -> (x,s))
a >>= f = State (\s -> let (x,s’) = runState a s
in runState (f x) s’)
Wir müssen nun zeigen, dass diese Implementierung die Monadengesetze erfüllt. return
ist eine Links-Identität für bind:
return x >>= f
= State (\s ->
let (x’,s’) = runState (return x) s
15
=
=
=
=
=
=
in
State
let
in
State
let
in
State
let
in
State
State
f x
runState (f x’) s’)
(\s ->
(x’,s’) = runState (State (\s -> (x,s))) s
runState (f x’) s’)
(\s ->
(x’,s’) = (\s -> (x,s)) s
runState (f x’) s’)
(\s ->
(x’,s’) = (x,s)
runState (f x’) s’)
(\s -> runState (f x) s)
(runState (f x))
return ist auch eine Rechts-Identität für bind:
a >>= return
= State (\s -> let (x,s’) = runState a
in runState (return x)
= State (\s -> let (x,s’) = runState a
in runState (State (\s
= State (\s -> let (x,s’) = runState a
in (\s -> (x,s)) s’)
= State (\s -> let (x,s’) = runState a
= State (\s -> runState a s)
= State (runState a)
= a
s
s’)
s
-> (x,s))) s’)
s
s in (x,s’))
Schließlich zeigen wir noch das Assoziativgesetz für den bind Operator.
(a >>= f) >>= g
= State (\s -> let (x,s’) = runState (a >>= f) s
in runState (g x) s’)
= State (\s ->
let (x,s’) = runState (State (\t ->
let (y,t’) = runState a t
in runState (f y) t’)) s
in runState (g x) s’)
= State (\s ->
let (x,s’) = (\t -> let (y,t’) = runState a t
in runState (f y) t’) s
in runState (g x) s’)
= State (\s ->
16
=
=
=
=
=
=
let (x,s’) = let (y,t’) = runState a s
in runState (f y) t’
in runState (g x) s’)
State (\s ->
let (y,t’) = runState a s
(x,s’) = runState (f y) t’
in runState (g x) s’)
State (\s ->
let (y,t’) = runState a s
in let (x,s’) = runState (f y) t’
in runState (g x) s’)
State (\s ->
let (y,t’) = runState a s
in (\t -> let (x,s’) = runState (f y) t
in runState (g x) s’) t’)
State (\s ->
let (y,t’) = runState a s
in runState (State (\t ->
let (x,s’) = runState (f y) t
in runState (g x) s’)) t’)
State (\s -> let (y,t’) = runState a s
in runState (f y >>= g) t’)
a >>= \x -> f x >>= g
Es fehlen noch die Definitionen für get und put. Die get-Funktion lässt den Zustand
unverändert und gibt ihn zusätzlich als erstes Argument des Ergebnispaares zurück.
get :: State s s
get = State (\s -> (s,s))
Die put-Funktion ignoriert den durchgereichten Zustand und ersetzt ihn durch den
übergebenen.
put :: s -> State s ()
put s = State (\_ -> ((),s))
Mit diesen Definitionen können wir die Funktion numberTree nun unter Verwendung
der monadischen Hilfsfunktion numberTreeState definieren:
numberTree :: Tree a -> Tree (Int,a)
numberTree t = fst (runState (numberTreeState t) 1)
17
Es stellt sich die Frage, ob die gezeigte Implementierung die einzig mögliche einer Zustandsmonade ist. Analog zur Verallgemeinerung der Listenmonade durch die MonadPlus
Typklasse können wir die State s-Monade zu beliebigen Zustandsmonaden abstrahieren, indem wir die Schnittstelle in einer Typklasse spezifizieren.
Zustandsmonaden stellen neben den monadischen Operationen zwei Funktionen get und
put zur Verfügung, die wir wie folgt in einer Typklasse abstrahieren können:
class Monad m => MonadState s m where
get :: m s
put :: s -> m ()
MonadState ist eine sogenannte Multi-Parameter-Typklasse, denn sowohl der Zustandstyp als auch der Monaden-Typkonstruktor sind Parameter von MonadState.
Multi-Parameter-Klassen gehören nicht zum Haskell’98 Standard, können aber im GHC
oder GHCi durch die Spracherweiterung MultiParamTypeClasses aktiviert werden.
Um entsprechende Instanzen deklarieren zu können ist zusätzlich noch die Erweiterung
FlexibleInstances notwendig.
Wir können den Typkonstruktor State s zu einer Instanz der Klasse MonadState s
machen, indem wir die vorherigen Defnitionen von get und put in die Instanzdeklaration
schreiben.
instance MonadState s (State s) where
get
= State (\s -> (s,s))
put s = State (\_ -> ((),s))
Wie bei MonadPlus können wir uns auch bei MonadState fragen, welche Gesetze für
Zustandsmonaden erfüllt sein sollen. Zwei sinnvolle Gesetze sind zum Beispiel das Gesetz
get >>= put
=
return ()
welches besagt, dass das Setzen des Zustands auf den aktuellen Zustand keinen Effekt
hat und das Gesetz
put s >> get
=
put s >> return s
welches besagt, dass get den zuvor gesetzten Zustand liefert und diesen nicht verändert.
Das folgende zeigt, dass unsere Implementierung diese Gesetze erfüllt.
18
=
=
=
=
=
=
=
get >>= put
State (\s ->
let (x,s’) = runState get s
in runState (put x) s’
State (\s ->
let (x,s’) = runState (State (\s -> (s,s))) s
in runState (put x) s’)
State (\s ->
let (x,s’) = (s,s)
in runState (put x) s’)
State (\s -> runState (put s) s)
State (\s -> runState (State (\_ -> ((),s))) s)
State (\s -> ((),s))
return ()
Auch das zweite Gesetz gilt:
put s
= State
let
in
= State
let
in
= State
let
in
= State
= State
= State
let
in
= State
let
in
= put s
>> get
(\t ->
(x,t’) = runState (put s) t
runState get t’)
(\t ->
(x,t’) = runState (State (\_ -> ((),s))) t
runState get t’)
(\t ->
(x,t’) = ((),s)
runState (State (\s -> (s,s))) t’)
(\t -> (s,s))
(\t -> let (x,t’) = ((),s) in (s,t’))
(\t ->
(x,t’) = runState (State (\_ -> ((),s))) t
runState (State (\s’ -> (s,s’))) t’
(\t ->
(x,t’) = runState (put s) t
runState (return s) t’
>> return s
Durch die MonadState-Klasse kann die numberTreeState-Funktion in beliebigen Zustandsmonaden ausgeführt werden, denn sie hat den Typ
numberTreeState
:: MonadState Int m => Tree a -> m (Tree (Int,a))
19
Bisher kennen wir keine anderen Zustandsmonaden, wir werden aber später alternative
Implementierungen kennen lernen.
5 Ausgewählte Programmiertechniken
Dieses Kapitel behandelt funktionale Programmiertechniken, die häufig eingesetzt werden, um Programme effizienter zu machen.
5.1 Differenzlisten
In der Übung (erste Aufgabe auf dem zweiten Übungszettel) haben wir unterschiedliche
Implementierungen der reverse-Funktion kennen gelernt. Eine naive Implementierung
mit quadratischer Laufzeit:
reverse :: [a] -> [a]
reverse []
= []
reverse (x:xs) = reverse xs ++ [x]
und eine Implementierung mit linearer Laufzeit, die die Akkumulatortechnik verwendet:
reverse :: [a] ->
reverse l = rev l
where
rev []
ys =
rev (x:xs) ys =
[a]
[]
ys
rev xs (x:ys)
Hierbei ist rev :: [a] -> [a] -> [a].
Wir wollen nun versuchen, die Klarheit der ersten Implementierung mit der Effizienz
der zweiten zu vereinen. Die erste Implementierung ist schneller verständlich als die
zweite, da sie einem einfacheren Rekursions-Schema folgt. Die zweite Implementierung
ist komplizierter, da sich hier zwei Argumente der rekursiven Funktion im rekursiven
Aufruf auf trickreiche Weise ändern.
Dank Currying ist rev auch eine einstellige Funktion, nämlich vom Typ [a] -> ([a]
-> [a]). Wir schreiben sie nun so um, dass dies auch in ihrer Implementierung sichtbar
wird.
reverse :: [a] -> [a]
reverse l = rev l []
where
rev []
= id
rev (x:xs) = rev xs . (x:)
20
Die rev-Funktion hat nun dieselbe rekursive Struktur wie die naive Implementierung von
reverse. id spielt dabei die Rolle der leeren Liste, (.) die Rolle der Konkatenation mit
(++) und (x:) repräsentiert die einelementige Liste [x]. Wir haben also nur das ListenMonoid durch das Funktions-Monoid ersetzt und dadurch die Effizienz der reverseFunktion verbessert.
Wir können diese Idee in einem abstrakten Datentyp sogenannter Differenzlisten1 ausdrücken.
import Data.Monoid
newtype DList a = DList ([a] -> [a])
instance Monoid (DList a) where
mempty
= DList id
DList xs ‘mappend‘ DList ys = DList (xs . ys)
Die Implementierung von mappend ist nicht rekursiv und hat daher konstante Laufzeit,
was im Kern der Grund für die Laufzeitverbesserung ist.
Wie in der letzten Implementierung von reverse können wir eine Differenzliste in eine
normale Liste umwandeln, indem wir sie auf die leere Liste anwenden.
fromDList :: DList a -> [a]
fromDList (DList xs) = xs []
Intuitiv ist eine Differenzliste eine Funktion, die die in ihr enthaltenen Elemente vorne
an ihr Argument anhängt. Eine normale Liste kann also wie folgt in eine Diffrenzliste
konvertiert werden.
toDList :: [a] -> DList a
toDList xs = DList (xs ++)
Wir können die Implementierung der reverse-Funktion umschreiben, um deutlich zu
machen, dass wir Differenzlisten verwenden.
reverse :: [a] -> [a]
reverse l = fromDList (rev l)
where
rev []
= mempty
rev (x:xs) = rev xs ‘mappend‘ toDList [x]
1
Der Name Differenzliste kommt aus der Logikprogrammierung - eine Analogie, auf die wir hier nicht
weiter eingehen.
21
Diese Implementierung verwendet die (++)-Funktion nur durch toDList und nur mit
einelementigen Listen als erstes Argument. Sie hat daher wie die Implementierung mit
Akkumulatortechnik lineare Laufzeit.
Differenzlisten werden typischerweise bei Baumdurchläufen verwendet, die eine Liste
erzeugen. In der ersten Übung haben wir Funktionen auf Binärbäumen implementiert,
die die Knotenbeschriftungen in Prefix-, Infix- und Postfix-Ordnung auflisten. Alle diese
Implementierungen verwendeten einen rekursiven Aufruf als linkes Argument von (++)
und habe daher im schlechtesten (unbalancierten) Fall quadratische Laufzeit.
Mit Differenzlisten können wir die Beschriftungen in Linearzeit auflisten, wie hier am
Beispiel der Infix-Ordnung:
data Tree a = Empty
| Branch (Tree a) a (Tree a)
deriving Eq
infixLabels :: Tree a -> [a]
infixLabels = fromDList . labels
where
labels Empty
= mempty
labels (Branch l x r) =
labels l ‘mappend‘ toDList [x]
‘mappend‘ labels r
Die Funktionen toDList und fromDList sind zueinander inverse Monoid-Isomorphismen
(einfacher Induktionsbeweis). Man kann daher jedes Programm über Listen, das nur die
Monoid-Operationen verwendet, in eines über Differenzlisten umschreiben, ohne dessen
Verhalten zu ändern.
Nicht jede Funktion, die man auf Listen schreiben kann, kann man auch direkt auf Differenzlisten implementieren. Zum Beispiel können wir nicht testen, ob eine Differenzliste
leer ist, ohne sie in eine normale Liste zu konvertieren:
nullDL :: DList a -> Bool
nullDL (DList dl) = null (dl [])
Das einzige, was wir mit der Funktion dl machen können, ist sie anzuwenden. Anders
können wir nicht sehen, wie viele Elemente sie vor ihr Argument hängt. Dieses Problem
könnte man noch umgehen, indem man zusätzlich eine Zahl für die Länge der Liste
speichert (welche es gleichzeitig erlauben würde, eine length-Funktion zu definieren).
Dieser Trick hat aber seine Grenzen. Auch er erlaubt nicht, zum Beispiel die map- oder
die concat-Funktion auf Differenzlisten zu implementieren.
Wir könnten wie folgt versuchen, eine map-Funktion für Differenzlisten zu implementieren:
22
mapDL :: (a -> b) -> DList a -> DList b
mapDL f (DList dl) = DList (\l -> ???)
Wir können die Funktion f jedoch nicht auf die Elemente von dl anwenden, ohne dl in
eine normale Liste zu konvertieren. Ähnliche Probleme haben wir bei der Implementierung einer concat-Funktion.
Wir werden später einer andere Implementierung von Listen als Funktionen kennen
lernen, die die Definition von map und concat erlaubt.
Ein anderes typisches Beispiel für Baumdurchläufe, die Listen erzeugen, sind showFunktionen. Die Show-Klasse enthält folgende Funktionen2 :
class Show a where
show :: a -> String
show x = shows x ""
showsPrec :: Int -> a -> ShowS
Der ShowS-Typ und die shows-Funktion sind dabei wie folgt definiert:
type ShowS = String -> String
shows :: Show a => a -> ShowS
shows = showsPrec 0
Der ShowS-Typ repräsentiert also Strings als Differenzlisten und shows ist wie die
show-Funktion, erzeugt aber solche Differenzlisten. Die Funktion showsPrec bekommt
einen zusätzlichen Parameter, den man verwenden kann, um unter Berücksichtigung
von Präzedenzen Klammern zu sparen. Wir werden ihn im Folgenden aber ignorieren.
Mit Hilfe des ShowS-Typen können wir Bäume in Linearzeit in Strings umwandeln.
instance Show a => Show (Tree a) where
showsPrec _ Empty =
showString "Empty"
showsPrec _ (Branch l x r) =
showString "Branch " .
showParen (l/=Empty) (shows l) .
showChar ’ ’ . shows x . showChar ’ ’ .
showParen (r/=Empty) (shows r)
2
Zusätzlich zu den gezeigten Funktionen enthält die Show-Klasse auch die Funktion showList :: Show
a => [a] -> String mit deren Hilfe man die Darstellung von Listen eines Typs anpassen kann.
Diese wird zum Beispiel von der Char Instanz überschrieben, um Strings nicht als Liste von Zeichen
darzustellen.
23
Analog zum DList-Typ verwenden wir Funktionskomposition statt Listenkonkatenation
um lineare Laufzeit zu erreichen.
Die Funktion showString entspricht der Funktion toDList und erzeugt aus einem
String einen ShowS-Wert.
showString :: String -> ShowS
showString = (++)
showChar tut das selbe für ein einzelnes Zeichen:
showChar :: Char -> ShowS
showChar = (:)
showParen erzeugt Klammern um einen ShowS-Wert, falls das übergebene Flag True ist.
showParen :: Bool -> ShowS -> ShowS
showParen True s = showChar ’(’ . s . showChar ’)’
showParen False s = s
All diese Funktionen sind in der Prelude definiert.
5.2 Continuations
Als Beispiel für die Maybe-Monade haben wir einen Datentyp
data Expr = Num Float
| Expr :+: Expr
| Expr :/: Expr
und eine Funktion
eval :: Expr -> Maybe Float
definiert, wobei die eval-Funktion Nothing liefert, wenn bei der Auswertung eine Division durch Null auftritt.
Das folgende Bild stellt den Baumdurchlauf für die Auswertung des Ausdrucks
Num 4 :/: (Num 1 :+: Num (-1)) :+: Num 4
24
grafisch dar.
Bild von einem Baumdurchlauf
Sobald der Wert Nothing auftritt, wird der Baumdurchlauf abgebrochen. Zum Beispiel
wird das rechte Argument des obersten :+:-Knotens nicht mehr ausgewertet, da die
Auswertung des linken Arguments fehlschlägt.
Der Wert Nothing wird von der Stelle im Baum, an der er zuerst auftritt, bis zur
Wurzel des Ausdrucks hoch gereicht. Die eval-Funktion testet dazu, ob die Ergebnisse
von rekursiven Aufrufen Nothing sind und gibt in diesem Fall selbst Nothing zurück.
Wenn ein Nothing-Wert sehr tief im Baum auftritt, ist dieses Hochreichen ineffizient.
Können wir die Abarbeitung nicht stoppen, ohne den Fehler durch den Baum zu reichen?
Um dies zu erreichen, schreiben wir die eval-Funktion in Continuation Passing Style (CPS). Eine Funktion in CPS nimmt als zusätzliches Argument eine Funktion, die
sogenannte Continuation. In CPS hat die eval-Funktion den folgenden Typ:
evalCPS :: Expr
-> (Float -> Maybe Float)
-> Maybe Float
Der Continuation übergeben wir das Ergebnis, das wir im Erfolgsfall zurück liefern
würden. Die Regel für Konstanten sieht deshalb so aus:
evalCPS (Num x) k = k x
Um mehrere Ausdrücke hintereinander auszuwerten, schachteln wir die zweite Auswertung in der Continuation der ersten:
evalCPS (e1 :+: e2) k =
evalCPS e1 (\v1 -> evalCPS e2 (\v2 -> k (v1 + v2)))
Die übergebene Continuation wird ganz innen benutzt. Im Fehlerfall geben wir als Ergebnis der Continuation Nothing zurück statt die übergebene Continuation aufzurufen:
evalCPS (e1 :/: e2) k =
evalCPS e1 (\v1 ->
evalCPS e2 (\v2 ->
if v2 == 0 then Nothing else k (v1 / v2)))
25
In diesem Program gibt es kein Pattern-Matching auf Nothing mehr! Im Fehlerfall gibt
evalCPS direkt Nothing zurück, ohne es durch den Berechnungsbaum hindurch zu reichen.
Eine alternative Implementierung der letzten Regel berechnet den Divisor zuerst und
spart die Berechnung des Dividenden, wenn der Divisor Null ist:
evalCPS (e1 :/: e2) k =
evalCPS e2 (\v2 ->
if v2 == 0 then Nothing
else evalCPS e1 (\v1 -> k (v1 / v2)))
Rufen wir evalCPS mit dem obigen Ausdruck auf, können wir Just als Continuation
übergeben um ein Ergebnis vom Typ Maybe Float zu erhalten.
ghci> let zero = Num 1 :+: Num (-1)
ghci> let expr = (Num 4 :/: zero) :+: Num 4
ghci> evalCPS expr Just
Nothing
Wir werten diesen Aufruf schrittweise aus:
=
=
=
=
=
=
evalCPS expr Just
evalCPS ((Num 4 :/: zero) :+: Num 4) Just
evalCPS (Num 4 :/: zero) (\x ->
evalCPS (Num 4) (\y -> Just (x+y)))
evalCPS zero (\b ->
if b==0 then Nothing
else evalCPS (Num 4) (\a ->
evalCPS (Num 4) (\y -> Just ((a/b)+y))))
evalCPS (Num 1 :+: Num (-1)) (\b ->
if b==0 then Nothing
else evalCPS (Num 4) (\a ->
evalCPS (Num 4) (\y -> Just ((a/b)+y))))
evalCPS (Num 1) (\c ->
evalCPS (Num (-1)) (\d ->
if (c+d)==0 then Nothing
else evalCPS (Num 4) (\a ->
evalCPS (Num 4) (\y ->
Just ((a/(c+d))+y)))))
evalCPS (Num (-1)) (\d ->
if (1+d)==0 then Nothing
else evalCPS (Num 4) (\a ->
26
evalCPS (Num 4) (\y ->
Just ((a/(1+d))+y))))
= if (1+(-1))==0 then Nothing
else evalCPS (Num 4) (\a ->
evalCPS (Num 4) (\y ->
Just ((a/(1+d))+y)))
= Nothing
Der Wert Nothing wird also sofort zurück gegeben, wenn er auftritt und nicht mehr
durch den Aufrufbaum gereicht.
Die hier gezeigte Funktion evalCPS ist zwar effizienter als die Funktion eval in der
Maybe-Monade aber auch weniger gut lesbar. Wir werden nun sehen, dass die neue
Implementierung auch monadisch ist, nämlich bezüglich einer Continuation-basierten
Variante der Maybe-Monade.
Inspiriert vom Typ von evalCPS definieren wir den folgenden Datentyp:
newtype CMaybe r a =
CMaybe ((a -> Maybe r) -> Maybe r)
Dieser Typ entspricht dem Ergebnistyp von evalCPS, wobei Float durch die Typparameter r und a ersetzt wurde. Werte vom CMaybe kann man in normale Maybe-Werte
konvertieren, indem man Just als Continuation übergibt:
fromCMaybe :: CMaybe a a -> Maybe a
fromCMaybe (CMaybe ca) = ca Just
Beim Konvertieren eines CMaybe-Wertes werden die Typparameter r und a unifiziert.
Vor der Konvertierung bleibt r in der Regel polymorph.
Wir definieren nun eine Monad-Instanz für CMaybe r mit deren Hilfe man CMaybe-Werte
definieren kann.
instance Monad (CMaybe r) where
return x = CMaybe (\k -> k x)
CMaybe ca >>= f = CMaybe (\k ->
ca (\x -> let CMaybe cb = f x in cb k))
Die Implementierung von return übergibt das Argument an die Continuation und die
Implementierung von >>= schachtelt die Berechnung des zweiten Arguments in der
Continuation des ersten. Dadurch ist das Ergebnis der Continuation vom Typ Maybe b
27
das Argument aber vom Typ a. Anders als bei der Maybe-Monade ist >>= ohne PatternMatching definiert.
Die Monadengesetze zeigen wir unabhängig vom Ergebnistyp der Continuation. Wir
ignorieren dabei der Übersichtlichkeit wegen die newtype-Konstruktoren:
=
=
=
=
=
return x >>= f
(\k -> k x) >>= f
(\k’ -> (\k -> k x) (\x’ -> f x’ k’))
(\k’ -> (\x’ -> f x’ k’) x)
(\k’ -> f x k’)
f x
=
=
=
=
=
ca >>=
(\k ->
(\k ->
(\k ->
(\k ->
ca
return
ca (\x -> return x k))
ca (\x -> (\k’ -> k’ x) k))
ca (\x -> k x))
ca k)
(ca >>= f) >>= g
= (\k -> ca (\x -> f x k)) >>= g
= \k’ -> (\k -> ca (\x -> f x k))
(\y -> g y k’)
= \k’ -> ca (\x -> f x (\y -> g y k’))
= \k’ -> ca (\x -> (\k ->
f x (\y -> g y k)) k’)
= \k’ -> ca (\x -> (f x >>= g) k’)
= ca >>= \x -> f x >>= g
Sowohl die Implementierung der Monadenoperationen als auch die Beweise für die Monadengesetze sind unabhängig vom Ergebnistyp Maybe r der Continuation. Wir werden
später weitere Continuation-Monaden kennen lernen, für die die Definition der Monadenoperationen (also auch die Beweise der Gesetze) mit den oben gezeigten übereinstimmen.
Wir geben nun eine MonadPlus-Instanz für CMaybe an, die die entsprechenden Operationen des Maybe-Typs liftet.
instance MonadPlus (CMaybe r) where
mzero = CMaybe (\_ -> mzero)
CMaybe ca ‘mplus‘ CMaybe cb =
CMaybe (\cont -> ca cont ‘mplus‘ cb cont)
28
Auch für diese Instanz zeigen wir die Gesetze unter Vernachlässigung der newtypeKonstruktoren.
mzero >>= f
= (\_ -> mzero) >>= f
\k -> (\_ -> mzero) (\x -> f x k)
= \k -> mzero
= mzero
=
=
=
=
=
=
(ca ‘mplus‘ cb) >>= f
\k -> (ca ‘mplus‘ cb) (\x -> f x k)
\k -> (\k’ -> ca k’ ‘mplus‘ cb k’)
(\x -> f x k)
\k -> ca (\x -> f x k)
‘mplus‘ cb (\x -> f x k)
\k -> (\k’ -> ca (\x -> f x k’)) k
‘mplus‘ (\d -> cb (\x -> f x k’)) k
(\k’ -> ca (\x -> f x k’))
‘mplus‘ (\k’ -> cb (\x -> f x k’))
(ca >>= f) ‘mplus‘ (cb >>= f)
Die CMaybe-Monade erfüllt also, anders als die Maybe-Monade, das Distributivgesetz zwischen >>= und mplus und kann daher zur Implementierung von Backtracking verwendet
werden.
ghci> let a = return False ‘mplus‘ return True
ghci> let b = a >>= guard
ghci> b :: Maybe ()
Nothing
ghci> fromCMaybe b
Just ()
Wir können nun die monadische Implementierung der eval-Funktion
eval
eval
eval
do
:: MonadPlus m => Expr -> m Float
(Num x) = return x
(a :+: b) =
x <- eval a
y <- eval b
return (x+y)
eval (a :/: b) =
do y <- eval b
guard (y/=0)
29
x <- eval a
return (x/y)
in der CMaybe-Monade ausführen, was einer Ausführung mit der evalCPS-Funktion entspricht.
Die Maybe-Monade ist nicht die einzige, die man mit Continuations kombinieren kann.
Wenn wir im CMaybe-Typ Maybe durch DList ersetzen, erhalten wir eine Continuationbasierte Listenmonade.
Wir haben gesehen, dass die obige Implementung von mplus der CMaybe-Monade das
Gesetz
(a‘mplus‘b) >>= f
=
(a>>=f) ‘mplus‘ (b>>=f)
erfüllt, obwohl die mplus-Funktion der Maybe-Monade es nicht erfüllt. Diese erfüllt stattdessen das Gesetz
return x ‘mplus‘ a
=
return x
das an ein Gesetz für catch in Fehlermonaden erinnert: Nur wenn das linke Argument
fehlschlägt wird das rechte Argument ausgeführt.
Können wir eine Funktion orElse für die CMaybe-Monade definieren, die das Gesetz
return x ‘orElse‘ a
=
return x
erfüllt? Das können wir:
orElse :: CMaybe a a -> CMaybe a a -> CMaybe r a
CMaybe ca ‘orElse‘ CMaybe cb =
CMaybe (\k -> (ca Just ‘mplus‘ cb Just) >>= k)
Diese Implementierung übergibt Just als Continuation an beide Alternativen, ruft auf
den Ergebnissen die mplus-Funktion der Maybe-Monade auf und übergibt das Ergebnis
dieses Aufruf mit dem bind-Operator der Maybe-Monade an die Continuation k. Im Typ
von orElse ist der Typ-Parameter r der Argumente gleich a, da wir wie in fromCMaybe
die Continuation Just :: a -> Maybe a übergeben.
Wenn wir die mplus-Funktion der Maybe-Monade auf diese Weise in den CMaybe-Typ
heben, überträgt sich die obige Eigenschaft:
30
return x ‘orElse‘ a
= (\k -> ((return x) Just ‘mplus‘ a Just)
>>= k)
= (\k -> ((\k’ -> k’ x) Just ‘mplus‘ a Just)
>>= k)
= (\k -> (Just x ‘mplus‘ a Just) >>= k)
= (\k -> Just x >>= k)
= (\k -> k x)
= return x
orElse verhält sich also genauso wie mplus in der Maybe-Monade:
ghci> let a = return False ‘orElse‘ return True
ghci> fromCMaybe (a >>= guard)
Nothing
Diese Methode, einen Wert aus der unterliegenden Maybe-Monade mit dem bindOperator an die Continuation zu übergeben, kann man anwenden um beliebige Werte
vom Maybe-Typ in den CMaybe-Typ zu heben:
toCMaybe :: Maybe a -> CMaybe r a
toCMaybe a = CMaybe (\k -> a >>= k)
Die Funktion toCMaybe ist dabei ein Monaden-Homomorphismus, das heißt es gilt:
toCMaybe (return x)
toCMaybe (a >>= f)
=
=
return x
toCMaybe a >>= toCMaybe . f
Hier die Beweise:
toCMaybe (return x)
= \k -> return x >>= k
= \k -> k x
= return x
toCMaybe (a >>= f)
\k -> (a >>= f) >>= k
\k -> a >>= (\w -> f w >>= k)
\k -> (\k3 -> a (\u -> (f u >>= k) k3))
\k -> (\k3 -> a (\u -> (\k5 ->
f u (\v -> k v k5)) k3))
= \k -> (\k3 -> a (\u -> f u (\v -> k v k3)))
=
=
=
=
31
= \k -> (\k1 -> (\k3 -> a (\u -> k1 u k3)))
(\y -> (\k4 -> f y (\v -> k v k4)))
= \k -> (\k1 -> (\k3 -> a (\u -> k1 u k3)))
(\y -> (\k2 -> \k4 ->
f y (\v -> k2 v k4)) k)
= (\k1 -> (\k3 -> a (\u -> k1 u k3)))
>>= \z -> \k2 -> \k4 -> f z (\v -> k2 v k4)
= (\k1 -> a >>= k1) >>= \z -> \k2 -> f z >>= k2
= toCMaybe a >>= toCMaybe . f
In den Beweisen verwenden wir die Links-Identität sowie das Assoziativgesetz der unterliegenden Maybe-Monade.
5.3 Continuation-basierte Listen
Bei den Instanzen für den CMaybe-Typ haben wir (im Gegensatz zur Definition von
orElse) nicht ausgenutzt, dass Maybe eine Monade ist. Die Monad-Instanz verwendet gar
keine Operationen des unterliegenden Typs, die MonadPlus-Instanz nur die Monoid(artigen) Operationen mzero und mplus.
Wir können also die selbe Konstruktion für andere Monoide durchführen und wählen als
Beispiel Differenzlisten:
newtype CList r a =
CList ((a -> DList r) -> DList r)
Solche Continuation-Listen können wir in normale Listen konvertieren, indem wir zuerst
(:) als Continuation übergeben und dann der Differenzliste die leere Liste übergeben:
fromCList :: CList a a -> [a]
fromCList (CList ca) =
fromDList (ca (DList . (:)))
Analog zur MonadPlus-Instanz für CMaybe r können wir eine Monoid-Instanz für CList
r a definieren:
instance Monoid (CList r a) where
mempty = CList (\_ -> mempty)
CList ca ‘mappend‘ CList cb =
CList (\k -> ca k ‘mappend‘ cb k)
Die Monad-Instanz für CList r definieren wir genau wie die für CMaybe r:
32
instance Monad (CList r) where
return x = CList ($x)
CList ca >>= f =
CList (\k -> ca (\x -> let CList cb = f x
in cb k))
Die Monaden-Gesetze brauchen wir nicht zu beweisen, da die Beweise für CMaybe r vom
Ergebnistyp der Continuation unabhängig sind und daher auch für CList gelten.
Da DList keine Monade ist können wir Differenzlisten nicht analog zu Maybe-Werten
in den CList-Typ heben (dazu bräuchten wir >>=). Stattdessen können wir ausnutzen, dass sowohl Listen als auch Continuation-Listen Monoide sind und einen MonoidIsomorphismus definieren:
toCList :: [a] -> CList r a
toCList = foldr mappend mempty . map return
Continuation-Listen können mehr als Differenzlisten, da sie Instanz der Monad-Klasse
sind. Das können wir ausnutzen, um zum Beipsiel eine map- und eine concat-Funktion
zu definieren:
mapCL :: (a -> b) -> CList r a -> CList r b
mapCL f cl = cl >>= return . f
concatCL :: CList r (CList r a) -> CList r a
concatCL cl = cl >>= id
Allerdings ist es noch immer nicht möglich einen Leerheitstest oder eine length-Funktion
zu definieren, ohne eine Continuation-Liste in eine normale Liste zu konvertieren.
Um dieses Manko auszumerzen, definieren wir eine weitere Variante des ContinuationTyps, die kein spezielles Monoid mehr verwendet.
newtype C m a = C ((a -> m) -> m)
Der Ergebnistyp ist jetzt polymorph vom Typ m. Um eine Monoid-Instanz zu definieren,
fordern wir nun, dass m ein Monoid ist.
instance Monoid m => Monoid (C m a) where
mempty
= C (\_ -> mempty)
C ca ‘mappend‘ C cb = C (\k -> ca k ‘mappend‘ cb k)
Die Monad-Instanz sieht aus wie immer.
33
instance Monad (C m) where
return x
= C ($x)
C ca >>= f = C (\k -> ca (\x -> let C cb = f x in cb k))
Um einen Wert vom Typ C m a nach m zu konvertieren übergeben wir eine Continuation
vom Typ (a -> m).
fromC :: Monoid m => (a -> m) -> C m a -> m
fromC f (C ca) = ca f
Als Konvertierung in die andere Richtung definieren wir wieder einen MonoidHomomorphismus von Listen in C m a:
toC :: Monoid m => [a] -> C m a
toC = foldr mappend mempty . map return
Nun können wir einen Leerheitstest als Monoid-Homomorphismus von C m a in
(Bool,&&,True) definieren, das heißt als Funktion nullC mit den Eigenschaften:
nullC mempty
nullC (a ‘mappend‘ b)
=
=
True
nullC a && nullC b
Dieses Boole’sche Monoid ist in Haskell in Data.Monoid wie folgt vordefiniert:
newtype All = All { getAll :: Bool }
instance Monoid All where
mempty = All True
x ‘mappend‘ y = All (getAll x && getAll y)
Analog dazu gibt es einen newtype Any, der das Monoid (Bool,||,False) repräsentiert.
Monoid-Homomorphismen von C m a in m definiert man, indem man als Continuation
eine Funktion f :: a -> m übergibt. Das jede sich so ergebende Funktion ein Homomorphismus ist, folgt direkt aus der Definition der Monoid-Instanz für C m a:
fromC f mempty
= fromC f (\_ -> mempty)
= (\_ -> mempty) f
= mempty
34
fromC f (a ‘mappend‘ b)
= fromC f (\k -> a k ‘mappend‘ b k)
= a f ‘mappend‘ b f
= fromC f a ‘mappend‘ fromC f b
Zusätzlich zu den Homomorphie-Gesetzen erfüllen solche Funktionen die Eigenschaft:
fromC f (return x)
=
f x
Denn:
fromC f (return x)
= return x f
= (\k -> k x) f
= f x
Die Funktion nullC definieren wir also als Homomorphismus, der jedem Listenelement
den Wert False zuordnet.
nullC :: C All a -> Bool
nullC l = getAll (fromC (\_ -> All False) l)
Analog dazu können wir eine lengthC-Funktion als Homomorphismus in (Int,+,0) definieren:
lengthC :: C (Sum Int) a -> Int
lengthC l = getSum (fromC (\_ -> Sum 1) l)
Hierbei ist Sum Int ein newtype um Int, der das Monoid (Int,+,0) darstellt. Analog
dazu gibt es Prod für das Monoid (Int,*,1).
Wir können nun die Länge einer Continuation-Liste mit Hilfe der Gesetze wie folgt
berechnen:
lengthC (return 1 ‘mappend‘ return 2)
= lengthC (return 1) + lengthC (return 2)
= 1 + 1
= 2
Als Kontrolle berechnen wir es auch einmal anhand der Definitionen der beteiligten
Funktionen.
35
=
=
=
=
=
=
=
lengthC (return 1 ‘mappend‘ return 2)
fromC (\_ -> 1) (return 1 ‘mappend‘ return 2)
(return 1 ‘mappend‘ return 2) (\_ -> 1)
(\k -> return 1 k + return 2 k) (\_ -> 1)
return 1 (\_ -> 1) + return 2 (\_ -> 1)
(\k -> k 1) (\_ -> 1) + (\k -> k 2) (\_ -> 1)
1 + 1
2
Unsere Implementierung hat noch einen entscheidenden Nachteil: Der Ergebnistyp m der
Continuation, ist nach außen sichtbar und kann deshalb nicht gleichzeitig mit unterschiedlichen Typen instanziiert werden. Zum Beispiel können wir keine Funktion schreiben, die gleichzeitig testet ob eine Liste leer ist und ihre Länge berechnet. Die folgende
Definition führt zu einem Typfehler.
nullLength l = (nullC l, lengthC l)
Hier müsste der Parameter m sowohl mit All als auch mit Sum Int belegt werden:
Couldn’t match expected type ‘Sum Int’
against inferred type ‘All’
Alle durch die Monoid oder Monaden-Instanzen erzeugten Listen haben einen polymorphen Typ m, das sieht man der Definition des Typs aber nicht an.
Mit einer Typsystem-Erweiterung, sogenannten Polymorphen Typen höheren Rangs,
können wir der obige Funktion aber einen korrekten Typ geben. Mit der Spracherweiterung RankNTypes können wir die Typen der beteiligten Funktionen wie folgt anpassen:
nullC
:: (forall m . Monoid m => C m a) -> Bool
lengthC
:: (forall m . Monoid m => C m a) -> Int
nullLength :: (forall m . Monoid m => C m a) -> (Bool, Int)
Die Definitionen der Funktionen bleiben unverändert.
Solche Typen können nicht inferiert werden und explizit polymorphe Argumente kann
man auch nicht per Currying weglassen. Hätten wir das Argument l bei einer der drei
Funktionen nicht explizit hingeschrieben oder eine Typsignatur weggelassen, hätte der
GHC einen Typfehler ausgegeben.
Solche Typsignaturen sind sehr umständlich. Besser ist es, die Typvariable m gar nicht
erst nach außen sichtbar zu machen, indem man sie schon bei der Typ-Deklaration als
polymorph deklariert:
36
newtype List a = List (forall m . Monoid m => (a -> m) -> m)
Alle Definitionen für C m lassen sich auf List übertragen. Die Typsignaturen werden
dabei dadurch vereinfacht, dass List nur noch einen Typ-Parameter a hat.
6 Parserkombinatoren
Ein Programm, das für ein gegebenes Wort w entscheidet, ob es von einer gegebenen kontextfreien Grammatik G beschrieben wird, heißt Parser für G. Dabei ist es
wünschenswert, dass der Parser zusätzlich zur Entscheidung, ob das Eingabewort erkannt
wird, Zusatzinformationen über die Eingabe als Ausgabe liefern kann. Solche Zusatzinformationen könnten zum Beispiel eine Linksableitung oder ein abstrakter Syntaxbaum
sein.
Es gibt unterschiedliche Ansätze, Parser zu implementieren:
• Parsergeneratoren wie YACC (für C) oder Happy (für Haskell) erzeugen aus
einer textuellen Beschreibung einer kontextfreien Grammatik einen Parser für diese
Grammatik.
• Rekursive Abstiegsparser sind durch gegenseitig rekursive Funktionen - eine
für jedes Nichtterminalsymbol der Grammatik - definiert.
• Parserkombinatoren erlauben die Definition von rekursiven Abstiegsparsern in
einer Grammatik-ähnlichen Notation. Mit Parserkombinatoren definierte Grammatiken sind also direkt als Parser ausführbar.
Im Folgenden behandeln wir die Verwendung und Implementierung solcher Parserkombinatoren.
6.1 Verwendung
Ein Kombinator-Parser ist ein Wert vom Typ Parser a und kann mit der parseFunktion auf ein Wort angewendet werden.
parse :: Parser a -> String -> Maybe a
Der Typ a beschreibt Zusatzinformation, die bei einem erfolgreichen Parser-Lauf
zurückgegeben wird. Parser, die nur entscheiden, ob das gegebene Wort erkannt wird,
liefern in der Regel () als Ergebnis. Zum Beispiel konstruiert der Kombinator
char :: Char -> Parser ()
37
einen Parser, der genau das gegebene Zeichen erkennt:
ghci> parse (char ’a’) "a"
Just ()
Bei anderen Eingaben liefert dieser Aufruf Nothing.
ghci> parse (char ’a’) ""
Nothing
ghci> parse (char ’a’) "b"
Nothing
ghci> parse (char ’a’) "ab"
Nothing
Einfache Parser können zu komplexeren kombiniert werden. Dazu gibt es zum Beispiel
den Kombinator
(*>) :: Parser a -> Parser b -> Parser b
der zwei Parser hintereinander ausführt und das Ergebnis des zweiten Parsers liefert.
Damit kann man zum Beispiel einen Parser definieren, der eine öffnende Klammer gefolgt
von einer schließenden erkennt:
parens = char ’(’ *> char ’)’
Die zu diesem Parser gehörige Grammatik sieht in Backus-Naur Form (BNF) so aus:
Parens ::= ’(’ ’)’
Es gibt auch eine Variante des obigen Kombinators, die es erlaubt, das Ergebnis des
ersten Arguments zu liefern:
(<*) :: Parser a -> Parser b -> Parser a
Bei beiden Kombinatoren wird das linke Argument vor dem rechten angewendet. Es gilt
also nicht (<*) = flip (*>).
Als Ergebnis eines erfolgreichen Parser-Laufs des Klammern-Parsers erhalten wir den
Wert ().
ghci> parse parens "()"
Just ()
38
Wir erweitern nun diesen Parser so, dass er korrekt geschachtelte Klammer-Ausdrücke
erkennt. Die zugehörige BNF sieht so aus:
Nested ::= ’(’ Nested ’)’ Nested
|
Hierbei ist die zweite Alternative das leere Wort und das definierte Nichtterminalsymbol
Nested wird auf der rechten Seite der Definition rekursiv verwendet.
In Haskell können wir diesen Parser ebenso rekursiv definieren:
nested
= char ’(’ *> nested *> char ’)’ *> nested
<|> empty
Hierbei verwenden wir den Kombinator
(<|>) :: Parser a -> Parser a -> Parser a
zur Deklaration der Alternativen und den Parser
empty :: Parser ()
zur Erkennung des leeren Wortes. Wir testen auch diesen Parser wieder mit der parseFunktion:
ghci> parse nested "(()(()()))"
Just ()
ghci> parse nested "(()()"
Nothing
Die bisher vorgestellten Kombinatoren erfüllen die folgenden Gleichungen. empty erkennt
nur das leere Wort und ist deshalb neutral bezüglich (*>) und (<*):
empty *> p
p <* empty
=
=
p
p
Außerdem gelten Distributivgesetze für die Sequenz-Kombinatoren und den <|>Kombinator, wie zum Beispiel:
(p <|> q) *> r
p *> (q <|> r)
=
=
(p *> r) <|> (q *> r)
(p *> q) <|> (p *> r)
39
Alle binären Kombinatoren sind assoziativ, zum Beispiel gilt
(p <|> q) <|> r
=
p <|> (q <|> r)
und es gibt auch ein neutrales Element
failure :: Parser a
für den <|>-Kombinator. failure ist ein Parser, der auf kein Wort passt, also die leere
Sprache repräsentiert.
Auf die gezeigte Weise kann man jede kontextfreie Grammatik mit Parserkombinatoren
ausdrücken. Ein Problem stellen dabei aber linksrekursive Grammatiken dar. Übersetzt
man die folgende Grammatik für die Sprache a*
AStar ::= AStar ’a’
|
in Parserkombinatoren
aStar = aStar *> char ’a’
<|> empty
dann terminiert der entsprechende Aufruf der parse-Funktion nicht (bzw. nur mit einem
Laufzeitfehler):
ghci> parse aStar "aaa"
*** Exception: stack overflow
Man muss Linksrekursion also eliminieren. Das Beispiel kann man so transformieren:
aStar = char ’a’ *> aStar
<|> empty
Dann terminiert die parse-Funktion.
ghci> parse aStar "aaa"
Just ()
40
Im Allgemeinen kann die Elimination von Linksrekursion komplizierter sein (siehe Vorlesung: Übersetzerbau). Die Klasse der kontextfreien Grammatiken, die man mit Parserkombinatoren ausdrücken kann, ist genau die Vereinigung der LL(k)-Grammatiken
für alle natürlichen Zahlen k. Parserkombinatoren erlauben also eine beliebig große Vorausschau. Wir werden später sehen, dass man mit Parserkombinatoren sogar Sprachen
erkennen kann, die nicht kontextfrei sind.
Wir lernen nun weitere Kombinatoren kennen, die es erlauben, Parser mit Zusatzinformation als Ausgabe zu definieren. Der einfachste dieser Parser wird durch
yield :: a -> Parser a
konstruiert. yield x ist ein Parser, der das leere Wort erkennt und in dem Fall x liefert.
Als komplizierteres Beispiel erweitern wir den Parser für korrekt geschachtelte KlammerAusdrücke um ein Ergebnis:
nesting :: Parser Int
nesting
= (\m n -> max (m+1) n)
<$> (char ’(’ *> nesting <* char ’)’)
<*> nesting
<|> yield 0
Der Parser nesting erkennt die selbe Sprache wie nested gibt aber zusätzlich die maximale Schachtelungstiefe aus:
ghci> parse nesting "(()(()()))"
Just 3
ghci> parse nesting ""
Just 0
ghci> parse nesting "(()()"
Nothing
Wir haben zur Definition von nesting zwei neue Kombinatoren verwendet. Der erste
(<$>) :: (a -> b) -> Parser a -> Parser b
wendet eine Funktion auf das Ergebnis eines Parsers an. Das Ergebnis von <$> ist ein
Parser, der die selbe Sprache erkennt wie das zweite Argument aber ein durch das erste
Argument verändertes Ergebnis liefert. Der Typ von <$> erinnert an die map-Funktion.
Tatsächlich können wir den Typkonstruktor Parser zu einer Instanz der Klasse Functor
machen,
41
instance Functor Parser where
fmap = (<$>)
denn es gilt
id <$> p
f <$> (g <$> p)
=
=
p
(f . g) <$> p
Sowohl <$> als auch <*> sind linksassoziativ. Im Beispiel wird also eine Funktion vom
Typ
Int -> Int -> Int
auf den Parser
(char ’(’ *> nesting <* char ’)’)
vom Typ Parser Int angewendet. Das Ergebnis ist ein Parser vom Typ
Parser (Int -> Int)
der eine Funktion liefert! Dieser wird dann mit dem Kombinator <*> mit dem Parser
nesting vom Typ Parser Int zu einem Parser vom Typ Parser Int kombiniert. Der
Kombinator <*> hat den folgenden Typ:
(<*>) :: Parser (a -> b) -> Parser a -> Parser b
Dieser Typ ähnelt dem des <$>-Kombinators, nur dass die Funktion nicht direkt
übergeben sondern von einem Parser geliefert wird. Tatsächlich können wir jede
Verwendung von <$> auch mit <*> ausdrücken, denn es gilt:
f <$> p
=
yield f <*> p
<*> konstruiert also einen Parser, der die gegebenen Parser hintereinander ausführt und
die Funktion, die der erste Parser liefert, auf das Ergebnis des zweiten Parsers anwendet.
Die beiden anderen Sequenz-Kombinatoren könnten wir mit Hilfe von <$> und <*>
definieren, denn es gilt:
p <* q
p *> q
=
=
(\x _ -> x) <$> p <*> q
(\_ y -> y) <$> p <*> q
42
Neben den gezeigten Gleichungen gelten auch die folgenden:
f <$> yield x
p <*> yield y
p <*> (q <*> r)
=
=
=
yield (f x)
($y) <$> p
(.) <$> p <*> q <*> r
In der ersten Gleichung wird eine Funktion auf das Ergebnis eines Parsers angewendet,
der das leere Wort erkennt und x liefert. Die zweite Gleichung zeigt, auf welche Weise
man die Funktion, die ein Parser liefert, auf einen Wert anwenden kann und die dritte behandelt die Hintereinanderausführung von Funktionen, die von Parsern geliefert
werden.
Wir betrachten ein weiteres Beispiel für Parser mit Ergebnis und verwenden dabei
zusätzlich die folgenden Kombinatoren.
anyChar :: Parser Char
check
:: (a -> Bool) -> Parser a -> Parser a
Der Parser anyChar c liest ein einzelnes Zeichen und gibt es zurück und check verändert
einen Parser so, dass er fehlschlägt, wenn sein Ergebnis das gegebene Prädikat nicht
erfüllt. Wir könnten zum Beispiel den Kombinator char mit Hilfe vom anyChar und
check definieren:
char :: Char -> Parser ()
char c = check (c==) anyChar *> empty
Das abschließende *> empty ist hier nur dazu da, den Ergebnistyp des Parsers von a
nach () zu ändern.
Ein Vorteil von Parserkombinatoren ist, dass man sich aufbauend auf existierenden Kombinatoren neue Kombinatoren definieren kann, um sie später zur Definition von Parsern
zu verwenden. Als Beispiel definieren wir den Kombinator many, der einen Parser beliebig
oft hintereinander ausführt.
many :: Parser a -> Parser [a]
many p = (:) <$> p <*> many p
<|> yield []
Die Ergebnisse des gegebenen Parsers p werden gesammelt und als Liste von many p
zurückgeliefert. Wir können die neuen Kombinatoren verwenden, um einen Parser für
Palindrome zu definieren.
43
palindrom
= check (\ (u,v) -> u == reverse v)
$ (,)
<$> many anyChar
<*> many anyChar
Dieser Parser erkennt zunächst zwei beliebige Worte u und v und testet dann, ob u die
Umkehrung von v ist.
ghci> parse palindrom "anna"
Just ("an","na")
ghci> parse palindrom "otto"
Just ("ot","to")
Dieses Beispiel zeigt, dass die Sprachklasse, die man mit Parserkombinatoren erkennen
kann, auch nicht-kontextfreie Sprachen enthält.
6.2 Implementierung
Wir werden nun sehen, wie man den Parser-Typ in Haskell implementieren kann.
Da die parse-Funktion die einzige ist, die auf Parsern aufgerufen wird und dabei keinen
neuen Parser erzeugt, könnten wir versuchen, den Parser-Typ als ebendiese Funktion
zu definieren:
type Parser a = String -> Maybe a
parse :: Parser a -> String -> Maybe a
parse p = p
Diese Darstellung stößt jedoch schnell an ihre Grenzen. Zum Beispiel bei der Definition
des *>-Kombinators:
(*>) :: Parser a -> Parser b -> Parser b
p *> q = \s -> p s ??? q s
Wir können keine sinnvolle Definition für *> angeben, weil der zweite Parser q nicht auf
der kompletten Eingabe aufgerufen werden soll sondern auf dem Rest der Eingabe, die
nach dem Parsen von p noch übrig ist.
Wir könnten den Parser-Typ daher wie folgt ändern:
type Parser a = String -> Maybe (a,String)
44
Zusätzlich zum Ergebnis, liefert jeder Parser nun den Teil der Eingabe zurück, den er
nicht verbraucht hat. Die parse-Funktion müssten wir dann wie folgt umschreiben:
parse :: Parser a -> String -> Maybe a
parse p s = case p s of
Just (x,"") -> Just x
_
-> Nothing
parse liefert genau dann ein Ergebnis, wenn der Parser eines liefert und die restliche
Eingabe leer ist.
Mit dieser Definition können wir *> sinnvoll implementieren:
(*>) :: Parser a -> Parser b -> Parser b
p *> q = \s -> case p s of
Just (_,s’) -> q s’
Nothing
-> Nothing
Wir ignorieren das Ergebnis des ersten Parsers p und geben nur die verbleibende Eingabe
an den Parser q weiter.
Wir versuchen nun weitere Kombinatoren zu definieren. empty liefert das Ergebnis ()
und die Eingabe unverändert zurück.
empty :: Parser ()
empty = \s -> Just ((),s)
Die char-Funktion liefert einen Parser, der () liefert, wenn das erste Zeichen der Eingabe
das gegebene Zeichen ist:
char :: Char -> Parser ()
char x (c:cs) | x == c
= Just (c,cs)
| otherwise = Nothing
Bei der Definition von <|> zur Deklaration von Alternativen, parsen wir erst mit dem
ersten Parser und, wenn dieser fehlschlägt, mit dem zweiten:
(<|>) :: Parser a -> Parser a -> Parser a
p <|> q = \s -> case p s of
Just xs -> Just xs
Nothing -> q s
45
Diese Implementierung erfüllt jedoch nicht das Distributivgesetz, wie das folgende Beispiel zeigt:
test1
test2
=
(empty <|> char ’a’) *> char ’b’
= (empty *> char ’b’)
<|> (char ’a’ *> char ’b’)
Der erste Parser erkennt das Wort äb" nicht, der zweite hingegen schon.
ghci> parser test1 "ab"
Nothing
ghci> parser test2 "ab"
Just ()
Wir definieren schließlich den Parser-Typ unter Verwendung von Listen statt MaybeWerten, um die Distributivgesetze zu erfüllen:
type Parser a = String -> [(a,String)]
Ein Parser kann also mehrere Ergebnisse liefern und zu jedem Ergebnis kann auch ein
unterschiedlicher Anteil der Eingabe übrig bleiben.
Die parse-Funktion testet, ob es unter den Ergebnissen eines mit leerer Resteingabe gibt
und gibt dieses dann zurück.
parse :: Parser a -> String -> Maybe a
parse p s = case filter (null.snd) $ p s of
(x,_):_ -> Just x
_
-> Nothing
Falls es mehrere Ergebnisse mit leerer Resteingabe gibt, wird einfach das erste zurück
gegeben.
Wir passen nun die bisher definierten Kombinatoren an Listen an. Das leere Wort kann
man auf genau eine Art parsen. Der Parser für empty liefert also eine einelementige Liste:
empty :: Parser ()
empty = \s -> [((),s)]
Der Parser für ein Zeichen liefert entweder eine einelementige oder eine leere Liste:
46
char :: Char -> Parser ()
char x (c:cs) | x == c = [((),cs)]
char x _
= []
Der folgende Aufuf zeigt, wie sich ein mit char erzeugter Parser verhält:
ghci> char ’a’ "abc"
[((),"bc")]
Das erste Zeichen ist weggelesen, neben dem einzigen Ergebnis () bleibt als "bc" als
Resteingabe. Wenn das erste Zeichen nicht das gesuchte ist, wird gar kein Ergebnis
geliefert:
ghci> char ’a’ "bc"
[]
Alternativ zur eben gezeigten Definition können wir char auch mit check und anyChar
definieren:
anyChar :: Parser Char
anyChar []
= []
anyChar (c:cs) = [(c,cs)]
check :: (a->Bool) -> Parser a -> Parser a
check ok p = filter (ok . fst) . p
char :: Char -> Parser ()
char c = check (c==) anyChar *> empty
anyChar liefert, auf die leere Eingabe angewendet, kein Ergebnis und ansonsten das erste
Zeichen der Eingabe als Ergebnis und die restlichen Zeichen als verbleibende Eingabe.
check ok p filtert aus den Ergebnissen von p die Ergebnisse heraus, die das Prädikat
ok erfüllen.
Den Sequenz-Operator *> definieren wir auf Listen wie folgt:
(*>) :: Parser a -> Parser b -> Parser b
p *> q =
\s -> [ xs | (_,s’) <- p s, xs <- q s’ ]
Wie bei der Maybe-Variante ignorieren wir das Ergebnis des ersten Parsers p, reichen
aber die verbleibende Eingabe s’ an den zweiten Parser q weiter und liefern dessen
Ergebnisse zurück.
Wir können zwei beliebige Zeichen hintereinander lesen, indem wir zwei anyChar-Parser
mit *> kombinieren:
47
ghci> (anyChar *> anyChar) "abc"
[(’b’,"c")]
Da das Gesamtergebnis, das Ergebnis des zweiten Parsers ist, erhalten wir als Ergebnis
das Zeichen ’b’. Als Resteingabe verbleibt "c".
Der Kombinator <*> verallgemeinert *>, da er das Ergebnis des ersten Parsers nicht
ignoriert sondern auf das Ergebnis des zweiten anwendet:
(<*>) :: Parser (a->b) -> Parser a -> Parser b
p <*> q =
\s -> [ (f x,s2) | (f,s1) <- p s,
(x,s2) <- q s1 ]
Mit Hilfe von <*> können wir die anderen Sequenz-Kombinatoren definieren. Da wir
<* noch nicht definiert haben, holen wir das hiermit nach:
(<*) :: Parser a -> Parser b -> Parser a
p <* q = const <$> p <*> q
Auch <$> können wir mit <*> definieren:
(<$>) :: (a -> b) -> Parser a -> Parser b
f <$> p = yield f <*> p
yield ist hierbei eine Verallgemeinerung des Parsers empty, die den gegebenen Wert als
Ergebnis liefert, aber keine Eingabe verbraucht.
yield :: a -> Parser a
yield x = \s -> [(x,s)]
Zur Illustration dieser Kombinatoren betrachten wir das Ergebnis eines Aufrufs des
Parsers anyChar <* anyChar:
ghci> let c = yield const
ghci> :t c
Parser (a -> b -> a)
ghci> let a = c <*> anyChar
ghci> :t a
Parser (b -> Char)
ghci> let ab = a <*> anyChar
ghci> :t ab
Parser Char
ghci> ab "abc"
[(’a’,"c")]
48
Der <*> Kombinator wendet schrittweise die const-Funktion auf die Ergebnisse der
beiden anyChar-Parser an. Im Ergebnis wird also das Zeichen ’a’ mit verbleibender
Eingabe "c" geliefert.
Es bleibt noch die Definition des <|>-Kombinators zur Deklaration von Alternativen in
einer Grammatik. <|> wendet beide Parser auf die Eingabe an und konkateniert deren
Ergebnisse.
(<|>) :: Parser a -> Parser a -> Parser a
p <|> q = \s -> p s ++ q s
Das folgende Beispiel zeigt, wie ein mit <|> definierter Parser unterschiedliche Ergebnisse liefert:
ghci> (empty <|> char ’a’) "abc"
[((),"abc"),((),"bc")]
Dieser Parser gibt entweder () zurück, ohne ein Zeichen von der Eingabe zu lesen, oder
liest ein ’a’ und liefert als verbleibende Eingabe "bc".
Der Parser failure ist neutral bezüglich <|>, da er kein Ergebnis liefert:
failure :: Parser a
failure _ = []
Die Definition von <|> für Listen erfüllt im Gegensatz zur Maybe-Variante das Distributivgesetz. Dadurch probiert ein Parser mit Backtracking alle möglichen Parser-Läufe.
Dies ist zum Beispiel für den Palindrom-Parser notwendig, der in der Maybe-Variante
Palindrome nicht erkennt.
Backtracking kann zu Effizienzproblemen führen. Daher gibt es einen alternativen
Alternativ-Kombinator <!>, der kein Backtracking verursacht sondern die zweite
Alternative nur ausführt, wenn die erste fehlschlägt:
(<!>) :: Parser a ->
p <!> q = \s -> case
[]
xs
Parser a -> Parser a
p s of
-> q s
-> xs
Wie <|> ist auch <!> assoziativ mit neutralem Element failure. Statt des Distributivgesetzes gilt:
yield x <!> p
=
yield x
49
Man verwendet <!> in der Regel dann, wenn man weiß, dass die zweite Alternative
nicht zum Erfolg führen kann, falls die erste schon erfolgreich war.
Der folgende Parser für Binärzahlen in LSB-Darstellung (least significant bit first) demonstriert noch einmal die Verwendung der definierten Kombinatoren:
binary
bit
= (\b n -> 2*n + b) <$> bit <*> binary
<!> yield 0
= char ’0’ *> yield 0
<!> char ’1’ *> yield 1
Der binary-Parser liest folgen von Nullen und Einsen und berechnet als Ergebnis den
Wert der zugehörigen Binärzahl. Dazu wird die Funktion (\b n -> 2*n + b) mit den
Kombinatoren <$> und <*> auf die Ergebnisse der Parser bit und binary angewendet. Wenn kein Zeichen mehr vorhanden ist, gibt binary die Zahl Null zurück. Da die
Alternativen in diesem Beispiel sich gegenseitig ausschließen, verwenden wir <!> statt
<|>.
Hier einige Beispielaufrufe:
ghci> parse
Just 0
ghci> parse
Just 0
ghci> parse
Just 1
ghci> parse
Just 1
ghci> parse
Just 2
ghci> parse
Just 3
ghci> parse
Just 11
ghci> parse
Just 42
binary ""
binary "0"
binary "1"
binary "10"
binary "01"
binary "110"
binary "1101"
binary "010101"
Ein einfache Verallgemeinerung des Parser-Typs ist es, den Typ der Eingabe zu parametrisieren. Mit einem Typ
type Parser tok a = [tok] -> [(a,[tok])]
50
kann man ohne wesentliche Änderungen an der Definition der Kombinatoren beliebige Tokenfolgen parsen. Dies ist besonders dann nötig, wenn dem Parser ein Scanner
vorgeschaltet ist, der die Eingabe in Symbolklassen zerlegt.
Die hier gezeigte Implementierung der Parserkombinatoren definiert den Parser-Typ
durch ein Typsynonym. Stattdessen sollte man einen newtype verwenden, damit man
Typklasseninstanzen für Parser angeben kann. Darauf haben wir hier nur aus Gründen
der Übersichtlichkeit verzichtet.
6.3 Monadische Parser
Wir haben am Beispiel der Palindrome gesehen, dass man mit Parserkombinatoren nicht
nur kontextfreie Sprachen erkennen kann. Das liegt auch daran, dass man zur Laufzeit Parser generieren kann, die von Ergebnissen anderer Parser abhängen. Der checkKombinator ist ein Bespiel für solche Parser, da er das Ergebnis eines Parsers verwendet
um die Sprache, die er erkennt, einzuschränken. Wir können diese Idee auch in einem
anderen Kombinator wieder finden.
Der Kombinator *>= übergibt das Ergebnis eines Parsers an eine Funktion, die daraus
einen neuen Parser berechnet:
(*>=) :: Parser a -> (a->Parser b) -> Parser b
Mit Hilfe dieses Kombinators können wir den Palindrom-Parser so umschreiben, dass
er nur noch ein beliebiges Wort, gefolgt von dessen Umkehrung liest, also nicht mehr
beliebige Kombinationen aus Worten, die erst anschließend getestet werden.
palindrom =
many anyChar *>= \u ->
word (reverse u)
Der Parser word (reverse u) kennt das zuvor gelesene Wort u und liest dann das Wort
reverse u. word ist eine Funktion, die aus einer bereits gelesenen Eingabe, zur Laufzeit
einen Parser generiert:
word :: String -> Parser ()
word []
= empty
word (c:cs) = char c *> word cs
Da der word-Kombinator () als Ergebnis liefert, gilt dies auch für den neuen PalindromParser:
51
ghci> parse palindrom "anna"
Just ()
ghci> parse palindrom "otto"
Just ()
ghci> parse palindrom "hans"
Nothing
Der *>=-Kombinator hat genau den Typ des monadischen >>=-Operators. Außerdem entspricht yield der return-Funktion. Da die Monadengesetze für diese ParserKombinatoren erfüllt sind, können wir eine Instanz der Klasse Monad für Parser angeben:
instance Monad Parser where
return = yield
(>>=) = (*>=)
Diese Instanz erlaubt es, Parser mit Hilfe von do-Notation zu definieren. Zum Beispiel
könnten wir den Parser für korrekt geschachtelte Klammer-Ausdrücke so umschreiben:
nested
= do char ’(’
nested
char ’)’
nested
<|> empty
Parser, die im do-Block hintereinander stehen, werden hintereinander ausgeführt, und
wir können auf die Ergebnisse von einzelnen Parsern mit einem Linkspfeil zugreifen:
nesting
= do char ’(’
m <- nesting
char ’)’
n <- nesting
return (max (m+1) n)
<|> return 0
Durch die do-Notation können wir die Ergebnisse der Parser beachten, die wir benötigen,
und brauchen nicht die Kombinatoren *> und <* zu verwenden, um einzelne Ergebnisse
zu ignorieren.
Schließlich können wir auch den Palindrom-Parser in do-Notation schreiben:
52
palindrom =
= do u <- many anyChar
word u
<|> empty
Wir kommen nun zur Implementierung des *>=-Kombinators:
(*>=) :: Parser a -> (a->Parser b) -> Parser b
p *>= f =
\s -> [ (y,s2) | (x,s1) <- p s,
(y,s2) <- f x s1 ]
Das Ergebnis x des Parsers p wird der Funktion f übergeben, die daraus einen neuen
Parser berechnet, der mit der verbleibenden Eingabe aufgerufen wird.
Der *>=-Kombinator ist der mächtigste der Sequenz-Kombinatoren. Wir haben bereits
gesehen, dass man <* und *> mit Hilfe von <*> ausdrücken kann. Das folgende Gesetz
zeigt, dass man <*> mit Hilfe von *>= ausdrücken kann:
p <*> q
=
p *>= \f -> q *>= \x -> yield (f x)
Man könnte also auch <$> durch *>= und yield definieren.
6.4 Applikative Funktoren
Wir haben bereits gesehen, dass <$> der Funktion fmap aus der Functor-Klasse entspricht. Ferner entsprechen yield und *>= den Monad-Operationen return und >>=.
Auch <*> ist in Haskell mit einer Typkonstruktorklasse abstrahiert:
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
(*>) :: f a -> f b -> f b
(<*) :: f a -> f b -> f a
Die Funktion pure entspricht yield bzw der return-Funktion der Klasse Monad. Obwohl
*> und <* durch <*> definiert werden können, wurden sie mit in die ApplicativeKlasse aufgenommen, um es Programmierern zu ermöglichen, effizientere Implementierungen anzugeben.
Instanzen der Klasse Applicative müssen die folgenden Gesetze erfüllen, die wir schon
für Parser kennen gelernt haben.
Die Functor-Instanz muss im folgenden Sinn verträglich mit der Implementierung von
pure und <*> sein:
53
fmap f u
=
pure f <*> u
Die Functor-Gesetze übertragen sich also auf applikative Funktoren. Außerdem muss
gelten:
u<*>(v<*>w)
pure (f x)
u<*>pure x
u *> v
u <* v
=
=
=
=
=
pure
pure
pure
pure
pure
(.)<*>u<*>v<*>w
f <*> pure x
($x) <*> u
(const id)<*>u<*>v
const <*> u <*> v
Für jeden Typkonstruktor M, der Instanz der Klasse Monad ist, kann man eine
Applicative-Instanz wie folgt definieren:
instance Functor M where
fmap = liftM
instance Applicative M where
pure = return
(<*>) = ap
Die Funktionen liftM und ap sind im Modul Control.Monad definiert. Für *> und
<* gibt es Default-Implementierungen in der Applicative-Klasse gemäß der obigen
Gesetze, man braucht dafür also keine Implementierungen anzugeben. Für viele vordefinierte Monaden ist auch eine Applicative-Instanz definiert, so dass man monadische
Programme auch in applikativem Stil schreiben kann.
Zum Beispiel ist bei der Definition von Arbitrary-Instanzen für QuickCheckTesteingaben der applikative Stil oft übersichtlicher. Statt:
arbitrary = return Leaf
‘mplus‘ do l <- arbitrary
x <- arbitrary
r <- arbitrary
return (Branch l x r)
kann man unter Verwendung der Kombinatoren <*> und <$> (letzterer ist im Modul
Data.Functor als Synonym für fmap definiert) die folgende Definition angeben:
arbitrary = return Leaf
‘mplus‘ Branch <$> arbitrary
<*> arbitrary
<*> arbitrary
Wie man sieht, ist es auch möglich monadische Kombinatoren (wie return und mplus)
mit den applikativen zu mischen.
54
7 Automatisiertes Testen
Tests sind ein wichtiges Hilfsmittel um Programmierfehler aufzudecken. Sie können zwar
niemals die Abwesenheit von Fehlern zeigen (dazu braucht man Beweise) zeigen jedoch
häufig deren Anwesenheit und sind oft einfacher zu erstellen als Korrektheitsbeweise.
Diese QuickSort Implementierung
qsort :: Ord a => [a] -> [a]
qsort []
= []
qsort (x:xs) =
filter (<x) xs ++ x : filter (>x) xs
können wir manuell im GHCi testen:
ghci> qsort [12,3,4]
[3,4,12]
Alternativ könnten wir eine Reihe solcher Tests in eine Datei schreiben und diese jedesmal ausführen, wenn wir die Implementierung ändern (Regressionstests).
7.1 Eigenschaftsbasiertes Testen
Regressionstests zu schreiben ist langweilig. Programmierer vernachlässigen diese Aufgabe deshalb oft und schreiben nur wenige Tests, die nicht alle interessanten Eingenschaften
überprüfen. Dabei ist es interessant, über Programmeigenschaften nachzudenken! Besser
wäre es aber, wenn man diese Eigenschaften selbst als Tests verwenden könnte, statt sich
Tests zu überlegen, die sie überprüfen.
In Haskell kann man statt Tests Eigenschaften von Funktionen schreiben und diese mit
automatisch generierten Eingaben testen. Dazu verwenden wir das Tool QuickCheck,
das man mit
bash# cabal install QuickCheck-1.2.0.1
installieren kann. (Wir verwenden QuickCheck in Version 1, da Version 2 eine kompliziertere (wenn auch mächtigere) Schnittstelle hat.)
Das folgende Haskell Prädikat spezifiziert, dass qsort idempotent sein soll.
import Test.QuickCheck
idempotence :: [Int] -> Bool
idempotence xs = qsort (qsort xs) == qsort xs
55
Wir können dieses Prädikat von Hand mit Beispieleingaben aufrufen.
ghci> idempotence [1,2,3]
True
Wir können aber auch die Funktion quickCheck verwenden, die Listen vom Typ [Int]
generiert und prüft, ob das Prädikat für diese erfüllt ist.
ghci> quickCheck idempotence
Falsifiable, after 14 tests:
[5,-1,-3]
ghci> quickCheck idempotence
Falsifiable, after 5 tests:
[1,0,-5,-5]
ghci> quickCheck idempotence
Falsifiable, after 11 tests:
[4,1,1]
Bei unterschiedlichen quickCheck-Aufrufen bekommen wir unterschiediche Gegenbeispiele für das angegebene Prädikat, da die Eingaben zufällig generiert werden.
Wir haben bei der Implementierung von qsort einen Fehler gemacht. Um diesen zu
finden testen wir qsort für eines der von quickCheck gefundenen Gegenbeispiele.
ghci> qsort [4,1,1]
[1,1,4]
ghci> qsort [1,1,4]
[1,4]
Das Ergebnis nach dem zweiten Aufruf enthält eine 1 zu wenig. Wir passen die Definition
daher wie folgt an und schreiben (>=x) anstelle von (>x):
qsort :: Ord a => [a] -> [a]
qsort []
= []
qsort (x:xs) =
filter (<x) xs ++ x : filter (>=x) xs
Zumindest für die obige Eingabe funktioniert die Implementierung nun.
ghci> idempotence [4,1,1]
True
56
Wir verwenden wieder quickCheck um weitere Fehler zu suchen.
ghci> quickCheck idempotence
Falsifiable, after 8 tests:
[-1,-4,-3,2,-5]
ghci> quickCheck idempotence
Falsifiable, after 14 tests:
[3,2,2,-5]
ghci> quickCheck idempotence
Falsifiable, after 7 tests:
[3,2,-3]
Noch immer enthält unsere Implementierung einen Fehler:
ghci> qsort [3,2,-3]
[2,-3,3]
Wir haben die rekursiven Aufrufe vergessen:
qsort :: Ord a => [a] -> [a]
qsort []
= []
qsort (x:xs) =
qsort (filter (<x) xs)
++ x : qsort (filter (>=x) xs)
Diese Implementierung ist augenscheinlich idempotent. Zumindest findet quickCheck
kein Gegenbeispiel mehr.
ghci> quickCheck idempotence
OK, passed 100 tests.
Idempotenz ist eine notwendige Eigenschaft einer Sortierfunktion aber nicht hinreichend.
Zum Beispiel wäre die Definition qsort = [] auch idempotent.
Als weitere Eigenschaft spezifizieren wir daher, dass alle Elemente aus dem Argument
von qsort auch im Ergebnis vorkommen müssen und umgekehrt.
preservation :: [Int] -> Bool
preservation xs =
null (xs \\ qsort xs) && null (qsort xs \\ xs)
57
Wir verwenden dazu die Funktion (\\) zur Berechnung der Differenz zweier Listen aus
dem Data.List Modul.
Auch diese Eigenschaft ist augenscheinlich erfüllt:
ghci> quickCheck preservation
OK, passed 100 tests.
Jede Funktion, die eine Permutation ihrer Eingabe liefert, erfüllt die obige Eigenschaft.
Wir könnten daher zusätzlich testen, ob das erste Element einer sortierten Liste das
kleinste ist um zu testen, ob die Funktion richtig herum sortiert.
smallest_first :: [Int] -> Bool
smallest_first xs =
head (qsort xs) == minimum xs
Die Funktion minimum berechnet das kleinste Element einer Liste und ist im Modul
Data.List vordefiniert.
Wenn wir diese Eigenschaft mit quickCheck testen, erhalten wir einen Fehler.
ghci> quickCheck smallest_first
*** Exception: Prelude.head: empty list
Diese Eigenschaft ist nur für nicht-leere Listen sinnvoll und liefert mit der leeren Liste
als Eingabe einen Patternmatch-Fehler.
QuickCheck stellt eine Funktion (==>) zur Spezifikation von Vorbedingungen bereit,
die wir verwenden um die obige Eigenschaft anzupassen.
smallest_first :: [Int] -> Property
smallest_first xs =
not (null xs) ==>
head (qsort xs) == minimum xs
Durch die Verwendung von (==>) ändert sich der Ergebnistyp der Eigenschaft von Bool
zu Property. Wir können sie aber weiterhin mit quickCheck testen.
ghci> quickCheck smallest_first
OK, passed 100 tests.
58
Häufig verwendet man statt einzelner Eigenschaften eine Referenz- oder PrototypImplementierung. Wenn man zum Beispiel eine offensichtlich korrekte aber ineffiziente
Variante einer Funktion programmieren kann, kann man diese benutzen um eine effiziente Implementierung dagegen zu testen. Beispielhaft testen wir unsere qsort-Funktion
gegen die vordefinierte sort-Funktion aus dem Data.List-Modul.
reference :: [Int] -> Bool
reference xs = qsort xs == sort xs
Für 100 von quickCheck generierte Eingaben vom Typ [Int] berechnet qsort das
selbe Ergebnis wie die vordefinierte Sortierfunktion, was zu einigem Vertrauen in die
Implementierung von qsort berechtigt.
ghci> quickCheck reference
OK, passed 100 tests.
7.2 Klassifikation der Testeingabe
Es gibt verschiedene Wege zu Informationen über die ausgeführten Tests zu gelangen.
Die Funktion verboseCheck gibt alle Tests der Reihe nach aus, was allerdings meistens
zu unübersichtlich ist. Deshalb gibt es Funktionen, die es erlauben Tests zu klassifizieren.
Mit der Funktion trivial kann man testen, wie viele der Tests trivial sind. Was trivial
bedeutet, bestimmt man dabei durch ein eigenes Prädikat. Zum Beipiel können wir den
Test gegen die Referenz-Implementierung wie folgt anpassen:
reference :: [Int] -> Property
reference xs =
trivial (null xs) $ qsort xs == sort xs
Nun gibt quickCheck aus, wie viele der getesteten Listen leer sind.
ghci> quickCheck reference
OK, passed 100 tests (18% trivial).
Die Funktion classify ist ähnlich wie trivial erlaubt aber den Namen der Eigenschaft
selbst zu wählen. Wenn wir das Referenz-Prädikat so implementieren
reference :: [Int] -> Property
reference xs =
classify (length xs < 5) "small" $
qsort xs == sort xs
59
erzeugt quickCheck die folgende Ausgabe:
ghci> quickCheck reference
OK, passed 100 tests (54% small).
Schließlich können wir auch die Längen selbst verwenden um Tests zu gruppieren.
reference
reference
collect
qsort
:: [Int] -> Property
xs =
(length xs) $
xs == sort xs
Danach gibt quickCheck aus wie viele Listen wie lang waren.
ghci> quickCheck reference
OK, passed 100 tests.
16% 0.
14% 1.
11% 2.
8% 4.
7% 7.
7% 6.
6% 3.
5% 5.
4% 9.
3% 21.
3% 13.
2% 8.
2% 17.
2% 15.
2% 10.
1% 31.
1% 29.
1% 25.
1% 24.
1% 23.
1% 22.
1% 19.
1% 11.
Es zeigt sich, dass Listen etwa bis zur Länge 30 generiert werden und kürzere Listen
häufiger als lange. Man kann die Klassifikationen auch kombinieren wie das folgende
Beispiel zeigt.
60
reference :: [Int] -> Property
reference xs =
classify (length xs < 5) "small" $
classify (length xs > 10) "large" $
qsort xs == sort xs
Jede vierte der generierten Listen enthält mehr als zehn Elemente.
ghci> quickCheck reference
OK, passed 100 tests.
63% small.
25% large.
(Die Zahlen variieren leicht in unterschiedlichen Läufen von quickCheck.)
7.3 Eingabe-Generatoren
In QuickCheck legt die Klasse Arbitrary fest, wie für einen bestimmten Typ Testdaten
erzeugt werden.
class Arbitrary a where
arbitrary :: Gen a
Hierbei ist Gen eine Monade, die Zufallsentscheidungen erlaubt. Wir werden später sehen,
wie man Gen implementieren kann und beschränken uns zunächst auf die Benutzung.
Wir definieren einen Datentyp für kleine natürliche Zahlen, den wir verwenden um unsere
QuickSort Implementierung zu testen.
newtype Digit = Digit Int
deriving (Eq, Ord)
Damit QuickCheck Gegenbeispiele anzeigen kann, müssen wir eine Show-Instanz definieren. Anders als eine ‘derivete’ Instanz, lässt unsere den newtype-Konstruktor weg:
instance Show Digit where
show (Digit d) = show d
Wir passen den Typ unserer Eigenschaft an um nur noch Listen von kleinen natürlichen
Zahlen zu sortieren.
61
reference :: [Digit] -> Property
reference xs =
classify (length xs < 5) "small" $
classify (length xs > 10) "large" $
qsort xs == sort xs
Wenn wir nun versuchen, quickCheck mit der angepassten Eigenschaft aufzurufen erhalten wie einen Typfehler.
ghci> quickCheck reference
No instance for (Arbitrary Digit)
QuickCheck weiß nicht, wie man Werte vom Typ Digit generiert. Wir müssen dies durch
eine Aribitrary-Instanz festlegen.
instance Arbitrary Digit where
arbitrary =
do d <- oneof (map return [0..9])
return $ Digit d
Die Definition von arbitrary für den Typ Digit wählt mit der Funktion oneof ::
[Gen a] -> Gen a eine kleine natürliche Zahl zufällig aus und gibt diese als Digit-Wert
zurück. Alternativ zu oneof (map return [0..9]) hätten wir auch choose (0,9) verwenden können. Die Funktion choose wählt zufällig einen Wert aus dem angegebenen
Intervall.
Die vordefinierte Arbitrary-Instanz für Listen verwendet unsere Instanz für Digit um
Werte vom Typ [Digit] zu erzeugen. Sie ist wie folgt definiert:
instance Arbitrary a => Arbitrary [a] where
arbitrary = sized $ \n ->
do l <- choose (0,n)
sequence [ arbitrary | _ <- [1..l] ]
Die Funktion
sized :: (Int -> Gen a) -> Gen a
kann man verwenden um einen Generator, der Werte bis zu einer gewissen Größe erzeugt,
zu definieren. In diesem Fall wird der Größen-Parameter n verwendet um die Länge der
erzeugten Liste zu beschränken. Mit choose wird eine Länge zwischen 0 und n gewählt
und dann mit sequence eine zufällige Liste entsprechender Länge erzeugt. Die Elemente
der Liste werden durch einen Aufruf der Funktion arbitrary für den Element-Typ
generiert.
62
7.4 Implementierung
Wir werden nun sehen, dass QuickCheck als Bibliothek in Haskell programmiert werden
kann. Die hier vorgestellte Implementierung ist eine vereinfachte Variante der wirklichen
Implementierung. Es fehlt zum Beispiel der Property-Typ und mit ihm die Möglichkeit,
Vorbedingungen zu spezifizieren oder die Verteilung der Testeingabe zu berechnen. Unsere Variante unterstützt aber ansonsten beliebige Eigenschaften mit Ergebnistyp Bool.
Die Gen-Monade zur Erzeugung zufälliger Test-Eingaben greift intern auf einen
Zufallsgenerator zu. Wir verwenden dazu die folgende Schnittstelle, die vom Modul
System.Random bereit gestellt wird:
data StdGen
newStdGen :: IO StdGen
split
:: StdGen -> (StdGen,StdGen)
randomR
:: (Int,Int) -> StdGen -> (Int,StdGen)
Zufallszahlen-Generatoren sind vom Typ StdGen und können mit split in zwei unabhängige Generatoren zerlegt werden. Die Funktion randomR liefert eine Zahl aus dem
angegebenen Intervall und einen neuen Zufallszahlen-Generator. Nur newStdGen zur Erzeugung eines Zufallszahlen-Generators ist eine IO-Aktion. Die anderen Funktionen sind
rein funktional.
Zusätzlich zu einem Zufallszahlen-Generator haben Gen-Berechnungen auch Zugriff auf
einen Parameter, der angibt, wie groß der generierte Wert höchstens sein soll.
newtype Gen a = Gen (Int -> StdGen -> a)
runGen :: Gen a -> Int -> StdGen -> a
runGen (Gen g) = g
Die Monadeninstanz für Gen ist ähnlich wie die der Zustandsmonade und fast genauso
wie die der Umgebungsmonade (siehe Übung). Wir geben daher nur die Instanz an und
verzichten auf einen Nachweis der Monadengesetze.
instance Monad Gen where
return x = Gen (\_ _ -> x)
a >>= f =
Gen (\size rnd ->
let (r1,r2) = split rnd
in runGen (f (runGen a size r1)) size r2)
63
Bei der Implementierung von (>>=) ist zu beachten, dass die Berechnungen des linken
und rechten Arguments mit unabhängigen Zufallszahlen-Generatoren ausgeführt werden.
Die choose-Funktion zur Definition eines Testfall-Generators für Zahlen definieren wir
mit Hilfe der Funktion randomR.
choose :: (Int,Int) -> Gen Int
choose bounds =
Gen (\_ rnd -> fst (randomR bounds rnd))
Wir ignorieren dabei den Größen-Parameter, rufen randomR mit den angegebenen Intervallgrenzen auf und ignorieren den Zufallszahlen-Generator, den randomR als zweites
Ergebnis liefert.
Wir können choose verwenden um in der Definition von oneof einen zufälligen Index
auszuwählen.
oneof :: [Gen a] -> Gen a
oneof [] = error "oneof empty list"
oneof xs =
do n <- choose (0,length xs-1)
xs !! n
Da man aus einer leeren Liste keinen Wert auswählen kann, brechen wir in diesem Fall
mit einem Fehler ab.
Schließlich definieren wir noch die Funktion sized, die den Zugriff auf den GrößenParameter ermöglicht.
sized :: (Int -> Gen a) -> Gen a
sized f = Gen (\size -> runGen (f size) size)
Der Größen-Parameter wird der Argument-Funktion übergeben und der resultierende
Generator ausgeführt.
Als weiteres Beispiel für die Verwendung von sized geben wir die DefaultImplementierung des Testfall-Generators für Int-Zahlen an:
instance Arbitrary Int where
arbitrary = sized $ \n -> choose (-n,n)
Wir konstruieren damit eine Zahl, deren Betrag die gegebene Größe nicht übersteigt.
Wir wollen nun eine Funktion quickCheck definieren, die eine in Haskell programmierte
Eigenschaft als Parameter nimmt und diese mit 100 zufälligen Eingaben testet. Bei einem
64
fehlschlagenden Test soll zudem die Eingabe, die zum Fehlschlag führte, ausgegeben
werden.
Wir definieren einen Typ Test für einen Testlauf, der eine String-Representation der
Argumente und das Ergebnis des Tests speichert:
type Test = ([String],Bool)
Wenn das einzige Argument einer Eigenschaft Instanz der Klassen Arbitrary und Show
ist, können wir die Eigenschaft in einen Generator für Test-Ergebnisse konvertieren.
genTest :: (Arbitrary a, Show a)
=> (a -> Bool) -> Gen Test
genTest p =
do x <- arbitrary
return ([show x],p x)
Dazu erzeugen wir ein zufälliges Argument mit arbitrary und wenden dann die Eigenschaft auf dieses Argument an. Zusätzlich geben wir im ersten Argument des Ergebnisses
die String-Representation des Arguments zurück.
Diese Funktion können wir verwenden um eine quickCheck-Funktion für einstellige Eigenschaften zu implementieren.
quickCheck :: (Arbitrary a, Show a)
=> (a -> Bool) -> IO ()
quickCheck p = do rnd <- newStdGen
check 1 rnd (genTest p)
Wir erzeugen einen initialen Zufallszahlen-Generator und rufen dann die Funktion check
auf, die den folgenden Typ hat.
check :: Int -> StdGen -> Gen Test -> IO ()
Das erste Argument ist ein Zähler für die Nummer des nächsten Tests. Das zweite Argument ist ein Zufallszahlen-Generator und das dritte ein Generator für Test-Ergebnisse.
check n rnd gtest
| n > 100
= putStrLn "OK, passed 100 tests."
| snd test = check (n+1) r2 gtest
| otherwise =
do putStrLn $ "Falsifiable, after "
++ show n ++ " tests:"
65
putStr . unlines $ fst test
where
(r1,r2) = split rnd
test
= runGen gtest (n ‘div‘ 2 + 3) r1
Die check-Funktion führt 100 Tests aus es sei denn, einer schlägt fehl. In diesem Fall
werden die Nummer des fehlschlagenden Tests und die den Fehlschlag verursachenden
Argumente ausgegeben. Jeder Test wird mit einem neuen Zufallszahlen-Generator ausgeführt, damit nicht jedesmal die selbe Eingabe generiert wird. Die Größenbeschränkung
berechnen wir mit einer linearen Gleichung aus der Nummer des Tests, so dass sie in
späteren Tests immer größer wird.
Sowohl der Test-Typ als auch die check-Funktion erlauben mehrere Argumente für ein
Test-Ergebnis. Der Typ der eben definierten quickCheck-Funktion erlaubt dagegen nur
einstellige Eigenschaften. Wir wollen quickCheck nun so verallgemeinern, dass Eigenschaften mit beliebig vielen Argumenten getestet werden können.
Dabei stellt sich die Frage, welchen Typ quickCheck haben soll, wenn man es sowohl
auf eine Funktion vom Typ Int -> Bool als auch auf eine vom Typ Int -> [Int] ->
Bool anwenden können soll. Welcher Typ verallgemeinert die folgenden Typen und alle
ähnlichen, d.h. solche, bei denen die übergebene Eigenschaft beliebig viele Argumente
hat, die Instanzen der Klassen Arbitrary und Show sind?
quickCheck :: (Int -> Bool)
-> IO ()
quickCheck :: (Int -> [Int] -> Bool) -> IO ()
Wir können eine solche quickCheck-Funktion mit Hilfe von Überladung, also mit einer
Typklasse, definieren. Dazu abstrahieren wir vom Typ der Eigenschaft.
quickCheck :: Testable p => p -> IO ()
quickCheck p =
do rnd <- newStdGen
check 1 rnd (genTest p)
Damit diese Definition typkorrekt ist, müssen wir die obige Definition von genTest durch
eine Funktion der Typklasse Testable ersetzen.
class Testable p where
genTest :: p -> Gen Test
Wir geben nun Instanzen dieser Klasse an, die es erlauben, Eigenschaften mit beliebig
vielen Argumenten zu testen.
Zunächst definieren wir eine Instanz für Bool, was einer Eigenschaft ohne Argumente
entspricht.
66
instance Testable Bool where
genTest b = return ([],b)
Die Liste der Argumente ist in diesem Fall leer und das Ergebnis des Tests ist der
übergebene Boole’sche Wert.
Außerdem definieren wir die folgende Instanz für Funktionen:
instance (Arbitrary a, Show a, Testable b)
=> Testable (a -> b) where
Diese Instanz macht auf einen Schlag Eigenschaften beliebiger Stelligkeit Testable, deren Argumente Instanzen der Klassen Arbitrary und Show sind. Zum Beispiel ist der
Typ Int -> [Int] -> Bool eine Instanz von Testable, wenn der Typ [Int] -> Bool
einer ist und dieser ist eine Instanz, wenn Bool einer ist, was der Fall ist.
Die Funktion genTest für Eigenschaften mit mindestens einem Argument definieren wir
wie folgt.
genTest p =
do x <- arbitrary
(args,ok) <- genTest (p x)
return (show x:args,ok)
Zunächst wählen wir ein zufälliges Argument x, rufen dann die Eigenschaft p mit x
auf und erzeugen rekursiv mit der genTest-Funktion der nächsten Instanz ein TestErgebnis. Diesem fügen wir als neues erstes Argument die String-Representation von x
hinzu und erhalten das Test-Ergebnis zur Eigenschaft p.
Das ‘echte’ QuickCheck ist im Wesentlichen so definiert wie hier angegeben erlaubt aber
neben der Definition von Vorbedingungen und der Klassifikation von Test-Eingaben
gewisse Parameter, die wir fest eingebaut haben, zu konfigurieren. So ist es zum Beispiel
möglich die Anzahl der auszuführenden Tests oder die Formel, nach der der GrößenParameter berechnet wird, zu verändern.
7.5 Quelltextüberdeckung
Kombinatoren wie classify oder collect erlauben einen gewissen Einblick in die Art
der durchgeführten Tests. Um zu beurteilen wie gründlich die Tests das Programm testen, wäre es aber hilfreich zu wissen, welche Teile des Programms von den Tests ausgeführt wurden und welche nicht.
Haskell Program Coverage (HPC) ist ein in den GHC integriertes Werkzeug, dass Statistiken darüber aufstellt, welcher Anteil eines Programms ausgeführt wurde. Diese Information kann auch in Form von farblich markiertem Programmtext im HTML-Format
67
angezeugt werden. HPC eignet sich daher dazu, die von QuickCheck ausgeführten Tests
zu bewerten und gibt Hinweise, was für Eigenschaften man gegebenenfalls hinzufügen
sollte um noch gründlicher zu testen.
Zur Demonstration von HPC speichern wir die zuvor definierten Eigenschaften für die
QuickSort-Funktion in einer Datei qsortChecks.hs zusammen mit einer main-Funktion,
die alle Eigenschaften ausführt.
main =
do quickCheck
quickCheck
quickCheck
quickCheck
idempotence
preservation
smallest_first
reference
Wir geben nun beim Kompilieren das hpc-Flag an um später diie Quelltextüberdeckung
protokollieren zu können.
bash# ghc -fhpc --make qsortChecks
Wenn wir die Datei ausführen, werden alle Eigenschaften von quickCheck überprüft und
währenddessen eine Datei qsortChecks.tix geschrieben in der die Überdeckungsinformation
enthalten ist.
bash# ./qsortChecks
OK, passed 100 tests.
OK, passed 100 tests.
OK, passed 100 tests.
OK, passed 100 tests.
bash# ls *.tix
qsortChecks.tix
Den Inhalt dieser Datei kann man mit dem Programm hpc verarbeiten. Zum Beispiel
gibt der Aufruf
bash# hpc report qsortChecks
eine kurze Statistik über die verwendeten Programmteile aus:
100% expressions used (57/57)
100% boolean coverage (0/0)
100% guards (0/0)
100% ’if’ conditions (0/0)
68
100% qualifiers (0/0)
100% alternatives used (2/2)
100% local declarations used (0/0)
100% top-level declarations used (6/6)
Wir können diese Information auch visuell aufbereiten, indem wir einen HTML-Report
generieren.
bash# hpc markup qsortChecks
Writing: Main.hs.html
Writing: hpc_index.html
Writing: hpc_index_fun.html
Writing: hpc_index_alt.html
Writing: hpc_index_exp.html
Dieser Aufruf generiert unterschiedliche HTML-Seiten mit Tabellen über die ausgeführten Programmteile. Außerdem wird für jedes Modul eine HTML-Version
generiert, in der ausgeführte Teile fett und nicht ausgeführte Teile gelb markiert werden.
Bei mehreren Läufen von mit -fhpc kompilierten Programmen wird die Überdeckungsinformation
in der .tix Datei akkumuliert. Dies schlägt fehl, wenn sich das Programm geändert
hat. In soeinem Fall (oder wenn man alte Läufe ignorieren möchte) muss man die .tix
Datei manuell Löschen, bevor man das Programm erneut ausführt.
8 Debugging
Haskell hat viele Vorteile (z.B. Laziness), die aber das Finden von Fehlern erschweren. In
imperativen Sprachen ist ein einfaches Debugging möglich mittels printf-Anweisungen;
in Haskell ist dies möglich mit der unsicheren Funktion unsafePerformIO:3
import System.IO.Unsafe (unsafePerformIO)
trace :: String -> a -> a
trace str x = unsafePerformIO $ do
putStr str
return x
Die Semantik ist die Identität, aber sobald die Berechnung von trace angestoßen wird,
wird der String als Seiteneffekt ausgegeben:
3
Dabei ist $ ein Operator, der die niedrigste Präzedenz hat und somit die Präzedenz der Funktionsanwendung umkehrt:f $ g x = f (g x).
69
ghci> trace "Hallo" (3 + 4)
Hallo 7
ghci> take 4 (map (trace "*") [1..])
[*1,*2,*3,*4]
Oft ist man auch an Werten interessiert:
traceAndShow :: Show a => a -> a
traceAndShow x = trace (show x) x
ghci> take 4 (map traceAndShow [1..])
[1 1,2 2,3 3,4 4]
Probleme dabei sind die Vermischung der Debug-Ausgabe mit der Ergebnisausgabe
(Lösung: Trace-Datei) und die Abhängigkeit der Ausgabe von der Auswertungsreihenfolge (beachte Laziness!).
x = let l = map traceAndShow [1..] in sum (take 4 l)
y = let l = map traceAndShow [1..] in sum (reverse (take 4 l))
ghci>
1 2 3
ghci>
4 3 2
x
4 10
y
1 10
Weitere Probleme:
• Die Funktion traceAndShow zerstört zum Teil die Laziness: traceAndShow [1..]
terminiert nicht, sobald es angestossen wird! Durch die dann strikte Auswertung
entsteht zum Einen ein Effizienzverlust, zum Anderen setzen z.B. einige Parserkombinatoren die Laziness voraus.
• Man erhält keine Informationen über den Fortschritt der Auswertung.
• Es ist nur die Beobachtung von Werten der Klasse Show möglich, also z.B. keine
Funktionen!
70
8.1 Debuggen mit Observationen (Hood)
Die Idee ist hier, Werte wie mit traceAndShow zu beobachten, aber “ ” für nicht
benötigte (d.h. auch noch nicht ausgewertete) Teilstrukturen anzuzeigen. Dazu
wird die Ausgabe bis zum Programmende (auch Strg-C) verzögert, ein zusätzlicher
String-Parameter wird benutzt zur Unterscheidung der Beobachtung:
ghci> :l Observe
ghci> take 2 (observe "List" [1..])
[1,2]
>>>>>>> Observations <<<<<<
List (1 : 2 : _)
ghci> observe "List" [1,2,3,4,5]!!3
4
>>>>>>> Observations <<<<<<
List (_ : _ : _ : 4 : _ : [])
Verwendung: In einem Programm kann man mittels import Observe das Modul importieren und dann observe name e wie vorher traceAndShow e benutzen.
Alle Datentypen können observiert werden. Funktionen werden durch den “benutzten
Teil” ihres Funktionsgraphen dargestellt:
ghci> map (observe "inc" (+1)) [1..3]
[2, 3, 4]
>>> Observations <<<
inc
--{ \ 3 -> 4
, \ 2 -> 3
, \ 1 -> 2
}
Observations von Funktionen sind meist wichtiger als die der Datenstrukturen, da sie
die Funktionalität wiederspiegeln. Wir betrachten eine Funktionsdefinition
f p_11 .. p_1n = e_1
...
f p_m1 .. p_mn = e_m
Um alle Aufrufe von f zu observieren, kann man f einfach wie folgt umdefinieren:
71
f = observe "f" f’
f’ p_11 .. p_1n = e_1
...
f’ p_m1 .. p_mn = e_m
So werden alle, also auch die rekursiven Aufrufe observiert. Besser ist daher oft:
f = observe "f" f’
f’ p_11 .. p_1n = e_1[f/f’]
...
f’ p_m1 .. p_mn = e_m[f/f’]
Außerdem möglich sind:
• Observations von IO-/Zustandsmonaden
• Definition von zusammenhängenden Observations.
8.2 Implementierung von Observationen für Daten
Zunächst: Definition einer Datenstruktur zur Repräsentation (teil-)ausgewerteter Daten.
data EvalTree
= Cons String [EvalRef]
| Uneval
-- entspricht ’_’
| Demand
-- entspricht ’!’, abgebrochene Berechnung
type EvalRef = IORef EvalTree
Ein Beispiel für eine abgebrochene Berechnung ist:
ghci> observe "List" [1, fac (-1)]
[1,
<< Ctrl-C >>
>>> Observations <<<
List (1:!:_)
Zuerst implementieren wir die nötigen Funktionen für eine Datenstruktur:
72
Cons "(:)" [ , ]
Cons "1" []
Cons "(:)" [ , ]
Uneval Demand
Abbildung 1: Repräsentation des Terms 1:!:
data Tree = Empty | Node Tree Tree
oTree :: EvalRef -> Tree -> Tree
oTree ref Empty = unsavePerformIO $ do
mkEvalTreeCons "Empty" ref 0
return Empty
oTree ref (Node tl tr) = unsafePerformIO $ do
[tlRef, trRef] <- mkEvalTreeCons "Node" ref 2
return (Node (oTree tlRef tl) (oTree trRef tr))
mkEvalTreeCons :: String -> EvalRef -> Int -> IO [EvalRef]
mkEvalTreeCons consName ref n = do
refs <- sequence $ replicate n $ newIORef Uneval
writeIORef ref (Cons consName refs)
return refs
Es soll nun wie folgt ausgewertet werden:
isNode (Node _ _) = True
isNode _
= False
ghci> isNode (observe "tree" (Node Empty Empty))
~> isNode (oTree ref (Node Empty Empty))
-- wobei ref auf folgendes zeigt:
-- Cons "Node" [ref1 ~> Uneval, ref2 ~> Uneval]
Verallgemeinerung auf beliebige Datentypen:
class Observe a where
obs :: a -> EvalRef -> a
instance Observe Tree where
obs = oTree
73
observer :: Observe a => a -> EvalRef -> a
observer x ref = unsafePerformIO $ do
writeIORef ref Demand
return (obs x ref)
Zusätzlich müssen die rekursiven Aufrufe in oTree durch observer ersetzt werden. Es
wird Demand geschrieben, bevor die Berechnung gestartet wird. Die Reihenfolge ist insgesamt wie folgt:
Anfrage des Werts
Kopfnormalform
Uneval −−−−−−−−−−−→ Demand −−−−−−−−−−→ Cons
Das Speichern aller Observations geschieht in einer globalen Variablen:
global :: IORef [IO ()]
global = unsafePerformIO (newIORef [])
observe :: Observe a => String -> a -> a
observe label x = unsafePerformIO $ do
ref <- newIORef Uneval
modifyIORef global (showInfo ref :)
return (observer x ref)
where
showInfo ref = do
putStrLn (label ++ "\n" ++ replicate (length label) ’-’)
showEvalTreeRef ref >>= putStrLn
runO :: IO () -> IO ()
runO act = do
writeIORef global []
catch act (\e -> putStr "Runtime Error: " >> print (e :: SomeException))
printObs
printObs :: IO ()
printObs = do
putStrLn ">>> Observations <<<"
readIORef global >>= sequence_
Es fehlt nun nur noch die Definition der Funktion showEvalTreeRef, die eine benutzerfreundliche Ausgabe des EvalTrees ermöglicht:
showEvalTreeRef :: EvalTreeRef -> IO String
showEvalTreeRef ref = readIORef ref >>= showEvalTree
74
showEvalTree :: EvalTree -> IO String
showEvalTree Uneval
= return "_"
showEvalTree Demand
= return "!"
showEvalTree (Cons cons []) = return cons
showEvalTree (Cons cons rs) = do
args <- mapM showEvalTreeRef rs
return $ "(" ++ unwords (cons : args) ++ ")"
Spezielle Darstellungen für Tupel oder Infix-Konstruktoren sind natürlich möglich (als
Übung).
Wie definiert man nun komfortabel Instanzen der Klasse Observe?
instance Observe a => Observe [a] where
obs (x:xs) = o2 (:) "(:)" x xs
obs []
= o0 [] "[]"
Hierbei sind folgende Funktionen definiert:
o0 :: a -> String -> EvalRef -> a
o0 cons consName ref = unsafePerformIO $ do
mkEvalTreeCons consName ref 0
return cons
o2 :: (Observe a, Observe b)
=> (a -> b -> c) -> String -> a -> b -> EvalRef -> c
o2 cons consName vA vB ref = unsafePerformIO $ do
[aRef, bRef] <- mkEvalTreeCons consName ref 2
return $ cons (observer vA aRef) (observer vB bRef)
8.3 Observieren von Funktionen
Es wäre natürlich auch schön, wenn man Funktionen oberservieren können würde.
Ähnlich wie bei (lazy) Datenstrukturen kann ein Funktion durch ihren verwendeten Teil
dargestellt werden. Hierzu wird der Teil des Funktionsgraphen dargestellt, der während
des Programmablaufs verwendet wurde:
> map (observe "inc" (+1)) [3,4,5]
[4,5,6]
>>> Observations <<<
inc
75
--{ \ 3 -> 4
, \ 4 -> 5
, \ 5 -> 6
}
Beachte: (+1) ist hier eine Konstante, der zugehörige Observer wird nur einmal berechnet. Eine Umsetzung wie bei Konstruktoren würde zum Überschreiben der letzten
Funktionsanwendung führen!
Lösung: Behandle -> als zweistelligen Konstruktor im EvalTree:
data EvalTree
= Cons String [EvalRef]
| Uneval
| Demand
| Fun [(EvalRef, EvalRef)]
In obigem Beispiel ergibt sich also die Repräsentation4 :
Fun [ (Cons "3" [], Cons "4" [])
, (Cons "4" [], Cons "5" [])
, (Cons "5" [], Cons "6" [])
]
Diese Repräsentation ist nicht ganz richtig, da wir am Ende der Liste immer noch eine
freie Position für mögliche weitere Anwendungen offen lassen müssen.
instance (Observe a, Observe b) => Observe (a -> b) where
obs f ref x = unsafePerformIO $ do
applRefs <- readIOFunRef
argRef
<- newIORef Uneval
resRef
<- newIORef Uneval
writeIORef r $ Fun ((argRef, resRef) : applRefs)
return $ observer (f $ observer x argRef) resRef
where
readIOFunRef = do
v <- readIORef ref
case v of
Fun applRefs -> return applRefs
_
-> do
4
Die Referenzen wurden hier zum besseren Verständnis weggelassen.
76
writeIORef ref (Fun [])
return []
showEvalTree :: EvalTree -> IO String
showEvalTree ...
showEvalTree (Fun appls) = do
resDStrs <- mapM showApp (reverse appls)
return $ unlines resStrs
where
showApp (rArg,rRes) = do
arg <- showEvalTreeRef rArgs
res <- showEvalTreeRef rRes
return $ concat ["{", arg, "->", res, "}"]
Die Ausgabe ist zunächst sehr einfach gehalten:
ghci> observe "(+)" (+) 3 4
7
>>> Observations <<<
(+)
--{3->{4->7}}
Sie kann aber mittels Pretty-Printing einfach optimiert werden (in der Übung).
Statt der Verwendung einer Datenstruktur ist auch eine Observation-Datei möglich. Die
ist leider weniger dynamisch, und die Anzeige muss aus einer Datei generiert werden,
dies ist aber auch später möglich. Vorteil ist aber, dass der zeitliche Ablauf erkennbar
ist (was aufgrund der Laziness jedoch nicht unbedingt hilfreich ist).
Der vorgestellte Ansatz geht zurück auf Hood – er ist in Hugs fest eingebaut (aber zum
Teil fehlerhaft). Vorteile des Ansatzes von Hood sind, dass er einfach zu benutzen ist,
und sowohl die Laziness von Programmen erhält als auch bei Spracherweiterungen funktioniert (da keine Programmtransformationen vorgenommen werden). Ein Nachteil des
Ansatzes ist aber, dass es nicht möglich ist, unabhängige Beobachtungen in Verbindung
zu setzen, was mit komplexeren Ansätzen versucht wurde.
8.4 Andere Ansätze
Andere Ansätze arbeiten meist Trace-basiert, d.h. die Berechnung wird aufgezeichnet.
Ein Trace wird später mit speziellen “Views” analysiert. Wichtigstes Tool ist hat.
Beispiel: Fehlerhaftes Insertion-Sort:
77
sort :: Ord a => [a] -> [a]
sort []
= []
sort (x:xs) = insert x (sort xs)
insert :: Ord a => a -> [a] -> [a]
insert x []
= [x]
insert x (y:ys) | x <= y
= x : ys
| otherwise = x : insert x y s
main = putStrLn (sort "program")
Wenn wir die main-Funktion ausführen wird der Fehler sichtbar:
ghci> main
"agop"
Eine Möglichkeit ist nun die Verwendung von hat-observe zur Beobachtung von Toplevel-Funktionen:
ghci> hat-observe sort
sort "program" = "agop"
...
sort "am" = "a"
sort "m" = "m"
sort "" = ""
ghci> hat-observe insert
insert ’a’ "m" = "a" -- Fehler
Vorteile: Es sind hier keine Observe-Annotationen notwendig! Zusätzlich ist ein PatternMatching auf Argumente/Ergebnisse möglich!
Einige weitere Views sind:
• hat-trail mit einer Bottom-up-Sicht der Berechnung: Man kann erfragen, wo die
Werte (insbesondere auch Fehler) herkommen:
agop \n
<- putStrLn "agop"
<- insert "p" "agor" | False -- Fehler
• hat-detect (deklaratives Debugging wie Freja, Budda)
78
sort [3, 2, 1]
sort (3:2:1:[]) = 3:3:3:[]? n
insert 1 [] = 1:[] ? y
insert 2 (1:[]) = 2:2:[]? n
insert 2 [] = 2:[] ? y
Error located!
Bug found: "insert 2 (1:[]) = 2:2:[]"
Nachteil: man verliert oft den Überblick, und falsche Antworten (bei großen Datenstrukturen) führen zu falschen Fehlerpositionen. Manchmal ist dies wenig zielgerichtet, oft kann man besser mit seiner Intuition arbeiten.
• hat-stack erlaubt die Anzeige des Laufzeitkellers für eine strikte Berechnung zu
einem Laufzeitfehler/Ctrl-C – dies entspricht der Java-Exception-Sicht.
• hat-explore (neu) ist ein Standarddebugger, der Innermost-Abstieg in beliebigen
Argumenten ermöglicht (zumindest so weit, wie die Berechnung ausgeführt wurde).
Zusätzlich ist eine Verkleinerung des möglichen Fehlercodes durch Slicing möglich.
In hat können alle Views parallel verwendet werden.
5
Vorteil dieses Ansatzes gegenüber Hood sind neben der Möglichkeit der Analyse der
Beziehungen zwischen unterschiedlichen Berechnungen auch die besseren Tools (gute
Browser, die aber Übung benötigen).
Nachteile:
• Langsamere Programmausführung, manchmal langsamer View.
• Programmtransformationen auf Haskell 98 beschränkt.
• Aufwändigere Installation/Benutzung.
9 Funktionale Datenstrukturen
In diesem Kapitel behandeln wir die Implementierung ausgewählter Datenstrukturen in
Haskell. Wir haben bereits Listen zur Darstellung von Sequenzen kennengelernt sowie
Suchbäume zur Darstellung von Mengen vergleichbarer Elemente.
5
Neue Views sind potentielle Themen für Abschlussarbeiten!
79
9.1 Queues
Listen entsprechen im Wesentlichen der Stack-Abstraktion, was die folgende StackImplementierung verdeutlicht:
type Stack a = [a]
emptyStack :: Stack a
emptyStack = []
isEmptyStack :: Stack a -> Bool
isEmptyStack = null
push :: a -> Stack a -> Stack a
push = (:)
top :: Stack a -> a
top = head
pop :: Stack a -> Stack a
pop = tail
Alle definierten Operationen haben konstante Laufzeit. Eng verwandt mit Stacks sind
Queues. Während Stacks nach dem Last-In First-Out (LIFO) Prinzip funktionieren,
arbeiten Queues nach dem First-In First-Out (FIFO) Prinzip. Die Elemente einer Queue
werden also in der Reihenfolge entnommen, in der sie eingefügt wurden.
Auch Queues könnten wir in Haskell als Listen implementieren:
type Queue a = [a]
emptyQueue :: Queue a
emptyQueue = []
isEmptyQueue :: Queue a -> Bool
isEmptyQueue = null
enqueue :: a -> Queue a -> Queue a
enqueue x q = q ++ [x]
next :: Queue a -> a
next = head
dequeue :: Queue a -> Queue a
dequeue = tail
80
Wie die Operationen top und pop haben auch next und dequeue konstante Laufzeit in
der Größe des Arguments. Die Laufzeit von enqueue ist aber linear, da die ++-Funktion
mit der gegebenen Queue als erstem Argument aufgerufen wird. Hätten wir Queues als
Listen in umgekehrter Reihenfolge dargestellt, dann könnten wir enqueue mit konstanter
Laufzeit (durch (:)) implementieren müssten für next und dequeue aber die last bzw.
die init-Funktion verwenden, die beide lineare Laufzeit haben.
Können wir eine Implementierung von Queues angeben, die sowohl enqueue als auch
next und dequeue in konstanter Laufzeit erlaubt? Da wir sowohl auf den Anfang (wegen
enqueue) als auch auf das Ende der Liste (wegen next und dequeue) in konstanter Zeit
zugreifen wollen, verwenden wir für die Darstellung zwei Listen:
data Queue a = Queue [a] [a]
emptyQueue :: Queue a
emptyQueue = Queue [] []
isEmptyQueue :: Queue a -> Bool
isEmptyQueue (Queue xs ys) = null xs && null ys
Die erste Liste enthält die ältesten Elemente, also die, die als nächstes entfernt werden, die zweite Liste hingegen enthält die neuesten Elemente, also die, die als letztes
eingefügt wurden und zwar in umgekehrter Reihenfolge. Um einer Queue ein Element
hinzuzufügen, können wir es daher vorne der zweiten Liste hinzufügen. Um eines zu
entfernen, nehmen wir es aus der ersten Liste.
enqueue :: a -> Queue a -> Queue a
enqueue x (Queue xs ys) = Queue xs (x:ys)
next :: Queue a -> a
next (Queue (x:_) _) = x
dequeue :: Queue a -> Queue a
dequeue (Queue (_:xs) ys) = Queue xs ys
Die Implementierungen von next und dequeue sind noch unvollständig. Beide Funktionen liefern kein Ergebnis, wenn die erste Liste leer ist, die zweite aber nicht. Dieser Fall
erfordert es, die zweite Liste, die die Elemente ja in umgekehrter Reihenfolge speichert,
komplett zu durchlaufen, um das nächste Element zu entfernen.
Um diesen ungünstigen Fall zu vermeiden, legen wir eine Invariante für den QueueDatentyp fest:
Wenn die erste Liste leer ist, ist auch die zweite leer.
81
Gilt diese Invariante, so finden wir bei dequeue das zu entfernende Element immer in
der ersten Liste, da diese immer ein Element enthält, wenn die zweite eines enthält.
Die oben gezeigten Implementierungen von enqueue und dequeue erhalten diese Invariante aber nicht aufrecht: Nach dem Einfügen eines Elementes in eine leere Queue ist die
erste Liste leer, die zweite aber nicht. Außerdem tritt diese Situation ein, wenn die erste
Liste vor dem Aufruf von dequeue einelementig ist.
Wir implementieren daher eine Konstruktor-Funktion queue, die sicher stellt, dass die
zweite Liste leer ist, falls die erste leer ist:
queue :: [a] -> [a] -> Queue a
queue [] ys = Queue (reverse ys) []
queue xs ys = Queue xs ys
Da die Elemente in der zweiten Liste in umgekehrter Reihenfolge gespeichert werden,
müssen wir die zweite Liste umdrehen, bevor wir sie als neue erste Liste verwenden. Mit
der queue-Funktion können wir enqueue und dequeue wie folgt definieren:
enqueue :: a -> Queue a -> Queue a
enqueue x (Queue xs ys) = queue xs (x:ys)
dequeue :: Queue a -> Queue a
dequeue (Queue (x:xs) ys) = queue xs ys
Im Unterschied zu den vorherigen Definitionen haben wir die queue-Funktion statt des
Queue-Konstruktors in den rechten Regelseiten verwendet.
Um zu testen, ob eine Queue leer ist, brauchen wir dank der Invariante nur noch zu
testen, ob die erste Liste leer ist:
isEmptyQueue :: Queue a -> Bool
isEmptyQueue (Queue xs _) = null xs
Die Implementierung der next-Funktion ist jetzt korrekt, da die Invariante verhindert,
dass die zweite Liste Elemente enthält, wenn die erste Liste leer ist.
Trotz des Aufrufs von queue in enqueue, hat enqueue konstante Laufzeit: Der potentiell
teure Aufruf von reverse passiert nur dann, wenn die erste Liste xs leer ist, und in
dem Fall ist auf Grund der Invariante auch ys leer also das Argument von reverse
einelementig.
Die Laufzeit von dequeue ist im schlechtesten Fall jedoch noch immer linear: Falls die
erste Liste einelementig ist und die zweite n − 1 Elemente enthält, benötigt der queue
Aufruf (auf Grund des reverse Aufrufs) n − 1 Schritte. Dieser Fall tritt zum Beispiel
82
dann ein, wenn n Elemente hintereinander mit enqueue einer leeren Queue hinzugefügt
werden.
Haben wir gegenüber der einfachen Implementierung mit einer einzigen Liste überhaupt
etwas gewonnen? Zwar ist die pessimale Laufzeit von dequeue linear, die amortisierte
Laufzeit der beiden Operationen ist aber konstant.
Bei amortisierter Laufzeit betrachtet man nicht die Laufzeit einer einzigen Operation
sondern die Laufzeit mehrerer Operationen hintereinander: Wenn beliebige n QueueOperationen hintereinander ausgeführt werden und die Gesamtlaufzeit dabei immer in
O(n) liegt, dann ist die amortisierte Laufzeit der Operationen konstant. Dabei können
einzelne Aufrufe der Operationen durchaus schlechtere Laufzeit haben, solange dabei nie
die Gesamtlaufzeit beeinträchtigt wird.
Wir betrachten beispielhaft die folgende Hintereinanderausführung mehrerer QueueOperationen:
dequeue
(dequeue
(dequeue
(enqueue 1
(enqueue 2
(enqueue 3 emptyQueue)))))
Mit der einfachen Implementierung ergibt sich daraus
dequeue
(dequeue
(dequeue
((([] ++ [3]) ++ [2]) ++ [1])))
Da ++ linksassoziativ aufgerufen wird, ist hier die Gesamtlaufzeit quadratisch in der
Anzahl der eingefügten Elemente also auch quadratisch in der Anzahl der verwendeten
Operationen. Die amortisierte Laufzeit der beiden Queue-Operationen ist also linear,
denn die n-fache Anwendung einer Operation mit linearer Laufzeit führt zu quadratischer
Gesamtlaufzeit. In diesem Fall ist die amortisierte Laufzeit der Operationen also nicht
besser als die pessimale.
Betrachten wir das selbe Beispiel mit der zweiten Queue-Implementierung ergibt sich
(verkürzt):
deq (deq (deq
= deq (deq (deq
= deq (deq (deq
= deq (deq (deq
(enq
(enq
(enq
(enq
1
1
1
1
(enq 2
(enq 2
(enq 2
(Q [3]
(enq 3 e)))))
(q [] [3])))))
(Q [3] [])))))
[2]))))
83
=
=
=
=
=
deq (deq (deq (Q [3] [1,2])))
deq (deq (q [] [1,2]))
deq (deq (Q [2,1] [])) -- teuer!
deq (Q [1] [])
Q [] []
Die Gesamtlaufzeit dieser Aufrufe ist linear in der Anzahl der Operationen, da fast alle
Schritte konstante Laufzeit haben. Nur ein Schritt hat lineare Laufzeit, die Gesamtlaufzeit bleibt aber linear in der Anzahl der Operationen. Daher ist die amortisierte Laufzeit
der Operationen (anders als die pessimale Laufzeit) konstant.
Diese Aufrufkette verdeutlicht, dass der teure reverse-Aufruf nur selten auftritt. Im Allgemeinen muss jedes eingefügte Element genau einmal “durch reverse hindurch” bevor
es wieder entfernt wird. Die reverse-Aufrufe sind so selten, dass die Gesamtlaufzeit
einer beliebigen Folge von Queue-Operationen immer lineare Gesamtlaufzeit hat.
Obwohl die pessimale Laufzeit von dequeue linear ist, ist die gezeigte QueueImplementierung auf Grund der konstanten amortisierten Laufzeit der Operationen
sehr brauchbar.
9.2 Arrays
In vielen imperativen Programmiersprachen werden Arrays bereit gestellt. Imperative
Arrays erlauben die Abfrage und Manipulation von Elementen an einer gegebenen Position mit konstanter Laufzeit. In Haskell gibt es eine (im Modul Data.Array) vordefinierte
Anbindung an Arrays, die es erlaubt, ein Element an einer gegebenen Position in konstanter Zeit abzufragen und Arrays in linearer Zeit aus Listen zu erzeugen. Allerdings
hat die Operation zum Ändern eines Index lineare Laufzeit. Sie kopiert das gesamte
Array, da Seiteneffekte in reinen funktionalen Sprachen wie Haskell nicht erlaubt sind.
Insbesondere soll auch das alte Array nach dem Update unverändert zur Verfügung
stehen.
Können wir Arrays mit konstanter Laufzeit auch rein funktional implementieren?
Wir definieren dazu den folgenden Datentyp:
data Array a = Entry a (Array a) (Array a)
Wir stellen zunächst fest, dass alle Werte dieses Typs unendlich sind, da es keinen Fall
für das leere Array gibt, doch dazu später mehr.
Auch Indizes scheinen im Array-Datentyp nicht dargestellt zu werden. Die Idee dieser Implementierung ist, dass der zu einem bestimmten Index gehörige Wert an einer
bestimmten Position im von Entry-Knoten erzeugten Binärbaum steht. Zum Beispiel
steht das Element mit dem Index Null an der Wurzel, links davon ist ein Array mit allen ungeraden Indizes und rechts davon eines mit allen geraden Indizes. Die Teil-Arrays
84
haben ihrerseits die selbe Struktur: Zieht man von den Indizes eins ab und dividiert
das Ergebnis (mit ganzzahliger Division) durch zwei steht an der Wurzel die Null, links
davon gerade Indizes und rechts ungerade. Insgesamt ergibt sich dadurch die folgende
Verteilung der Indizes:
0
3
7
...
11
...
1
2
5
4
9
...
13
...
8
...
6
12
...
10
...
14
...
...
Abbildung 2: Indizes in einem funktionale Array
Diese Verteilung erlaubt es, die Abfrage eines Elementes effizient zu implementieren:
(!) :: Array a -> Int -> a
Entry x odds evens ! n
| n == 0 = x
| odd n = odds ! m
| even n = evens ! m
where
m = (n-1) ‘div‘ 2
Wenn der Index Null ist, steht das gesuchte Element an der Wurzel. Wenn nicht suchen wir rekursiv im linken oder rechten Teil-Array, je nachdem, ob der Index ungerade
(dann links) oder gerade ist (dann rechts). Der neue Index wird dabei dekrementiert und
halbiert.
Um zum Beispiel das Element an Position 9 nachzuschlagen, steigen wir rekursiv in den
linken Teilbaum ab, da die 9 ungerade ist und suchen dort rekursiv den Index 4. Dieser
ist gerade, deshalb suchen wir rekursiv im rechten Teilbaum den Index 1. Dieser Index
85
ist wieder ungerade, also suchen wir im linken Teilbaum den Eintrag mit Index Null
geben also die Wurzel dieses Teilbaums aus.
Auch die Funktion zum Ändern eines Eintrags lässt sich auf diese Weise implementieren:
update :: Array a -> Int -> a -> Array a
update (Entry x odds evens) n y
| n == 0 = Entry y odds evens
| odd n = Entry x (update odds m y) evens
| even n = Entry x odds (update evens m y)
where
m = (n-1) ‘div‘ 2
Je nachdem, ob der Index gerade oder ungerade ist, steigen wir wieder rekursiv in das
rechte oder linke Teil-Array ab und manipulieren einen entsprechend angepassten Index.
Die update-Funktion erzeugt dabei ein neues Array, lässt also das Argument anders
als Array-Updates in imperativen Sprachen unverändert. update kopiert aber nicht das
ganze Array sondern nur den Pfad von der Wurzel zum gesuchten Element. Gemeinsame
Teile im Argument und Ergebnis werden geteilt, also nicht kopiert.
Die Laufzeit der (!) und update-Funktionen ist logarithmisch in der Größe des Index,
also insbesondere unabhängig von der Array-Größe. Wenn man ehrlich ist, ist auch in
imperativen Sprachen der Array-Zugriff nicht konstant sondern logarithmisch in der
Indexgröße, da alle (logarithmisch vielen) Bits des Index angesehen werden müssen, um
den richtigen Eintrag zu finden.
Der Vorteil funktionaler Arrays ist, dass sowohl die neue als auch die alte Variante
eines Arrays nach einem Update verfügbar sind. Der zusätzliche Speicherbedarf ist dabei
logarithmisch in der Indexgröße. Mit imperativen Arrays verwendet man in diesem Fall
meist eine Kopie, benötigt also linearen zusätzlichen Speicherbedarf in der Array-Größe.
Wie wir bereits festgestellt haben, sind alle Werte vom Typ Array a unendlich. Es stellt
sich also die Frage, wie wir endliche Arrays darstellen. Das leere Array ist ein unendliches
Array, das nur Fehlermeldungen enthält:
emptyArray :: Array a
emptyArray = Entry err emptyArray emptyArray
where
err = error "accessed non-existent entry"
Wir können aus einer Liste ein Array machen, indem wir sukzessive update auf ein leeres
Array anwenden:
fromList :: [a] -> Array a
fromList = foldl insert emptyArray . zip [0..]
86
where
insert a (n,x) = update a n x
Die Laufzeit von fromList ist O(n log n). Allerdings werden in dieser Variante viele
Entry-Konstruktoren erzeugt und durch spätere update Aufrufe wieder ersetzt. Die folgende Implementierung vermeidet dies, indem sie die Eingabeliste in zwei Teile, nämlich
die Elemente mit ungeradem und die mit geradem Index, aufteilt.
fromList :: [a] -> Array a
fromList [] = emptyArray
fromList (x:xs) =
Entry x (fromList ys) (fromList zs)
where (ys,zs) = split xs
Die split-Funktion berechnet aus einer Liste zwei, indem sie die Elemente abwechselnd
der einen und der anderen hinzufügt:
split :: [a] -> ([a],[a])
split []
= ([],[])
split [x]
= ([x],[])
split (x:y:zs) = (x:xs,y:ys)
where (xs,ys) = split zs
Diese Variante von fromList hat zwar auch die Laufzeit O(n log n) erzeugt aber keine
unnötigen Entry-Knoten, die sie später wieder verwirft und ist deshalb schneller. Es ist
sogar möglich, fromList mit linearer Laufzeit zu implementieren (Okasaki ’97).
Die hier gezeigte Implementierung von Arrays ist (mit einer weiteren wichtigen Optimierung, auf die wir hier nicht eingehen) im Modul Data.IntMap implementiert.
9.3 Array-Listen
Arrays erlauben anders als Listen einen effizienten Zugriff auf Elemente an einem beliebigen Index. Listen bieten anders als Arrays effiziente Funktionen zum Entfernen des ersten
Elements und Hinzufügen eines neuen ersten Elements. Die Funktionen (:) und tail
hätten mit der beschriebenen Array-Implementierung lineare Laufzeit, da sich durch sie
die Indizes aller Einträge verschieben.
Array-Listen bieten wie Arrays einen effizienten Zugriff auf beliebige Elemente und
wie Listen effiziente Funktionen zum Hinzufügen und Entfernen des ersten Elements.
Ihre interne Darstellung ähnelt der von Binärzahlen. Eine Array-Liste ist eine Liste
vollständiger, nur an Blättern beschrifteter Binärbäume, deren Höhe ihrer Position in
der Liste entspricht.
Hier sind beispielhaft Listen der Länge eins bis fünf dargestellt:
87
o
|
5
.-----o
/ \
4
5
o-----o
|
/ \
3
4
5
.-----.-----o
/ \
/\
/\
2 3 4 5
o-----.-----o
|
/ \
1
/\
/\
2 3 4 5
Eine Array-Liste der Länge n enhält genau an den Positionen einen vollständigen
Binärbaum, an denen die Binärdarstellung von n eine 1 hat (wenn man mit dem
niedrigstwertigen Bit anfängt). Ein Binärbaum an Position i in der Liste enthält dabei
genau 2i Elemente. Eine Array-Liste ist also eine Liste optionaler Binärbäume, wobei
das letzte Element immer vorhanden sein muss. Eine Liste wie
o-----.-----.
|
7
ist also nicht erlaubt. Insgesamt ergeben sich die folgenden Invarianten:
1. Der letzte Baum ist nicht leer.
2. Jeder Binärbaum ist vollständig.
3. Ein Baum an Position i hat 2i Elemente.
Diese Darstellung erlaubt es, alle erwähnten Operationen in logarithmischer Laufzeit zu
implementieren.
Wir stellen Array-Listen als Werte des folgenden Datentyps dar.
88
type ArrayList a = [Bit a]
data Bit a = Zero | One (BinTree a)
data BinTree a = Leaf a
| Fork (BinTree a) (BinTree a)
Die leere Array-Liste ist die leere Liste.
empty :: ArrayList a
empty = []
Dank der ersten Invariante genügt es für den Leerheitstest, zu testen, ob die Liste von
Bits leer ist. Eine nicht-leere Liste nur aus Zeros ist nicht erlaubt.
isEmpty :: ArrayList a -> Bool
isEmpty = null
Wir wollen nun eine Funktion (<:) für Array-Listen definieren, die sich wie (:) für
Listen verhält, also ein neues erstes Element hinzufügt. Da sich die Länge der ArrayListe dabei um eins erhöht, ist die (<:)-Funktion dem Inkrementieren einer Binärzahl
nachempfunden. Wenn das niedrigste Bit Null ist, wird es auf eins gesetzt, wenn es eins
ist, wird es auf Null gesetzt und die restlichen Bits werden inkrementiert.
(<:) :: a -> ArrayList a -> ArrayList a
x <: l = cons (Leaf x) l
Wir verwenden eine Hilfsfunktion cons auf Binärbäumen, da wir im rekursiven Aufruf
mehrere Elemente auf einmal zum Inkrementieren benutzen:
cons
cons
cons
cons
:: BinTree a -> ArrayList a -> ArrayList a
u []
= [One u]
u (Zero : ts) = One u : ts
u (One v : ts) = Zero : cons (Fork u v) ts
Statt einfach nur die Bits zu manipulieren, fügen wir einer Eins einen Binärbaum entsprechender Größe hinzu. Die Invarianten erhalten wir dadurch aufrecht, dass wir im
rekursiven Aufruf einen doppelt so großen Baum verwenden, wie im Aufruf selbst. Die
Bäume werden dabei nicht durchlaufen, also ist die Laufzeit von cons durch die Länge
der Liste von Binärbäumen beschränkt. Diese ist wegen der ersten Invariante logarithmisch in der Länge der Array-Liste.
Das folgende Beispiel zeigt die Schrittweise Anwendung von (<:).
89
ghci> 3 <: empty
[One (Leaf 3)]
ghci> 2 <: it
[Zero,One (Fork (Leaf 2) (Leaf 3))]
ghci> 1 <: it
[One (Leaf 1),One (Fork (Leaf 2) (Leaf 3))]
Statt head und tail definieren wir, um Namenskonflikte zu vermeiden, Funktionen
first und rest. Wir definieren diese Funktionen mit Hilfe einer einzigen Funktion, die
beide Ergebnisse berechnet.
first :: ArrayList a -> a
first l = x
where (Leaf x, _) = decons l
rest :: ArrayList a -> ArrayList a
rest l = xs
where (_, xs) = decons l
Die Funktion decons arbeitet wie cons auf Binärbäumen statt Bits. Sie ist dem Dekrementieren einer Binärzahl nachempfunden und liefert den Teilbaum zurück, der zum
niedrigsten Bit gehört, das nicht Null ist. Bäume aus höherwertigen Bits werden dabei
aufgeteilt. Der linke Teil wird als erste Komponente des Ergebnisses zurück geliefert, der
andere Teil wird vor das Ergebnis der rekursiven Dekrementierung gehängt.
decons :: ArrayList a -> (BinTree a, ArrayList a)
decons [One u]
= (u, [])
decons (One u : ts) = (u, Zero : ts)
decons (Zero : ts) = (u, One v : ws)
where
(Fork u v, ws) = decons ts
Die erste Regel sorgt dafür, dass die erste Invariante, dass der letzte Eintrag der Liste
von Bits nicht Null ist, aufrecht erhalten wird. Auch die anderen Invarianten bleiben
erhalten. Die Implementierung verlässt sich auf die Invarianten, da nur durch sie sicher
gestellt ist, dass das Pattern-Matching auf Fork beim rekursiven Aufruf nicht fehlschlägt.
Auch das Patten-Matching in first ist nur auf Grund der Invarianten sicher.
Die Laufzeit von decons, also auch von first und rest ist durch die Anzahl der Bits
beschränkt also logarithmisch in der Länge der Array-Liste. Hier ist ein Beispielaufruf
auf eine vier-elementige Liste:
decons .-----.-----o
90
/ \
/\
/\
1 2 3 4
let (Fork u v, ws) = decons .-----o
/ \
/\
/\
1 2 3 4
in (u, One v : ws)
let (Fork u v, ws) =
let (Fork u’ v’, ws’) = decons o
/ \
/\
/\
1 2 3 4
in (u’, One v’ : ws’)
in (u, One v : ws)
let (Fork u v, ws) =
let Fork u’ v’ = o
/ \
/\
/\
1 2 3 4
ws’ = []
in (u’, One v’ : ws’)
in (u, One v’ : ws’)
let Fork u v = o
/ \
1
2
ws = [One o ]
/ \
3
4
in (u, One v : ws)
(1, o-----o )
|
/ \
2
3
4
Wir definieren nun Funktionen zum Zugriff auf Elemente anhand ihres Index. Wie bei
Arrays erlaubt (!) ein Element abzufragen.
(!) :: ArrayList a -> Int -> a
l ! n = select 1 l n
91
Wir verwenden eine Hilfsfunktion select, die als zusätzlichen Parameter die Größe des
nächsten Binärbaums mitführt.
select :: Int -> ArrayList a -> Int -> a
Diese Größe wird in jedem rekursiven Aufruf verdoppelt. Wenn das erste Bit Null ist,
suchen wir in den restlichen Bits weiter.
select size_t (Zero : ts) n =
select (2*size_t) ts n
Wenn das erste Bit Eins ist, entscheiden wir anhand der Größe des nächsten Binärbaums,
ob wir das gesuchte Element in ihm finden oder rekursiv abteigen. Wenn der gesuchte
Index kleiner als die Größe des nächsten Binärbaums ist, suchen wir in diesem, sonst
rekursiv in den restlichen Bits, mit einem entsprechend angepassten Index.
select size_t (One t : ts) n
| n < size_t =
selectBinTree (size_t‘div‘2) t n
| otherwise =
select (2*size_t) ts (n-size_t)
Die Berechnung des Größenparameters ist dabei nur korrekt, wenn die Invarianten gelten.
Wenn man zum Beispiel die Nullen bei der Darstellung wegließe, könnte man den Index
nicht mehr auf diese Weise berechnen.
Die Funktion selectBinTree verwenden wir, um ein Element in einem vollständigen
Binärbaum zu suchen. Auch sie hat einen Größenparameter, der hier die Größe des
linken Teilbaums des Arguments beschreibt, oder Null ist, wenn das Argument in Blatt
ist.
selectBinTree :: Int -> BinTree a -> Int -> a
selectBinTree 0
(Leaf x)
0 = x
selectBinTree size_u (Fork u v) n
| n < size_u =
selectBinTree (size_u‘div‘2) u n
| otherwise =
selectBinTree (size_u‘div‘2) v (n-size_u)
Wie bei select, verwenden wir auch hier den Größenparameter, um zu entscheiden, ob
wir in den linken Teilbaum absteigen oder ihn überspringen.
92
Die Laufzeit von select ist beschränkt durch die Anzahl der Bits plus die Größe des
größten Binärbaums. Beides ist logarithmisch in der Länge der Array-Liste, also auch
die Laufzeit von (!).
Auch das Pattern-Matching in selectBinTree ist nur dann sicher, wenn die Invarianten
gelten, also der Binärbaum vollständig ist.
Schließlich definieren wir noch eine Funktion modify zur Manipulation eines Elements an
einem Index. Zusätzlich zum Index bekommt diese Funktione einen Funktions-Parameter
übergeben, der auf das zu ändernde Element angewendet wird.
modify :: Int->(a->a)->ArrayList a->ArrayList a
modify = update 1
Auch modify verwendet eine Hilfsfunktion mit zusätzlichem Größenparameter. Die Implementierung dieser Funktion ähnelt der von select, baut aber die komplette Liste
wieder auf, während sie zum gesuchten Element absteigt.
Wenn das erste Bit Null ist, verarbeiten wir rekursiv die restlichen Bits.
update size_t n f (Zero : ts) =
Zero : update (2*size_t) n f ts
Wenn nicht, entscheiden wir uns wieder fürs Absteigen oder Überspringen und verwenden
im ersten Fall die Funktion updateBinTree.
update size_t n f (One t : ts)
| n < size_t =
One (updateBinTree (size_t‘div‘2) n f t):ts
| otherwise =
One t : update (2*size_t) (n-size_t) f ts
updateBinTree steigt in den Binäybaum wie select, liefert aber den veränderten Baum
zurück, statt nur das gesuchte Element.
updateBinTree 0
0 f (Leaf x) = Leaf (f x)
updateBinTree size_u n f (Fork u v)
| n < size_u =
Fork (updateBinTree (size_u‘div‘2) n f u) v
| otherwise =
Fork u (updateBinTree
(size_u‘div‘2) (n-size_u) f v)
93
Trotzdem ist die Laufzeit von update wie die von select nur logarithmisch, da wesentliche Teile des Binärbaums und auch der Liste von Binärbäumen geteilt, also nicht
kopiert, werden.
Obwohl wir uns bemüht haben, die Invarianten bei der Definition der Operatoren aufrecht zu erhalten, ist die Implementierung komplex genug, dass Fehler nicht ausgeschlossen sind. Um uns zu vergewissern, dass die Invarianten tatsächlich erhalten bleiben,
können wir die definierten Funktionen testen. Dazu verwenden wir QuickCheck, damit
wir nur die Eigenschaften und nicht die Test-Eingaben selbst definieren müssen.
Das Prädikat isValid prüft, ob eine gegebene Array-Liste die geforderten Invarianten
erfüllt.
isValid :: ArrayList a -> Bool
isValid l = (isEmpty l || nonZero (last l))
&& all zeroOrComplete l
&& and (zipWith zeroOrHeight [0..] l)
Die Funktion nonZero testet, ob ein Bit Eins ist, zeroOrComplete testet ob ein Bit
Null ist oder der enthaltene Baum vollständig und zeroOrHeight testet, ob ein Bit Null
ist oder der enthaltene Baum die gegebene Höhe hat. Statt die Anzahl der Blätter zu
zählen, genügt es bei einem vollständigen Baum, die Höhe zu berechnen.
Wir verzichten hier auf die Angabe der Hilfsfunktionen. Deren Definitionen sowie geeignete Eigenschaften zum Testen der Operationen stehen im Modul PartialArrayList.
Nach der Definition eines geeigneten QuickCheck-Generators für Array-Listen, können
wir automatisch testen, ob unsere Implementierung korrekt ist. Alle gezeigten Funktionen sind korrekt implementiert, es wäre aber ein leichtes gewesen, Fehler einzubauen.
Zum Beispiel sieht die folgende Regel für die cons-Funktion auf den ersten Blick korrekt
aus, ist es aber nicht:
cons u (One v : ts) = cons (Fork u v) ts
Ebenso ist das folgende keine korrekte Regel für decons:
decons (One u : ts)
=
(u, ts)
Obwohl QuickCheck gute Dienste leistet, solche Fehler zu finden, wäre es schön, wenn
wir sie gar nicht erst machen könnten. Das Problem ist, dass unser Datentyp für ArrayListen Werte erlaubt, die keine gültigen Array Listen sind. Besser wäre, wenn wir den
Typ so definieren könnten, dass gar keine ungültigen Array-Listen dargestellt werden
können. Dann wären die obigen Fehler Typfehler und würden schon zur Kompilier-Zeit
erkannt.
94
Auf den ersten Blick ist unklar, we man eine so komplexe Invariante wie die für ArrayListen im Typsystem kodieren kann. Dies ist aber tatsächlich möglich.
Wir beginnen mit einer einfachen Idee, die es uns später erlaubt, die erste Invariante
sicher zu stellen, dass am Ende der Liste immer ein Baum steht. Dazu verwenden wir
statt normaler Listen einen eigenen Listendatentyp, der sicher stellt, dass am Ende
immer ein Element steht. So einen Datentyp für nicht-leere Listen könnte man wie folgt
definieren:
data NEList a = End a | Cons a (NEList a)
Schwieriger ist es, sicherzustellen, dass alle Einträge einer Liste vollständige Binärbäume
einer festen, in jedem Schritt um eins wachsenden Höhe sind. Der folgende Datentyp für
Array-Listen stellt dies sicher. Da wir intern nicht-leere Listen von Bäumen verwenden,
stellen wir die leere Array-Liste durch einen eigenen Konstruktor dar:
data ArrayList a = Empty
| NonEmpty (TreeList a)
Eine Liste von Bäumen ist entweder einelementig oder beginnt mit einem Bit gefolgt
von weiteren Bäumen.
data TreeList a = Single a
| Bit a :< TreeList (a,a)
Bemerkenswert ist hierbei der sich ändernde Typparameter von TreeList. Datentypen,
die in ihrer Definition mit veränderten Typparametern verwendet werden, nennt man
nicht-regulär oder nested data types. Der Effekt dieser Definition ist, dass die Restliste
einer TreeList Int nicht Ints sondern Paare von Ints enthält! Die Restliste der Restliste enthält Paare von Paaren von Ints und so weiter. Dadurch wird die Baumstruktur
der enthaltenen Binärbäume durch die Paar-Konstruktoren erzeugt, wie die folgenden
Beispiele zeigen:
Single 1
Zero :< Single (2,3)
One 1 :< Zero :< Single ((2,3),(4,5))
Alle diese Werte sind vom Typ TreeList Int, der Bit-Datentyp ist also nun wie folgt
definiert und enthält keine expliziten Binärbäume mehr:
data Bit a = Zero | One a
95
Der Versuch, eine ungültige Array-Liste zu bauen, wird jetzt vom Typchecker verhindert:
ghci> Zero :< Single (42 :: Int)
Couldn’t match expected type ‘(a, a)’
against inferred type ‘Int’
Die Funktionen auf Array-Listen lassen sich wie folgt auf den neuen Datentyp
übertragen.
Die leere Array-Liste wird durch Empty dargestellt:
empty :: ArrayList a
empty = Empty
isEmpty :: ArrayList a -> Bool
isEmpty Empty = True
isEmpty _
= False
Um ein Element vorne an eine Array-Liste anzuhängen, definieren wir wieder eine Funktion (<:). Wir behandeln zunächst leere Array-Listen gesondert:
(<:) :: a -> ArrayList a -> ArrayList a
x <: Empty
= NonEmpty (Single x)
x <: NonEmpty l = NonEmpty (cons x l)
Die Funktion cons definieren wir wieder in Anlehnung an das Inkrementieren einer
Binärzahl:
cons
cons
cons
cons
:: a -> TreeList a -> TreeList a
x (Single y)
= Zero :< Single (x,y)
x (Zero :< xs) = One x :< xs
x (One y :< xs) = Zero :< cons (x,y) xs
Wenn wir bei dieser Definition in der ersten oder letzten Regel die Null vergessen, führt
das zu einem Typfehler:
Occurs check:
cannot construct the infinite type:
a = (a, a)
96
Bemerkenswert ist der rekursive Aufruf von cons in der letzten Regel. Sein erstes Argument ist vom Typ (a,a) und die Liste xs ist vom Typ TreeList (a,a). Wenn der Typ
einer Funktion im rekursiven Aufruf ein anderer ist, als der beim umgebenden Aufruf,
nennt man das polymorphe Rekursion. Diese wird typischerweise bei nicht-regulären Datentypen verwendet, die ja eine rekursive Komponente mit veränderten Typparametern
haben.
Der Typ einer polymorph rekursiven Funktion6 kann nicht inferiert werden, wir dürfen
die Typsignatur von cons also nicht weglassen. Tun wir es doch, bekommen wir den
eben gezeigten Typfehler.
Zur Definition von first und rest behandeln wir einelementige Array-Listen gesondert
und verwenden dann wieder eine Hilfsfunktion decons, die die Ergebnisse von first
und rest auf einmal berechnet.
first :: ArrayList a -> a
first (NonEmpty (Single x)) = x
first (NonEmpty l) = fst $ decons l
rest :: ArrayList a -> ArrayList a
rest (NonEmpty (Single _)) = Empty
rest (NonEmpty l) = NonEmpty . snd $ decons l
decons wird nie mit einelementigen Listen aufgerufen, entspricht also dem Dekrementieren einer Binärzahl größer als zwei.
decons
decons
decons
decons
where
:: TreeList a -> (a, TreeList a)
(One x :< xs) = (x, Zero :< xs)
(Zero :< Single (x,y)) = (x, Single y)
(Zero :< xs) = (x, One y :< ys)
((x,y),ys) = decons xs
Da vor jeden Aufruf von decons die einelementige Liste gesondert behandelt wird, ist
diese partielle Definition von decons sicher. Auch hier bekämen wir wieder Typfehler,
wenn wir im Ergebnis Listen erzeugen würden, die die Invarianten verletzen oder die
Typsignatur wegließen.
Die Definition der Funktionen zum Zugriff auf einen beliebigen Index wird durch die
neue Darstellung dadurch erschwert, dass wir die Funktionen zum Absteigen in die
vollständigen Binärbäume nicht mehr so leicht definieren können. Unterschiedlich große
Binärbäume haben unterschiedliche Typen, wir können also keine einzige Funktion
schreiben, die Bäume beliebiger Größe akzeptiert.
6
im Gegensatz zum Typ einer (nur) polymorphen, rekursiven Funktion
97
Eine mögliche Lösung des Problems ist es, die Funktion zum Nachschlagen eines Blattes
in einem Binärbaum als zusätzlichen Parameter mitzuführen. Der Typ dieser Funktion
ändert sich nämlich genau wie der Typ der TreeList, die wir verarbeiten.
Die Funktion (!) ist wie folgt definiert:
(!) :: ArrayList a -> Int -> a
Empty
! _ = error "ArrayList.!: empty list"
NonEmpty l ! n = select 1 sel l n
where
sel x m
| m == 0
= x
| otherwise =
error $ "ArrayList.!: invalid index "
++ show n
Wir verwenden wieder eine Hilfsfunktion select, die die Größe des nächsten Binärbaums
mitführt. Zusätzlich führt sie nun aber auch noch eine Funktion sel mit, die ein Blatt
in diesem Binärbaum nachschlagen kann. Im ersten Aufruf hat die sel-Funktion den
Typ a -> Int -> a, dieser ändert sich aber in rekursiven Aufrufen wie der Typ der
TreeList. Dadurch, dass wir sel lokal definieren, können wir den ursprünglichen Index
n im Fehlerfall ausgeben. Der Index m muss Null sein, da ein einelementiger Binärbaum
nur am Index Null ein Element enthält.
Der Typ der select-Funktion zeigt, dass die übergebene Funktion als Argument genau
den Typ nimmt, den die TreeList (als erstes) enthält.
select :: Int
-> (b -> Int -> a)
-> TreeList b -> Int -> a
In der Definition von select behandeln wir zunächst den Fall einer einelementigen Liste:
select _ sel (Single x) n = sel x n
Hierbei ignorieren wir den Größenparameter, da die Funktion sel die Größe den
Binärbaums kennt und den Index nachschlagen kann.
Die zweite Regel verwendet wie vorher den Größenparameter, um zu entscheiden, ob
der nächste Baum übersprungen werden soll. Zusätzlich übergeben wir im rekursiven
Aufruf eine angepasste Funktion, die einen Wert in einem doppelt so großen Binärbaum
nachschlägt.
98
select size_x sel (bit :< xs) n =
case bit of
Zero -> select (2*size_x) descend xs n
One x ->
if n < size_x then sel x n else
select (2*size_x) descend xs (n-size_x)
where
descend (l,r) m | m < size_x = sel l m
| otherwise = sel r (m-size_x)
Da der Teilbaum, den descend als Argument erhält, doppelt so groß ist wie x, entspricht
die Größe von x genau der Größe des linken Teilbaums dieses Arguments. Die descendFunktion entscheidet anhand dieser Größe, ob sie in den linken oder rechten Teilbaum des
Binärbaums absteigt und verwendet statt eines rekursiven Aufrufs die vorher übergebene
Funktion sel, die für halb so große Bäume definiert wurde.
Die modify-Funktion definieren wir analog dazu auch durch eine Hilfsfunktion update
mit zwei Zusatzparametern: einem für die Größe des nächsten Baums und einem zum
Verändern eines solchen Baums.
modify :: Int->(a->a)->ArrayList a->ArrayList a
modify _ _ Empty
=
error "ArrayList.modify: empty list"
modify n f (NonEmpty l) =
NonEmpty $ update 1 upd n l
where
upd m x
| m == 0
= f x
| otherwise =
error $ "ArrayList.modify: invalid index "
++ show n
Die upd-Funktion für einen einelementigen Baum, wendet die gegebene Funktion auf diesen Baum, der ja nur durch seine Beschriftung selbst dargestellt wird, an. Bei ungültigen
Indizes liefert sie eine Fehlermeldung mit dem ursprünglichen Index.
Die update-Funktion nimmt als Argument eine solche upd-Funktion, die die Elemente
der übergeben TreeList manipuliert.
update :: Int
-> (Int -> a -> a)
-> Int -> TreeList a -> TreeList a
Die Regel für einelementige Listen, wendet diese upd-Funktion auf das Element der Liste
an:
99
update _ upd n (Single x) = Single $ upd n x
Die zweite Regel definiert wie select eine abgewandelte Funktion descend für den
rekursiven Aufruf, die die ursprüngliche upd Funktion verwendet.
update size_x upd n (bit :< xs) =
case bit of
Zero ->
Zero :< update (2*size_x) descend n xs
One x ->
if n < size_x then One (upd n x) :< xs else
bit :<
update (2*size_x) descend (n-size_x) xs
where
descend m (l,r)
| m < size_x = (upd m l, r)
| otherwise = (l, upd (m-size_x) r)
Damit ist die Implementierung typsicherer Array-Listen komplett. Wir brauchen nun
nicht mehr QuickCheck zu verwenden, um zu testen, ob die Invarianten eingehalten
werden, da dies schon durch die Typprüfung sichergestellt ist. Wir sollten natürlich
trotzdem Tests schreiben, die die Korrektheit der Operationen prüfen. Nur weil die Invariante aufrecht erhalten wird, heißt das noch nicht, dass die Funktionen die Reihenfolge
der Elemente nicht aus Versehen verändern oder falsche Elemente manipuliert werden.
Tests, die die Korrektheit der Implementierung überprüfen, stehen im Modul ArrayList,
das auch die hier gezeigte Implementierung enthält.
9.4 Tries
Im Kapitel über Arrays haben wir gesehen, wie man Indizes effizient Werte zuordnen
kann, ohne die Indizes explizit zu speichern. Ein Array haben wir dabei als Baum dargestellt, in dem jede Position implizit einem Index entsprach. Dabei war die Entfernung
dieser Position von der Wurzel des Baumes genau die Länge der Binärdarstellung des
Index. In diesem Kapitel werden wir Datenstrukturen, sogenannte Tries7 , kennen lernen,
die diese Idee für andere Schlüssel als Zahlen verwenden.
Die Idee hinter Tries steht im Kontrast zur expliziten Darstellung der Schlüssel in einem
Suchbaum oder einer sortierten Liste. Eine Zuordnung von beliebigen vergleichbaren
Schlüsseln zu beliebigen Werten kann man als Liste von Paaren darstellen, wie hier am
Beispiel von Char-Schlüsseln:
7
Trie kommt von retrieve wird aber dennoch von einigen, zur Unterscheidung von tree, wie try ausgesprochen.
100
type CharMap a = [(Char,a)]
Die leere Zurodnung ist die leere Liste.
emptyCharMap :: CharMap a
emptyCharMap = []
Wir schlagen einen Wert in einer CharMap nach, indem wir den zugehörigen Wert zum gegebenen Schlüssel suchen und liefern Nothing zurück, falls kein Wert zu diesem Schlüssel
gespeichert ist:
lookupChar :: Char -> CharMap a -> Maybe a
lookupChar _ [] = Nothing
lookupChar c ((c’,x):xs)
| c == c’
= Just x
| otherwise = lookupChar c xs
Um einen neuen Eintrag zu speichern, fügen wir ihn vorne an die CharMap an und löschen
den alten Eintrag aus ihr.
insertChar :: Char -> a -> CharMap a -> CharMap a
insertChar c x xs = (c,x) : deleteChar c xs
Löschen können wir einen Eintrag, indem wir nur solche Einträge behalten, die ein
anderes Zeichen als Schlüssel enthalten.
deleteChar :: Char -> CharMap a -> CharMap a
deleteChar c = filter ((c/=) . fst)
Effizienter wäre eine Implementierung mittels eines Suchbaums, die man immer dann
verwenden kann, wenn es eine Ordnung auf dem Typ der Schlüssel gibt.
Wir lernen nun eine weitere Möglichkeit kennen, Werte Schlüsseln zuzuordnen, die sich
an der Struktur der Schlüssel orientiert. Als erstes Beispiel verwenden wir Strings als
Schlüssel. Statt die Ordnung auf Strings auszunutzen und einen Suchbaum zu verwenden, nutzen wir deren Struktur, um Werte an bestimmte Positionen in einem Baum zu
schreiben. Zum Beispiel speichert der folgende Baum die Zuordnung
"to"
"tom"
"tea"
"ten"
->
->
->
->
17
42
11
10
101
t
o
e
17
m
42
a
n
11
Abbildung 3: String-Trie
102
10
In disem Baum enthalten manche Knoten Werte und andere nicht. Der zu einem Wert
gehörige Schlüssel kann an den Kanten, die von der Wurzel zu diesem Wert führen,
abgelesen werden. Jede StringMap ist also ein Knoten und besteht aus einem optionalen
Wert und einer Zuordnung von Zeichen zu kleineren StringMaps, die die Zuordnung vom
Restwort zu einem Wert speichern:
data StringMap a =
StringMap (Maybe a) (CharMap (StringMap a))
Hierbei verwenden wir die oben definierte CharMap, um die Kanten in dem Baum zu
speichern. Die obige Beispielzuordnung wird mit diesem Datentyp wie folgt dargestellt:
StringMap Nothing
[(’t’,StringMap Nothing
[(’o’,StringMap (Just 17)
[(’m’,StringMap (Just 42) [])])
,(’e’,StringMap Nothing
[(’a’,StringMap (Just 11) [])
,(’n’,StringMap (Just 10) [])])])]
Die leere StringMap speichert keinen Wert (ein Wert an der Wurzel wäre der Eintrag, der
dem leeren String zugeordnet ist) und eine leere Zuordnung von Zeichen zu StringMaps.
emptyStringMap :: StringMap a
emptyStringMap = StringMap Nothing emptyCharMap
Zum Nachschlagen eines zu einem String gespeicherten Wertes untersuchen wir die
Struktur des Schlüssels.
lookupString :: String -> StringMap a -> Maybe a
Wenn der Schlüssel der leere String ist, geben wir den an der Wurzel gespeicherten
Eintrag zurück:
lookupString [] (StringMap a _) = a
Wenn der Schlüssel aus einem ersten Zeichen c und restlichen Zeichen cs besteht, suchen
wir aus der CharMap die zu c gehörige StringMap heraus und suchen in dieser rekursiv
den Schlüssel cs. Durch die Verwendung der Maybe-Monade ist das Gesamtergebnis
Nothing, wenn die CharMap keinen Eintrag für c enthält.
103
lookupString (c:cs) (StringMap _ b) =
lookupChar c b >>= lookupString cs
Um einen Wert unter einem String einzufügen, speichern wir ihn an der Wurzel, wenn
der String leer ist,
insertString :: String -> a -> StringMap a -> StringMap a
insertString [] x (StringMap _ b) = StringMap (Just x) b
oder wir fügen der CharMap unter dem ersten Zeichen c einen Eintrag hinzu, der die
alten Einträge enthält und zusätzlich den neuen unter den restlichen Zeichen cs.
insertString (c:cs) x
case lookupChar c b
Nothing ->
insertChar c
(insertString
Just m ->
insertChar c
(insertString
(StringMap a b) = StringMap a $
of
cs x emptyStringMap) b
cs x m) b
Diese Definition verwendet die Funktion lookupChar, und fügt je nach deren Ergebnis
die restlichen Zeichen entweder in die leere StringMap oder oder in die nachgschlagene ein. Da die rechten Seiten des case-Ausdrucks sich nur im letzten Argument von
insertString unterscheiden, können wir Code-Duplikation vermeiden, indem wir die
Fallunterscheidung in dieses Argument hineinziehen:
insertString (c:cs) x (StringMap a b) =
StringMap a
(insertChar c
(insertString cs x
(maybe emptyStringMap id (lookupChar c b)))
b)
Die Funktion maybe :: b -> (a -> b) -> Maybe a -> b ist in der Prelude vordefiniert.
Die Laufzeit von insertString ist (wenn wir von der Laufzeit der ineffizient implementierten CharMap absehen) linear in der Länge des als Schlüssel übergebenen Strings.
Anders als bei Suchbäumen, deren Laufzeit logarithmisch in der Anzahl der gespeicherten Werte ist, ist die Laufzeit von Trie-Funktionen unabhängig von der Größe des Tries.
Da auch Suchbaum-Implementierungen den Schlüssel ansehen müssen, um ihn zu vergleichen, hängt auch deren Laufzeit von der Größe der Schlüssel ab, so dass die Laufzeit
104
von Trie-Operationen theoretisch besser ist. Oft ist aber der Vergleich eines Schlüssels
nicht so teuer wie der Abstieg entsprechend seiner Struktur in einem Trie. Welche Datenstruktur in der Praxis besser ist, hängt vom Anwendungsbeispiel ab, insbesondere
davon, wie dicht die Datenstruktur besetzt ist.
Beim Löschen eines Eintrags gehen wir ähnlich vor wie zum Einfügen, um den gesuchten
Schlüssel zu finden.
deleteString :: String -> StringMap a -> StringMap a
Wenn der Schlüssel der leere String ist, löschen wir den Eintrag an der Wurzel.
deleteString [] (StringMap _ b) =
StringMap Nothing b
Ansonsten entfernen wir aus der unter dem ersten Zeichen gespeicherten StringMap den
Reststring.
deleteString (c:cs) (StringMap a b) =
case lookupChar c b of
Nothing -> StringMap a b
Just m ->
StringMap a
(insertChar c (deleteString cs d) b)
Auch hier können wir die Duplikation gemeinsamer Teile der rechten Seiten vermeiden,
indem wir die maybe-Funktion verwenden.
deleteString (c:cs) (StringMap a b) =
StringMap a
(maybe b
(\d -> insertChar c (deleteString cs m) b)
(lookupChar c b))
Sowohl die insertString als auch die deleteString Funktion verwenden abgesehen
vom rekursiven Aufruf die lookupChar Funktion zusammen mit insertChar, um die
StringMap mit dem Reststring zu verändern. Eleganter wäre, wenn man dazu nicht zwei
Funktionen verwenden müsste, die die CharMap beide durchlaufen, sondern eine einzige
Funktion updateChar zum Verändern einer CharMap verwenden könnte.
Da wir mit updateChar sowohl Elemente einfügen als auch entfernen wollen, geben wir
ihr den folgenden Typ.
105
updateChar :: Char
-> (Maybe a -> Maybe a)
-> CharMap a -> CharMap a
Das erste Argument ist das Zeichen, dessen Eintrag geändert werden soll und das zweite
eine Funktion, die die Änderung vornimmt. Sowohl der Argument- als auch der Ergebnistyp dieser Funktion ist Maybe a. Um einen Wert einzufügen, übergeben wir dieser
Funktion Nothing, um eines zu löschen, liefert diese Funktion Nothing.
Zum Ändern einer leeren CharMap rufen wir also die übergebene Funktion mit Nothing
auf und tragen das Ergebnis dieses Aufrufs in die CharMap ein, wenn es nicht Nothing
ist.
updateChar c upd [] =
maybe [] (\x -> [(c,x)]) (upd Nothing)
Bei einer nicht-leeren CharMap übergeben wir Just x an upd, falls x unter dem Zeichen c
gespeichert ist, und Ändern den Eintrag unter c gemäß des Ergebnisses dieses Aufrufs. Es
ist also nicht nur möglich vorhandene Einträge zu löschen sondern auch, sie zu verändern.
updateChar c upd ((c’,x):xs)
| c == c’
=
maybe xs (\y -> (c,y):xs) (upd (Just x))
| otherwise = (c’,x) : updateChar c upd xs
Statt updateChar zu verwenden, um insertString und deleteString zu definieren,
definieren wir eine Funktion updateString, mit deren Hilfe wir beide Funktion definieren können. Angenommen, updateString wäre schon definiert, dann könnten wir
insertString und deleteString wie folgt definieren.
insertString s x = updateString s (const (Just x))
deleteString s
= updateString s (const Nothing)
Der Typ der Funktion updateString entspricht dem von updateChar.
updateString :: String
-> (Maybe a -> Maybe a)
-> StringMap a -> StringMap a
Um den unter dem leeren String gespeicherten Wert zu ändern, wenden wir die
übergebene upd-Funktion auf diesen an.
106
updateString [] upd (StringMap a b) =
StringMap (upd a) b
Bei einem nicht-leeren String wenden wir updateChar und geschachtelt updateString
an. Dabei übergeben wir den updateString Aufruf als upd-Funktion an updateChar und
kombinieren diesen dazu mit Funktionen, die dafür sorgen, dass er einen Maybe-Wert als
Argument nimmt und als Ergebnis liefert.
updateString (c:cs) upd (StringMap a b) =
StringMap a
(updateChar c
(Just . updateString cs upd
. maybe emptyStringMap id)
b)
Die neuen Implementierungen von insertString und deleteString durchlaufen die
CharMaps seltener. Das allgemeinere update-Verfahren hat, so wie wir es implementiert
haben, aber auch einen Nachteil. Beim Löschen eines nicht vorhandenen Werts, wird ein
Eintrag für den gelöschten Schlüssel erzeugt (und mit Nothing belegt), auch wenn dieser
vorher gar nicht vorhanden war:
ghci> deleteString "a" emptyStringMap
StringMap Nothing [(’a’,StringMap Nothing [])]
Die alte Implementierung hat dieses Problem zwar nicht, entfernt allerdings auch keine
vorhandenen Einträge, wenn sie leer sind. Mit der alten (wie mit der neuen) Implementierung von deleteString ergibt sich:
ghci> let a=insertString "a" 42 emptyStringMap
ghci> deleteString "a" a
StringMap Nothing [(’a’,StringMap Nothing [])]
Um leere Zweige im Baum zu vermeiden, kann man die Implementierung der updateFunktionen anpassen (siehe Übung).
9.5 Verallgemeinerte Tries
Die Idee, die Struktur der Schlüssel auszunutzen und ihnen feste Positionen in einer Datenstruktur zuzuordnen, lässt sich auf andere Datentypen verallgemeinern. Wir lernen
nun zwei Beispiele kennen, die das verdeutlichen. Zunächst betrachten wir einen Datentyp für Binärzahlen, um den Zusammenhang zwischen Tries und den zuvor definierten
107
Arrays zu klären. Später betrachten wir als Beispiel eines komplizierteren rekursiven
Datentyps Bäume als Schlüssel.
Positive Binärzahlen können als Werte des folgenden Datentyps dargestellt werden.
data Nat = One | O Nat | I Nat
One ist die Darstellung der Zahl 1 oder allgemeiner des höchst-wertigen Bits einer beliebigen positiven Zahl. Führende Nullen (also auch die Zahl 0) können mit diesem Datentyp
nicht dargestellt werden. Der äußerste Konstruktor ist immer das niedrigste Bit. Zum
Beispiel wird die Zahl 6 als O (I One) dargestellt.
Die Trie-Struktur für diesen Datentyp enthält Knoten mit drei Einträgen:
• einem für den Eintrag des Schlüssels One,
• eine NatMap für die restlichen Bits der Schlüssel, die mit O beginnen, und
• eine NatMap für die restlichen Bits der Schlüssel, die mit I beginnen.
data NatMap a =
NatMap (Maybe a) (NatMap a) (NatMap a)
Dieser Datentyp kann aus der Deklaration des Nat-Datentyps abgeleitet werden. Der
NatMap-Konstruktor hat für jeden Konstruktor des Nat-Typs ein Argument. Die Typen
der Argumente des NatMap-Konstruktors ergeben sich aus den Typen der Argumente
der entsprechenden Nat-Konstrutoren. Hier hat der One-Konstruktor kein Argument,
der NatMap-Konstruktor hat also an der entsprechenden Stelle einen Wert vom Typ
Maybe a. Die beiden anderen Konstruktoren haben jeweils ein Argument vom Typ Nat,
der NatMap-Konstruktor hat also an den entsprechenden Stellen Argumente vom Typ
NatMap a.
An der Wurzel einer NatMap steht der Eintrag, der zu One gehört, Darunter stehen die
NatMaps, die zu allen geraden bzw. ungeraden Schlüsseln gehören. Das folgende Bild
zeigt die Schlüssel der ersten vier Ebenen einer NatMap.
Wenn wir die Einträge in ihre Dezimaldarstellung konvertieren, ergibt sich fast das Bild
der Indizes in unserer Array-Implementierung, nur dass die Schlüssel alle um eins größer
sind als die Array-Indizes, die bei Null anfangen, statt bei eins.
Wie ein Array ist auch eine NatMap immer unendlich. Anders als ein Array enthält eine
NatMap aber den Wert Nothing (statt eines Laufzeitfehlers) an Positionen, die keinem
Wert zugeordnet sind. Die leere NatMap definieren wir also wie folgt.
emptyNatMap :: NatMap a
emptyNatMap =
NatMap Nothing emptyNatMap emptyNatMap
108
1
O
I
01
O
001
O
0001
I
0011
I
O
011
101
O
0101
11
I
O
0111
1001
I
111
I
1011
O
1101
I
1111
Abbildung 4: Nat-Trie
Die Funktion zum Nachschlagen eines Schlüssels in einer NatMap folgt, wie die Definition
des NatMap-Datentyps selbst, der Struktur der Werte vom Typ Nat.
lookupNat
lookupNat
lookupNat
lookupNat
:: Nat -> NatMap a -> Maybe a
One
(NatMap a _ _) = a
(O n) (NatMap _ b _) = lookupNat n b
(I n) (NatMap _ _ c) = lookupNat n c
Wenn der Schlüssel One ist, wird das erste Argument geliefert, wenn er mit O beginnt,
wird lookupNat rekursiv auf das zweite Argument angewendet und, wenn er mit I
beginnt, auf das dritte.
Die insert- und delete-Funktionen definieren wir wieder mit Hilfe einer verallgemeinerten update-Funktion.
insertNat :: Nat -> a -> NatMap a -> NatMap a
insertNat n = updateNat n . const . Just
deleteNat :: Nat -> NatMap a -> NatMap a
deleteNat n = updateNat n (const Nothing)
Auch updateNat folgt wie lookupNat der Struktur der Nat-Werte.
updateNat :: Nat
-> (Maybe a -> Maybe a)
109
-> NatMap a -> NatMap a
updateNat One
upd (NatMap a b c) =
NatMap (upd a) b c
updateNat (O n) upd (NatMap a b c) =
NatMap a (updateNat n upd b) c
updateNat (I n) upd (NatMap a b c) =
NatMap a b (updateNat n upd c)
Anders als bei StringMaps brauchen wir uns hier nicht um leere Zweige zu kümmern
(können wir auch gar nicht!), da diese durch die unendliche Struktur der NatMap-Werte
nicht zu vermeiden sind. Die Darstellung von NatMaps ist anders als die von StringMaps
nicht redundant.
Die NatMaps entsprechen also, abgesehen von der Index-Verschiebung und den exliziten
Nothing-Einträgen, genau unseren Arrays. Auch die Laufzeiten der Funktionen sind
identisch. Der Array-Zugriff hat logarithmische Laufzeit in der Größe des Index, der
NatMap-Zugriff hat lineare Laufzeit in der Größe der gegebenen Binärzahl. Da die Größe
einer Binärzahl logarithmisch in der Größe der dargestellten Zahl ist, entsprechen sich
diese Laufzeiten.
Neben den definierten Funktionen sind weitere denkbar. Zum Beispiel können wir eine
map-Funktion für NatMaps angeben, die eine Funktion auf die Wert einer NatMap anwendet, indem wir eine Instanz der Klasse Functor definieren. Auch eine Funktion, die
eine NatMap in eine Liste ihrer Schlüssel/Wert-Paare umwandelt, wäre nützlich. Leider
können wir keine solche Funktion definieren, die terminiert, da NatMaps immer unendlich
groß sind, selbst, wenn sie nur endlich viele Schlüssel/Wert-Paare enthalten.
Wir können aber eine Monoid-Instanz definieren, bei der die Verknüpfung die Vereinigung
zweier NatMaps berechnet. Dabei soll die Implementierung von mappend die Einträge der
linken NatMap bevorzugen, wenn beide Argumente einen Eintrag zum selben Schlüssel
enthalten.
instance Monoid (NatMap a) where
mempty = emptyNatMap
NatMap a1 b1
NatMap (a1
(b1
(c1
c1 ‘mappend‘ NatMap a2 b2 c2 =
‘mplus‘ a2)
‘mappend‘ b2)
‘mappend‘ c2)
Auch den Schnitt zweier NatMaps könnten wir auf diese Weise berechnen.
110
Nat-Werte als Schlüssel sind etwas einfacher als Strings, im Folgenden betrachten wir
etwas kompliziertere Schlüssel, nämlich Bäume:
data Tree = Leaf String | Fork Tree Tree
Die Blätter solcher Bäume sind mit Strings beschriftet, innere Knoten haben genau zwei
Nachfolger und sind unbeschriftet.
Die zu diesem Typ gehörende Trie-Struktur ist eine Baum-Struktur, in der jede Position
zu einem Baum vom Typ Tree gehört. Die Schlüssel für eine TreeMap sind Trees.
data TreeMap a =
TreeMap (StringMap a) (TreeMap (TreeMap a))
Wieder ergibt sich die Definition des TreeMap-Typs aus der des Tree-Typs. Der TreeMapKonstruktor hat zwei Argumente, da der Tree-Typ zwei Konstruktoren hat. Das erste
Argument ist eine StringMap, da das (einzige) Argument des ersten Tree-Konstruktors
Leaf vom Typ String ist. Der zweite Tree-Konstruktor Fork hat zwei Argumente, die
beide vom Typ Tree sind. Das zweite Argument des TreeMap-Konstruktors hat daher
den Typ TreeMap (TreeMap a). Mehrere Argumente eines Konstruktors werden also in
der Trie-Struktur zu geschachtelten Tries entsprechender Typen. Dieses Muster haben
wir auch bei der Definition der StringMap benutzt, wo das zu (:) gehörige Argument
den Typ CharMap (StringMap a) hat.
Anders als bei der StringMap ist durch Anwendung dieses Musters auf den TreeDatentyp der Typ TreeMap ein Nested Datatype. Statt auf die Typvariable a wird zumindest ein Vorkommen des TreeMap-Typkonstruktors auf einen anderen Typ, nämlich
TreeMap a angewendet. Nested Datatypes sind uns schon bei der Definition von ArrayListen begegnet, wo wir sie ausgenutzt haben, um Invarianten der Darstellung im Typsystem zu kodieren. Wie dort brauchen wir auch hier polymorphe Rekursion, um rekursive
Funktionen auf TreeMaps zu definieren.
Zunächst definieren wir die leere TreeMap.
emptyTreeMap :: TreeMap a
emptyTreeMap =
TreeMap emptyStringMap emptyTreeMap
Schon hier hat der rekursive Aufruf von emptyTreeMap einen anderen Typ als der umgebende Aufruf. Der Typ von emptyTreeMap kann also nicht inferiert werden und wir
dürfen die Typsignatur nicht weglassen.
Die lookup-Funktion folgt wieder der Struktur des Schlüssels, der jetzt vom Typ Tree
ist.
111
lookupTree :: Tree -> TreeMap a -> Maybe a
lookupTree (Leaf s)
(TreeMap a _) =
lookupString s a
lookupTree (Fork l r) (TreeMap _ b) =
lookupTree l b >>= lookupTree r
In der ersten Regel verwenden wir einfach lookupString um die Beschriftung des gegebenen Blattes in der zugehörigen StringMap nachzuschlagen. In der zweiten Regel schachteln wir zwei Aufrufe von lookupTree in der Maybe-Monade, wenn einer fehlschlägt,
schlägt also der gesamte Aufruf fehl. Der erste rekursive Aufruf wendet lookupTree mit
einem anderen Typ an als der umgebende Aufruf, der den gleichen Typ hat wie der
zweite rekursive Aufruf. Das Egebnis des ersten rekursiven Aufrufs ist eine TreeMap auf
die wir wieder lookupTree aufrufen. Auch lookupTree ist also polymorph rekursiv.
Ebenso verhält es sich mit der updateTree-Funktion.
updateTree :: Tree
-> (Maybe a -> Maybe a)
-> TreeMap a -> TreeMap a
updateTree (Leaf s)
upd (TreeMap a b) =
TreeMap (updateString s upd a) b
updateTree (Fork l r) upd (TreeMap a b) =
TreeMap a
(updateTree l
(Just . updateTree r upd
. maybe emptyTreeMap id)
b)
In der ersten Regel rufen wir die updateString-Funktion mit der Beschriftung des gegebenen Blattes auf, in der zweiten schachteln wir zwei rekursive Aufrufe von updateTree
mit unterschiedlichen Typen und passen den inneren so an, dass er Maybe-Werte nimmt
und liefert.
Die insert und delete-Funktionen definieren wir wie üblich.
insertTree::Tree -> a -> TreeMap a -> TreeMap a
insertTree t = updateTree t . const . Just
deleteTree :: Tree -> TreeMap a -> TreeMap a
deleteTree t = updateTree t (const Nothing)
Zum Beispiel liefert der Aufruf
112
insertTree
(Fork (Leaf "a") (Leaf "bc"))
42
emptyTreeMap
das Ergebnis
TreeMap
emptyStringMap
(TreeMap
(StringMap
Nothing
[(’a’,
StringMap
(Just (TreeMap
(StringMap
Nothing
[(’b’,
StringMap
Nothing
[(’c’,
StringMap (Just 42) [])])])
emptyTreeMap)))])
emptyTreeMap)
Verkürzt und etwas übersichtlicher lässt sich dieses Ergebnis wie folgt darstellen:
Die Konstruktoren des als Schlüssel verwendeten Baums werden also von links nach
rechts der Reihe nach verwendet, um die zugehörige Position im Trie zu finden. Der
Abstand eines Eintrags von der Wurzel des Tries entspricht der Größe des als Schlüssel
verwendeten Baums. Die Laufzeiten von lookupTree und updateTree sind entsprechend
linear in der Größe des als Schlüssel verwendeten Baums.
10 Graphen
Mathematisch sind Graphen definiert als:
G = (V, E) mit V Menge von Knoten, E ⊆ V × V Menge von Kanten
Wie können Graphen nun in Haskell implementiert werden? Die Datentypen leiten wir
direkt aus der mathematischen Definition ab, zusätzlich fügen wir noch Beschriftungen
zu Knoten und Kanten hinzu:
113
Leaf
Fork
emptyStringMap
Leaf Fork
emptyTreeMap
'a'
Leaf Fork
emptyTreeMap
'b'
'c'
42
Abbildung 5: Beispiel einer ‘TreeMap‘
114
module Graph where
type
type
type
type
Graph a b = (Nodes a, Edges b)
NodeId
= Int
Nodes a
= [(NodeId, a)]
Edges b
= [(NodeId, b, NodeId)]
Wir stellen zunächst Überlegungen zur Schnittstelle an, um die Implementierung und
Effizienzbetrachtungen kümmern wir uns später.
Viele (imperative) Graphalgorithmen arbeiten mit Markierungen. Ähnliches wäre auch
in unserem Framework über Beschriftungen möglich, z.B. durch Erweiterung um eine
boolesche Komponente. Allerdings entspricht dies nicht dem üblichen induktiven Programmieren in funktionalen Sprachen.
Schöner wäre eine induktive Darstellung der Graphen, wie:
• leerer Graph (Konstruktor emptyGraph)
• Graph, der aus einem Knoten (mit seinem Kontext, den ein- und ausgehenden
Kanten) und einem Restgraph besteht (Konstruktor &v, wobei v die zugehörige
Knotennummer ist)
Mit dieser Darstellung ließe sich eine Tiefensuche wie folgt implementieren (in Pseudocode):
dfs
dfs
dfs
dfs
:: [NodeId] -> Graph a b -> [NodeId]
[]
_
= []
(v:vs) (c &v g) = v : dfs (succs c ++ vs) g
(_:vs) g
= dfs vs g
Problematisch ist natürlich das doppelte Vorkommen von v in den Pattern (NichtLinearität) und der parametrisierte Konstruktor &. Als Lösung kann dieses Matching
mit Hilfe einer Funktion umgesetzt werden:
type Context a b = ([(NodeId, b)], a, [(NodeId, b)])
match :: NodeId -> Graph a b
-> Maybe (Context a b, -- Kontext
Graph a b)
-- Restgraph
dfs :: [NodeId] -> Graph a b -> [NodeId]
dfs []
_ = []
dfs (v:vs) g = case match v g of
Nothing
-> dfs vs g
Just ((_ ,_ ,succs), g’) -> v : dfs (map fst succs ++ vs) g’
115
Es fehlt noch die Definition der Funktion match, die wir durch Suchen des Knotens und
Aufsammeln der Vorgänger- und Nachfolgerknoten implementieren.
match n (nodes, edges) = do
a <- lookup n nodes
let ctxt = ( [(m, b) | (m, b, n’) <- edges, n’ == n]
, a
, [(m, b) | (n’, b, m) <- edges, n’ == n] )
grph = ( filter ((/= n) . fst) nodes
, [ e | e@(m, _, m’) <- edges, m /= n, m’ /= n] )
return (ctxt, grph)
Für die Konstruktion von Graphen definieren wir einige Funktionen:
addNode :: NodeId -> a -> Graph a b -> Graph a b
addNode n a (nodes, edges) =
maybe ((n, a) : nodes, edges)
(error $ "Node " ++ show n ++ "already in graph")
(lookup n nodes)
addEdge :: NodeId -> b -> NodeId -> Graph a b -> Graph a b
addEdge n b m (nodes, edges) =
maybe (errNode n)
(\_ -> maybe (errNode m)
(const (nodes, (n, b, m) : edges))
(lookup m nodes))
(lookup n nodes)
where errNode n = error $ "Node " ++ show n ++ " not in graph"
addNodeWithSuccs :: NodeId -> a -> [(NodeId, b)]
-> Graph a b -> Graph a b
addNodeWithSuccs n a succs = foldr (.) (addNode n a)
[addEdge n b m | (m, b) <- succs]
Beachte: Knoten / Kanten können nun mittels match in anderer Reihenfolge entnommen werden als sie hinzugefügt wurden.
In unserer Implementierung soll es mehr auf die Idee der Schnittstelle ankommen, die
Repräsentation eines Graphen ist so noch ineffizient. Eine effizientere Darstellung ist
beispielsweise möglich unter Verwendung von Braunbäumen oder höhenbalancierten
Suchbäumen, die NodeIds auf Vorgänger-/Nachfolgerknoten abbilden. Hierdurch wird
die Implementierung von match komplizierter, ist aber effizient möglich. Dadurch wird
das Hinzufügen von Knoten/Kanten und das Matchen logarithmisch in der Graphgröße.
116
Eine weitere Verbesserungsmöglichkeit: Die NodeIds sind nicht abstrakt, sondern müssen
durch die Anwendung (und damit den Programmierer) generiert werden. Dies lässt sich
durch eine monadische Erweiterung der Graph-Konstruktion um einen NodeId-Zustand
verbessern, sodass Graphen wie folgt konstruiert werden können:
g = do
n <- addNode "a"
m <- addNode "b"
addEdge n 42 m
11 Generische Programmierung
Der Begriff Generische Programmierung wird für verschiedene Konzepte verwendet. Zum
Beispiel nennt man das, was in Java Generics heißt, in Haskell Polymorphie, aber auch
das Konzept der Überladung, zum Beispiel arithmetischer Funktionen, wird gelegentlich als generisch bezeichnet. In diesem Kapitel behandeln wir sogenannte Datentypgenerische Programmierung, mit deren Hilfe man gleichartige Funktionen auf unterschiedlichen Datentypen mit Hilfe einer einzigen Implementierung definieren kann.
Im vorigen Kapitel haben wir Tries für unterschiedliche Datenstrukturen kennengelernt
und dabei gesehen, dass Implementierungen sowohl der Map-Typen als auch der zugehörigen Funktionen einem festen Schema folgen. Bereits früher sind wir solchen Funktionen begegnet, die zwar für unterschiedliche Datentypen gleichartig, aber nicht identisch, implementiert werden.
Zum Beispiel folgt der Gleichheits-Test in der Regel einem festen Muster. Trotzdem kann
man keine allgemeine Implementierung für
(==) :: a -> a -> Bool
angeben, da sich die Implementierungen für unterschiedliche Typen unterscheiden. In
Haskell wurde deshalb die Typklasse Eq eingeführt, die es erlaubt, für unterschiedliche
Datentypen unterschiedliche Implementierungen für == anzugeben. Da solche Implementierungen sich in der Regel ähneln, gibt es außerdem die Möglichkeit, Eq-Instanzen automatisch vom Compiler nach einem festen Muster generieren zu lassen (Schlüsselwort
deriving). Eine Alternative zu solch speziellem Compiler-Support ist die im Folgenden
vorgestellte Datentyp-generische Programmierung.
Statt ein festes Muster zur Implementierung von == für unterschiedliche Datentypen immer wieder anzuwenden, kann man es ein einziges Mal für einen bestimmten universellen
Datentyp definieren und alle anderen Typen in diesen Typ konvertieren. Dieses Vorgehen
erscheint zunächst umständlicher, da man nun zwar keine Gleichheits-Funktion für jeden
Typ mehr angeben muss, dafür aber eine Konvertierungsfunktion. Bei genauerem Hinsehen zeigt sich jedoch ein Vorteil: Die Konvertierungsfunktionen kann man verwenden, um
117
eine unbegrenzte Zahl generischer Funktionen anzuwenden. Statt viele Funktionen für
viele Typen zu implementieren braucht man also nur noch eine Konvertierungsfunktion
für jeden Typ und eine Implementierung für jede generische Funktion.
Bool
==
[Bool]
compare
(Bool,[()])
universal data type
serialize
Either [()] Bool
...
...
Abbildung 6: Avoiding quadratic number of function definitions with universal datatype
Um drei generische Funktionen für vier Datentypen zu implementieren, braucht man
unter Verwendung eines universellen Datentyps nur 4 + 3 statt 4 ∗ 3 Funktionen zu
implementieren.
Es stellt sich die Frage, wie der universelle Datentyp beschaffen sein muss, um die Definition möglichst vieler generischer Funktionen zu unterstützen. Zunächst muss es möglich
sein, jeden Datentyp8 injektiv in den universellen Datentyp abzubilden, da wir ansonsten keine sinnvolle Gleichheits-Funktion implementieren könnten. Darüber hinaus muss
die Struktur eines Wertes erhalten bleiben, damit die Implementierung einer generischen
Funktion auf dem universellen Datentyp das Muster, dem man für den Original-Datentyp
folgen würde, anwenden kann.
Wir verwenden deshalb den folgenden universellen Datentyp.
data Universal
= Unit
| Pair Universal Universal
| This Universal
| That Universal
8
Wir vernachlässigen hierbei primitive Datentypen wie Int oder Char und beschränken uns auf selbst
definierte, algebraische Datentypen (ohne Funktionen).
118
Diesen Datentyp können wir verwenden, um das Muster, dem die Gleichheits-Funktion
folgt, zu formalisieren. Dazu geben wir einfach eine ==-Funktion für den Typ Universal
an.
instance Eq Universal where
Unit
== Unit
=
Pair u1 v1 == Pair u2 v2 =
This u1
== This u2
=
That u1
== That u2
=
_
== _
=
True
u1 == u2 && v1 == v2
u1 == u2
u1 == u2
False
Wir können nun Werte beliebiger Typen, die sich in den Universal-Typ konvertieren
lassen, mit dieser Funktion vergleichen. Zur Konvertierung in den Universal-Typ definieren wir eine Typklasse Generic
class Generic a where
universal :: a -> Universal
mit deren Hilfe wir einen generischen Gleichheits-Test implementieren können.
genericEq :: Generic a => a -> a -> Bool
genericEq x y = universal x == universal y
Die Funktion universal ist selbst eine Datentyp-generische Funktion und zwar die einzige, die man für jeden Typ gesondert programmieren muss. Sie folgt einem festen Muster,
welches wir im Folgenden untersuchen.
Um mehrere Konstruktoren eines Datentyps auseinander zu halten, verwendet man die
Konstruktoren This und That. Zum Beispiel konvertiert man Werte vom Typ Bool wie
folgt:
instance Generic Bool where
universal False = This Unit
universal True = That Unit
Hierbei verwenden wir Unit als Argument von This und That, da die Konstruktoren
von Bool keine Argumente haben (Konstruktoren mit Argumenten widmen wir uns
später). Bei Datentypen mit mehr als zwei Konstruktoren, können wir This und That
geschachtelt verwenden. Beispielhaft betrachten wir die Konvertierung eines Datentyps
für vier Farben.
data Colour = Red | Green | Blue | Yellow
119
In der Generic-Instanz für Colour schachteln wir die This und That Konstruktoren so,
dass man an der Anzahl der That-Konstruktoren erkennen kann, um welche Farbe es
sich handelt.
instance Generic Colour where
universal Red
= This Unit
universal Green = That (This Unit)
universal Blue
= That (That (This Unit))
universal Yellow = That (That (That (This Unit)))
Alternativ zu so einer linearen Kodierung der Farben, können wir auch eine Art
Binärkodierung verwenden.
instance Generic Colour where
universal Red
= This (This
universal Green = This (That
universal Blue
= That (This
universal Yellow = That (That
Unit)
Unit)
Unit)
Unit)
Mit dieser Kodierung ist die Anzahl der verwendeten Konstruktoren pro Regel logarithmisch in der Anzahl der Regeln statt linear.
Zur Definition der generischen universal-Funktion verwenden wir also zur Unterscheidung von n Konstruktoren eine Schachtelung von log(n) This und That Konstruktoren
entsprechend der Binärdarstellung der Nummer des Konstruktors.
Wir können nun die generische Gleichheits-Funktion auf Boole’sche Werte und auf Farben anwenden, aber nicht auf einen Boole’schen Wert und eine Farbe:
ghci> genericEq False False
True
ghci> genericEq Red Blue
False
ghci> genericEq False Yellow
Couldn’t match expected type ‘Bool’
against inferred type ‘Colour’
Wir kommen nun zu Datentypen, deren Konstruktoren Argumente haben und definieren
dazu eine Generic-Instanz für Listen.
instance Generic a => Generic [a] where
universal []
= This Unit
universal (x:xs) = That (Pair (universal x) (universal xs))
120
Wieder unterscheiden wir die Konstruktoren mit This und That, verwenden aber
zusätzlich den Pair-Konstruktor, um die Argumente von (:) zu speichern. Durch diese
Definition können wir nun zum Beispiel Listen von Farben konvertieren:
ghci> universal [Red]
That (Pair (This (This Unit)) (This Unit))
Im Allgemeinen verwenden wir Unit bei Konstruktoren ohne Argumente und schachteln n − 1 Pair-Konstruktoren bei Konstruktoren mit n Argumenten. Auch bei der
Schachtelung von Pair-Konstruktoren haben wir unterschiedliche Möglichkeiten. Zum
Beispiel können wir die Elemente linear oder als balancierten Baum schachteln. Die Art
der Schachtelung hat aber anders als bei This und That keinen Einfluss auf die Anzahl
der benötigten Pair Konstruktoren, da ein Binärbaum mit n Blättern unabhängig von
seiner Struktur immer genau n − 1 innere Knoten hat.
Die Konstruktoren des Universal-Datentyps entsprechen genau den Konstruktoren der
()-, (,)-, und Either-Typen:
instance Generic () where
universal () = Unit
instance (Generic a, Generic b) => Generic (a,b) where
universal (x, y) = Pair (universal x) (universal y)
instance (Generic a, Generic b) => Generic (Either a b) where
universal (Left x) = This (universal x)
universal (Right y) = That (universal y)
Diese Typen reichen aus, um die Strukturinformation beliebiger algebraischer Datentypen zu kodieren, denn nach dem oben erklärten Muster lassen sich alle algebraischen
Datentypen in den Universal-Typ konvertieren.
Bei der Definition von Konvertierungs-Funktionen ist man nicht an das beschriebene
Muster gebunden, es stellt nur eine mögliche Art dar, beliebige Datentypen zu konvertieren. Zum Beispiel können wir Listen auch konvertieren, ohne This und That zu
verwenden:
instance Generic a => Generic [a] where
universal []
= Unit
universal (x:xs) = Pair (universal x) (universal xs)
Diese Definition führt zu einer kompakteren Darstellung von Listen:
121
ghci> universal [Red]
Pair (This (This Unit)) Unit
Bei eigenen Konvertierungs-Funktionen müssen wir sicherstellen, dass diese injektiv sind,
das heißt, dass keine unterschiedlichen Werte des Original-Typs auf den selben Wert des
Universal-Typs abgebildet werden. Weiterhin sollte die Strukturinformation bei der
Konvertierung vollständig erhalten bleiben. Beides ist mit Konvertierungs-Funktionen,
die nach dem generischen Muster erstellt werden, der Fall.
Ein weiteres Beispiel für eine generische Haskell-Funktion ist die show-Funktion zum
Umwandeln eines Wertes in einen String. Auch diese Funktion kann man generisch über
die Struktur des Arguments definieren. Die im Universal-Typ gespeicherte StrukturInformation reicht zur Definition von show aber nicht aus. Es fehlt die Information über
die Konstruktor-Namen, die im erzeugten String vorkommen.
Es ist möglich, den Universal-Typ um weitere Informationen zu erweitern, auch um
solche, mit deren Hilfe wir show implementieren könnten. Wir beschränken uns aber
auf den gezeigten Universal-Datentyp und definieren anstelle von show eine generische
Funktion serialize, die einen beliebigen Datentyp in eine Bitfolge übersetzt:
serialize :: Generic a => a -> [Bool]
serialize = binary . universal
binary ist dabei eine Funktion, die einen Universal-Wert in eine Liste Boole’scher
Werte übersetzt.
binary
binary
binary
binary
binary
:: Universal
Unit
=
(Pair u v) =
(This
u) =
(That
u) =
-> [Bool]
[False,False]
[False,True ] ++ binary u ++ binary v
[True ,False] ++ binary u
[True ,True ] ++ binary u
Da der Universal-Typ vier Konstruktoren hat, verwenden wir zwei Bits für jeden und
serialisieren sie von links nach rechts. Hier ein Beispielaufruf:
ghci> binary (Pair (That Unit) Unit)
[False,True,True,True,False,False,False,False]
Dadurch, dass wir die binary-Funktionion für den Universal-Typ definiert haben,
können wir beliebige Daten, deren Typ eine Instanz der Klasse Generic ist, in Bitfolgen
transformieren.
122
ghci> serialize False
[True,False,False,False]
ghci> serialize [()]
[False,True,False,False,False,False]
Wie man sieht, verwendet diese Implementierung mehr Bits als man erwarten könnte.
Zum Beispiel kann man Boole’sche Werte mit einem einzigen Bit kodieren satt wie hier
mit vieren. Gelegentlich ist eine generische Implementierung mittels des universellen
Datentyps weniger effizient als eine auf einen bestimmten Datentyp spezialisierte Implementierung.
Die erzeugten Bitfolgen lassen sich auf eindeutige Weise in den Universal-Datentyp
zurück übersetzen. Zusammen mit einer Funktion, die Universal-Werte in beliebige
Datentypen zurück konvertiert, kann man also auch eine Funktion deserialize schreiben, die Daten aus einer Bitfolge einliest (Übung).
Zum Abschluss dieses Kapitels implementieren wir eine generische Trie-Struktur, die
man mit Schlüsseln beliebigen (nach Universal konvertierbaren) Typs verwenden kann.
Dazu definieren wir zunächst einen Trie für den Universal-Typ nach dem im vorigen
Kapitel diskutierten Muster.
data UniMap a = UniMap (Maybe a)
(UniMap (UniMap a))
(UniMap a)
(UniMap a)
Die Definitionen der empty-, lookup- und update-Funktionen sind im bereitgestellten
Generic-Modul verfügbar. Aufbauend auf dieser Implementierung definieren wir Zugriffsfunktionen für beliebige Datentypen, hier am Beispiel der lookup-Funktion:
lookupG :: Generic k => k -> UniMap a -> Maybe a
lookupG = lookupUni . universal
Mit solchen Zugriffsfunktionen können wir in eine emptyUniMap Werte zu beliebigen
Schlüsseln eintragen.
ghci> let m =
ghci> lookupG
Just 42
ghci> lookupG
Nothing
ghci> lookupG
Nothing
insertG [True,False] 42 emptyUniMap
[True,False] m
[False] m
True m
123
Der letzte Aufruf ist verdächtig. Obwohl wir m mit Schlüsseln vom Typ [Bool] verwendet
haben, können wir sie auch mit anderen Schlüssel-Typen, die eine Generic-Instanz sind,
verwenden. Das ist eine potentielle Fehlerquelle, denn obwohl es in diesem Beispiel richtig
ist, dass kein Wert unter dem Schlüssel True abgelegt wurde, ist nicht sichergestellt, dass
unterschiedliche Werte unterschiedlicher Typen verschiedene Universal-Darstellungen
haben. Die Konvertierungsfunktionen sind nur injektiv bezüglich eines bestimmten Typs,
nicht über Typgrenzen hinweg.
Wir definieren deshalb einen Trie für generische Werte, bei dem jeder einzelne Trie mit
nur einem Schlüsseltyp (verschiedene Tries aber mit unterschiedlichen Schlüsseltypen)
verwendet werden können.
newtype GenMap k a = GenMap (UniMap a)
GenMap ist im Wesentlichen nur ein neuer Name für UniMap mit einer wichtigen Besonderheit: Der GenMap Typkonstruktor hat einen zusätzlichen Parameter k für den
Schlüsseltyp. Dieser Parameter ist ein sogenannter Phantom-Typ, da er auf der rechten Seite der Definition nicht vorkommt. Wir verwenden ihn in den Typsignaturen der
Zugriffsfunktionen für GenMaps, um sicher zu stellen, dass mit einer gegebenen GenMap
immer Schlüssel desselben Typs verwendet werden.
Die Implementierung der Zugriffsfunktionen für GenMaps greift auf die für UniMaps
zurück, verwendet aber restriktivere Typsignaturen (hier am Beispiel von lookupGen):
lookupGen :: Generic k => k -> GenMap k a -> Maybe a
lookupGen k (GenMap m) = lookupUni (universal k) m
Dadurch, dass der Typparameter k im ersten und zweiten Argument von lookupGen
identisch ist, können wir nur mit Schlüsseln eines einzigen Typs auf eine bestimmte
GenMap zugreifen.
Wir können GenMaps ähnlich verwenden wie im obigen Beispiel. Sobald wir aber eine
Zugriffsfunktion auf einer GenMap mit einem konkreten Schlüssel ausgeführt haben, ist
der Typ der GenMap auf diesen Schlüsseltyp festgelegt.
ghci> let m = insertGen [True,False] 42 emptyGenMap
ghci> lookupGen [True,False] m
Just 42
ghci> lookupGen [False] m
Nothing
ghci> lookupGen True m
Couldn’t match expected type ‘Bool’
against inferred type ‘[Bool]’
ghci> :t m
m :: GenMap [Bool] Int
124
Der lookupGen Aufruf mit einem Bool-Schlüssel führt zu einem Typfehler, da vorher
mit einem Schlüssel vom Typ [Bool] auf m zugegriffen wurde. Auf eine neue GenMap
können wir mit Bool-Schlüsseln zugreifen:
ghci> let m = insertGen True 42 emptyGenMap
ghci> :t m
m :: GenMap Bool Int
ghci> lookupGen True m
Just 42
Wir haben durch Phatom-Typen erreicht, dass auf eine GenMap nur mit Schlüsseln eines
Typs zugegriffen werden kann. Die erste Zugriffsfunktion legt dabei den Schlüsseltyp fest
und stellt dadurch sicher, dass sich gleich dargestellte Schlüssel unterschiedlicher Typen
nicht in die Quere kommen.
125
Herunterladen