Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 617 – Neuer PEG-Parser für CPython

Autor:
Guido van Rossum <guido at python.org>, Pablo Galindo <pablogsal at python.org>, Lysandros Nikolaou <lisandrosnik at gmail.com>
Discussions-To:
Python-Dev Liste
Status:
Final
Typ:
Standards Track
Erstellt:
24. Mrz 2020
Python-Version:
3.9
Post-History:
02. Apr 2020

Inhaltsverzeichnis

Wichtig

Dieses PEP ist ein historisches Dokument. Die aktuelle, kanonische Dokumentation finden Sie nun unter Vollständige Grammatikspezifikation.

×

Siehe PEP 1, um Änderungen vorzuschlagen.

Übersicht

Dieses PEP schlägt den Ersatz des aktuellen LL(1)-basierten Parsers von CPython durch einen neuen PEG-basierten Parser vor. Dieser neue Parser würde die Eliminierung mehrerer "Hacks" ermöglichen, die in der aktuellen Grammatik existieren, um die LL(1)-Beschränkung zu umgehen. Er würde die Wartungskosten in einigen Bereichen im Zusammenhang mit der Compiler-Pipeline wie der Grammatik, dem Parser und der AST-Generierung erheblich reduzieren. Der neue PEG-Parser würde auch die LL(1)-Beschränkung für die aktuelle Python-Grammatik aufheben.

Hintergrund zu LL(1)-Parsern

Die aktuelle Python-Grammatik ist eine LL(1)-basierte Grammatik. Eine Grammatik kann als LL(1) bezeichnet werden, wenn sie von einem LL(1)-Parser analysiert werden kann, was wiederum als Top-Down-Parser definiert ist, der die Eingabe von links nach rechts analysiert und die linkeste Ableitung des Satzes mit nur einem Token Vorschau durchführt. Der traditionelle Ansatz zur Konstruktion oder Generierung eines LL(1)-Parsers besteht darin, eine Parse-Tabelle zu erstellen, die die möglichen Übergänge zwischen allen möglichen Zuständen des Parsers kodiert. Diese Tabellen werden normalerweise aus den First-Sets und Follow-Sets der Grammatik erstellt.

  • Für eine Regel ist das First-Set die Sammlung aller Terminals, die zuerst in einer vollständigen Ableitung dieser Regel auftreten können. Intuitiv hilft dies dem Parser, zwischen den Alternativen in einer Regel zu entscheiden. Zum Beispiel, gegeben die Regel
    rule: A | B
    

    wenn nur A mit dem Terminal a beginnen kann und nur B mit dem Terminal b beginnen kann und der Parser das Token b sieht, wenn er diese Regel parst, weiß er, dass er dem Nicht-Terminal B folgen muss.

  • Eine Erweiterung dieser einfachen Idee ist notwendig, wenn eine Regel zur leeren Zeichenkette abgeleitet werden kann. Für eine Regel ist das Follow-Set die Sammlung von Terminals, die unmittelbar rechts von dieser Regel in einer partiellen Ableitung auftreten können. Intuitiv löst dies das Problem der leeren Alternative. Zum Beispiel, gegeben diese Regel
    rule: A 'b'
    

    wenn der Parser das Token b hat und das Nicht-Terminal A nur mit dem Token a beginnen kann, dann kann der Parser feststellen, dass dies ein ungültiges Programm ist. Aber wenn A zur leeren Zeichenkette abgeleitet werden könnte (eine sogenannte ε-Produktion), dann würde der Parser ein gültiges leeres A erkennen, da das nächste Token b im Follow-Set von A liegt.

Die aktuelle Python-Grammatik enthält keine ε-Produktionen, daher sind die Follow-Sets bei der Erstellung der Parse-Tabellen nicht erforderlich. Derzeit liest in CPython ein Parser-Generatorprogramm die Grammatik und erzeugt eine Parsing-Tabelle, die eine Menge von deterministischen endlichen Automaten (DFA) darstellt, die in ein C-Programm, den Parser, aufgenommen werden können. Der Parser ist ein Kellerautomat, der diese Daten verwendet, um einen Concrete Syntax Tree (CST), manchmal auch direkt als "Parse-Baum" bezeichnet, zu erzeugen. Bei diesem Prozess werden die First-Sets indirekt bei der Generierung der DFAs verwendet.

LL(1)-Parser und Grammatiken sind in der Regel effizient und einfach zu implementieren und zu generieren. Es ist jedoch unter der LL(1)-Beschränkung nicht möglich, bestimmte gängige Konstrukte auf eine Weise auszudrücken, die für den Sprachdesigner und den Leser natürlich ist. Dies schließt einige in der Python-Sprache ein.

