Wie wird vorgegangen, wenn ein Lexer auf der Grundlage einer Grammatik geschrieben wird?


12

Während der Beantwortung der Frage Erläuterung zu Grammatiken, Lexika und Parsern lautete die Antwort:

[...] Eine BNF-Grammatik enthält alle Regeln, die Sie für die lexikalische Analyse und Analyse benötigen.

Das kam mir etwas seltsam vor, weil ich bis jetzt immer gedacht hatte, dass ein Lexer überhaupt nicht auf einer Grammatik basiert, während ein Parser stark auf einer basiert. Ich war zu diesem Schluss gekommen, nachdem ich zahlreiche Blogposts über das Schreiben von Lexern gelesen hatte, und nicht einen, der jemals 1 EBNF / BNF als Grundlage für das Design verwendet hatte.

Wenn sowohl Lexer als auch Parser auf einer EBNF / BNF-Grammatik basieren, wie würde man dann vorgehen, um mit dieser Methode einen Lexer zu erstellen? Das heißt, wie würde ich ein Lexer unter Verwendung einer gegebenen EBNF / BNF-Grammatik konstruieren?

Ich habe viele, viele Posts gesehen, die sich mit dem Schreiben eines Parsers unter Verwendung von EBNF / BNF als Leitfaden oder Blaupause befassen, aber bisher ist noch keiner aufgetaucht, der das Äquivalent zum Lexer-Design zeigt.

Nehmen Sie zum Beispiel die folgende Grammatik:

input = digit| string ;
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
string = '"', { all characters - '"' }, '"' ;
all characters = ? all visible characters ? ;

Wie kann man ein Lexer erstellen, das auf der Grammatik basiert? Ich könnte mir vorstellen, wie ein Parser aus einer solchen Grammatik heraus geschrieben werden könnte, aber ich verstehe das Konzept, dasselbe mit einem Lexer zu tun, nicht.

Gibt es bestimmte Regeln oder eine Logik, die zum Ausführen einer solchen Aufgabe verwendet werden, wie beim Schreiben eines Parsers? Ehrlich gesagt frage ich mich, ob Lexer-Designs überhaupt eine EBNF / BNF-Grammatik verwenden, geschweige denn auf einer basieren.


1 Erweiterte Backus-Naur-Form und Backus-Naur-Form

Antworten:


17

Lexer sind nur einfache Parser, die als Leistungsoptimierung für den Hauptparser verwendet werden. Wenn wir einen Lexer haben, arbeiten der Lexer und der Parser zusammen, um die vollständige Sprache zu beschreiben. Parser ohne separate Lexing-Stufe werden manchmal als "scannerlos" bezeichnet.

Ohne Lexer müsste der Parser zeichenweise arbeiten. Da der Parser Metadaten zu jedem Eingabeelement speichern muss und möglicherweise Tabellen für jeden Eingabeelementstatus vorberechnen muss, würde dies zu einer inakzeptablen Speicherbelegung bei großen Eingabegrößen führen. Insbesondere benötigen wir keinen separaten Knoten pro Zeichen im abstrakten Syntaxbaum.

Da der Text zeichenweise mehrdeutig ist, würde dies auch zu einer viel größeren Mehrdeutigkeit führen, die ärgerlich ist. Stell dir eine Regel vor R → identifier | "for " identifier. wobei der Bezeichner aus ASCII-Buchstaben besteht. Um Mehrdeutigkeiten zu vermeiden, benötige ich jetzt einen 4-stelligen Lookahead, um zu bestimmen, welche Alternative ausgewählt werden soll. Bei einem Lexer muss der Parser nur prüfen, ob er ein IDENTIFIER- oder FOR-Token hat - einen 1-Token-Lookahead.

Zweistufige Grammatik.

Lexer übersetzen das eingegebene Alphabet in ein bequemeres Alphabet.

Ein scannerloser Parser beschreibt eine Grammatik (N, Σ, P, S), wobei die Nichtterminals N die linken Seiten der Regeln in der Grammatik sind, das Alphabet Σ zB ASCII-Zeichen, die Produktionen P die Regeln in der Grammatik sind und das Startsymbol S ist die Regel der obersten Ebene des Parsers.

