Ich habe in den letzten Tagen viel recherchiert, um besser zu verstehen, warum diese getrennten Technologien existieren und wo ihre Stärken und Schwächen liegen.
Einige der bereits vorhandenen Antworten wiesen auf einige ihrer Unterschiede hin, gaben jedoch nicht das vollständige Bild wieder und schienen einigermaßen unvoreingenommen zu sein, weshalb diese Antwort verfasst wurde.
Diese Darstellung ist lang, aber wichtig. ertrage es mit mir (oder wenn du ungeduldig bist, scrolle bis zum Ende, um ein Flussdiagramm zu sehen).
Um die Unterschiede zwischen Parser-Kombinatoren und Parser-Generatoren zu verstehen, muss man zuerst den Unterschied zwischen den verschiedenen Arten von Parsing verstehen, die existieren.
Parsing
Parsing ist der Prozess der Analyse einer Zeichenfolge nach einer formalen Grammatik. (In der Informatik wird Parsing verwendet, um einem Computer zu ermöglichen, in einer Sprache geschriebenen Text zu verstehen, wobei normalerweise ein Analysebaum erstellt wird , der den geschriebenen Text darstellt und die Bedeutung der verschiedenen geschriebenen Teile in jedem Knoten des Baums speichert. Dieser Analysebaum kann dann für verschiedene Zwecke verwendet werden, z. B. zum Übersetzen in eine andere Sprache (in vielen Compilern verwendet), zum direkten Interpretieren der schriftlichen Anweisungen (SQL, HTML), sodass Tools wie Linters
ihre Arbeit erledigen können usw. Manchmal ist ein Analysebaum nicht explizitgeneriert, sondern die Aktion, die an jedem Knotentyp im Baum ausgeführt werden soll, wird direkt ausgeführt. Dies erhöht die Effizienz, aber unter Wasser existiert immer noch ein impliziter Analysebaum.
Das Parsen ist ein Problem, das rechnerisch schwierig ist. Zu diesem Thema wurde über 50 Jahre lang geforscht, aber es gibt noch viel zu lernen.
Grob gesagt gibt es vier allgemeine Algorithmen, mit denen ein Computer Eingaben analysieren kann:
- LL-Analyse. (Kontextfreies Parsing von oben nach unten.)
- LR-Analyse. (Kontextfreies Parsing von unten nach oben.)
- PEG + Packrat-Analyse.
- Earley Parsing.
Beachten Sie, dass diese Arten des Parsens sehr allgemeine theoretische Beschreibungen sind. Es gibt mehrere Möglichkeiten, jeden dieser Algorithmen auf physischen Maschinen mit unterschiedlichen Kompromissen zu implementieren.
LL und LR können nur kontextfreie Grammatiken betrachten (das heißt, der Kontext um die geschriebenen Token ist nicht wichtig, um zu verstehen, wie sie verwendet werden).
PEG- / Packrat-Parsing und Earley-Parsing werden viel seltener verwendet: Earley-Parsing kann viel mehr Grammatiken verarbeiten (einschließlich solcher, die nicht unbedingt kontextfrei sind), ist jedoch weniger effizient (wie der Drache behauptet) Buch (Abschnitt 4.1.1); Ich bin nicht sicher, ob diese Behauptungen noch korrekt sind).
Syntaxanalyse der Ausdrucksgrammatik + Packrat-Syntaxanalyse ist eine Methode, die relativ effizient ist und auch mehr Grammatiken als LL und LR verarbeiten kann, aber Mehrdeutigkeiten verbirgt, wie im Folgenden kurz erläutert wird.
LL (von links nach rechts, Ableitung ganz links)
Dies ist möglicherweise die natürlichste Art, über das Parsen nachzudenken. Die Idee ist, sich das nächste Token in der Eingabezeichenfolge anzusehen und dann zu entscheiden, welcher von möglicherweise mehreren möglichen rekursiven Aufrufen zum Generieren einer Baumstruktur verwendet werden soll.
Dieser Baum wird von oben nach unten erstellt, dh, wir beginnen am Stamm des Baums und lesen die Grammatikregeln auf die gleiche Weise wie in der Eingabezeichenfolge. Es kann auch als Konstruktion eines 'Postfix'-Äquivalents für den' Infix'-Token-Stream angesehen werden, der gelesen wird.
Parser, die Parsing im LL-Stil ausführen, können so geschrieben werden, dass sie der angegebenen ursprünglichen Grammatik sehr ähnlich sind. Dies macht es relativ einfach, sie zu verstehen, zu debuggen und zu verbessern. Klassische Parser-Kombinatoren sind nichts anderes als "Lego-Teile", die zu einem Parser im LL-Stil zusammengesetzt werden können.
LR (von links nach rechts, Ableitung ganz rechts)
LR-Parsing läuft umgekehrt von unten nach oben: Bei jedem Schritt werden die obersten Elemente auf dem Stapel mit der Grammatikliste verglichen, um festzustellen, ob sie
auf eine übergeordnete Regel in der Grammatik reduziert werden können . Wenn nicht, wird das nächste Token aus dem Eingabestream verschoben und auf den Stapel gelegt.
Ein Programm ist korrekt, wenn wir am Ende einen einzelnen Knoten auf dem Stapel haben, der die Ausgangsregel aus unserer Grammatik darstellt.
Schau voraus
In beiden Systemen ist es manchmal erforderlich, mehr Token aus der Eingabe herauszusuchen, bevor entschieden werden kann, welche Auswahl getroffen werden soll. Dies ist die (0)
, (1)
, (k)
oder (*)
-Syntax Sie sehen , nachdem die Namen dieser beiden allgemeinen Algorithmen, wie LR(1)
oder LL(k)
. k
steht in der Regel für "so viel wie Ihre Grammatik benötigt", während in der *
Regel für "dieser Parser führt Backtracking durch" steht, das leistungsfähiger / einfach zu implementieren ist, aber einen viel höheren Speicher- und Zeitverbrauch aufweist als ein Parser, der einfach weiter analysieren kann linear.
Beachten Sie, dass Parser im LR-Stil bereits viele Token auf dem Stapel haben, wenn sie sich entscheiden, nach vorne zu schauen, sodass sie bereits weitere Informationen zum Versenden haben. Dies bedeutet, dass sie für dieselbe Grammatik häufig weniger Vorausschau benötigen als einen Parser im LL-Stil.
LL vs. LR: Mehrdeutigkeit
Wenn man die beiden obigen Beschreibungen liest, mag man sich fragen, warum es eine Analyse im LR-Stil gibt, da eine Analyse im LL-Stil viel natürlicher erscheint.
Das Parsen im LL-Stil hat jedoch ein Problem: Linke Rekursion .
Es ist sehr natürlich, eine Grammatik wie folgt zu schreiben:
expr ::= expr '+' expr | term
term ::= integer | float
Ein Parser im LL-Stil bleibt jedoch beim Parsen dieser Grammatik in einer Endlosschleife stecken: Wenn Sie die am weitesten links stehende Möglichkeit der expr
Regel ausprobieren, wird diese Regel erneut wiederholt, ohne dass Eingaben erforderlich sind.
Es gibt Möglichkeiten, dieses Problem zu beheben. Am einfachsten ist es, Ihre Grammatik so umzuschreiben, dass diese Art der Rekursion nicht mehr auftritt:
expr ::= term expr_rest
expr_rest ::= '+' expr | ϵ
term ::= integer | float
(Hier steht ϵ für die 'leere Zeichenkette')
Diese Grammatik ist jetzt richtig rekursiv. Beachten Sie, dass es sofort viel schwieriger zu lesen ist.
In der Praxis kann eine Linksrekursion indirekt mit vielen anderen dazwischen liegenden Schritten auftreten. Dies macht es schwierig, nach etwas Ausschau zu halten. Aber der Versuch, es zu lösen, erschwert das Lesen Ihrer Grammatik.
In Abschnitt 2.5 des Drachenbuchs heißt es:
Wir scheinen einen Konflikt zu haben: Einerseits brauchen wir eine Grammatik, die das Übersetzen erleichtert, andererseits brauchen wir eine deutlich andere Grammatik, die das Parsen erleichtert. Die Lösung besteht darin, mit der Grammatik für eine einfache Übersetzung zu beginnen und sie sorgfältig zu transformieren, um das Parsen zu erleichtern. Durch Eliminieren der linken Rekursion können wir eine Grammatik erhalten, die zur Verwendung in einem prädiktiven rekursiven Abstammungsübersetzer geeignet ist.
Parser im LR-Stil haben nicht das Problem dieser Linksrekursion, da sie den Baum von unten nach oben erstellen.
Die mentale Übersetzung einer Grammatik wie oben in einen Parser im LR-Stil (der häufig als endlicher Automat implementiert wird )
ist jedoch sehr schwierig (und fehleranfällig), da es häufig Hunderte oder Tausende von Zuständen gibt Zustandsübergänge zu berücksichtigen. Aus diesem Grund werden Parser im LR-Stil normalerweise von einem Parser-Generator generiert , der auch als "Compiler-Compiler" bezeichnet wird.
So lösen Sie Mehrdeutigkeiten
Wir haben oben zwei Methoden zur Lösung von Linksrekursions-Ambiguitäten gesehen: 1) Neuschreiben der Syntax 2) Verwenden eines LR-Parsers.
Es gibt aber auch andere Arten von Unklarheiten, die schwerer zu lösen sind: Was ist, wenn zwei verschiedene Regeln gleichzeitig gleichermaßen anwendbar sind?
Einige gebräuchliche Beispiele sind:
Sowohl Parser im LL-Stil als auch im LR-Stil haben Probleme damit. Probleme mit dem Parsen arithmetischer Ausdrücke können durch Einführen von Operatorpriorität gelöst werden. In ähnlicher Weise können andere Probleme wie das Dangling Else gelöst werden, indem man ein Vorrangverhalten auswählt und daran festhält. (In C / C ++ zum Beispiel gehört der Dangling else immer zum nächsten 'if').
Eine andere "Lösung" für dieses Problem ist die Verwendung der Parser Expression Grammar (PEG): Diese ähnelt der oben verwendeten BNF-Grammatik, aber wählen Sie im Zweideutigkeitsfall immer die erste aus. Natürlich wird das Problem dadurch nicht wirklich "gelöst", sondern es wird ausgeblendet, dass tatsächlich eine Mehrdeutigkeit vorliegt: Die Endbenutzer wissen möglicherweise nicht, welche Auswahl der Parser vornimmt, und dies kann zu unerwarteten Ergebnissen führen.
Weitere Informationen, die viel ausführlicher sind als dieser Beitrag, einschließlich der Frage, warum es im Allgemeinen unmöglich ist zu wissen, ob Ihre Grammatik keine Mehrdeutigkeiten aufweist, und die Auswirkungen davon sind der wunderbare Blog-Artikel LL und LR im Kontext: Warum analysieren? Werkzeuge sind schwer . Ich kann es nur empfehlen. Es hat mir sehr geholfen, all die Dinge zu verstehen, über die ich gerade spreche.
50 Jahre Forschung
Aber das Leben geht weiter. Es stellte sich heraus, dass "normale" Parser im LR-Stil, die als Automaten mit endlichen Zuständen implementiert wurden, oft Tausende von Zuständen und Übergängen benötigten, was ein Problem bei der Programmgröße darstellte. Daher wurden Varianten wie Simple LR (SLR) und LALR (Look-Ahead LR) geschrieben, die andere Techniken kombinieren, um den Automaten zu verkleinern und den Speicherbedarf der Parserprogramme zu verringern.
Eine andere Möglichkeit, die oben aufgeführten Unklarheiten zu lösen, besteht darin, verallgemeinerte Techniken zu verwenden , bei denen im Falle einer Unklarheit beide Möglichkeiten beibehalten und analysiert werden: Entweder kann es nicht gelingen, die Zeile zu analysieren (in diesem Fall ist die andere Möglichkeit die 'korrekt' eins) und beide zurückgeben (und auf diese Weise zeigen, dass eine Mehrdeutigkeit vorliegt), falls beide korrekt sind.
Interessanterweise hat sich nach der Beschreibung des Generalized LR- Algorithmus herausgestellt, dass ein ähnlicher Ansatz zum Implementieren von Generalized LL-Parsern verwendet werden kann , der ähnlich schnell ist ($ O (n ^ 3) $ Zeitkomplexität für mehrdeutige Grammatiken, $ O (n) $ für völlig eindeutige Grammatiken, wenn auch mit mehr Buchhaltung als ein einfacher (LA) LR-Parser (was einen höheren Konstantenfaktor bedeutet), aber wiederum ermöglichen, dass ein Parser in einem rekursiven Abstiegsstil (von oben nach unten) geschrieben wird, der viel natürlicher ist zu schreiben und zu debuggen.
Parser-Kombinatoren, Parser-Generatoren
Mit dieser langen Darstellung kommen wir nun zum Kern der Frage:
Was ist der Unterschied zwischen Parser-Kombinatoren und Parser-Generatoren und wann sollte einer über dem anderen verwendet werden?
Es sind wirklich verschiedene Arten von Tieren:
Parser-Kombinatoren wurden erstellt, weil Parser von oben nach unten geschrieben wurden und festgestellt wurde, dass viele von ihnen eine Menge gemeinsam haben .
Parser-Generatoren wurden erstellt, weil die Leute nach Parsern suchten, die nicht die Probleme hatten, die Parser im LL-Stil hatten (dh Parser im LR-Stil), was sich als sehr schwierig von Hand erwies. Übliche sind Yacc / Bison, die (LA) LR implementieren.
Interessanterweise ist die Landschaft heutzutage etwas durcheinander:
Es ist möglich, Parser-Kombinatoren zu schreiben , die mit dem GLL- Algorithmus arbeiten , wodurch die Mehrdeutigkeitsprobleme der klassischen LL-Parser behoben werden, während sie genauso lesbar / verständlich sind wie alle Arten von Top-Down-Parsing.
Parser-Generatoren können auch für Parser im LL-Stil geschrieben werden. ANTLR macht genau das und verwendet andere Heuristiken (Adaptive LL (*)), um die Mehrdeutigkeiten, die klassische Parser im LL-Stil hatten, aufzulösen.
Im Allgemeinen ist es schwierig, einen LR-Parser-Generator zu erstellen und die Ausgabe eines (LA) LR-ähnlichen Parser-Generators zu debuggen, der auf Ihrer Grammatik ausgeführt wird, da Ihre ursprüngliche Grammatik in das "Inside-Out" -LR-Formular übersetzt wird. Auf der anderen Seite, Tools wie Yacc / Bison haben viele Jahre Optimierungen hatten, und eine Menge für die Verwendung in der freien Natur zu sehen ist , was bedeutet , dass jetzt viele Menschen es als betrachten die Art und Weise Parsing zu tun und sind skeptisch gegenüber neuen Ansätzen.
Welche Sie verwenden sollten, hängt davon ab, wie schwer Ihre Grammatik ist und wie schnell der Parser sein muss. Abhängig von der Grammatik kann eine dieser Techniken (/ Implementierungen der verschiedenen Techniken) schneller sein, einen geringeren Speicherbedarf aufweisen, einen geringeren Speicherbedarf aufweisen oder erweiterbarer oder leichter zu debuggen sein als die anderen. Ihr Kilometerstand kann variieren .
Randnotiz: Zum Thema Lexikalische Analyse.
Die lexikalische Analyse kann sowohl für Parser-Kombinatoren als auch für Parser-Generatoren verwendet werden. Die Idee ist, einen 'dummen' Parser zu haben, der sehr einfach zu implementieren ist (und daher schnell ist), der einen ersten Durchgang über Ihren Quellcode durchführt, beispielsweise das Wiederholen von Leerzeichen, Kommentaren usw. und möglicherweise das 'Tokenisieren' in einem sehr Grob gesagt die verschiedenen Elemente, die Ihre Sprache ausmachen.
Der Hauptvorteil ist, dass dieser erste Schritt den eigentlichen Parser sehr viel einfacher macht (und aus diesem Grund möglicherweise schneller). Der Hauptnachteil ist, dass Sie einen separaten Übersetzungsschritt haben und z. B. das Melden von Fehlern mit Zeilen- und Spaltennummern aufgrund der Entfernung von Leerzeichen schwieriger wird.
Ein Lexer ist letztendlich "nur" ein weiterer Parser und kann mit einer der oben genannten Techniken implementiert werden. Wegen seiner Einfachheit werden oft andere Techniken als für den Hauptparser verwendet, und zum Beispiel gibt es zusätzliche "Lexer-Generatoren".
Tl; Dr:
Hier ist ein Flussdiagramm, das für die meisten Fälle gilt:
javac
, Scala). Es gibt Ihnen die größte Kontrolle über den internen Parser-Status, was Ihnen hilft, gute Fehlermeldungen zu generieren (was in den letzten Jahren…