Parserkombinatoren

Werbung
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.
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 ()
einen Parser, der genau das gegebene Zeichen erkennt:
ghci> parse (char 'a') "a"
Just ()
Bei anderen Eingaben liefert dieser Aufruf Nothing.
1
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 Parser-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 ()
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:
2
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)
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*_
3
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 ()
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
4
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,
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:
5
(<*>) :: 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
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.
6
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.
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 sehr groß ist. Palindrome ohne markierte Mitte sind zwar noch mit kontextfreien
Grammatiken beschreibbar, aber nicht mit deterministischen Kellerautomaten beschreibbar. Der Grund ist, dass ein Kellerautomat die Mitte des Wortes nicht finden kann,
sondern nur raten kann, wann er sich in der Mitte des Wortes befindet. Unsere Parserkombinatoren sind aber in der Lage diese Sprache zu erkennen.
Wir werden aber auch noch sehen, das sie sogar Sprachen erkennen können, die nicht
kontextfrei sind.
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
7
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)
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:
8
(<|>) :: Parser a -> Parser a -> Parser a
p <|> q = \s -> case p s of
Just xs -> Just xs
Nothing -> q s
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:
9
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:
10
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")]
11
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
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:
12
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])]
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.
13
Parsen nicht-kontextfreier Sprachen
Zum Parsen nicht-kontextfreier Sprachen gibt es unterschiedliche mögliche Ansätze.
Zunächst ist es möglich, mit Hilfe der check-Funktion Tests auf Ergebnisse eines Parsers
anzuwenden, welche dann das gesamte Parseergebnis beeinflussen. Als Beispiel definieren
wir einen Parser für die nicht-kontextfreie Sprache {an bn cn | n ≥ 0}:
anbncn :: Parser Int
anbncn =
(\(x,_,_) -> x) <$>
(check (\(n,m,k) -> n==m && m==k) ((,,) <$> star 'a' <*> star 'b' <*> star 'c'))
Hierbei ist star ein Parser, welcher jede optionale Wiederholung eines Characters parst:
star :: Char -> Parser Int
star c = (+1) <$> (char c *> star c)
<|> yield 0
Die Idee ist also zunächst die Sprache a∗ b∗ c∗ zu parsen, hierbei die Anzahl der Vorkommen
der einzelnen Buchstaben zu zählen und anschließend die Anzahlen zu vergleichen.
Ein anderer Ansatz ist die Konstruktion eines Parsers, welche unendlich viele Regeln
besitzt. Hierzu betrachten wir zunächst die Aufzählung aller Wörter der Sprache an bn cn :
epsilon | abc | aabbcc | aaabbbccc | . . .
Diese Regeln einfach so aufzuzählen, würde zwar einen Parser konstruieren, welcher im
Erfolgsfall korrekt arbeitet. Für den Fall eines Wortes, welches nicht in der Sprache liegt,
würde er aber nicht terminieren. Es ist aber möglich, einen passenden (unendlichen)
Regulären Ausdruck zu definieren, bei dem die Entscheidung ob eine weitere Alternative
gewählt werden soll, stets durch ein weiteres Vorkommen eines Buchstaben ‘a’ geschützt
wird:
epsilon | a(bc | a(bbcc | a(bbbccc | a(bbbbcccc | . . . ))))
Dies können wir wie folgt in unsere Parser einbauen:
anbncn2 :: Parser Int
anbncn2 = let bs n = foldr
cs n = foldr
bcs n = bs n
abc n = char
yield 0 <|> abc
(*>) (yield n) (replicate n (char 'b'))
(*>) (yield n) (replicate n (char 'c'))
*> cs n
'a' *> ((bcs n) <|> abc (n+1)) in
1
Eine dritte noch elegantere Lösung stellt die Konstruktion eines geeigneten ‘c’-Parsers,
in Abhängigkeit des Ergebnisses (Anzahl von a’s und b’s) des Parseprozesses für an bn
dar. Dies ist aber auch gezielter, als mit Hilfe der check-Funktion möglich.
14
Monadische Parser
Wir benötigen einen sequenzoperator für Parser, welcher den zweiten Parser über das
Ergebnis des ersten Parsers parameterisiert. 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 ->
(empty <|> anyChar *> empty) *>
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
Bei Palindromen ungerader Lände, darf der mittlere Buchstabe nicht zwei mal gelesen
werden. Deshalb fügen wir dem Palindromparser noch die Regel (empty <|> anyChar
*> empty) *> hinzu. Hierbei verwenden wir den Zusatz *> empty nur um die beiden
Alternativen typgleich zu machen.
Da der word-Kombinator () als Ergebnis liefert, gilt dies auch für den neuen PalindromParser:
ghci> parse palindrom "anna"
Just ()
ghci> parse palindrom "rentner"
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
(>>=) = (*>=)
15
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:
palindrom = do u <- many anyChar
empty <|> anyChar *> empty
word (reverse 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)
16
Man könnte also auch <$> durch *>= und yield definieren.
Als weitere Anwendung der monadischen Parserkombinatoren betrachten wir noch einmal
das Beispiel an bn cn .
abcn3 = do
n <- anbn
foldr (*>) empty (replicate n (char 'c'))
return n
anbn = yield 0
<|> (+1) <$> (char 'a' *> anbn <* char 'b')
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 Applicative-Klasse
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:
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
17
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 QuickCheck-Testeingaben
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.
So wie wir Monad zu MonadPlusPlus erweitert haben, kann man auch Applicative zu
Alternative erweitern, in dem man noch die folgenden Funktionen hinzu fügt:
class Applicative f => Alternative f where
empty :: f a
<|> :: f a -> f a -> f a
Die Funktionen many :: Alternative f => f a -> f [a] (für die optionale Wiederholung) und some :: Alternative f => f a -> f [a] für die mindestens einmalige
Wiederholung sind ebenfalls für die Klasse Alternative verallgemeinert.
18
Im GHC hat sich ab Version 7.10 die Klassenstruktur verändert und der Programmierer
muss die Functor- und Applicative-Instanz definieren, bevor er die Instanz für Monad
definieren kann. Ob dies eine gute Entscheidung war, ist sicherlich diskusionswürdig.
19
Herunterladen