Der Lexer definiert nun ein Alphabet mit den Token a, b, c,…. Dadurch kann der Hauptparser diese Token als Alphabet verwenden: Σ = {a, b, c,…}. Für den Lexer sind diese Token nicht endständig und die Startregel S L ist S L → ε | ein S | b S | c S | …, Das heißt: eine beliebige Folge von Token. Die Regeln in der Lexer-Grammatik sind alle Regeln, die zur Erzeugung dieser Token erforderlich sind.

Der Leistungsvorteil ergibt sich aus dem Ausdrücken der Lexer-Regeln als reguläre Sprache . Diese können wesentlich effizienter analysiert werden als kontextfreie Sprachen. Insbesondere können reguläre Sprachen in O (n) Raum und O (n) Zeit erkannt werden. In der Praxis kann ein Codegenerator einen solchen Lexer in hocheffiziente Sprungtabellen verwandeln.

Token aus Ihrer Grammatik extrahieren.

Um auf Ihr Beispiel einzugehen: Die Regeln digitund werden stringzeichenweise ausgedrückt. Wir könnten diese als Token verwenden. Der Rest der Grammatik bleibt erhalten. Hier ist die Lexer-Grammatik, die als rechtslineare Grammatik geschrieben wurde, um zu verdeutlichen, dass sie regelmäßig ist:

digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
string = '"' , string-rest ;
string-rest = '"' | STRING-CHAR, string-rest ;
STRING-CHAR = ? all visible characters ? - '"' ;

Da es sich jedoch um einen regulären Ausdruck handelt, verwenden wir normalerweise reguläre Ausdrücke, um die Tokensyntax auszudrücken. Hier sind die obigen Tokendefinitionen als reguläre Ausdrücke, die unter Verwendung der .NET-Zeichenklassen-Ausschlusssyntax und der POSIX-Zeichenklassen geschrieben wurden:

digit ~ [0-9]
string ~ "[[:print:]-["]]*"

Die Grammatik für den Hauptparser enthält dann die übrigen Regeln, die vom Lexer nicht behandelt werden. In Ihrem Fall ist das nur:

input = digit | string ;

Wenn Lexer nicht einfach zu gebrauchen sind.

Beim Entwerfen einer Sprache wird normalerweise darauf geachtet, dass die Grammatik sauber in eine Lexer- und eine Parser-Ebene unterteilt werden kann und dass die Lexer-Ebene eine reguläre Sprache beschreibt. Das ist nicht immer möglich.

  • Beim Einbetten von Sprachen. Einige Sprachen können Sie Code in Strings zu interpolieren: "name={expression}". Die Ausdruckssyntax ist Teil der kontextfreien Grammatik und kann daher nicht durch einen regulären Ausdruck gekennzeichnet werden. Um dies zu lösen, kombinieren wir entweder den Parser mit dem Lexer neu oder wir führen zusätzliche Token wie ein STRING-CONTENT, INTERPOLATE-START, INTERPOLATE-END. Die Grammatikregel für eine Zeichenfolge könnte dann wie folgt aussehen: String → STRING-START STRING-CONTENTS { INTERPOLATE-START Expression INTERPOLATE-END STRING-CONTENTS } STRING-END. Natürlich kann der Ausdruck auch andere Zeichenfolgen enthalten, was uns zum nächsten Problem führt.

  • Wenn sich Token gegenseitig enthalten könnten. In C-ähnlichen Sprachen sind Schlüsselwörter nicht von Bezeichnern zu unterscheiden. Dies wird im Lexer gelöst, indem Schlüsselwörter gegenüber Bezeichnern priorisiert werden. Eine solche Strategie ist nicht immer möglich. Stellen Sie sich eine Konfigurationsdatei vor Line → IDENTIFIER " = " REST, in der der Rest ein beliebiges Zeichen bis zum Ende der Zeile ist, selbst wenn der Rest wie ein Bezeichner aussieht. Eine Beispielzeile wäre a = b c. Der Lexer ist wirklich dumm und weiß nicht, in welcher Reihenfolge die Token vorkommen dürfen. Wenn wir also IDENTIFIER vor REST priorisieren, gibt uns der Lexer IDENT(a), " = ", IDENT(b), REST( c). Wenn wir REST vor IDENTIFIER priorisieren, gibt uns der Lexer nur REST(a = b c).

    Um dies zu lösen, müssen wir den Lexer mit dem Parser neu kombinieren. Die Trennung kann etwas aufrechterhalten werden, indem der Lexer faul gemacht wird: Jedes Mal, wenn der Parser das nächste Token benötigt, fordert er es vom Lexer an und teilt dem Lexer den Satz akzeptabler Token mit. Tatsächlich erstellen wir für jede Position eine neue Regel der obersten Ebene für die Lexer-Grammatik. Hier würde dies zu Anrufen führen nextToken(IDENT), nextToken(" = "), nextToken(REST), und alles funktioniert einwandfrei. Dies erfordert einen Parser, der den vollständigen Satz akzeptabler Token an jedem Ort kennt, was einen Bottom-Up-Parser wie LR impliziert.

  • Wenn der Lexer seinen Zustand halten muss. Die Python-Sprache begrenzt Codeblöcke beispielsweise nicht durch geschweifte Klammern, sondern durch Einrückungen. Es gibt Möglichkeiten, innerhalb einer Grammatik mit layoutsensitiver Syntax umzugehen, aber diese Techniken sind für Python übertrieben. Stattdessen überprüft der Lexer den Einzug jeder Zeile und gibt INDENT-Token aus, wenn ein neuer eingerückter Block gefunden wird, und DEDENT-Token, wenn der Block beendet wurde. Dies vereinfacht die Hauptgrammatik, da nun so getan werden kann, als wären diese Token geschweifte Klammern. Der Lexer muss nun jedoch den Status beibehalten: den aktuellen Einzug. Das heißt, der Lexer beschreibt technisch gesehen keine reguläre Sprache mehr, sondern eigentlich eine kontextsensitive Sprache. Glücklicherweise ist dieser Unterschied in der Praxis nicht relevant und Pythons Lexer kann immer noch in O (n) Zeit arbeiten.