Da LL(1)-Parser nur ein Token vorausschauen können, um Möglichkeiten zu unterscheiden, können einige Regeln in der Grammatik mehrdeutig sein. Zum Beispiel die Regel

rule: A | B

ist mehrdeutig, wenn die First-Sets von sowohl A als auch B gemeinsame Elemente haben. Wenn der Parser ein Token im Eingabeprogramm sieht, mit dem sowohl A als auch B beginnen können, ist es ihm unmöglich zu deduzieren, welche Option erweitert werden soll, da kein weiteres Token des Programms untersucht werden kann, um die Mehrdeutigkeit aufzulösen. Die Regel kann in äquivalente LL(1)-Regeln umgewandelt werden, aber dann kann es für einen menschlichen Leser schwieriger sein, ihre Bedeutung zu erfassen. Beispiele später in diesem Dokument zeigen, dass die aktuelle LL(1)-basierte Grammatik stark unter diesem Szenario leidet.

Eine weitere breite Klasse von Regeln, die durch LL(1) ausgeschlossen sind, sind linksrekursive Regeln. Eine Regel ist linksrekursiv, wenn sie zu einer Satzform abgeleitet werden kann, bei der sie selbst das linkeste Symbol ist. Zum Beispiel diese Regel

rule: rule 'a'

ist linksrekursiv, da die Regel zu einem Ausdruck erweitert werden kann, der mit sich selbst beginnt. Wie später beschrieben wird, ist Linksrekursion der natürliche Weg, bestimmte gewünschte Spracheigenschaften direkt in der Grammatik auszudrücken.

Hintergrund zu PEG-Parsern

Eine PEG (Parsing Expression Grammar) Grammatik unterscheidet sich von einer kontextfreien Grammatik (wie der aktuellen) dadurch, dass ihre Schreibweise stärker widerspiegelt, wie der Parser bei der Analyse vorgehen wird. Der grundlegende technische Unterschied besteht darin, dass der Auswahloperator geordnet ist. Das bedeutet, wenn man schreibt

rule: A | B | C

wird ein Parser einer kontextfreien Grammatik (wie ein LL(1)-Parser) Konstruktionen generieren, die anhand eines Eingabe-Strings ableiten, welche Alternative (A, B oder C) erweitert werden muss, während ein PEG-Parser prüft, ob die erste Alternative erfolgreich ist, und nur wenn sie fehlschlägt, fährt er mit der zweiten oder dritten Option in der Reihenfolge fort, in der sie geschrieben sind. Das macht den Auswahloperator nicht kommutativ.

Im Gegensatz zu LL(1)-Parsern können PEG-basierte Parser nicht mehrdeutig sein: Wenn ein String analysiert werden kann, hat er genau einen gültigen Parse-Baum. Das bedeutet, dass ein PEG-basierter Parser nicht die Mehrdeutigkeitsprobleme der vorherigen Sektion aufweisen kann.

PEG-Parser werden typischerweise als rekursiver Abstiegs-Parser konstruiert, bei dem jede Regel in der Grammatik einer Funktion im Programm entspricht, das den Parser implementiert, und der Parsing-Ausdruck (die "Erweiterung" oder "Definition" der Regel) den "Code" in der genannten Funktion darstellt. Jede Parsing-Funktion nimmt konzeptionell einen Eingabe-String als Argument und liefert eines der folgenden Ergebnisse:

  • Ein "Erfolg"-Ergebnis. Dieses Ergebnis zeigt an, dass der Ausdruck von dieser Regel analysiert werden kann und die Funktion optional vorwärts gehen oder ein oder mehrere Zeichen des ihr übergebenen Eingabe-Strings verbrauchen kann.
  • Ein "Fehler"-Ergebnis, in diesem Fall wird keine Eingabe verbraucht.

Beachten Sie, dass "Fehler"-Ergebnisse nicht bedeuten, dass das Programm fehlerhaft ist oder die Analyse fehlschlägt, denn da der Auswahloperator geordnet ist, zeigt ein "Fehler"-Ergebnis lediglich "versuche die folgende Option" an. Eine direkte Implementierung eines PEG-Parsers als rekursiver Abstiegs-Parser hat im schlimmsten Fall eine exponentielle Zeitkomplexität im Vergleich zu LL(1)-Parsern, da PEG-Parser eine unendliche Vorschau haben (was bedeutet, dass sie eine beliebige Anzahl von Tokens berücksichtigen können, bevor sie sich für eine Regel entscheiden). Normalerweise vermeiden PEG-Parser diese exponentielle Zeitkomplexität mit einer Technik namens "Packrat-Parsing" [1], die nicht nur das gesamte Programm vor dem Parsen in den Speicher lädt, sondern dem Parser auch erlaubt, beliebig zurückzugehen. Dies wird durch Memoisation der bereits abgeglichenen Regeln für jede Position effizient gemacht. Die Kosten des Memoisations-Caches sind, dass der Parser natürlich mehr Speicher verbrauchen wird als ein einfacher LL(1)-Parser, der normalerweise tabellenbasiert ist. Wir werden später in diesem Dokument erklären, warum wir diese Kosten als akzeptabel erachten.

