PEP 690 – Lazy Imports
- Autor:
- Germán Méndez Bravo <german.mb at gmail.com>, Carl Meyer <carl at oddbird.net>
- Sponsor:
- Barry Warsaw <barry at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Abgelehnt
- Typ:
- Standards Track
- Erstellt:
- 29. Apr. 2022
- Python-Version:
- 3.12
- Post-History:
- 03. Mai 2022, 03. Mai 2022
- Resolution:
- Discourse-Nachricht
Zusammenfassung
Dieses PEP schlägt eine Funktion vor, um das Finden und Ausführen von importierten Modulen transparent aufzuschieben, bis der importierte Objekt zum ersten Mal verwendet wird. Da Python-Programme üblicherweise weit mehr Module importieren, als eine einzelne Programmausführung tatsächlich benötigt, können Lazy Imports die Gesamtzahl der geladenen Module erheblich reduzieren, wodurch Startzeit und Speichernutzung verbessert werden. Lazy Imports eliminieren außerdem weitgehend das Risiko von Importzyklen.
Motivation
Gängiger Python-Code-Stil bevorzugt Imports auf Modulebene, damit sie nicht in jedem Geltungsbereich wiederholt werden müssen, in dem das importierte Objekt verwendet wird, und um die Ineffizienz der wiederholten Ausführung des Importsystems zur Laufzeit zu vermeiden. Dies bedeutet, dass der Import des Hauptmoduls eines Programms typischerweise zu einer sofortigen Kaskade von Imports der meisten oder aller Module führt, die das Programm möglicherweise jemals benötigt.
Betrachten Sie das Beispiel eines Python-Kommandozeilenprogramms (CLI) mit mehreren Unterbefehlen. Jeder Unterbefehl kann unterschiedliche Aufgaben ausführen, die den Import unterschiedlicher Abhängigkeiten erfordern. Aber eine gegebene Ausführung des Programms führt nur einen einzigen Unterbefehl aus, oder möglicherweise keinen (d. h. wenn nur die Hilfeinformationen mit --help angefordert werden). Top-Level-Eager-Imports in einem solchen Programm führen zum Import vieler Module, die niemals verwendet werden; die aufgewendete Zeit für die (möglicherweise kompilierende und) ausführende Verarbeitung dieser Module ist reine Verschwendung.
Um die Startzeit zu verbessern, machen einige große Python-CLIs Imports zu Lazy Imports, indem sie Imports manuell in Funktionen platzieren, um den Import teurer Teilsysteme zu verzögern. Dieser manuelle Ansatz ist arbeitsintensiv und fragil; ein falsch platzierter Import oder eine Umstrukturierung kann mühsame Optimierungsarbeit leicht zunichtemachen.
Die Python-Standardbibliothek enthält bereits integrierte Unterstützung für Lazy Imports über importlib.util.LazyLoader. Es gibt auch Pakete von Drittanbietern wie demandimport. Diese stellen ein „lazy module object“ bereit, das seinen eigenen Import verzögert, bis zum ersten Attributzugriff. Dies ist nicht ausreichend, um alle Imports zu lazy zu machen: Imports wie from foo import a, b importieren das Modul foo immer noch eager, da sie sofort auf ein Attribut davon zugreifen. Es verursacht außerdem spürbare Laufzeit-Overheads bei jedem Modulattributzugriff, da es eine Python-seitige __getattr__ oder __getattribute__ Implementierung erfordert.
Autoren von wissenschaftlichen Python-Paketen haben auch ausgiebig Lazy Imports verwendet, um Benutzern zu ermöglichen, z. B. import scipy as sp zu schreiben und dann leicht auf viele verschiedene Untermodule mit z. B. sp.linalg zuzugreifen, ohne dass alle vielen Untermodule im Voraus importiert werden müssen. SPEC 1 kodifiziert diese Praxis in Form einer lazy_loader-Bibliothek, die explizit in einem Paket __init__.py verwendet werden kann, um lazy zugängliche Untermodule bereitzustellen.
Benutzer von statischer Typisierung müssen auch Namen für die Verwendung in Typannotationen importieren, die zur Laufzeit möglicherweise nie verwendet werden (wenn PEP 563 oder möglicherweise zukünftige PEP 649 verwendet werden, um eine vorzeitige Laufzeitbewertung von Annotationen zu vermeiden). Lazy Imports sind in diesem Szenario sehr attraktiv, um Overheads von unnötigen Imports zu vermeiden.
Dieses PEP schlägt eine allgemeinere und umfassendere Lösung für Lazy Imports vor, die alle oben genannten Anwendungsfälle abdecken kann und keine nachweisbaren Overheads im realen Einsatz verursacht. Die Implementierung in diesem PEP hat bereits Startup-Zeit-Verbesserungen von bis zu 70 % und Speicherreduzierungen von bis zu 40 % bei realen Python-CLIs demonstriert.
Lazy Imports eliminieren außerdem die meisten Importzyklen. Bei Eager Imports können leicht „falsche Zyklen“ auftreten, die durch einfaches Verschieben eines Imports an das Ende eines Moduls oder inline in eine Funktion oder durch Umstellung von from foo import bar auf import foo behoben werden. Mit Lazy Imports funktionieren diese „Zyklen“ einfach. Die einzigen Zyklen, die bestehen bleiben, sind diejenigen, bei denen zwei Module tatsächlich jeweils einen Namen aus dem anderen auf Modulebene verwenden; diese „echten“ Zyklen können nur durch Refactoring der beteiligten Klassen oder Funktionen behoben werden.
Begründung
Das Ziel dieser Funktion ist es, Imports transparent lazy zu machen. „Lazy“ bedeutet, dass der Import eines Moduls (Ausführung des Modulkörpers und Hinzufügen des Modulobjekts zu sys.modules) erst dann erfolgen sollte, wenn auf das Modul (oder einen daraus importierten Namen) tatsächlich während der Ausführung zugegriffen wird. „Transparent“ bedeutet, dass es außer dem verzögerten Import (und den notwendigerweise beobachtbaren Effekten davon, wie verzögerte Import-Nebeneffekte und Änderungen an sys.modules) keine weiteren beobachtbaren Verhaltensänderungen gibt: Das importierte Objekt ist normal im Modul-Namespace vorhanden und wird transparent geladen, wenn es zum ersten Mal verwendet wird: Sein Status als „lazy imported object“ ist von Python oder von C-Erweiterungscode nicht direkt beobachtbar.
Die Anforderung, dass das importierte Objekt, auch bevor der Import tatsächlich stattgefunden hat, wie gewohnt im Modul-Namespace vorhanden sein muss, bedeutet, dass wir eine Art „lazy object“-Platzhalter benötigen, um das noch nicht importierte Objekt darzustellen. Die Transparenzanforderung diktiert, dass dieser Platzhalter niemals für Python-Code sichtbar sein darf; jeder Zugriff darauf muss den Import auslösen und ihn durch das reale importierte Objekt ersetzen.
Angesichts der Möglichkeit, dass Python-Code (oder C-Erweiterungscode) Objekte direkt aus dem __dict__ eines Moduls extrahiert, ist der einzige Weg, um versehentliches Auslaufen von Lazy-Objekten zuverlässig zu verhindern, die Dictionary-Struktur selbst dafür verantwortlich zu machen, die Auflösung von Lazy-Objekten bei der Suche sicherzustellen.
Wenn eine Suche feststellt, dass der Schlüssel auf ein Lazy-Objekt verweist, löst sie das Lazy-Objekt sofort auf, bevor es zurückgegeben wird. Um Nebeneffekte zu vermeiden, die Dictionaries mitten in der Iteration verändern, werden alle Lazy-Objekte in einem Dictionary vor Beginn einer Iteration aufgelöst; dies kann zu Leistungseinbußen bei der Verwendung von Masseniterationen (iter(dict), reversed(dict), dict.__reversed__(), dict.keys(), iter(dict.keys()) und reversed(dict.keys())) führen. Um diese Leistungseinbußen bei der überwiegenden Mehrheit der Dictionaries, die niemals Lazy-Objekte enthalten, zu vermeiden, stehlen wir ein Bit aus dem dk_kind-Feld für ein neues dk_lazy_imports-Flag, um zu verfolgen, ob ein Dictionary Lazy-Objekte enthalten kann oder nicht.
Diese Implementierung verhindert umfassend das Auslaufen von Lazy-Objekten und stellt sicher, dass sie immer auf das reale importierte Objekt aufgelöst werden, bevor jemand sie für irgendeinen Zweck erhalten kann, während gleichzeitig erhebliche Leistungseinbußen für allgemeine Dictionaries vermieden werden.
Spezifikation
Lazy Imports sind opt-in und können entweder global über eine neue -L Flagge für den Python-Interpreter oder über einen Aufruf einer neuen Funktion importlib.set_lazy_imports() aktiviert werden. Diese Funktion nimmt zwei Argumente entgegen: einen booleschen Wert enabled und einen excluding Container. Wenn enabled true ist, werden Lazy Imports von diesem Zeitpunkt an aktiviert. Wenn es false ist, werden sie von diesem Zeitpunkt an deaktiviert. (Die Verwendung des excluding Schlüsselworts wird unten unter „Pro-Modul-Opt-Out“ diskutiert.)
Wenn die Flagge -L an den Python-Interpreter übergeben wird, wird ein neues sys.flags.lazy_imports auf True gesetzt, andernfalls existiert es als False. Dieses Flag wird verwendet, um -L an neue Python-Subprozesse weiterzugeben.
Das Flag in sys.flags.lazy_imports spiegelt nicht unbedingt den aktuellen Status von Lazy Imports wider, sondern nur, ob der Interpreter mit der Option -L gestartet wurde. Der tatsächliche aktuelle Status, ob Lazy Imports zu einem bestimmten Zeitpunkt aktiviert sind oder nicht, kann mit importlib.is_lazy_imports_enabled() abgerufen werden, was True zurückgibt, wenn Lazy Imports am Aufrufpunkt aktiviert sind, oder andernfalls False.
Wenn Lazy Imports aktiviert sind, wird das Laden und Ausführen aller (und nur) Top-Level-Imports aufgeschoben, bis auf den importierten Namen zum ersten Mal zugegriffen wird. Dies kann sofort geschehen (z. B. in der nächsten Zeile nach der Importanweisung) oder viel später (z. B. bei der Verwendung des Namens innerhalb einer Funktion, die von anderem Code zu einem späteren Zeitpunkt aufgerufen wird).
Für diese Top-Level-Imports gibt es zwei Kontexte, die sie eager (nicht lazy) machen: Imports innerhalb von try / except / finally oder with Blöcken und Star-Imports (from foo import *). Imports innerhalb von Ausnahmebehandlungsblöcken (dies schließt with Blöcke ein, da diese ebenfalls Ausnahmen „abfangen“ und behandeln können) bleiben eager, damit etwaige Ausnahmen, die aus dem Import entstehen, behandelt werden können. Star-Imports müssen eager bleiben, da die Durchführung des Imports der einzige Weg ist, um zu wissen, welche Namen in den Namespace aufgenommen werden sollen.
Imports innerhalb von Klassendefinitionen oder innerhalb von Funktionen/Methoden sind keine „Top-Level“-Imports und sind niemals lazy.
Dynamische Imports mit __import__() oder importlib.import_module() sind ebenfalls niemals lazy.
Der Status von Lazy Imports (d. h. ob sie aktiviert wurden und welche Module ausgeschlossen sind; siehe unten) ist pro Interpreter, aber global innerhalb des Interpreters (d. h. alle Threads sind betroffen).
Beispiel
Nehmen wir an, wir haben ein Modul spam.py
# simulate some work
import time
time.sleep(10)
print("spam loaded")
Und ein Modul eggs.py, das es importiert
import spam
print("imports done")
Wenn wir python -L eggs.py ausführen, wird das Modul spam niemals importiert (da es nach dem Import nie referenziert wird), "spam loaded" wird niemals gedruckt, und es gibt keine 10-sekündige Verzögerung.
Aber wenn eggs.py den Namen spam nach dem Import einfach referenziert, reicht das aus, um den Import von spam.py auszulösen
import spam
print("imports done")
spam
Wenn wir nun python -L eggs.py ausführen, sehen wir zuerst die Ausgabe "imports done", dann eine 10-sekündige Verzögerung und dann "spam loaded", nachdem dies ausgegeben wurde.
Natürlich ist es in realen Anwendungsfällen (insbesondere bei Lazy Imports) nicht ratsam, sich auf Import-Nebeneffekte wie diese zu verlassen, um echte Arbeit auszulösen. Dieses Beispiel dient nur zur Klärung des Verhaltens von Lazy Imports.
Eine andere Möglichkeit, den Effekt von Lazy Imports zu erklären, ist, als ob jede Lazy-Import-Anweisung stattdessen direkt vor jeder Verwendung des importierten Namens im Quellcode geschrieben worden wäre. Man kann sich Lazy Imports also ähnlich wie die Transformation dieses Codes vorstellen
import foo
def func1():
return foo.bar()
def func2():
return foo.baz()
Zu diesem
def func1():
import foo
return foo.bar()
def func2():
import foo
return foo.baz()
Dies gibt einen guten Eindruck davon, wann der Import von foo bei Lazy Imports stattfindet, aber Lazy Import ist nicht wirklich äquivalent zu dieser Code-Transformation. Es gibt mehrere bemerkenswerte Unterschiede
- Im Gegensatz zum letzteren Code existiert unter Lazy Imports der Name
fooimmer noch im globalen Namespace des Moduls und kann von anderen Modulen, die dieses importieren, importiert oder referenziert werden. (Solche Referenzen würden ebenfalls den Import auslösen.) - Der Laufzeit-Overhead von Lazy Imports ist deutlich geringer als im letzteren Fall; nach der ersten Referenz auf den Namen
foo, die den Import auslöst, haben nachfolgende Referenzen null Import-System-Overhead; sie sind von einer normalen Namensreferenz nicht zu unterscheiden.
In gewissem Sinne verwandeln Lazy Imports die Importanweisung in eine reine Deklaration eines importierten Namens oder mehrerer Namen, die später bei Referenzierung vollständig aufgelöst werden.
Ein Import im Stil from foo import bar kann ebenfalls lazy sein. Wenn der Import stattfindet, wird der Name bar als Lazy Import in den Modul-Namespace aufgenommen. Die erste Referenz auf bar importiert foo und löst bar zu foo.bar auf.
Gedachte Verwendung
Da Lazy Imports eine potenziell rückwärts inkompatible semantische Änderung darstellen, sollten sie nur vom Autor oder Maintainer einer Python-Anwendung aktiviert werden, der bereit ist, die Anwendung gründlich unter den neuen Semantiken zu testen, sicherzustellen, dass sie wie erwartet funktioniert, und bei Bedarf bestimmte Imports auszuoptieren (siehe unten). Lazy Imports sollten nicht spekulativ vom Endbenutzer einer Python-Anwendung mit der Erwartung eines Erfolgs aktiviert werden.
Es liegt in der Verantwortung des Anwendungsentwicklers, der Lazy Imports für seine Anwendung aktiviert, alle Bibliotheksimports auszuoptieren, die für das korrekte Funktionieren seiner Anwendung als eager erforderlich sind; es liegt nicht in der Verantwortung der Bibliotheksautoren, sicherzustellen, dass ihre Bibliothek sich unter Lazy Imports exakt gleich verhält.
Die Dokumentation der Funktion, der -L Flagge und der neuen importlib APIs wird klar über die beabsichtigte Verwendung und die Risiken der Einführung ohne Tests informieren.
Implementierung
Lazy Imports werden intern durch ein „lazy import“-Objekt dargestellt. Wenn ein Lazy Import stattfindet (z. B. import foo oder from foo import bar), wird der Schlüssel "foo" oder "bar" sofort zum Namespace-Dictionary des Moduls hinzugefügt, aber mit seinem Wert auf ein nur intern verwendetes „lazy import“-Objekt gesetzt, das alle notwendigen Metadaten zur späteren Ausführung des Imports speichert.
Ein neues boolesches Flag in PyDictKeysObject (dk_lazy_imports) wird gesetzt, um anzuzeigen, dass dieses spezielle Dictionary Lazy Import-Objekte enthalten kann. Dieses Flag wird nur verwendet, um alle Lazy-Objekte bei „Bulk“-Operationen effizient aufzulösen, wenn ein Dictionary Lazy-Objekte enthalten kann.
Immer wenn ein Schlüssel in einem Dictionary nachgeschlagen wird, um seinen Wert zu extrahieren, wird der Wert daraufhin überprüft, ob es sich um ein Lazy Import-Objekt handelt. Wenn ja, wird das Lazy-Objekt sofort aufgelöst, die relevanten importierten Module ausgeführt, das Lazy Import-Objekt im Dictionary (wenn möglich) durch den tatsächlichen importierten Wert ersetzt und der aufgelöste Wert von der Lookup-Funktion zurückgegeben. Ein Dictionary könnte als Teil eines Import-Nebeneffekts während der Auflösung eines Lazy Import-Objekts mutieren. In diesem Fall ist es nicht möglich, den Schlüsselwert effizient durch das aufgelöste Objekt zu ersetzen. In diesem Fall erhält das Lazy Import-Objekt einen gecachten Verweis auf das aufgelöste Objekt. Bei der nächsten Verwendung wird dieser gecachte Verweis zurückgegeben und das Lazy Import-Objekt im Dictionary durch den aufgelösten Wert ersetzt.
Da dies alles intern von der Dictionary-Implementierung gehandhabt wird, können Lazy Import-Objekte niemals aus dem Modul-Namespace ausbrechen und für Python-Code sichtbar werden; sie werden immer bei ihrer ersten Referenz aufgelöst. Keine Stub-, Dummy- oder Thunk-Objekte sind jemals für Python-Code sichtbar oder in sys.modules platziert. Wenn ein Modul lazy importiert wird, erscheint bis zu seiner tatsächlichen Importierung beim ersten Zugriff überhaupt kein Eintrag dafür in sys.modules.
Wenn zwei verschiedene Module (moda und modb) beide einen Lazy import foo enthalten, hat das Namespace-Dictionary jedes Moduls ein unabhängiges Lazy Import-Objekt unter dem Schlüssel "foo", das den Import desselben foo-Moduls verzögert. Dies ist kein Problem. Wenn es zuerst eine Referenz auf z. B. moda.foo gibt, wird das Modul foo wie üblich importiert und in sys.modules platziert, und das Lazy-Objekt unter dem Schlüssel moda.__dict__["foo"] wird durch das tatsächliche Modul foo ersetzt. An diesem Punkt bleibt modb.__dict__["foo"] ein Lazy Import-Objekt. Wenn später auf modb.foo zugegriffen wird, wird ebenfalls versucht, foo zu importieren. Dieser Import findet das Modul bereits in sys.modules vor, wie es bei nachfolgenden Imports desselben Moduls in Python normal ist, und zu diesem Zeitpunkt wird das Lazy Import-Objekt bei modb.__dict__["foo"] durch das tatsächliche Modul foo ersetzt.
Es gibt zwei Fälle, in denen ein Lazy Import-Objekt ein Dictionary verlassen kann
- In ein anderes Dictionary: Um die Leistung von Bulk-Kopieroperationen wie
dict.update()unddict.copy()aufrechtzuerhalten, prüfen diese nicht auf Lazy Import-Objekte und lösen sie nicht auf. Wenn das Quell-Dict jedoch das Flagdk_lazy_importsgesetzt hat, das anzeigt, dass es Lazy-Objekte enthalten kann, wird dieses Flag an das aktualisierte/kopierte Dictionary weitergegeben. Dies stellt immer noch sicher, dass das Lazy Import-Objekt nicht ohne Auflösung in Python-Code ausbrechen kann. - Über den Garbage Collector: Lazy importierte Objekte sind immer noch Python-Objekte und leben im Garbage Collector; als solche können sie gesammelt und z. B. über
gc.get_objects()eingesehen werden. Wenn ein Lazy-Objekt auf diese Weise für Python-Code sichtbar wird, ist es opak und inert; es hat keine nützlichen Methoden oder Attribute. Einrepr()davon würde so aussehen:<lazy_object 'fully.qualified.name'>.
Wenn ein Lazy-Objekt einem Dictionary hinzugefügt wird, wird das Flag dk_lazy_imports gesetzt. Sobald es gesetzt ist, wird das Flag nur gelöscht, wenn *alle* Lazy Import-Objekte im Dictionary aufgelöst sind, z. B. vor der Dictionary-Iteration.
Alle Dictionary-Iterationsmethoden, die Werte beinhalten (wie dict.items(), dict.values(), PyDict_Next() usw.), versuchen, *alle* Lazy Import-Objekte im Dictionary aufzulösen, bevor die Iteration beginnt. Da nur (einige) Modul-Namespace-Dictionaries dk_lazy_imports gesetzt haben werden, werden die zusätzlichen Overheads der Auflösung aller Lazy Import-Objekte innerhalb eines Dictionaries nur von denjenigen Dictionaries getragen, die sie benötigen. Die Minimierung des Overheads bei normalen, nicht-lazy Dictionaries ist der alleinige Zweck des Flags dk_lazy_imports.
PyDict_Next versucht, alle Lazy Import-Objekte beim ersten Zugriff auf Position 0 aufzulösen, und diese Imports könnten mit Ausnahmen fehlschlagen. Da PyDict_Next keine Ausnahme setzen kann, gibt PyDict_Next in diesem Fall sofort 0 zurück, und jede Ausnahme wird als nicht aufhebbare Ausnahme auf stderr ausgegeben.
Aus diesem Grund führt dieses PEP PyDict_NextWithError ein, das sich genauso verhält wie PyDict_Next, aber einen Fehler setzen kann, wenn es 0 zurückgibt, und dies sollte nach dem Aufruf über PyErr_Occurred() überprüft werden.
Die Eager-Natur von Imports innerhalb von try / except / with Blöcken oder innerhalb von Klassen- oder Funktionsdefinitionen wird im Compiler über einen neuen EAGER_IMPORT_NAME Opcode gehandhabt, der immer eager importiert. Top-Level-Imports verwenden IMPORT_NAME, was je nach -L und/oder importlib.set_lazy_imports() lazy oder eager sein kann.
Debugging
Debug-Protokollierung von python -v wird protokolliert, wann immer eine Importanweisung angetroffen wurde, aber die Ausführung des Imports aufgeschoben wird.
Pythons Funktion -X importtime zur Profilerstellung von Importkosten passt sich natürlich an Lazy Imports an; die profilierte Zeit ist die Zeit, die tatsächlich für den Import aufgewendet wird.
Obwohl Lazy Import-Objekte im Allgemeinen nicht für Python-Code sichtbar sind, kann es in einigen Debugging-Fällen nützlich sein, von Python-Code aus zu überprüfen, ob der Wert an einem bestimmten Schlüssel in einem bestimmten Dictionary ein Lazy Import-Objekt ist, ohne dessen Auflösung auszulösen. Zu diesem Zweck kann importlib.is_lazy_import() verwendet werden
from importlib import is_lazy_import
import foo
is_lazy_import(globals(), "foo")
foo
is_lazy_import(globals(), "foo")
In diesem Beispiel gibt, wenn Lazy Imports aktiviert wurden, der erste Aufruf von is_lazy_import True zurück und der zweite False.
Pro-Modul-Opt-Out
Aufgrund der unten genannten Rückwärtskompatibilitätsprobleme kann es für eine Anwendung, die Lazy Imports verwendet, notwendig sein, bestimmte Imports als eager zu erzwingen.
Im eigenen Code, da Imports innerhalb eines try- oder with-Blocks niemals lazy sind, kann dies einfach erreicht werden
try: # force these imports to be eager
import foo
import bar
finally:
pass
Dieses PEP schlägt die Hinzufügung eines neuen Kontextmanagers importlib.eager_imports() vor, damit die obige Technik weniger umständlich ist und keine Kommentare zur Verdeutlichung der Absicht benötigt.
from importlib import eager_imports
with eager_imports():
import foo
import bar
Da Imports innerhalb von Kontextmanagern immer eager sind, kann der eager_imports() Kontextmanager einfach ein Alias für einen Null-Kontextmanager sein. Die Wirkung des Kontextmanagers ist nicht transitiv: foo und bar werden eager importiert, aber Imports innerhalb dieser Module folgen weiterhin den üblichen Lazy-Regeln.
Der schwierigere Fall kann auftreten, wenn ein Import in Code von Drittanbietern, der nicht einfach geändert werden kann, als eager erzwungen werden muss. Zu diesem Zweck nimmt importlib.set_lazy_imports() ein zweites optionales, nur-keyword-Argument excluding entgegen, das auf einen Container von Modulnamen gesetzt werden kann, innerhalb dessen alle Imports eager sind
from importlib import set_lazy_imports
set_lazy_imports(excluding=["one.mod", "another"])
Die Auswirkung hiervon ist ebenfalls oberflächlich: Alle Imports innerhalb von one.mod sind eager, aber nicht Imports in allen Modulen, die von one.mod importiert werden.
Der Parameter excluding von set_lazy_imports() kann ein Container beliebiger Art sein, der daraufhin überprüft wird, ob er einen Modulnamen enthält. Wenn der Modulname im Objekt enthalten ist, werden Imports darin eager sein. Daher kann beliebige Opt-out-Logik in einer __contains__ Methode kodiert werden
import re
from importlib import set_lazy_imports
class Checker:
def __contains__(self, name):
return re.match(r"foo\.[^.]+\.logger", name)
set_lazy_imports(excluding=Checker())
Wenn Python mit der Flagge -L ausgeführt wurde, sind Lazy Imports bereits global aktiviert, und die einzige Auswirkung des Aufrufs von set_lazy_imports(True, excluding=...) ist das globale Setzen der eager Modulnamen/Callback. Wenn set_lazy_imports(True) ohne das excluding Argument aufgerufen wird, wird die Ausschlussliste/Callback gelöscht und alle geeigneten Imports (Modul-Level-Imports, die nicht in try/except/with sind und keine import * sind) sind von diesem Zeitpunkt an lazy.
Dieses Opt-out-System ist so konzipiert, dass die Möglichkeit einer lokalen Begründung über die Lazy-Natur eines Imports erhalten bleibt. Sie müssen nur den Code eines Moduls und das excluding Argument zu set_lazy_imports, falls vorhanden, sehen, um zu wissen, ob ein gegebener Import eager oder lazy sein wird.
Testen
Die CPython-Testsuite wird mit aktivierten Lazy Imports bestanden (mit einigen übersprungenen Tests). Ein Buildbot sollte die Testsuite mit aktivierten Lazy Imports ausführen.
C API
Für Autoren von C-Erweiterungsmodulen ist die vorgeschlagene öffentliche C-API wie folgt
| C API | Python API |
|---|---|
PyObject *PyImport_SetLazyImports(PyObject *enabled, PyObject *excluding) |
importlib.set_lazy_imports(enabled: bool = True, *, excluding: typing.Container[str] | None = None) |
int PyDict_IsLazyImport(PyObject *dict, PyObject *name) |
importlib.is_lazy_import(dict: typing.Dict[str, object], name: str) -> bool |
int PyImport_IsLazyImportsEnabled() |
importlib.is_lazy_imports_enabled() -> bool |
void PyDict_ResolveLazyImports(PyObject *dict) |
|
PyDict_NextWithError() |
void PyDict_ResolveLazyImports(PyObject *dict)löst alle Lazy-Objekte in einem Dictionary auf, falls vorhanden. Muss vor dem Aufruf vonPyDict_NextWithError()oderPyDict_Next()verwendet werden.PyDict_NextWithError()funktioniert genauso wiePyDict_Next(), mit der Ausnahme, dass alle Fehler an den Aufrufer weitergegeben werden, indem0zurückgegeben und eine Ausnahme gesetzt wird. Der Aufrufer solltePyErr_Occurred()verwenden, um auf Fehler zu prüfen.
Abwärtskompatibilität
Dieser Vorschlag bewahrt die vollständige Abwärtskompatibilität, wenn die Funktion deaktiviert ist, was der Standard ist.
Auch wenn sie aktiviert ist, wird die meiste Software normal weiterlaufen, ohne beobachtbare Änderungen (außer verbesserter Startzeit und geringerem Speicherverbrauch). Namensraum-Pakete sind nicht betroffen: Sie funktionieren wie bisher, nur eben verzögert.
In einigen bestehenden Codes können verzögerte Importe aktuell unerwartete Ergebnisse und Verhaltensweisen hervorrufen. Probleme, die beim Aktivieren von verzögerten Imports in einer bestehenden Codebasis auftreten können, beziehen sich auf
Import-Nebeneffekte
Import-Nebenwirkungen, die sonst durch die Ausführung importierter Module während der Ausführung von Import-Anweisungen erzeugt würden, werden aufgeschoben, bis auf die importierten Objekte zugegriffen wird.
Diese Import-Nebenwirkungen können umfassen
- Code, der während des Imports nebenwirkungsbehaftete Logik ausführt;
- Abhängigkeit von importierten Untermodulen, die als Attribute im übergeordneten Modul gesetzt sind.
Ein relevanter und typischer betroffener Fall ist die click-Bibliothek zum Erstellen von Python-Kommandozeilen-Schnittstellen. Wenn z.B. cli = click.group() in main.py definiert ist und sub.py cli aus main importiert und ihm über einen Dekorator Unterbefehle hinzufügt (@cli.command(...)), der eigentliche cli()-Aufruf aber in main.py steht, können verzögerte Imports verhindern, dass die Unterbefehle registriert werden, da in diesem Fall Click von Nebenwirkungen des Imports von sub.py abhängt. In diesem Fall besteht die Lösung darin, sicherzustellen, dass der Import von sub.py sofort erfolgt, z.B. durch die Verwendung des Kontextmanagers importlib.eager_imports().
Dynamische Pfade
Es könnte Probleme mit dynamischen Python-Importpfaden geben; insbesondere das Hinzufügen (und anschließende Entfernen nach dem Import) von Pfaden aus sys.path
sys.path.insert(0, "/path/to/foo/module")
import foo
del sys.path[0]
foo.Bar()
In diesem Fall, wenn verzögerte Imports aktiviert sind, erfolgt der Import von foo nicht tatsächlich, während die Ergänzung zu sys.path vorhanden ist.
Eine einfache Lösung dafür (die auch den Code-Stil verbessert und die Bereinigung sicherstellt) wäre, die sys.path-Modifikationen in einen Kontextmanager zu legen. Dies löst das Problem, da Imports innerhalb eines with-Blocks immer sofort erfolgen.
Aufgeschobene Ausnahmen
Ausnahmen, die während eines verzögerten Imports auftreten, blubbern nach oben und löschen die teilweise erstellten Module aus sys.modules, genau wie Ausnahmen während eines normalen Imports.
Da Fehler, die während eines verzögerten Imports ausgelöst werden, später auftreten als bei einem sofortigen Import (d.h. überall dort, wo auf den Namen zuerst zugegriffen wird), ist es auch möglich, dass sie versehentlich von Ausnahmehandhabern abgefangen werden, die nicht damit rechneten, dass der Import innerhalb ihres try-Blocks ausgeführt wird, was zu Verwirrung führt.
Nachteile
Nachteile dieses PEPs sind
- Es bietet eine subtil inkompatible Semantik für das Verhalten von Python-Imports. Dies ist eine potenzielle Belastung für Bibliotheksautoren, die von ihren Benutzern möglicherweise gebeten werden, beide Semantiken zu unterstützen, und eine weitere Möglichkeit, die Python-Benutzer/Leser beachten müssen.
- Einige beliebte Python-Codierungsmuster (insbesondere zentralisierte Registries, die von einem Dekorator gefüllt werden) beruhen auf Import-Nebenwirkungen und erfordern möglicherweise eine explizite Opt-out-Option, um wie erwartet mit verzögerten Imports zu funktionieren.
- Ausnahmen können jederzeit beim Zugriff auf Namen, die verzögerte Imports darstellen, ausgelöst werden, was zu Verwirrung und zur Fehlersuche bei unerwarteten Ausnahmen führen kann.
Verzögerte Importsemantik ist bereits heute in der Python-Standardbibliothek möglich und wird sogar unterstützt, sodass diese Nachteile durch dieses PEP nicht neu eingeführt werden. Bisher hat die bestehende Nutzung von verzögerten Imports durch einige Anwendungen kein Problem dargestellt. Dieses PEP könnte jedoch die Nutzung von verzögerten Imports populärer machen und diese Nachteile potenziell verschärfen.
Diese Nachteile müssen gegen die erheblichen Vorteile abgewogen werden, die die Implementierung von verzögerten Imports durch dieses PEP bietet. Letztendlich werden diese Kosten höher sein, wenn die Funktion weit verbreitet ist; aber eine weite Verbreitung deutet auch darauf hin, dass die Funktion viel Wert bietet, was die Kosten vielleicht rechtfertigt.
Sicherheitsimplikationen
Die verzögerte Ausführung von Code könnte Sicherheitsbedenken hervorrufen, wenn sich der Prozessbesitzer, der Shell-Pfad, sys.path oder andere sensible Umgebungs- oder Kontextzustände zwischen der Ausführung der import-Anweisung und dem ersten Verweis auf das importierte Objekt ändern.
Leistungsauswirkungen
Die Referenzimplementierung hat gezeigt, dass die Funktion nur geringe Auswirkungen auf die Leistung bestehender realer Codebasen hat (Instagram Server, mehrere CLI-Programme bei Meta, Jupyter Notebooks, die von Meta-Forschern verwendet werden), während sie erhebliche Verbesserungen bei der Startzeit und dem Speicherverbrauch bietet.
Die Referenzimplementierung zeigt keine messbare Veränderung der Gesamtleistung auf der pyperformance Benchmark-Suite.
Wie man das lehrt
Da die Funktion opt-in ist, sollten Anfänger sie nicht standardmäßig antreffen. Die Dokumentation des -L-Flags und von importlib.set_lazy_imports() kann das Verhalten von verzögerten Imports verdeutlichen.
Die Dokumentation sollte auch verdeutlichen, dass die Wahl für verzögerte Imports eine Nicht-Standard-Semantik für Python-Imports bedeutet, die dazu führen kann, dass Python-Bibliotheken unerwartet fehlschlagen. Die Verantwortung, diese Fehler zu identifizieren und zu umgehen (oder die Nutzung von verzögerten Imports einzustellen), liegt vollständig bei der Person, die sich entscheidet, verzögerte Imports für ihre Anwendung zu aktivieren, nicht beim Bibliotheksautor. Python-Bibliotheken sind nicht verpflichtet, verzögerte Importsemantiken zu unterstützen. Das höfliche Melden einer Inkompatibilität kann für den Bibliotheksautor nützlich sein, aber er kann auch einfach sagen, dass seine Bibliothek die Verwendung mit verzögerten Imports nicht unterstützt, und dies ist eine gültige Wahl.
Einige bewährte Vorgehensweisen, um einige der möglichen Probleme zu bewältigen und verzögerte Imports besser zu nutzen, sind
- Vermeiden Sie die Abhängigkeit von Import-Nebenwirkungen. Die wohl häufigste Abhängigkeit von Import-Nebenwirkungen ist das Registrierungs-Muster, bei dem die Füllung einer externen Registrierung implizit während des Imports von Modulen erfolgt, oft über Dekoratoren. Stattdessen sollte die Registrierung über einen expliziten Aufruf aufgebaut werden, der einen Erkennungsprozess durchführt, um dekorierte Funktionen oder Klassen in explizit benannten Modulen zu finden.
- Importieren Sie immer benötigte Untermodule explizit und verlassen Sie sich nicht darauf, dass ein anderer Import sicherstellt, dass ein Modul seine Untermodule als Attribute hat. Das heißt, wenn es keinen expliziten
from . import barinfoo/__init__.pygibt, machen Sie immerimport foo.bar; foo.bar.Bazund nichtimport foo; foo.bar.Baz. Letzteres funktioniert (unzuverlässig) nur, weil das Attributfoo.barals Nebenwirkung des Imports vonfoo.barirgendwo anders hinzugefügt wird. Mit verzögerten Imports geschieht dies möglicherweise nicht immer rechtzeitig. - Vermeiden Sie Sternchen-Importe, da diese immer sofort erfolgen.
Referenzimplementierung
Die anfängliche Implementierung ist als Teil von Cinder verfügbar. Diese Referenzimplementierung wird innerhalb von Meta verwendet und hat nachweislich Verbesserungen der Startzeit (und der Gesamtlaufzeit für einige Anwendungen) im Bereich von 40 % - 70 % erzielt, sowie eine erhebliche Reduzierung des Speicher-Footprints (bis zu 40 %), dank der Tatsache, dass Imports, die im üblichen Ablauf nicht verwendet werden, nicht ausgeführt werden müssen.
Eine aktualisierte Referenzimplementierung, basierend auf dem CPython-Main-Branch, ist ebenfalls verfügbar.
Abgelehnte Ideen
Umschließen von aufgeschobenen Ausnahmen
Um die Verwirrung zu verringern, könnten Ausnahmen, die bei der Ausführung eines verzögerten Imports auftreten, durch eine LazyImportError-Ausnahme (eine Unterklasse von ImportError) ersetzt werden, mit einem __cause__, der auf die ursprüngliche Ausnahme gesetzt ist.
Die Sicherstellung, dass alle Fehler bei verzögerten Imports als LazyImportError ausgelöst werden, würde die Wahrscheinlichkeit verringern, dass sie versehentlich abgefangen und mit einer anderen erwarteten Ausnahme verwechselt werden. In der Praxis gab es jedoch Fälle, z.B. innerhalb von Tests, wo fehlerhafte Module die Ausnahme unittest.SkipTest auslösen und dies ebenfalls in LazyImportError gewickelt würde, was dazu führt, dass solche Tests fehlschlagen, da der eigentliche Ausnahmetyp verborgen ist. Die Nachteile scheinen hier den hypothetischen Fall zu überwiegen, dass unerwartete verzögerte Ausnahmen versehentlich abgefangen werden.
Pro-Modul-Opt-In
Ein pro-Modul-Opt-in mittels zukünftiger Imports (d.h. from __future__ import lazy_imports) ist nicht sinnvoll, da __future__-Imports keine Feature-Flags sind, sondern für den Übergang zu Verhaltensweisen, die in Zukunft zum Standard werden. Es ist nicht klar, ob verzögerte Imports jemals als Standardverhalten sinnvoll sein werden, daher sollten wir dies nicht mit einem __future__-Import versprechen.
Es gibt andere Fälle, in denen eine Bibliothek lokal verzögerte Imports für ein bestimmtes Modul aktivieren möchte; z.B. ein verzögertes Top-Level- __init__.py für eine große Bibliothek, um ihre Unterkomponenten als verzögerte Attribute zugänglich zu machen. Vorerst, um die Funktion einfacher zu halten, konzentriert sich dieses PEP auf den Anwendungsfall "Anwendung" und befasst sich nicht mit dem Anwendungsfall Bibliothek. Der zugrunde liegende Mechanismus der Verzögerung, der in diesem PEP eingeführt wird, könnte in Zukunft auch zur Adressierung dieses Anwendungsfalls verwendet werden.
Explizite Syntax für einzelne Lazy-Imports
Wenn das Hauptziel von verzögerten Imports ausschließlich darin bestünde, Importzyklen und Vorwärtsreferenzen zu umgehen, wäre eine explizit gekennzeichnete Syntax für bestimmte gezielte Imports, die verzögert erfolgen sollen, sehr sinnvoll. Aber in der Praxis wäre es sehr schwierig, mit diesem Ansatz robuste Startzeit- oder Speichernutzungsvorteile zu erzielen, da dies erfordern würde, die meisten Imports in Ihrer Codebasis (und in Drittanbieterabhängigkeiten) zu konvertieren, um die Syntax für verzögerte Imports zu verwenden.
Es wäre möglich, eine "flache" Verzögerung anzustreben, bei der nur die Top-Level-Imports von Teilsystemen aus dem Hauptmodul explizit verzögert werden, während Imports innerhalb der Teilsysteme alle sofort erfolgen. Dies ist jedoch extrem fragil – es bedarf nur eines falsch platzierten Imports, um die sorgfältig konstruierte flache Verzögerung zunichte zu machen. Das globale Aktivieren von verzögerten Imports hingegen bietet eine tiefgreifende, robuste Verzögerung, bei der Sie immer nur für die genutzten Imports bezahlen.
Es mag Anwendungsfälle geben (z.B. für statisches Typisieren), bei denen einzeln markierte verzögerte Imports wünschenswert sind, um Vorwärtsreferenzen zu vermeiden, aber die Leistungs-/Speichervorteile von global verzögerten Imports nicht benötigt werden. Da dies ein anderer Satz von motivierenden Anwendungsfällen ist und eine neue Syntax erfordert, ziehen wir es vor, dies nicht in dieses PEP aufzunehmen. Ein anderes PEP könnte auf dieser Implementierung aufbauen und die zusätzliche Syntax vorschlagen.
Umgebungsvariable zur Aktivierung von Lazy-Imports
Die Bereitstellung eines Opt-in über Umgebungsvariablen begünstigt den Missbrauch der Funktion zu leicht. Es mag für einen Python-Benutzer verlockend sein, beispielsweise die Umgebungsvariable global in seiner Shell zu setzen, in der Hoffnung, alle von ihm ausgeführten Python-Programme zu beschleunigen. Diese Nutzung mit ungetesteten Programmen führt wahrscheinlich zu unbegründeten Fehlermeldungen und einer Wartungsbelastung für die Autoren dieser Tools. Um dies zu vermeiden, entscheiden wir uns dafür, überhaupt keine Umgebungsvariablen-Opt-in bereitzustellen.
Entfernen der -L Flagge
Wir stellen das -L CLI-Flag bereit, das theoretisch auf ähnliche Weise missbraucht werden könnte, indem ein Endbenutzer ein einzelnes Python-Programm ausführt, das mit python somescript.py oder python -m somescript ausgeführt wird (im Gegensatz zur Verteilung über Python-Paketierungswerkzeuge). Aber der potenzielle Missbrauchsumfang ist mit -L wesentlich geringer als mit einer Umgebungsvariable, und -L ist für einige Anwendungen wertvoll, um die Startzeitvorteile zu maximieren, indem sichergestellt wird, dass alle Imports vom Beginn eines Prozesses an verzögert werden. Daher entscheiden wir uns dafür, ihn beizubehalten.
Es ist bereits der Fall, dass die Ausführung von beliebigen Python-Programmen mit Kommandozeilen-Flags, für die sie nicht bestimmt sind (z.B. -s, -S, -E oder -I), unerwartete und fehlerhafte Ergebnisse haben kann. -L ist in dieser Hinsicht nichts Neues.
Halb-Lazy-Imports
Es wäre möglich, den Import-Loader bis zum Auffinden des Modulquellcodes eilig auszuführen, die tatsächliche Ausführung des Moduls und die Erstellung des Modulobjekts dann aber aufzuschieben. Der Vorteil wäre, dass bestimmte Klassen von Importfehlern (z.B. ein einfacher Tippfehler im Modulnamen) eilig abgefangen würden, anstatt auf die Verwendung eines importierten Namens verschoben zu werden.
Der Nachteil wäre, dass die Vorteile von verzögerten Imports bei der Startzeit erheblich reduziert würden, da ungenutzte Imports zumindest einen stat()-Aufruf auf dem Dateisystem erfordern würden. Es würde auch eine möglicherweise nicht offensichtliche Trennung zwischen *welchen* Importfehlern eilig und *welchen* verzögert ausgelöst werden, wenn verzögerte Imports aktiviert sind.
Diese Idee wird derzeit abgelehnt, da in der Praxis Verwirrung über Tippfehler bei Imports kein beobachtetes Problem bei der Referenzimplementierung war. Im Allgemeinen sind verzögerte Imports nicht für immer verzögert, und Fehler treten bald genug auf, um abgefangen und behoben zu werden (es sei denn, der Import ist wirklich ungenutzt).
Eine weitere mögliche Motivation für halb-verzögerte Imports wäre, Modulen selbst über ein Flag zu ermöglichen, ob sie verzögert oder sofort importiert werden. Dies wird sowohl abgelehnt, da es halb-verzögerte Imports erfordert und einige der Leistungsvorteile der Import-Verzögerung aufgibt, als auch weil Module im Allgemeinen nicht entscheiden, wie oder wann sie importiert werden, sondern das importierende Modul entscheidet dies. Es gibt keine klare Begründung dafür, dass dieses PEP diese Kontrolle umkehren soll; stattdessen bietet es nur mehr Optionen für den importierenden Code, die Entscheidung zu treffen.
Lazy dynamische Imports
Es wäre möglich, eine lazy=True oder eine ähnliche Option zu __import__() und/oder importlib.import_module() hinzuzufügen, um ihnen die Durchführung von verzögerten Imports zu ermöglichen. Diese Idee wird in diesem PEP aus Mangel an einem klaren Anwendungsfall abgelehnt. Dynamische Imports liegen bereits weit außerhalb der PEP 8 Code-Stil-Empfehlungen für Imports und können leicht genauso verzögert gemacht werden, wie gewünscht, indem sie an der gewünschten Stelle im Codefluss platziert werden. Diese werden nicht häufig auf Modulebene verwendet, wo verzögerte Imports angewendet werden.
Deep eager-imports-Überschreibung
Der vorgeschlagene Kontextmanager importlib.eager_imports() und die ausgeschlossenen Module in importlib.set_lazy_imports(excluding=...) überschreiben beide haben oberflächliche Effekte: sie erzwingen nur die sofortige Ausführung für den Ort, an dem sie angewendet werden, nicht transitiv. Es wäre möglich, eine tiefe/transitive Version eines oder beider bereitzustellen. Diese Idee wird in diesem PEP abgelehnt, da die Implementierung komplex wäre (unter Berücksichtigung von Threads und asynchronem Code), die Erfahrung mit der Referenzimplementierung gezeigt hat, dass sie nicht notwendig ist, und weil sie eine lokale Argumentation über die Verzögerung von Imports verhindert.
Ein tiefer Override kann zu verwirrendem Verhalten führen, da die transitiv importierten Module aus mehreren Orten importiert werden können, von denen einige den "tiefen sofortigen Override" verwenden und andere nicht. Somit können diese Module immer noch verzögert initial importiert werden, wenn sie zuerst aus einem Ort importiert werden, der den Override nicht hat.
Mit tiefen Overrides ist es nicht möglich, lokal zu argumentieren, ob ein bestimmter Import verzögert oder sofort erfolgt. Mit dem in diesem PEP spezifizierten Verhalten ist eine solche lokale Argumentation möglich.
Lazy Imports als Standardverhalten machen
Die verzögerten Imports zur Standard-/Alleinfunktion von Python-Imports zu machen, anstatt als Opt-in, hätte langfristige Vorteile, da Bibliotheksautoren (eventuell) nicht mehr die Möglichkeit beider Semantiken berücksichtigen müssten.
Die Rückwärtsinkompatibilitäten sind jedoch so, dass dies nur über einen langen Zeitraum mit einem __future__-Import in Betracht gezogen werden könnte. Es ist überhaupt nicht klar, ob verzögerte Imports zur Standard-Importsemantik für Python werden sollten.
Dieses PEP vertritt die Position, dass die Python-Community mehr Erfahrung mit verzögerten Imports sammeln muss, bevor sie in Betracht gezogen wird, sie zum Standardverhalten zu machen, daher wird dies vollständig einem möglichen zukünftigen PEP überlassen.
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-0690.rst
Zuletzt geändert: 2025-02-01 08:55:40 GMT