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 digit
und werden string
zeichenweise 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.