Begründung

In diesem Abschnitt beschreiben wir eine Liste von Problemen, die in der aktuellen Parser-Maschinerie in CPython vorhanden sind und die Notwendigkeit eines neuen Parsers motivieren.

Einige Regeln sind nicht tatsächlich LL(1)

Obwohl die Python-Grammatik technisch gesehen eine LL(1)-Grammatik ist (weil sie von einem LL(1)-Parser analysiert wird), sind mehrere Regeln nicht LL(1) und es werden mehrere Workarounds in der Grammatik und in anderen Teilen von CPython implementiert, um damit umzugehen. Betrachten Sie zum Beispiel die Regel für Zuweisungsausdrücke

namedexpr_test: [NAME ':='] test

Diese einfache Regel ist mit der Python-Grammatik nicht kompatibel, da NAME zu den Elementen des First-Set der Regel test gehört. Um diese Einschränkung zu umgehen, ist die tatsächliche Regel, die in der aktuellen Grammatik vorkommt:

namedexpr_test: test [':=' test]

Was eine viel breitere Regel ist als die vorherige und Konstruktionen wie [x for x in y] := [1,2,3] erlaubt. Die Art und Weise, wie die Regel auf ihre gewünschte Form beschränkt wird, ist durch die Ablehnung dieser unerwünschten Konstruktionen bei der Umwandlung des Parse-Baums in den abstrakten Syntaxbaum. Dies ist nicht nur unschön, sondern auch eine beträchtliche Wartungsbelastung, da sie die AST-Erstellungsroutinen und den Compiler zwingt, in eine Situation zu geraten, in der sie wissen müssen, wie gültige von ungültigen Programmen getrennt werden, was ausschließlich die Verantwortung des Parsers sein sollte. Dies führt auch dazu, dass die aktuelle Grammatikdatei nicht korrekt widerspiegelt, was die tatsächliche Grammatik ist (d. h. die Sammlung aller gültigen Python-Programme).

Ähnliche Workarounds erscheinen in mehreren anderen Regeln der aktuellen Grammatik. Manchmal ist dieses Problem unlösbar. Zum Beispiel zeigt bpo-12782: Multiple context expressions do not support parentheses for continuation across lines, wie eine LL(1)-Regel, die das Schreiben unterstützt,

with (
    open("a_really_long_foo") as foo,
    open("a_really_long_baz") as baz,
    open("a_really_long_bar") as bar
):
  ...

nicht möglich ist, da die First-Sets der Grammatik-Elemente, die als Context Manager auftreten können, die öffnende Klammer enthalten, was die Regel mehrdeutig macht. Diese Regel ist nicht nur konsistent mit anderen Teilen der Sprache (wie der Regel für mehrere Importe), sondern auch sehr nützlich für Auto-Formatierungs-Tools, da geklammerte Gruppen normalerweise verwendet werden, um Elemente zu gruppieren, die zusammen formatiert werden sollen (auf die gleiche Weise, wie Tools mit dem Inhalt von Listen, Mengen usw. arbeiten).

Komplizierte AST-Analyse

Ein weiteres Problem des aktuellen Parsers ist die enorme Kopplung zwischen den AST-Generierungsroutinen und der spezifischen Form der erzeugten Parse-Bäume. Dies macht den Code zur Generierung des AST besonders kompliziert, da viele Aktionen und Entscheidungen implizit sind. Zum Beispiel weiß der AST-Generierungscode, welche Alternativen einer bestimmten Regel basierend auf der Anzahl der Kindknoten in einem gegebenen Parse-Knoten erzeugt werden. Dies macht den Code schwer nachvollziehbar, da diese Eigenschaft nicht direkt mit der Grammatikdatei zusammenhängt und von Implementierungsdetails beeinflusst wird. Infolgedessen muss ein erheblicher Teil des AST-Generierungscodes zur Inspektion und Analyse der spezifischen Form der Parse-Bäume verwendet werden, die er empfängt.

Fehlende Linksrekursion

Wie zuvor beschrieben, ist eine Einschränkung von LL(1)-Grammatiken, dass sie keine Linksrekursion zulassen. Dies macht das Schreiben einiger Regeln sehr unnatürlich und weit entfernt von der Art und Weise, wie Programmierer normalerweise über das Programm denken. Zum Beispiel dieser Konstrukt (eine einfachere Variation mehrerer Regeln in der aktuellen Grammatik)

