Compilerbau/Kapitel 2: Syntaxanalyse
Aus Halloserv Wiki
Nachdem der Lexer den Quellcode in eine Liste von Token-Attribut Paaren aufgespalten hat, geht es nun darum, die syntaktische Struktur des Quellprogrammes zu erkennen. Ziel des zur syntaktischen Analyse eingesetzten Parsers ist es, aus der gegebenen Liste einen Ableitungsbaum (bzw. einen abstrakten Syntaxbaum, AST) zu bauen, der die Struktur des Programmes enkodiert. Um die Struktur zu definieren, und auch zu ihrer Erkennnung, benutzt man kontextfreie Grammatiken (KFG). KFGs haben den Vorteil, dass sie ein einfacher und verständlicher Formalismus sind, für viele Fälle eine Lösung für das Wortproblem in O(n) liegt und es etliche Tools gibt, die Parser aus KFGs konstruieren (z.B. yacc, bison, antlr, javacc).
Diese Grammatik klammert Operatoren nach links und beachtet Punkt- vor Strichrechnung.
Für die Eingabe x + x + x erhält man dann diesen Ableitungsbaum:
Inhaltsverzeichnis |
Arten von Parsern
Es existieren zwei grundsätzliche Arten von Parsern, Top-Down/Predictive Parser und Bottom-Up/Shift-Reduce Parser. Top-Down Parser bauen den Ableitungsbaum von der Wurzel zu den Blättern auf (Tiefensuche), während Bottom-Up Parser den Aufbau bei dem linkesten Blatt beginnen und zur Wurzel fortsetzen.
kontextfreie Grammatiken
So wie der Lexer mit regulären Ausdrücken und DFAs arbeitet, brauchen wir für den Parser die kontextfreie Grammatik. Sie wird in diesem Abschnitt definiert.
Eine kontextfreie Grammatik ist ein 4-Tupel G = (N,T,P,S), wobei
- N eine Menge von Nichtterminalen Symbolen,
- T eine Menge von Terminalsymbolen,
-
die Vereinigung von Nichtterminalen und Terminalen,
-
eine endliche Menge von Produktionen und
-
das Startymbol darstellen.
In den kommenden Abschnitten sind diese Symbole wie folgt definiert:
Alle angegebenen Grammatikproduktion sind weiterhin Element in P
Die Ableitungsrelation
bezüglich G, ist definiert als
:
genau dann, wenn α = α1Aα2,β = α1γα2 und
. Es gilt weiterhin
-
ist die reflexive und transitive Hülle von
,
, falls eine Ableitung
von α1 nach αn,
existiert.
-
ist Satzform, falls
.
In einer Linksableitungsrelation
wird immer das linkeste Nichtterminal abgeleitet.
, falls
(beachte, dass
).
Die Linkssatzform sowie
sind analog zur Ableitungsrelation definiert.
Top-Down Parser mit Recursive Descent
Dieser Abschnitt spezifiziert einen Parser formal. Wir beginnen damit, den Parser als nicht-deterministische Funktion zu entwerfen um ihn dann zu transformieren und vereinfachen. Schliesslich werden wir ein Kriterium für Determinismus suchen (dies schränkt die Grammatik ein).
Gegeben: KFG G mit L = L(G)
Gesucht: Ein Parser für L.
Für
betrachte
, beziehungsweise [α]: = PL(α).
Man kann nun aus der Definition und Struktur von α mit konstruktiver Induktion (Fallunterscheidung über α) eine Implementierung für [α] bestimmen:
Diese Spezifikation wäre einfach in ein Programm umzusetzen. Wie man an diesem Beispiel allerdings gut sieht, gibt es zwei Probleme:
- Die Definition des Parsers ist nicht deterministisch: [A](w) exploriert alle Teile der rechten Seite einer Produktion. Dies kann dazu führen, dass die Implementation exponentiell zum Input-String ist.
- Parser rufen sich selbst mit dem gleichen Argument auf (siehe z.B. [X](w)), was zu Endlosrekursion führt. Alle Rekursiv-absteigenden Parser, die links-rekursive Regeln enthalten (Regeln, deren linke Seite auch als erstes Symbol der rechten Seite auftaucht), zeigen dieses Verhalten.
Zu beiden Problemen gibt es eine Lösung, die in den kommenden Abschnitten behandelt wird: Auflösen von Links-Rekursion und Lookahead.
Links-Rekursion
Wie vorher bemerkt, führt Links-Rekursion zu einer Endlosschleife im Parser. Hier wird erklärt, wie man sie auflöst.
Eine Grammatik ist linksrekursiv, wenn ein Nichtterminal
existiert, das linksrekursiv ist.
Ein Nichtterminal
ist linksrekursiv, falls gilt
, im speziellen ist es direkt linksrekursiv, falls gilt
.
Um die Linksrekursion zu eliminieren, sind zwei Schritte nötig. Einmal muss die direkte Linksrekursion am Nichtterminal A beseitigt werden, und zum anderen die allgemeine Rekursion, d.h. G muss zykelfrei und frei von ε-Produktionen sein.
Seien
alle Regeln für A (mit βi startet nicht mit A). Dann kann man die Linksrekursion entfernen, in dem man die Regeln mit diesen neuen ersetzt - die neue Grammatik erkennt die gleiche Sprache wie die ursprüngliche:
Wähle die Reihenfolge
für Nichtterminale.
for i = 1 to n
- for j = 1 to i - 1
- Ersetze alle Produktionen der Form
mit j < i und
durch
- Entferne direkte Linksrekursion für Ai
- Gibt es eine Produktion
und
, dann ist l > i (d.h. l wird noch bearbeitet werden)
- Ersetze alle Produktionen der Form
Lookahead
Wie vorher bemerkt, ist es ein Problem, dass der Parser nicht weiss, welcher Teil der rechten Seite zum Weitergehen verwendet werden soll. Würde der Parser alle Möglichkeiten ausprobieren, würde das zu einem Rechenaufwand führen, der exponentiell zur Eingabe ist. Um dieses Problem zu lösen, wird der Lookahead eingesetzt. Lookahead bedeutet, dass der Parser einige (k) Symbole im Input vorausschaut, um zu erkennen, welche rechte Regelseite sinnvoll ist und welche nicht. Als Ergebnis erhält man einen Parser, der linear in der Größe der Eingabe arbeitet.
Um den Lookahead zu realisieren, verwenden wir zwei Funktionen, firstk und followk.
(bezüglich einer KFG)
Man kann diese Funktionen nun verwenden, um die oben gestellte Frage nach der korrekten rechten Seite zu beantworten. Betrachtet man diese Ableitung:
, so dass der Parser [A] die Entscheidung für die Regel
machen muss, wenn er ξ in der Eingabe (genauer die ersten k Zeichen von ξ) sieht. Wir können nun berechnen, das das k-Präfix von ξ folgende Eigenschaft hat:
Das führt uns zur Definition der Lookahead-Menge einer Produktion und starken LL(K)-Grammatiken (das erste L steht für "von links nach rechts parsbar", das zweite für "generiert Linksableitung").
Der LL(k) Lookahead einer Produktion berechnet sich wie folgt:

