PEP 638 – Syntactic Macros
- Autor:
- Mark Shannon <mark at hotpy.org>
- Discussions-To:
- Python-Dev thread
- Status:
- Entwurf
- Typ:
- Standards Track
- Erstellt:
- 24-Sep-2020
- Post-History:
- 26-Sep-2020
Zusammenfassung
Dieser PEP fügt Python Unterstützung für syntaktische Makros hinzu. Ein Makro ist eine Funktion zur Kompilierzeit, die einen Teil des Programms transformiert, um Funktionalität zu ermöglichen, die sich im normalen Bibliotheks-Code nicht sauber ausdrücken lässt.
Das Wort „syntaktisch“ bedeutet, dass diese Art von Makro auf dem Syntaxbaum des Programms operiert. Dies verringert die Wahrscheinlichkeit von Fehlinterpretationen, die bei textbasierten Substitutionsmakros auftreten können, und ermöglicht die Implementierung von hygienischen Makros.
Syntaktische Makros ermöglichen es Bibliotheken, den abstrakten Syntaxbaum während der Kompilierung zu modifizieren, was die Möglichkeit bietet, die Sprache für spezifische Domänen zu erweitern, ohne die Komplexität der Sprache insgesamt zu erhöhen.
Motivation
Neue Sprachfunktionen können kontrovers, disruptiv und manchmal spaltend sein. Python ist heute ausreichend mächtig und komplex, dass viele vorgeschlagene Ergänzungen aufgrund der zusätzlichen Komplexität ein Nettverlust für die Sprache darstellen.
Auch wenn eine Sprachänderung bestimmte Muster einfach ausdrücken mag, hat sie ihren Preis. Jede neue Funktion macht die Sprache größer, schwieriger zu lernen und schwerer zu verstehen. Python wurde einst als Python Fits Your Brain beschrieben, aber das wird mit jeder hinzugefügten Funktion immer weniger wahr.
Aufgrund der hohen Kosten, die mit dem Hinzufügen einer neuen Funktion verbunden sind, ist es sehr schwierig oder unmöglich, eine Funktion hinzuzufügen, die nur einigen Benutzern zugutekommt, unabhängig davon, wie viele Benutzer es sind oder wie vorteilhaft diese Funktion für sie auch sein mag.
Die Verwendung von Python in den Bereichen Data Science und maschinelles Lernen ist in den letzten Jahren sehr stark gewachsen. Die meisten Kernentwickler von Python haben jedoch keinen Hintergrund in Data Science oder maschinellem Lernen. Dies macht es für die Kernentwickler extrem schwierig zu beurteilen, ob eine Spracherweiterung für maschinelles Lernen lohnenswert ist.
Indem Spracherweiterungen modular und verteilbar, wie Bibliotheken, gestaltet werden können, können domänenspezifische Erweiterungen implementiert werden, ohne Benutzer außerhalb dieser Domäne negativ zu beeinträchtigen. Ein Webentwickler wünscht sich wahrscheinlich einen ganz anderen Satz von Erweiterungen als ein Data Scientist. Wir müssen es der Community ermöglichen, ihre eigenen Erweiterungen zu entwickeln.
Ohne irgendeine Form von benutzerdefinierten Spracherweiterungen wird es einen ständigen Kampf geben zwischen denen, die die Sprache kompakt und ihren Gehirnen angepasst halten wollen, und denen, die eine neue Funktion wünschen, die ihrem Domänen- oder Programmierstil entspricht.
Verbesserung der Ausdrucksstärke von Bibliotheken für spezifische Domänen
Viele Domänen weisen wiederkehrende Muster auf, die sich nur schwer oder gar nicht als Bibliothek ausdrücken lassen. Makros können diese Muster auf prägnantere und weniger fehleranfällige Weise ausdrücken.
Testen neuer Sprachfunktionen
Es ist möglich, potenzielle Spracherweiterungen mithilfe von Makros zu demonstrieren. Makros hätten beispielsweise die `with`-Anweisung und den `yield from`-Ausdruck zum Testen ermöglicht. Dies hätte möglicherweise zu einer qualitativ hochwertigeren Implementierung bei der ersten Veröffentlichung geführt, indem mehr Tests zugelassen würden, bevor diese Funktionen in die Sprache aufgenommen werden.
Es ist fast unmöglich sicherzustellen, dass eine neue Funktion vor ihrer Veröffentlichung vollständig zuverlässig ist; Fehler im Zusammenhang mit den `with`- und `yield from`-Funktionen wurden noch viele Jahre nach ihrer Veröffentlichung behoben.
Langzeitstabilität des Bytecode-Interpreters
Historisch gesehen wurden neue Sprachfunktionen durch naive Kompilierung des AST in neue, komplexe Bytecode-Instruktionen implementiert. Diese Bytecodes hatten oft ihre eigene interne Ablaufsteuerung, die Operationen durchführte, die im Compiler hätten erfolgen können und sollen.
Zum Beispiel wurde bis vor kurzem die Ablaufsteuerung innerhalb der `try`-`finally`- und `with`-Anweisungen durch komplizierte Bytecodes mit kontextabhängiger Semantik verwaltet. Die Ablaufsteuerung innerhalb dieser Anweisungen wird jetzt im Compiler implementiert, was den Interpreter einfacher und schneller macht.
Durch die Implementierung neuer Funktionen als AST-Transformationen kann der bestehende Compiler den Bytecode für eine Funktion generieren, ohne den Interpreter modifizieren zu müssen.
Ein stabiler Interpreter ist notwendig, wenn wir die Leistung und Portabilität der CPython VM verbessern wollen.
Begründung
Python ist sowohl ausdrucksstark als auch leicht zu lernen; es ist weithin als die am einfachsten zu erlernende, weit verbreitete Programmiersprache anerkannt. Sie ist jedoch nicht die flexibelste. Diesen Titel hat Lisp.
Da Lisp homoikonisch ist, was bedeutet, dass Lisp-Programme Lisp-Datenstrukturen sind, können Lisp-Programme von Lisp-Programmen manipuliert werden. So kann ein Großteil der Sprache in sich selbst definiert werden.
Wir hätten diese Fähigkeit gerne in Python, ohne die vielen Klammern, die Lisp charakterisieren. Glücklicherweise ist Homoikonizität nicht notwendig, damit eine Sprache sich selbst manipulieren kann. Alles, was benötigt wird, ist die Fähigkeit, Programme nach dem Parsen, aber vor der Übersetzung in eine ausführbare Form zu manipulieren.
Python verfügt bereits über die erforderlichen Komponenten. Der Syntaxbaum von Python ist über das Modul `ast` verfügbar. Alles, was benötigt wird, ist ein Marker, der dem Compiler mitteilt, dass ein Makro vorhanden ist, und die Fähigkeit des Compilers, Benutzercode zurückzurufen, um den AST zu manipulieren.
Spezifikation
Syntax
Lexikalische Analyse
Jede Folge von Bezeichnerzeichen, gefolgt von einem Ausrufezeichen (Exklamationszeichen, britisch), wird als `MACRO_NAME` tokenisiert.
Anweisungsform
macro_stmt = MACRO_NAME testlist [ "import" NAME ] [ "as" NAME ] [ ":" NEWLINE suite ]
Ausdrucksform
macro_expr = MACRO_NAME "(" testlist ")"
Auflösung von Mehrdeutigkeiten
Die Anweisungsform eines Makros hat Vorrang, so dass der Code `macro_name!(x)` als Makro-Anweisung und nicht als Ausdrucksanweisung, die einen Makro-Ausdruck enthält, geparst wird.
Semantik
Kompilierung
Beim Auftreten eines `macro` während der Übersetzung in Bytecode sucht der Codegenerator nach dem für das Makro registrierten Makro-Prozessor und übergibt den AST, der an der Wurzel des Makros liegt, an die Prozessorfunktion. Der zurückgegebene AST wird dann für den ursprünglichen Baum eingesetzt.
Bei Makros mit mehreren Namen werden mehrere Bäume an den Makro-Prozessor übergeben, aber nur einer wird zurückgegeben und eingesetzt, wodurch der umschließende Block von Anweisungen gekürzt wird.
Dieser Prozess kann wiederholt werden, damit Makros AST-Knoten zurückgeben können, die andere Makros enthalten.
Der Compiler sucht erst dann nach einem Makro-Prozessor, wenn dieses Makro erreicht ist, so dass innere Makros keine registrierten Prozessoren benötigen. Beispielsweise muss in einem `switch`-Makro für die `case`- und `default`-Makros kein Prozessor registriert sein, da sie vom `switch`-Prozessor eliminiert würden.
Um die Definition von Makros zum Importieren zu ermöglichen, sind die Makros `import!` und `from!` vordefiniert. Sie unterstützen die folgende Syntax
"import!" dotted_name "as" name
"from!" dotted_name "import" name [ "as" name ]
Das `import!`-Makro führt einen Import zur Kompilierzeit von `dotted_name` durch, um den Makro-Prozessor zu finden, und registriert ihn dann unter `name` für den aktuell kompilierten Geltungsbereich.
Das `from!`-Makro führt einen Import zur Kompilierzeit von `dotted_name.name` durch, um den Makro-Prozessor zu finden, und registriert ihn dann unter `name` (unter Verwendung von `name` nach `as`, falls vorhanden) für den aktuell kompilierten Geltungsbereich.
Beachten Sie, dass `import!` und `from!`, da sie das Makro nur für den Geltungsbereich definieren, in dem der Import vorhanden ist, alle Verwendungen eines Makros durch einen expliziten `import!`- oder `from!`-Befehl vorangestellt werden müssen, um die Klarheit zu verbessern.
Zum Beispiel, um das Makro „compile“ aus „my.compiler“ zu importieren
from! my.compiler import compile
Definition von Makro-Prozessoren
Ein Makro-Prozessor wird durch ein Vierertupel definiert, bestehend aus `(func, kind, version, additional_names)`.
- `func` muss aufrufbar sein, der `len(additional_names)+1` Argumente entgegennimmt, von denen alle abstrakte Syntaxbäume sind, und einen einzelnen abstrakten Syntaxbaum zurückgibt.
- `kind` muss eine der folgenden sein:
- `macros.STMT_MACRO`: Ein Anweisungs-Makro, bei dem der Körper des Makros eingerückt ist. Dies ist die einzige Form, die zusätzliche Namen haben darf.
- `macros.SIBLING_MACRO`: Ein Anweisungs-Makro, bei dem der Körper des Makros die nächste Anweisung im selben Block ist. Die folgende Anweisung wird als Körper in das Makro verschoben.
- `macros.EXPR_MACRO`: Ein Ausdrucks-Makro.
- `version` wird verwendet, um Versionen von Makros zu verfolgen, damit generierte Bytecodes korrekt zwischengespeichert werden können. Es muss eine Ganzzahl sein.
- `additional_names` sind die Namen der zusätzlichen Teile des Makros und müssen ein Tupel von Zeichenketten sein.
# (func, _ast.STMT_MACRO, VERSION, ())
stmt_macro!:
multi_statement_body
# (func, _ast.SIBLING_MACRO, VERSION, ())
sibling_macro!
single_statement_body
# (func, _ast.EXPR_MACRO, VERSION, ())
x = expr_macro!(...)
# (func, _ast.STMT_MACRO, VERSION, ("subsequent_macro_part",))
multi_part_macro!:
multi_statement_body
subsequent_macro_part!:
multi_statement_body
Der Compiler prüft, ob die verwendete Syntax mit der deklarierten Art übereinstimmt.
Zur Bequemlichkeit wird der Dekorator `macro_processor` im Modul `macros` bereitgestellt, um eine Funktion als Makro-Prozessor zu kennzeichnen.
def macro_processor(kind, version, *additional_names):
def deco(func):
return func, kind, version, additional_names
return deco
Dies kann zur Deklaration von Makro-Prozessoren verwendet werden, zum Beispiel
@macros.macro_processor(macros.STMT_MACRO, 1_08)
def switch(astnode):
...
AST-Erweiterungen
Zwei neue AST-Knoten werden benötigt, um Makros auszudrücken: `macro_stmt` und `macro_expr`.
class macro_stmt(_ast.stmt):
_fields = "name", "args", "importname", "asname", "body"
class macro_expr(_ast.expr):
_fields = "name", "args"
Darüber hinaus benötigen Makro-Prozessoren ein Mittel, um Ablaufsteuerung oder seitenwirksamen Code auszudrücken, der einen Wert erzeugt. Ein neuer AST-Knoten namens `stmt_expr` wird hinzugefügt, der eine Anweisung und einen Ausdruck kombiniert. Dieser neue AST-Knoten ist eine Unterklasse von `expr`, enthält aber eine Anweisung, um Nebeneffekte zu ermöglichen. Er wird in Bytecode übersetzt, indem die Anweisung kompiliert und dann der Wert kompiliert wird.
class stmt_expr(_ast.expr):
_fields = "stmt", "value"
Hygiene und Debugging
Makro-Prozessoren müssen oft neue Variablen erstellen. Diese Variablen müssen so benannt werden, dass sie den ursprünglichen Code und andere Makros nicht kontaminieren. Es werden keine Regeln für die Benennung erzwungen, aber um die Hygiene zu gewährleisten und das Debugging zu erleichtern, wird das folgende Benennungsschema empfohlen:
- Alle generierten Variablennamen sollten mit einem `$` beginnen.
- Rein künstliche Variablennamen sollten mit `$$mname` beginnen, wobei `mname` der Name des Makros ist.
- Variablen, die von echten Variablen abgeleitet sind, sollten mit `$vname` beginnen, wobei `vname` der Name der Variablen ist.
- Alle Variablennamen sollten die Zeilennummer und den Spaltenoffset enthalten, getrennt durch einen Unterstrich.
Beispiele
- Rein generierter Name: `$$macro_17_0`
- Von einer Variable abgeleiteter Name für ein Ausdrucks-Makro: `$var_12_5`
Beispiele
Zur Kompilierzeit geprüfte Datenstrukturen
Es ist üblich, Tabellen von Daten in Python als große Wörterbücher zu kodieren. Diese können jedoch schwer zu pflegen und fehleranfällig sein. Makros ermöglichen es, solche Daten in einem besser lesbaren Format zu schreiben. Zur Kompilierzeit können die Daten dann überprüft und in ein effizientes Format umgewandelt werden.
Nehmen wir zum Beispiel an, wir haben zwei Wörterbuch-Literale, die Codes zu Namen und umgekehrt zuordnen. Dies ist fehleranfällig, da die Wörterbücher doppelte Schlüssel haben können oder eine Tabelle nicht die Umkehrung der anderen ist. Ein Makro könnte die beiden Zuordnungen aus einer einzigen Tabelle generieren und gleichzeitig überprüfen, ob keine Duplikate vorhanden sind.
color_to_code = {
"red": 1,
"blue": 2,
"green": 3,
}
code_to_color = {
1: "red",
2: "blue",
3: "yellow", # error
}
würde zu
bijection! color_to_code, code_to_color:
"red" = 1
"blue" = 2
"green" = 3
Domänenspezifische Erweiterungen
Wo ich Makros als wirklich wertvoll erachte, sind spezifische Domänen, nicht allgemeine Sprachfunktionen.
Zum Beispiel Parser. Hier ist ein Teil einer Parser-Definition für Python, die Makros verwendet.
choice! single_input:
NEWLINE
simple_stmt
sequence!:
compound_stmt
NEWLINE
Compiler
Laufzeitcompiler wie `numba` müssen den Python-Quellcode wiederherstellen oder versuchen, den Bytecode zu analysieren. Für sie wäre es einfacher und zuverlässiger, den AST direkt zu erhalten.
from! my.jit.library import jit
jit!
def func():
...
Abgleich symbolischer Ausdrücke
Beim Abgleich von etwas, das Syntax repräsentiert, wie z. B. einem Python `ast`-Knoten oder einem `sympy`-Ausdruck, ist es praktisch, gegen die tatsächliche Syntax abzugleichen und nicht gegen die sie darstellende Datenstruktur. Zum Beispiel könnte ein Taschenrechner mithilfe eines domänenspezifischen Makros zum Abgleichen von Syntax implementiert werden.
from! ast_matcher import match
def calculate(node):
if isinstance(node, Num):
return node.n
match! node:
case! a + b:
return calculate(a) + calculate(b)
case! a - b:
return calculate(a) - calculate(b)
case! a * b:
return calculate(a) * calculate(b)
case! a / b:
return calculate(a) / calculate(b)
was übersetzt werden könnte in
def calculate(node):
if isinstance(node, Num):
return node.n
$$match_4_0 = node
if isinstance($$match_4_0, _ast.Add):
a, b = $$match_4_0.left, $$match_4_0.right
return calculate(a) + calculate(b)
elif isinstance($$match_4_0, _ast.Sub):
a, b = $$match_4_0.left, $$match_4_0.right
return calculate(a) - calculate(b)
elif isinstance($$match_4_0, _ast.Mul):
a, b = $$match_4_0.left, $$match_4_0.right
return calculate(a) * calculate(b)
elif isinstance($$match_4_0, _ast.Div):
a, b = $$match_4_0.left, $$match_4_0.right
return calculate(a) / calculate(b)
Nullkostengrenzen und Anmerkungen
Annotationen, entweder als Dekoratoren oder als PEP 3107-Funktionsannotationen, haben Laufzeitkosten, auch wenn sie nur als Marker für Prüfer oder als Dokumentation dienen.
@do_nothing_marker
def foo(...):
...
kann durch das Nullkosten-Makro ersetzt werden
do_nothing_marker!:
def foo(...):
...
Prototyping von Spracherweiterungen
Obwohl Makros für domänenspezifische Erweiterungen am wertvollsten wären, ist es möglich, mögliche Spracherweiterungen mithilfe von Makros zu demonstrieren.
f-Strings
Der f-String `f"..."` könnte als Makro `f!("...")` implementiert werden. Nicht ganz so schön zu lesen, aber trotzdem nützlich zum Experimentieren.
Try-finally-Anweisung
try_!:
body
finally!:
closing
würde grob übersetzt werden als
try:
body
except:
closing
else:
closing
- Hinweis
- Es muss darauf geachtet werden, `return`, `break` und `continue` korrekt zu behandeln. Der obige Code ist lediglich illustrativ.
With-Anweisung
with! open(filename) as fd:
return fd.read()
Das Obige würde erfordern, `open` speziell zu behandeln. Eine Alternative, die expliziter wäre, wäre
with! open!(filename) as fd:
return fd.read()
Makro-Definitionsmakros
Sprachen, die syntaktische Makros haben, stellen normalerweise ein Makro zur Definition von Makros bereit. Dieser PEP tut dies absichtlich nicht, da noch nicht klar ist, wie ein gutes Design aussehen würde, und wir der Community erlauben wollen, ihre eigenen Makros zu definieren.
Eine mögliche Form könnte sein
macro_def! name:
input:
... # input pattern, defining meta-variables
output:
... # output pattern, using meta-variables
Abwärtskompatibilität
Dieser PEP ist vollständig abwärtskompatibel.
Leistungsaspekte
Für Code, der keine Makros verwendet, gibt es keine Auswirkungen auf die Leistung.
Für Code, der Makros verwendet und bereits in Bytecode kompiliert wurde, gibt es einen geringfügigen Mehraufwand, um zu prüfen, ob die zum Kompilieren des Codes verwendeten Makroversionen mit den importierten Makro-Prozessoren übereinstimmen.
Für Code, der nicht kompiliert wurde oder mit unterschiedlichen Versionen der Makro-Prozessoren kompiliert wurde, gäbe es den üblichen Mehraufwand der Bytecode-Kompilierung plus jeglichen zusätzlichen Mehraufwand für die Makro-Verarbeitung.
Es ist erwähnenswert, dass die Geschwindigkeit der Quell-zu-Bytecode-Kompilierung für die Python-Leistung weitgehend irrelevant ist.
Implementierung
Um die AST-Transformation zur Kompilierzeit durch Python-Code zu ermöglichen, müssen alle AST-Knoten im Compiler Python-Objekte sein.
Um dies effizient zu tun, müssen alle Knoten im Modul `_ast` unveränderlich gemacht werden, um die Leistung nicht zu stark zu beeinträchtigen. Sie müssen unveränderlich sein, um zu garantieren, dass der AST ein *Baum* bleibt und kein zyklischer GC unterstützt werden muss. Sie unveränderlich zu machen bedeutet, dass sie kein `__dict__`-Attribut haben, was sie kompakt macht.
AST-Knoten im Modul `ast` bleiben veränderlich.
Derzeit werden alle AST-Knoten mit einem Arena-Allocator alloziert. Die Umstellung auf die Verwendung des Standard-Allocators könnte die Kompilierung etwas verlangsamen, hat aber Vorteile in Bezug auf die Wartung, da viel Code gelöscht werden kann.
Referenzimplementierung
Bisher keine.
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0638.rst
Zuletzt geändert: 2025-02-01 08:55:40 GMT