expr: expr '+' term | term

kann nicht von einem LL(1)-Parser analysiert werden. Das traditionelle Gegenmittel ist, die Grammatik neu zu schreiben, um das Problem zu umgehen

expr: term ('+' term)*

Das Problem, das bei dieser Form auftritt, ist, dass der Parse-Baum eine sehr unnatürliche Form annehmen muss. Das liegt daran, dass mit dieser Regel für das Eingabeprogramm a + b + c der Parse-Baum abgeflacht wird (['a', '+', 'b', '+', 'c']) und nachbearbeitet werden muss, um einen linksrekursiven Parse-Baum zu konstruieren ([['a', '+', 'b'], '+', 'c']). Das erzwungene Schreiben der zweiten Regel führt nicht nur dazu, dass der Parse-Baum die gewünschte Assoziativität nicht korrekt widerspiegelt, sondern belastet auch spätere Kompilierungsphasen zusätzlich, um diese Fälle zu erkennen und nachzubearbeiten.

Zwischenliegender Parse-Baum

Das letzte Problem des aktuellen Parsers ist die zwischenzeitliche Erstellung eines Parse-Baums oder Concrete Syntax Tree, der später in einen Abstract Syntax Tree umgewandelt wird. Obwohl die Konstruktion eines CST in Parser- und Compiler-Pipelines sehr üblich ist, wird in CPython dieser zwischenliegende CST von nichts anderem verwendet (er wird nur indirekt über das parser-Modul offengelegt, und ein überraschend kleiner Teil des Codes bei der CST-Produktion wird im Modul wiederverwendet). Was schlimmer ist: Der gesamte Baum wird im Speicher gehalten, wobei viele Zweige, die aus Ketten von Knoten mit einem einzelnen Kind bestehen, erhalten bleiben. Dies hat sich als erheblicher Speicherverbrauch erwiesen (zum Beispiel in bpo-26415: Excessive peak memory consumption by the Python parser).

Die Notwendigkeit, ein Zwischenergebnis zwischen der Grammatik und dem AST zu erzeugen, ist nicht nur unerwünscht, sondern macht auch den AST-Generierungsschritt wesentlich komplizierter und erhöht die Wartungsbelastung erheblich.

Der neue vorgeschlagene PEG-Parser

Der neu vorgeschlagene PEG-Parser enthält die folgenden Komponenten

  • Ein Parser-Generator, der eine Grammatikdatei lesen und einen PEG-Parser in Python oder C erzeugen kann, der diese Grammatik analysieren kann.
  • Eine PEG-Metagrammatik, die automatisch einen Python-Parser generiert, der für den Parser-Generator selbst verwendet wird (das bedeutet, es gibt keine manuell geschriebenen Parser).
  • Ein generierter Parser (unter Verwendung des Parser-Generators), der direkt C- und Python-AST-Objekte erzeugen kann.

Linksrekursion

PEG-Parser unterstützen normalerweise keine Linksrekursion, aber wir haben eine Technik implementiert, die der von Medeiros et al. [2] beschriebenen ähnelt, jedoch den Memoisations-Cache anstelle von statischen Variablen verwendet. Dieser Ansatz ist näher an dem von Warth et al. [3] beschriebenen. Dies ermöglicht es uns, nicht nur einfache linksrekursive Regeln, sondern auch komplexere Regeln zu schreiben, die indirekte Linksrekursion beinhalten, wie

rule1: rule2 | 'a'
rule2: rule3 | 'b'
rule3: rule1 | 'c'

und "versteckte Linksrekursion" wie

rule: 'optional'? rule '@' some_other_rule

Syntax

Die Grammatik besteht aus einer Sequenz von Regeln der Form

rule_name: expression

Optional kann rechts neben dem Regelnamen ein Typ angegeben werden, der den Rückgabetyp der C- oder Python-Funktion angibt, die der Regel entspricht

rule_name[return_type]: expression

Wenn der Rückgabetyp weggelassen wird, wird in C ein void * und in Python ein Any zurückgegeben.

Grammatik-Ausdrücke

# Kommentar

Python-ähnliche Kommentare.

e1 e2

Ordne e1, dann ordne e2.

rule_name: first_rule second_rule
e1 | e2

Ordne e1 oder e2.

Die erste Alternative kann auch auf der nächsten Zeile nach dem Regelnamen zur Formatierung stehen. In diesem Fall muss ein | vor der ersten Alternative stehen, so

rule_name[return_type]:
    | first_alt
    | second_alt
( e )

Ordne e.

rule_name: (e)

Ein etwas komplexeres und nützlicheres Beispiel beinhaltet die Verwendung des Gruppierungsoperators zusammen mit den Wiederholungsoperatoren