Eine Grammatik ist eine starke LL(K)-Grammatik, wenn für alle Regeln
gilt:
.
Wir können nun eine deterministische Parserfunktion für Nichtterminale konstruieren:
Eine KFG ist eine LL(K)-Grammatik, falls für alle Ableitungen der Form:
gilt, dass
impliziert β = γ.
Jede LL(1) Grammatik ist auch stark LL(1).
Für
gibt es Grammatiken, die nicht stark LL(k) sind.
Betrachte G mit den Produktionen
. G ist LL(2), da die in fett gehaltenen Abschnitte in beiden Fällen disjunkt sind:
Die Bedingung für stark LL(2) ist jedoch nicht erfüllt, da die Lookaheads für die Produktionen von A nicht disjunkt sind:
Implementierung des Lookaheads
Die Implementierung von firstk betreiben wir ähnlich wie beim Parser durch Fallunterscheidung über α.
Wir müssen nun noch eine Fallunterscheidung über X durchführen:
Wie man leicht sieht, hat diese Implementierung noch ein Problem: sie ist rekursiv. Man kann aber die Implementierung von firstk als Gleichungssystem mit der Unbekannten
auffassen. Dann ist eine Lösung der Vektor
von Mengen von terminalen Strings.
Hier nutzen wir eine Fixpunktiteration, um den Vektor
zu berechnen. Um die nächste Iterationsstufe (LA') zu erreichen, müssen wir jeweils auf die aktuelle (LA) zurückgreifen:
Berechne first1(A) für alle Nichterminale
:
Nach Schritt 6 verändern sich die Mengen nicht mehr - der Fixpunkt ist erreicht. Um den Lookahead zu berechnen, brauchen wir noch die follow1 Mengen, diese berechnen sich ähnlich:
Für unsere modifizierte Ausdrucksgrammatik folgen deshalb diese follow1' Mengen:
Berechne follow1(A) für alle Nichtterminale
:
* )In der follow Menge des Startsymbols muss ε enthalten sein.
Auch hier haben wir einen Fixpunkt erreicht und die follow1 Mengen erhalten. Nun können wir den Lookahead berechnen:
Wie man hier sieht, ist die Grammatik stark LL(1), d.h. LL(1). Der rekursiv-absteigende Parser kann also mit einem Symbol Lookahead deterministisch werden.
Bottom-Up / Shift Reduce Parser
Der Bottom-Up Parser baut eine Rechtsableitung von den Blättern zur Wurzel auf (d.h. rückwärts). Die Startproduktion
ist die letzte Produktion, die der Parser bearbeitet. Man kann sich den Parser als Stackmaschine vorstellen:
Startkonfiguration:
, wobei ε den leeren Stack und w das Eingabewort darstellt. Der Parser geht dann wie folgt vor:
In Konfiguration
- Gibt es eine rechte Regelseite auf dem Stack γ = αβ und es existiert eine Regel
, dann ist der neue Zustand
(reduce)
- Ist die Eingabe nicht leer, kann sie aufgespalten werden in ein Symbol und den Rest: w = aw'. Der neue Zustand ist dann
(shift)
- Falls
wurde das Eingabewort erkannt
Man wiederholt die einzelnen Schritte bis keine weitere Aktion mehr möglich ist. Dabei kann nichtdeterministisches Verhalten auftreten, weil ein shift fast immer möglich ist, bei der Aufteilung γ = αβ (reduce) und wenn es mehrere Nichtterminale mit gleicher rechter Seite gibt.
parse 2 + x * x
Der so beschriebene Parser ist auch bekannt als LR Parser (L für "von links nach rechts", R für "Rechtsableitung"). Die Arbeitsweise dieses Stackparsers wird vollständig durch einen endlichen Automaten bestimmt, der auf dem Stack arbeitet: dem charakteristischen Automaten (CA) einer Grammatik. Generalvoraussetzung ist, dass jede Grammatik startseparierbar ist.
Eine KFG G = (N,T,P,S) ist startseparierbar, falls S auf keiner rechten Regelseite in P vorkommt. Jede Grammatik kann startsepariert werden durch
mit
α ist Griff für
. Jedes Präfix γα ist ein erfolgreiches Präfix.
Ein kontextfreies Item ist eine Produktion mit einer Position auf der rechten Regelseite:
Ein predict Item ist ein Item der Form
, ein reduce Item der Form
.
Ein Item
ist gültig für ein erfolgreiches Präfix
. Insbesondere gilt: Falls
gültig:
.
Der charakteristischer Automat Char(G) = (Q,Σ,q0,δ,F) von G erkennt die erfolgreichen Präfixe mit Griff am Ende.
- Q = Items(G) Menge der kontextfreien Items von G
- Σ = V
-
LR(0) Parser
Es gilt nun, das Problem des nicht-Determinismus zu lösen. Als ersten Schritt konstruieren wir eine deterministische Version des charakteristischen Automaten, den LR(0) Parser
Die predict Operation fügt einem Zustand alle durch ε Transitionen erreichbaren Items hinzu:
, wobei gilt:
(d.h.
bedeutet, dass man im CA von Item1 mit Hilfe einer ε-Transition zu Item2 gelangt).
Der Abschluss einer Item-Menge q ist definiert als
.
Der LR(0) Automat zu G ist der DFA A = (Q,Σ,δ,q0,F) mit
-
- Σ = V (Der Automat arbeitet auf Stackworten)
-
-
-
Mit dem LR(0) Automaten kann man jetzt einen Parser konstruieren. Allerdings ist dieser nur deterministisch, falls die Grammatik LR(0) ist - ansonsten treten Konflikte in den Zuständen des Automaten auf.
Hierzu verwenden wir den Automaten aus dem vorigen Beispiel und bauen ihn in einen LR(0) Automaten um:
Die Indizes in diesem Automaten werden für ein späteres Beispiel benötigt.
Die auftretenden Konflikte lassen sich in shift-reduce und reduce-reduce Konflikte aufteilen. Im obigen Beispiel enthält der Zustand
einen shift-reduce Konflikt, da sowohl ein shift auf dem Symbol * ausgeführt werden kann, wie auch ein reduce auf dem Nichtterminal E.
- Ein shift-reduce Konflikt liegt vor, wenn ein Zustand ein reduce Item
und ein Item
enthält.
- Ein reduce-reduce Konflikt tritt auf, wenn ein Zustand zwei reduce Item
enthält.
Eine Grammatik G heisst LR(0), falls alle Zustände des zu G gehörenden LR(0) Automaten konfliktfrei sind.
Parsen mit dem Automaten
Um eine Eingabe mit Hilfe des LR(0) Automatens zu parsen, wenden wir so lange eine parse Funktion auf einen Stack mit Automatenzuständen und den verbleibenden Eingabestring an, bis die Eingabe leer ist und der oberste Zustand auf dem Stack ein Item
enthält.
Die in dieser Definition verwendete Funktion pop gibt den Stack (zweiter Parameter) ohne die obersten | α | (erster Parameter) Einträge zurück.
</div>
Die hier verwendeten Zustandsindizes finden sich im obigen Graphen an den Zuständen wieder.
Kanonisches LR(k) Parsing
Um die im vorherigen Abschnitt beschriebenen Konflikte aufzuheben, greifen wir auf das bereits bekannte Mittel Lookahead zurück. Wir betrachten hier den kanonischen Weg, Lookahead zu implementieren, der aber sehr aufwendig in der Berechnung ist. Die folgenden Abschnitte zeigen dann effizientere Möglichkeiten auf.
Eine Grammatik G heisst LR(k) Grammatik, falls für alle Ableitungen der Form:
gilt, dass
impliziert A = B,β = γ und v = w (γα ist der Stackinhalt, Wissen über den Stackinhalt und die nächsten k Symbole reicht zur Entscheidung, welche Produktion greift).
LR(k) Grammatiken haben einige interessante Eigenschaften:
- Jede LL(K) Grammatik ist auch eine LR(k) Grammatik.
- Für
kann jede LR(k+1) Grammatik zu einer äquivalenten LR(k) Grammatik umgeformt werden. Daraus ergibt sich, dass zu jeder LR(k) Grammatik eine äquivalente LR(1) Grammatik existiert.
- Falls L eine deterministisch erkennbare (mit deterministischem Stackautomaten), kontextfreie Sprache ist, gibt es eine LR(1) Grammatik für L.
Die nun folgende Definition des LR(k) DFA folgt generell der des LR DFA, wobei der grösste Unterschied die hinzugefügten Lookahead Mengen sind.
Ein LR(k) Item ist ein Tupel aus Produktion, Position und Lookahead Menge
:
.
Ein Item
ist gültig, für ein erfolgreiches Präfix γα, falls
mit
.
Lookahead Mengen werden durch predict propagiert:
Sei q eine Menge von LR(k) Items. Dann ist
mit
.
</div>
Die Closure einer Item-Menge q ist definiert als
.
Die Definition des LR(k) Automaten ist sehr ähnlich der des LR(0) Automaten:
Der LR(k) Automat zu G ist der DFA A = (Q,Σ,δ,q0,F) mit
-
- Σ = V (Der Automat arbeitet auf Stackworten)
-
-
-
Auch ein LR(k) Automat kann Konflikte enthalten, die zu einem nicht-Determinismus im Parser führen:
- Ein Zustand q enthält einen shift-reduce Konflikt, falls er die Produktionen
enthält, mit
.
- Ein Zustand q hat einen reduce-reduce Konflikt, wenn gilt:
mit
und
.
Praktikable Methoden für LR Parsing mit Lookahead
Da die kanonischen Parser häufig zu unhandlich grossen Zustandsautomaten führen, suchen wir einen anderen Weg zum Konstruieren der Parser. Hier bieten sich zwei Methoden an: Simple LR Parsing (SLR) und Lookahead LR Parsing (LALR). Beide arbeiten mit LR(0) Parsern, wie sie weiter oben vorgestellt wurden.
Simple LR
SLR erweitert die Items eines LR(0) Automaten um Lookahead-Informationen in Form der followk Mengen, das heisst:
.
Hier wird der Konflikt aus dem früheren Beispiel durch SLR aufgelöst, die Grammatik ist also SLR(1):
Lookahead LR
Der Lookahead LR(k) (LALR) wird mit k = 1 in fast allen Parsergeneratoren eingesetzt. Anstelle von followk verwendet er Lookahead-Informationen, die von der Ableitung abhängig sind. Diese Informationen werden aus dem LR(0) Automaten gewonnen.
Mit
gilt:
Das heißt wir betrachten alle Pfade, die von q0 nach q führen und das Suffix α besitzen; für jedes resultierendes Pfadpräfix γ sehen wir uns alle Ableitungen vom Startsymbol an, die die Form
haben - die k-Präfixe der w sind dann in der Lookahead Menge des Items
in q.
Man kann den LAk mit drei Schritten berechnen:
- Führe
zurück auf
:
- Für die predict Items kann ein Gleichungssystem aufgestellt werden, das mit Fixpunktiteration gelöst wird: Setze
. Unterscheide nach der Herkunft der predict Items:
- Start-Item
:
-
, d.h. es existiert eine Regel
- diese muss durch die Abschlussoperation (
) hier her gelangt sein, was wiederum heisst, dass eine Regel
existiert, mit γα = γ0:
Wir wissen, dass gilt:
, also gilt auch:
.
- Start-Item
- Für die reduce Items kann man setzen:
.
Für k = 1 gibt es Algorithmen, die das in den Schritten 2 und 3 erstellte Gleichungssystem in linearer Zeit lösen.
Ausgabe des Parsers
Ein Parser erzeugt eine Syntaxrepräsentation, d.h.
, so dass
, mit
.
Es gibt zwei Möglichkeiten der Syntaxrepräsentation, einen Ableitungsbaum und die abstrakte Syntax.
Ableitungsbaum
Ein Ableitungsbaum zu (N,T,P,S) ist ein endlicher, geordneter, gerichteter Baum mit Knotenmenge V, so dass jeder Knoten mit einer Produktion
markiert ist.
- Die Wurzel ist mit der Startporduktion
markiert
- Für jeden Knoten gilt: Mit
, hat ein Knoten v die Kinder
mit Markierungen
.
Man kann die Produktion
als n-stelligen Operator auffassen.
Wir benutzen hierzu die bekannte Ausdrucksgrammatik. Erzeuge den Ableitungsbaum durch Anwenden der reduzierenden Produktionen auf einen Hilfsstack. Der n-stellige Operator wirkt auf die obersten n Stackeinträge.
Der Ableitungsbaum enthält auf diese Weise nutzlose Informationen (d.h. der Term hat keine Bedeutung, im Baum eingekreist), nämlich über Kettenproduktionen und Klammerstrukturen. Stattdessen kann man eine andere Methode wählen, die im nächsten Abschnitt behandelt wird.
abstrakte Syntax
Die Grammatik für die abstrakte Syntax enthält nur die wesentlichen Informationen:
Für 2+x*x ergibt sich dann folgende Struktur:
</div>
Diese Struktur entspricht auch Typdefinitionen in OCaml:
type arith = Two | X | Add of arith * arith | Mul of arith * arith
Und der Ausdruck würde dann folgendermassen aussehen:
Add Two (Mul x x)
</div>

das leere
Länge eines
Wiederholung
das
mit
.
. Ein Spezialfall ist
.