Sehr nette Antwort @amon, Danke. Ich werde einige Zeit brauchen, um es vollständig zu verdauen. Ich habe mich jedoch einige Fragen zu Ihrer Antwort gestellt. Im achten Absatz zeigen Sie, wie ich meine EBNF-Beispielgrammatik in Regeln für einen Parser umwandeln kann. Würde die Grammatik, die Sie gezeigt haben, auch vom Parser verwendet werden? Oder gibt es noch eine eigene Grammatik für den Parser?
Christian Dean

@Engineer Ich habe ein paar Änderungen vorgenommen. Ihr EBNF kann direkt von einem Parser verwendet werden. Mein Beispiel zeigt jedoch, welche Teile der Grammatik von einem separaten Lexer behandelt werden können. Alle anderen Regeln werden weiterhin vom Hauptparser behandelt, aber in Ihrem Beispiel ist das gerecht input = digit | string.
amon

4
Der große Vorteil von Parsern ohne Scanner besteht darin, dass sie viel einfacher zu komponieren sind. das extreme Beispiel dafür sind Parser combinator Bibliotheken, wo man nichts tun , aber compose Parser. Das Erstellen von Parsern ist interessant für Fälle wie ECMAScript-eingebettet in HTML-eingebettet in PHP-gestreut mit SQL mit einer Template-Sprache im Vordergrund oder Ruby-Beispiele-eingebettet in Markdown. Embedded-in-Ruby-Dokumentation-Kommentare oder so ähnlich.
Jörg W Mittag

Der letzte Punkt ist sehr wichtig, aber ich finde, dass die Art und Weise, wie Sie es geschrieben haben, irreführend ist. Es ist wahr, dass Lexer mit einer einrückungsbasierten Syntax nicht einfach verwendet werden können, aber das Parsen ohne Scanner ist in diesem Fall noch schwieriger. Sie möchten also tatsächlich ein Lexer verwenden, wenn Sie über diese Art von Sprache verfügen, und es um den entsprechenden Status erweitern.
user541686

@Mehrdad Lexer-gesteuerte Einrückungs- / Dedent-Token im Python-Stil sind nur für sehr einfache einrückungssensitive Sprachen möglich und nicht allgemein anwendbar. Eine allgemeinere Alternative sind Attributgrammatiken, deren Unterstützung in Standardwerkzeugen jedoch fehlt. Die Idee ist, dass wir jedes AST-Fragment mit seinem Einzug versehen und allen Regeln Einschränkungen hinzufügen. Das Hinzufügen von Attributen mit Combinator-Parsing ist unkompliziert. Dies erleichtert auch das Parsen ohne Scanner.
amon
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.