rule_name: (e1 e2)*
[ e ] oder e?

Ordne e optional.

rule_name: [e]

Ein nützlicheres Beispiel ist die Definition, dass ein nachgestelltes Komma optional ist

rule_name: e (',' e)* [',']
e*

Ordne null oder mehr Vorkommen von e.

rule_name: (e1 e2)*
e+

Ordne ein oder mehr Vorkommen von e.

rule_name: (e1 e2)+
s.e+

Ordne ein oder mehr Vorkommen von e, getrennt durch s. Der generierte Parse-Baum enthält den Trenner nicht. Dies ist ansonsten identisch mit (e (s e)*).

rule_name: ','.e+
&e

Erfolgreich sein, wenn e analysiert werden kann, ohne Eingabe zu verbrauchen.

!e

Fehlschlagen, wenn e analysiert werden kann, ohne Eingabe zu verbrauchen.

Ein Beispiel aus der vorgeschlagenen Python-Grammatik besagt, dass ein Primärelement aus einem Atom besteht, dem kein . oder ( oder [ folgt

primary: atom !'.' !'(' !'['
~

Verpflichte dich zur aktuellen Alternative, auch wenn sie beim Parsen fehlschlägt.

rule_name: '(' ~ some_rule ')' | some_alt

In diesem Beispiel, wenn eine öffnende Klammer analysiert wird, wird die andere Alternative nicht berücksichtigt, auch wenn some_rule oder ‘)’ nicht analysiert werden können.

Variablen in der Grammatik

Ein Unterausdruck kann benannt werden, indem man ihm einen Bezeichner und ein Gleichheitszeichen voranstellt. Der Name kann dann in der Aktion (siehe unten) verwendet werden, wie folgt

rule_name[return_type]: '(' a=some_other_rule ')' { a }

Grammatik-Aktionen

Um die Zwischenschritte zu vermeiden, die die Beziehung zwischen der Grammatik und der AST-Generierung verschleiern, erlaubt der vorgeschlagene PEG-Parser die direkte Erzeugung von AST-Knoten für eine Regel durch Grammatikaktionen. Grammatikaktionen sind sprachspezifische Ausdrücke, die ausgewertet werden, wenn eine Grammatikregel erfolgreich analysiert wurde. Diese Ausdrücke können in Python oder C geschrieben werden, abhängig von der gewünschten Ausgabe des Parser-Generators. Das bedeutet, wenn man einen Parser in Python und einen in C erzeugen möchte, müssten zwei Grammatikdateien geschrieben werden, jede mit einem anderen Satz von Aktionen, wobei alles andere als die genannten Aktionen in beiden Dateien identisch bleibt. Als Beispiel einer Grammatik mit Python-Aktionen wird der Teil des Parser-Generators, der Grammatikdateien parst, aus einer Metagrammatikdatei mit Python-Aktionen bootstrapped, die als Ergebnis des Parsens den Grammatikbaum erzeugt.

Im spezifischen Fall der neu vorgeschlagenen PEG-Grammatik für Python erlauben Aktionen die direkte Beschreibung, wie der AST in der Grammatik selbst zusammengesetzt wird, was ihn klarer und wartungsfreundlicher macht. Dieser AST-Generierungsprozess wird durch die Verwendung einiger Hilfsfunktionen unterstützt, die gemeinsame AST-Objektmanipulationen und andere erforderliche Operationen, die nicht direkt mit der Grammatik zusammenhängen, auslagern.

Um diese Aktionen anzugeben, kann jede Alternative durch den Aktionscode in geschweiften Klammern gefolgt werden, der den Rückgabewert der Alternative angibt

rule_name[return_type]:
    | first_alt1 first_alt2 { first_alt1 }
    | second_alt1 second_alt2 { second_alt1 }

Wenn die Aktion weggelassen wird und C-Code generiert wird, gibt es zwei verschiedene Möglichkeiten

  1. Wenn es einen einzelnen Namen in der Alternative gibt, wird dieser zurückgegeben.
  2. Wenn nicht, wird ein Dummy-Name-Objekt zurückgegeben (dieser Fall sollte vermieden werden).

Wenn die Aktion weggelassen wird und Python-Code generiert wird, wird eine Liste mit allen analysierten Ausdrücken zurückgegeben (dies ist für Debugging gedacht).

Die vollständige Metagrammatik für die vom PEG-Generator unterstützten Grammatiken ist

start[Grammar]: grammar ENDMARKER { grammar }

grammar[Grammar]:
    | metas rules { Grammar(rules, metas) }
    | rules { Grammar(rules, []) }

metas[MetaList]:
    | meta metas { [meta] + metas }
    | meta { [meta] }

meta[MetaTuple]:
    | "@" NAME NEWLINE { (name.string, None) }
    | "@" a=NAME b=NAME NEWLINE { (a.string, b.string) }
    | "@" NAME STRING NEWLINE { (name.string, literal_eval(string.string)) }

rules[RuleList]:
    | rule rules { [rule] + rules }
    | rule { [rule] }

rule[Rule]:
    | rulename ":" alts NEWLINE INDENT more_alts DEDENT {
          Rule(rulename[0], rulename[1], Rhs(alts.alts + more_alts.alts)) }
    | rulename ":" NEWLINE INDENT more_alts DEDENT { Rule(rulename[0], rulename[1], more_alts) }
    | rulename ":" alts NEWLINE { Rule(rulename[0], rulename[1], alts) }

rulename[RuleName]:
    | NAME '[' type=NAME '*' ']' {(name.string, type.string+"*")}
    | NAME '[' type=NAME ']' {(name.string, type.string)}
    | NAME {(name.string, None)}

alts[Rhs]:
    | alt "|" alts { Rhs([alt] + alts.alts)}
    | alt { Rhs([alt]) }

more_alts[Rhs]:
    | "|" alts NEWLINE more_alts { Rhs(alts.alts + more_alts.alts) }
    | "|" alts NEWLINE { Rhs(alts.alts) }

alt[Alt]:
    | items '$' action { Alt(items + [NamedItem(None, NameLeaf('ENDMARKER'))], action=action) }
    | items '$' { Alt(items + [NamedItem(None, NameLeaf('ENDMARKER'))], action=None) }
    | items action { Alt(items, action=action) }
    | items { Alt(items, action=None) }

items[NamedItemList]:
    | named_item items { [named_item] + items }
    | named_item { [named_item] }

named_item[NamedItem]:
    | NAME '=' ~ item {NamedItem(name.string, item)}
    | item {NamedItem(None, item)}
    | it=lookahead {NamedItem(None, it)}

lookahead[LookaheadOrCut]:
    | '&' ~ atom {PositiveLookahead(atom)}
    | '!' ~ atom {NegativeLookahead(atom)}
    | '~' {Cut()}

item[Item]:
    | '[' ~ alts ']' {Opt(alts)}
    |  atom '?' {Opt(atom)}
    |  atom '*' {Repeat0(atom)}
    |  atom '+' {Repeat1(atom)}
    |  sep=atom '.' node=atom '+' {Gather(sep, node)}
    |  atom {atom}

atom[Plain]:
    | '(' ~ alts ')' {Group(alts)}
    | NAME {NameLeaf(name.string) }
    | STRING {StringLeaf(string.string)}

# Mini-grammar for the actions

action[str]: "{" ~ target_atoms "}" { target_atoms }

target_atoms[str]:
    | target_atom target_atoms { target_atom + " " + target_atoms }
    | target_atom { target_atom }

target_atom[str]:
    | "{" ~ target_atoms "}" { "{" + target_atoms + "}" }
    | NAME { name.string }
    | NUMBER { number.string }
    | STRING { string.string }
    | "?" { "?" }
    | ":" { ":" }

Als illustratives Beispiel erlaubt diese einfache Grammatikdatei die direkte Generierung eines vollständigen Parsers, der einfache arithmetische Ausdrücke analysieren kann und einen gültigen C-basierten Python-AST zurückgibt

start[mod_ty]: a=expr_stmt* $ { Module(a, NULL, p->arena) }
expr_stmt[stmt_ty]: a=expr NEWLINE { _Py_Expr(a, EXTRA) }
expr[expr_ty]:
    | l=expr '+' r=term { _Py_BinOp(l, Add, r, EXTRA) }
    | l=expr '-' r=term { _Py_BinOp(l, Sub, r, EXTRA) }
    | t=term { t }

term[expr_ty]:
    | l=term '*' r=factor { _Py_BinOp(l, Mult, r, EXTRA) }
    | l=term '/' r=factor { _Py_BinOp(l, Div, r, EXTRA) }
    | f=factor { f }

factor[expr_ty]:
    | '(' e=expr ')' { e }
    | a=atom { a }

atom[expr_ty]:
    | n=NAME { n }
    | n=NUMBER { n }
    | s=STRING { s }

Hier ist EXTRA ein Makro, das zu start_lineno, start_col_offset, end_lineno, end_col_offset, p->arena expandiert, dies sind Variablen, die automatisch vom Parser injiziert werden; p zeigt auf ein Objekt, das den Zustand für den Parser verwaltet.

Eine ähnliche Grammatik, die für die Erzeugung von Python-AST-Objekten geschrieben wurde

start: expr NEWLINE? ENDMARKER { ast.Expression(expr) }
expr:
    | expr '+' term { ast.BinOp(expr, ast.Add(), term) }
    | expr '-' term { ast.BinOp(expr, ast.Sub(), term) }
    | term { term }

term:
    | l=term '*' r=factor { ast.BinOp(l, ast.Mult(), r) }
    | term '/' factor { ast.BinOp(term, ast.Div(), factor) }
    | factor { factor }

factor:
    | '(' expr ')' { expr }
    | atom { atom }

atom:
    | NAME { ast.Name(id=name.string, ctx=ast.Load()) }
    | NUMBER { ast.Constant(value=ast.literal_eval(number.string)) }

Migrationsplan

Dieser Abschnitt beschreibt den Migrationsplan bei der Portierung auf den neuen PEG-basierten Parser, falls dieses PEP angenommen wird. Die Migration wird in mehreren Schritten durchgeführt, die es anfangs ermöglichen, bei Bedarf auf den vorherigen Parser zurückzufallen

  1. Beginnend mit Python 3.9 Alpha 6, die neue PEG-basierte Parser-Maschinerie in CPython mit einem Befehlszeilen-Flag und einer Umgebungsvariable aufnehmen, die das Umschalten zwischen dem neuen und dem alten Parser ermöglicht, zusammen mit expliziten APIs, die den Aufruf der neuen und alten Parser unabhängig voneinander ermöglichen. Zu diesem Zeitpunkt werden alle Python-APIs wie ast.parse und compile den vom Flag oder der Umgebungsvariablen gesetzten Parser verwenden, und der Standard-Parser wird der neue PEG-basierte Parser sein.
  2. Zwischen Python 3.9 und Python 3.10 werden der alte Parser und der zugehörige Code (wie das "parser"-Modul) beibehalten, bis eine neue Python-Version erscheint (Python 3.10). In der Zwischenzeit und bis der alte Parser entfernt wird, werden keine neuen Python-Grammatik-Ergänzungen mehr hinzugefügt, die den PEG-Parser erfordern. Das bedeutet, dass die Grammatik LL(1) bleiben wird, bis der alte Parser entfernt wird.
  3. In Python 3.10, entfernen Sie den alten Parser, das Befehlszeilen-Flag, die Umgebungsvariable und das "parser"-Modul sowie den zugehörigen Code.

Performance und Validierung

Wir haben umfangreiche Zeit- und Validierungstests für den neuen Parser durchgeführt, und dies gibt uns Vertrauen, dass der neue Parser von hoher Qualität ist, um den aktuellen Parser zu ersetzen.

Validierung

Um mit der Validierung zu beginnen, kompilieren wir regelmäßig die gesamte Python 3.8 Standardbibliothek und vergleichen jeden Aspekt des resultierenden AST mit dem, der vom Standardcompiler erzeugt wurde. (Dabei haben wir ein paar Fehler in der Behandlung von Zeilen- und Spaltennummern durch den Standard-Parser gefunden, die wir alle über eine Reihe von Issues und PRs upstream behoben haben.)

Wir haben auch gelegentlich eine viel größere Codebasis (die ca. 3800 beliebtesten Pakete auf PyPI) kompiliert und dies hat uns geholfen, ein paar (sehr wenige) zusätzliche Fehler im neuen Parser zu finden.

(Ein Bereich, den wir nicht ausgiebig untersucht haben, ist die Ablehnung aller falschen Programme. Wir haben Unit-Tests, die eine bestimmte Anzahl von expliziten Ablehnungen überprüfen, aber es könnte mehr Arbeit geleistet werden, z.B. durch die Verwendung eines Fuzzers, der zufällige subtile Fehler in bestehenden Code einfügt. Wir sind offen für Hilfe in diesem Bereich.)

Performance

Wir haben die Leistung des neuen Parsers so optimiert, dass er sowohl in Geschwindigkeit als auch im Speicherverbrauch innerhalb von 10 % des aktuellen Parsers liegt. Während der PEG/Packrat-Parsing-Algorithmus naturgemäß mehr Speicher verbraucht als der aktuelle LL(1)-Parser, haben wir einen Vorteil, da wir kein separates CST erstellen.

Unten sind einige Benchmarks. Diese konzentrieren sich auf die Kompilierung von Quellcode in Bytecode, da dies die realistischste Situation ist. Die Rückgabe eines AST an Python-Code ist nicht so repräsentativ, da der Prozess der Konvertierung des internen AST (nur für C-Code zugänglich) in einen externen AST (eine Instanz von ast.AST) mehr Zeit benötigt als der Parser selbst.

Alle hier berichteten Messungen wurden auf einem aktuellen MacBook Pro durchgeführt, wobei der Median von drei Läufen genommen wurde. Es wurden keine besonderen Vorkehrungen getroffen, um andere laufende Anwendungen auf demselben Rechner zu stoppen.

Die ersten Zeitmessungen sind für unsere kanonische Testdatei, die 100.000 Zeilen enthält, die endlos die folgenden drei Zeilen wiederholen

1 + 2 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + ((((((11 * 12 * 13 * 14 * 15 + 16 * 17 + 18 * 19 * 20))))))
2*3 + 4*5*6
12 + (2 * 3 * 4 * 5 + 6 + 7 * 8)
  • Nur das Parsen und Wegwerfen des internen AST dauert 1,16 Sekunden bei einer maximalen RSS von 681 MiB.
  • Das Parsen und Konvertieren in ast.AST dauert 6,34 Sekunden, maximale RSS 1029 MiB.
  • Das Parsen und Kompilieren in Bytecode dauert 1,28 Sekunden, maximale RSS 681 MiB.
  • Mit dem aktuellen Parser dauert das Parsen und Kompilieren 1,44 Sekunden, maximale RSS 836 MiB.

Für diese spezielle Testdatei ist der neue Parser schneller und verbraucht weniger Speicher als der aktuelle Parser (vergleichen Sie die letzten beiden Punkte).

Wir haben auch Zeitmessungen mit einer realistischeren Nutzlast durchgeführt, der gesamten Python 3.8 Standardbibliothek. Diese Nutzlast besteht aus 1.641 Dateien, 749.570 Zeilen, 27.622.497 Bytes. (Obwohl 11 Dateien aufgrund von Kodierungsproblemen, manchmal absichtlich, von keinem Python 3-Parser kompiliert werden können.)

  • Das Kompilieren und Wegwerfen des internen AST dauerte 2,141 Sekunden. Das sind 350.040 Zeilen/Sekunde oder 12.899.367 Bytes/Sekunde. Die maximale RSS betrug 74 MiB (die größte Datei in der Standardbibliothek ist viel kleiner als unsere kanonische Testdatei).
  • Das Kompilieren in Bytecode dauerte 3,290 Sekunden. Das sind 227.861 Zeilen/Sekunde oder 8.396.942 Bytes/Sekunde. Maximale RSS 77 MiB.
  • Das Kompilieren in Bytecode mit dem aktuellen Parser dauerte 3,367 Sekunden. Das sind 222.620 Zeilen/Sekunde oder 8.203.780 Bytes/Sekunde. Maximale RSS 70 MiB.

Beim Vergleich der letzten beiden Punkte stellen wir fest, dass der neue Parser geringfügig schneller ist, aber geringfügig (ca. 10 %) mehr Speicher verbraucht. Wir glauben, dass dies akzeptabel ist. (Außerdem gibt es wahrscheinlich noch ein paar Optimierungen, die wir vornehmen können, um den Speicherverbrauch zu reduzieren.)

Abgelehnte Alternativen

Wir haben alternative Wege zur Implementierung des neuen Parsers nicht ernsthaft in Betracht gezogen, aber hier ist eine kurze Diskussion von LALR(1).

Vor dreißig Jahren entschied sich der erste Autor, seinen eigenen Weg mit Pythons Parser zu gehen, anstatt LALR(1) zu verwenden, was damals der Industriestandard war (z.B. Bison und Yacc). Die Gründe waren hauptsächlich emotional (Bauchgefühl, Intuition), basierend auf früheren Erfahrungen mit Yacc in anderen Projekten, bei denen die Grammatikentwicklung mehr Aufwand erforderte als erwartet (teilweise wegen Shift-Reduce-Konflikten). Eine spezifische Kritik an Bison und Yacc, die immer noch gilt, ist, dass ihre Metagrammatik (die Notation, die verwendet wird, um die Grammatik in den Parser-Generator einzuspeisen) keine EBNF-Annehmlichkeiten wie [optional_clause] oder (repeated_clause)* unterstützt. Durch die Verwendung eines benutzerdefinierten Parser-Generators konnte ein Syntaxbaum, der der Struktur der Grammatik entsprach, automatisch generiert werden, und mit EBNF konnte dieser Baum mit der "menschenfreundlichen" Struktur der Grammatik übereinstimmen.

Andere Varianten von LR wurden nicht berücksichtigt, ebenso wenig wie LL (z.B. ANTLR). PEG wurde gewählt, weil es bei grundlegenden Kenntnissen des rekursiven Abstiegs-Parsers leicht zu verstehen war.

Referenzen

[4] Guidis Serie über PEG-Parsing https://medium.com/@gvanrossum_83706/peg-parsing-series-de5d41b2ed60


Quelle: https://github.com/python/peps/blob/main/peps/pep-0617.rst

Zuletzt geändert: 2025-02-01 07:28:42 GMT