Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 690 – 延迟导入

作者:
Germán Méndez Bravo <german.mb at gmail.com>, Carl Meyer <carl at oddbird.net>
发起人:
Barry Warsaw <barry at python.org>
讨论至:
Discourse 帖子
状态:
已拒绝
类型:
标准跟踪
创建日期:
2022年4月29日
Python 版本:
3.12
发布历史:
2022年5月3日, 2022年5月3日
决议:
Discourse 消息

目录

摘要

本 PEP 提出了一项功能,可透明地将导入模块的查找和执行推迟到首次使用导入的对象的那一刻。由于 Python 程序通常导入比程序单次调用实际使用的模块多得多的模块,因此延迟导入可以大大减少加载模块的总数,从而提高启动时间和内存使用率。延迟导入也基本消除了导入循环的风险。

动机

常见的 Python 代码风格 偏好 在模块级别进行导入,这样就不必在导入对象使用的每个范围内重复导入,并避免在运行时重复执行导入系统的低效率。这意味着导入程序的 main 模块通常会导致对程序可能需要的大部分或所有模块进行级联导入。

考虑一个具有多个子命令的 Python 命令行程序 (CLI) 的示例。每个子命令可能执行不同的任务,需要导入不同的依赖项。但程序的给定调用只会执行单个子命令,或者可能根本不执行(即,如果仅请求 --help 使用信息)。此类程序中的顶级强制导入将导致导入许多根本不会使用的模块;花费在(可能编译和)执行这些模块上的时间纯属浪费。

为了提高启动时间,一些大型 Python CLI 通过手动将导入语句内联到函数中来延迟导入昂贵的子系统,从而实现延迟导入。这种手动方法劳动密集且脆弱;一个放错位置的导入或重构很容易使细致的优化工作付诸东流。

Python 标准库已通过 importlib.util.LazyLoader 内置支持延迟导入。还有第三方包,例如 demandimport。这些包提供了一个“延迟模块对象”,该对象将自己的导入推迟到第一次属性访问。这不足以使所有导入都延迟:像 from foo import a, b 这样的导入仍然会强制导入 foo 模块,因为它们会立即访问其中的属性。它还对每次模块属性访问都施加了显著的运行时开销,因为它需要一个 Python 级别的 __getattr____getattribute__ 实现。

科学 Python 包的作者也广泛使用了延迟导入,以便用户可以编写例如 import scipy as sp,然后轻松访问许多不同的子模块,例如 sp.linalg,而无需预先导入所有许多子模块。SPEC 1lazy_loader 库的形式规范了这种做法,该库可以在包的 __init__.py 中显式使用,以提供延迟可访问的子模块。

静态类型用户还需要导入永远不会在运行时使用(如果使用 PEP 563 或可能在将来的 PEP 649 中避免对注解进行运行时强制评估)的名称用于类型注解。在这种情况下,延迟导入非常有吸引力,可以避免不必要导入的开销。

本 PEP 提出了一种更通用、更全面的延迟导入解决方案,可以涵盖以上所有用例,并且在实际使用中不会产生可检测的开销。本 PEP 中的实现已在现实世界的 Python CLI演示了高达 70% 的启动时间改进和高达 40% 的内存使用量减少。

延迟导入也消除了大多数导入循环。使用强制导入时,很容易出现“假循环”,只需将导入移到模块底部或函数内联,或者从 from foo import bar 切换到 import foo 即可解决。使用延迟导入时,这些“循环”会正常工作。唯一仍然存在的循环是两个模块实际上都使用对方的某个名称(在模块级别);这些“真实”循环只能通过重构涉及的类或函数来解决。

基本原理

此功能旨在使导入透明地延迟。“延迟”意味着模块的导入(执行模块体并将模块对象添加到 sys.modules)不应发生,直到模块(或从其中导入的名称)在执行期间实际被引用。“透明”意味着除了延迟导入(以及因此产生的可观察效果,例如延迟导入副作用和 sys.modules 的更改)之外,没有其他可观察的行为变化:导入的对象在模块命名空间中正常存在,并且在首次使用时透明加载:其“延迟导入对象”的身份无法从 Python 或 C 扩展代码直接观察到。

导入的对象即使在实际导入之前也必须在模块命名空间中正常存在的要求,意味着我们需要某种“延迟对象”占位符来表示尚未导入的对象。透明度要求规定,此占位符永远不能对 Python 代码可见;任何对其的引用都必须触发导入并将其替换为真实的导入对象。

考虑到 Python(或 C 扩展)代码可能直接从模块 __dict__ 中提取对象的可能性,唯一可靠地防止延迟对象意外泄露的方法是让字典本身负责确保查找时延迟对象的解析。

当查找发现键引用延迟对象时,它会在返回之前立即解析延迟对象。为避免在迭代过程中副作用修改字典,在开始迭代之前会解析字典中的所有延迟对象;这在使用批量迭代时(iter(dict)reversed(dict)dict.__reversed__()dict.keys()iter(dict.keys())reversed(dict.keys()))可能会产生性能损失。为了避免对绝大多数从不包含任何延迟对象的字典产生此性能损失,我们从 dk_kind 字段中窃取一个位,用于一个新的 dk_lazy_imports 标志,以跟踪字典是否可能包含延迟对象。

此实现全面防止了延迟对象的泄露,确保它们在任何人获取它们以供任何使用之前始终解析为真实的导入对象,同时避免了对普通字典的任何显著性能影响。

规范

延迟导入是选择加入的,可以通过 Python 解释器的 -L 标志,或者通过调用新的 importlib.set_lazy_imports() 函数来全局启用。此函数接受两个参数,一个布尔值 enabled 和一个 excluding 容器。如果 enabled 为 true,则从该点开始将启用延迟导入。如果为 false,则从该点开始将其禁用。(excluding 关键字的使用将在下文“按模块选择退出”中讨论。)

-L 标志传递给 Python 解释器时,一个新的 sys.flags.lazy_imports 被设置为 True,否则它存在为 False。此标志用于将 -L 传播到新的 Python 子进程。

sys.flags.lazy_imports 中的标志并不一定反映延迟导入的当前状态,只反映解释器是否以 -L 选项启动。实际上,是否启用延迟导入的当前状态可以使用 importlib.is_lazy_imports_enabled() 检索,该函数将在调用点返回 True(如果启用了延迟导入)或 False

当启用延迟导入时,所有(仅)顶级导入的加载和执行将推迟到导入的名称首次被使用。这可能发生在立即(例如,在导入语句后的下一行)或稍后(例如,当在由其他代码在稍后调用的函数中使用该名称时)。

对于这些顶级导入,有两种情况会使其成为强制导入(非延迟):try / except / finallywith 块内的导入,以及星形导入(from foo import *)。异常处理块内的导入(这包括 with 块,因为它们也可以“捕获”并处理异常)仍然是强制的,这样就可以处理由导入引起的任何异常。星形导入必须是强制的,因为执行导入是知道哪些名称应添加到命名空间唯一的方法。

类定义内部或函数/方法内部的导入不是“顶级的”,并且永远不会是延迟的。

使用 __import__()importlib.import_module() 的动态导入也永远不会是延迟的。

延迟导入状态(即是否已启用,以及任何排除的模块;见下文)是每个解释器的,但在解释器内是全局的(即,所有线程都会受到影响)。

示例

假设我们有一个模块 spam.py

# simulate some work
import time
time.sleep(10)
print("spam loaded")

以及一个导入它的模块 eggs.py

import spam
print("imports done")

如果我们运行 python -L eggs.py,则 spam 模块永远不会被导入(因为它在导入后从未被引用),"spam loaded" 永远不会被打印,并且不会有 10 秒的延迟。

但是,如果 eggs.py 在导入后只是引用了 spam 名称,那足以触发 spam.py 的导入

import spam
print("imports done")
spam

现在如果我们运行 python -L eggs.py,我们将首先看到输出 "imports done",然后是 10 秒延迟,然后是 "spam loaded"

当然,在实际用例中(尤其是在使用延迟导入时),不建议依赖于导入副作用来触发实际工作。此示例仅用于阐明延迟导入的行为。

解释延迟导入效果的另一种方法是,这就像每个延迟导入语句都被写在源代码中,紧接在每个导入名称的使用之前一样。所以人们可以认为延迟导入类似于将此代码转换为

import foo

def func1():
    return foo.bar()

def func2():
    return foo.baz()

变成这个

def func1():
    import foo
    return foo.bar()

def func2():
    import foo
    return foo.baz()

这可以很好地说明在延迟导入下 foo 的导入何时发生,但延迟导入实际上并不等同于此代码转换。有几个值得注意的区别

  • 与后者的代码不同,在延迟导入下,foo 名称仍然存在于模块的全局命名空间中,并且可以被导入或被导入此模块的其他模块引用。(此类引用也将触发导入。)
  • 延迟导入的运行时开销远低于后者代码;在第一次引用 foo 名称(触发导入)后,后续引用将具有零导入系统开销;它们与普通名称引用无法区分。

从某种意义上说,延迟导入将导入语句变成了一个已导入名称或名称的声明,稍后将在引用时完全解析。

风格为 from foo import bar 的导入也可以延迟。当发生导入时,名称 bar 将作为延迟导入添加到模块命名空间中。第一次引用 bar 将导入 foo 并将 bar 解析为 foo.bar

预期用途

由于延迟导入是可能产生破坏性语义更改的,因此它们应该仅由 Python 应用程序的作者或维护者启用,他们准备彻底测试新语义下的应用程序,确保其行为符合预期,并根据需要选择退出任何特定的导入(见下文)。延迟导入不应由 Python 应用程序的最终用户推测性地启用,而不抱有成功的期望。

应用程序开发人员为其应用程序启用延迟导入的责任是选择退出任何最终需要强制导入才能正常工作的库导入;库作者不负责确保他们的库在延迟导入下表现完全相同。

该功能的文档,-L 标志,以及新的 importlib API 将清楚地说明预期的用途和采用风险。

实施

延迟导入在内部由一个“延迟导入”对象表示。当发生延迟导入时(例如 import foofrom foo import bar),键 "foo""bar" 会立即添加到模块命名空间字典中,但其值设置为一个仅内部使用的“延迟导入”对象,该对象保留了以后执行导入所需的所有元数据。

PyDictKeysObject 中的一个新布尔标志(dk_lazy_imports)被设置为信号,表明此特定字典可能包含延迟导入对象。此标志仅用于在字典可能包含延迟对象时,有效地“批量”解析所有延迟对象。

任何时候在字典中查找键以提取其值时,都会检查该值是否为延迟导入对象。如果是,延迟对象将被立即解析,相关的导入模块将被执行,延迟导入对象将被替换为字典中的实际导入值(如果可能),然后将解析后的值从查找函数返回。字典可能会在解析延迟导入对象的导入副作用期间发生变异。在这种情况下,无法有效地用解析的对象替换键值。在这种情况下,延迟导入对象将获得指向解析对象的缓存指针。在下次访问时,将返回缓存的引用,并且该延迟导入对象将被替换为字典中的解析值。

因为这一切都由字典实现内部处理,所以延迟导入对象永远不会从模块命名空间中泄露,从而对 Python 代码可见;它们总是在第一次引用时被解析。没有存根、 dummy 或 thunk 对象会暴露给 Python 代码或放置在 sys.modules 中。如果一个模块被延迟导入,在它第一次被引用并实际导入之前,它在 sys.modules 中不会有任何条目。

如果两个不同的模块(modamodb)都包含延迟的 import foo,则每个模块的命名空间字典将在键 "foo" 下有一个独立的延迟导入对象,从而延迟导入同一个 foo 模块。这不是问题。当第一次引用 moda.foo 时,foo 模块将被导入并像往常一样放入 sys.modules 中,并且 moda.__dict__["foo"] 下的延迟对象将被替换为实际的 foo 模块。此时 modb.__dict__["foo"] 仍将是一个延迟导入对象。当 modb.foo 稍后被引用时,它也将尝试 import foo。这个导入将在 sys.modules 中找到已经存在的模块,就像 Python 中后续导入同一个模块的正常情况一样,此时它将用实际的 foo 模块替换 modb.__dict__["foo"] 处的延迟导入对象。

在两种情况下,延迟导入对象可能会从字典中泄露

  • 到另一个字典:为了保持 dict.update()dict.copy() 等批量复制操作的性能,它们不会检查或解析延迟导入对象。但是,如果源字典设置了 dk_lazy_imports 标志,表示它可能包含延迟对象,那么该标志将传递给更新/复制的字典。这仍然确保延迟导入对象不会在未解析的情况下泄露到 Python 代码中。
  • 通过垃圾回收器:延迟导入的对象仍然是 Python 对象,存在于垃圾回收器中;因此,它们可以被收集并例如通过 gc.get_objects() 看到。如果延迟对象以这种方式对 Python 代码可见,它是不透明且惰性的;它没有有用的方法或属性。它的 repr() 将显示为类似:<lazy_object 'fully.qualified.name'>

当一个延迟对象被添加到字典时,dk_lazy_imports 标志会被设置。一旦设置,只有当字典中的 *所有* 延迟导入对象都被解析(例如,在字典迭代之前)时,该标志才会被清除。

涉及值的字典迭代方法(例如 dict.items()dict.values()PyDict_Next() 等)将在开始迭代之前尝试解析字典中的 *所有* 延迟导入对象。由于只有(某些)模块命名空间字典才会设置 dk_lazy_imports,因此解析字典中所有延迟导入对象的额外开销仅由需要它的字典承担。最小化普通非延迟字典的开销是 dk_lazy_imports 标志的唯一目的。

PyDict_Next 将在第一次访问位置 0 时尝试解析所有延迟导入对象,并且这些导入可能会因异常而失败。由于 PyDict_Next 在这种情况下无法设置异常,因此 PyDict_Next 将立即返回 0,并且任何异常都将打印到 stderr 作为无法引发的异常。

因此,本 PEP 引入了 PyDict_NextWithError,它的工作方式与 PyDict_Next 相同,但在返回 0 时可以设置错误,并且这应该通过调用后的 PyErr_Occurred() 来检查。

try / except / with 块内的导入以及类或函数体内的导入的强制性是通过编译器中的新 EAGER_IMPORT_NAME 操作码来处理的,该操作码始终强制导入。顶级导入使用 IMPORT_NAME,它可以是延迟的或强制的,具体取决于 -L 和/或 importlib.set_lazy_imports()

调试

来自 python -v 的调试日志将在遇到导入语句但导入的执行将被推迟时包含日志。

Python 的 -X importtime 功能用于分析导入成本,自然地适应延迟导入;分析的时间是实际花费在导入上的时间。

虽然延迟导入对象通常对 Python 代码不可见,但在某些调试情况下,从 Python 代码检查给定字典中给定键处的值是否为延迟导入对象可能很有用,而不会触发其解析。为此,可以使用 importlib.is_lazy_import()

from importlib import is_lazy_import

import foo

is_lazy_import(globals(), "foo")

foo

is_lazy_import(globals(), "foo")

在此示例中,如果延迟导入已启用,第一次调用 is_lazy_import 将返回 True,第二次将返回 False

按模块选择退出

由于下文提到的向后兼容性问题,应用程序在使用延迟导入时可能需要强制某些导入为强制导入。

在第一方代码中,由于 trywith 块内的导入永远不是延迟的,因此可以轻松实现

try:  # force these imports to be eager
    import foo
    import bar
finally:
    pass

本 PEP 建议添加一个新的 importlib.eager_imports() 上下文管理器,以便上述技术可以更简洁,并且不需要注释来阐明其意图

from importlib import eager_imports

with eager_imports():
    import foo
    import bar

由于上下文管理器中的导入总是强制的,eager_imports() 上下文管理器可以只是一个空上下文管理器的别名。上下文管理器的效果不是传递性的:foobar 将被强制导入,但这些模块内的导入将仍然遵循通常的延迟规则。

更困难的情况可能发生在无法轻松修改的第三方代码中的导入必须强制为强制导入时。为此,importlib.set_lazy_imports() 接受第二个可选的关键字参数 excluding,它可以设置为一个模块名称容器,其中所有导入都将是强制的

from importlib import set_lazy_imports

set_lazy_imports(excluding=["one.mod", "another"])

这也只是浅层影响:one.mod 内的所有导入都将是强制的,但 one.mod 导入的所有模块中的导入则不是。

set_lazy_imports()excluding 参数可以是任何将被检查是否包含模块名称的容器。如果模块名称包含在对象中,则其中的导入将是强制的。因此,可以通过 __contains__ 方法编码任意的 opt-out 逻辑

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())

如果 Python 是使用 -L 标志执行的,那么延迟导入将已经全局启用,并且调用 set_lazy_imports(True, excluding=...) 的唯一效果将是全局设置强制模块名称/回调。如果 set_lazy_imports(True) 被调用且没有 excluding 参数,则排除列表/回调将被清除,并且从那时起,所有符合条件的导入(模块级别的导入,不包含在 try/except/with 中,也不是 import *)都将是延迟的。

这个 opt-out 系统旨在保持对导入的惰性进行局部推理的可能性。您只需要查看一个模块的代码,以及 set_lazy_importsexcluding 参数(如果有),就可以知道给定的导入是强制还是延迟的。

测试

CPython 测试套件在启用延迟导入的情况下可以运行(但有一些测试会被跳过)。一个 buildbot 应该运行启用延迟导入的测试套件。

C API

对于 C 扩展模块的作者,拟议的公共 C API 如下

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) 解析字典中的所有延迟对象(如果存在)。用于在调用 PyDict_NextWithError()PyDict_Next() 之前。
  • PyDict_NextWithError() 的工作方式与 PyDict_Next() 相同,除了它通过返回 0 并设置异常来将任何错误传播给调用者。调用者应使用 PyErr_Occurred() 检查任何错误。

向后兼容性

此提案在功能禁用时(默认情况下)保留了完整的向后兼容性。

即使启用,大多数代码也将继续正常工作,而不会有任何可观察的变化(除了改进的启动时间和内存使用量)。命名空间包不受影响:它们像现在一样工作,只是延迟地。

在某些现有代码中,延迟导入可能会产生当前意外的结果和行为。启用延迟导入时,我们可能遇到的问题与以下方面有关:

导入副作用

在导入语句执行期间导入模块的执行原本会产生的导入副作用将在导入的对象被使用之前被推迟。

这些导入副作用可能包括

  • 代码在导入期间执行任何副作用逻辑;
  • 依赖于导入的子模块被设置为父模块的属性。

一个相关且典型的受影响案例是用于构建 Python 命令行界面的 click 库。如果例如 cli = click.group() 定义在 main.py 中,并且 sub.pymain 导入 cli 并通过装饰器(@cli.command(...))向其添加子命令,但实际的 cli() 调用在 main.py 中,那么延迟导入可能会阻止子命令被注册,因为在这种情况下 Click 依赖于 sub.py 导入的副作用。在这种情况下,解决方法是确保 sub.py 的导入是强制的,例如通过使用 importlib.eager_imports() 上下文管理器。

动态路径

可能存在与动态 Python 导入路径相关的问题;特别是,向 sys.path 添加(然后导入后移除)路径

sys.path.insert(0, "/path/to/foo/module")
import foo
del sys.path[0]
foo.Bar()

在这种情况下,如果启用延迟导入,foo 的导入实际上不会在添加 sys.path 的时候发生。

一个简单的解决方法(这也可以改进代码风格并确保清理)是将 sys.path 修改放在一个上下文管理器中。这解决了问题,因为 with 块内的导入始终是强制的。

延迟异常

在延迟导入期间引发的异常会冒泡并从 sys.modules 中擦除部分构造的模块,就像正常导入期间的异常一样。

由于在延迟导入期间引发的错误会比在正常导入时晚(即, wherever the name is first referenced),因此它们也可能被那些不期望在 try 块内运行导入的异常处理程序意外捕获,从而导致混淆。

缺点

此 PEP 的缺点包括

  • 它为 Python 导入的行为提供了微妙不兼容的语义。这可能给库作者带来负担,他们可能会被用户要求同时支持这两种语义,并且是 Python 用户/读者需要注意的又一个可能性。
  • 一些流行的 Python 编码模式(特别是通过装饰器填充的集中式注册表)依赖于导入副作用,并且可能需要显式 opt-out 才能按预期与延迟导入一起工作。
  • 访问表示延迟导入的名称时,可能会在任何时候引发异常,这可能会导致混淆和意外异常的调试。

延迟导入语义在 Python 标准库中已经成为可能,甚至得到了支持,因此这些缺点不是本 PEP 新引入的。到目前为止,一些应用程序对延迟导入的现有使用并未被证明有问题。但是,本 PEP 可以使延迟导入的使用更加普及,从而可能加剧这些缺点。

必须权衡这些缺点与本 PEP 实现的延迟导入所带来的显著好处。最终,如果该功能被广泛使用,这些成本将更高;但广泛使用也表明该功能提供了很大的价值,可能足以证明这些成本是合理的。

安全隐患

代码的延迟执行可能会带来安全问题,如果进程所有者、shell 路径、sys.path 或其他敏感环境或上下文状态在 import 语句执行时与导入对象首次被引用的时间之间发生变化。

性能影响

参考实现表明,该功能对现有真实代码库(Instagram Server、Meta 的多个 CLI 程序、Meta 研究人员使用的 Jupyter notebook)的性能影响可忽略不计,同时通过不执行最终在常见流程中未使用的导入,显著提高了启动时间和内存使用量。

参考实现显示,在 pyperformance benchmark suite 上,没有可测量的变化

如何教授此内容

由于该功能是 opt-in 的,初学者默认不应遇到它。对 -L 标志和 importlib.set_lazy_imports() 的文档可以阐明延迟导入的行为。

文档还应阐明,选择延迟导入意味着选择不标准的 Python 导入语义,这可能导致 Python 库以意外的方式中断。识别这些中断并使用 opt-out(或停止使用延迟导入)来解决这些问题的责任完全在于选择为其应用程序启用延迟导入的人,而不是库作者。Python 库没有义务支持延迟导入语义。礼貌地报告不兼容性可能对库作者有用,但他们可以选择简单地说他们的库不支持与延迟导入一起使用,这是一个有效的选择。

为了处理可能出现的一些问题并更好地利用延迟导入,一些最佳实践包括

  • 避免依赖导入副作用。最常见的依赖导入副作用是注册表模式,其中某些外部注册表的填充是在导入模块期间隐式发生的,通常通过装饰器。相反,注册表应通过显式调用来构建,该调用执行发现过程以查找在明确指定的模块中的装饰函数或类。
  • 始终显式导入所需的子模块,不要依赖其他导入来确保模块具有子模块作为属性。也就是说,除非在 foo/__init__.py 中有显式的 from . import bar,否则始终执行 import foo.bar; foo.bar.Baz,而不是 import foo; foo.bar.Baz。后者(不可靠地)工作的原因是因为 foo.bar 属性作为 foo.bar 在其他地方导入的副作用而被添加。使用延迟导入时,这可能不会始终及时发生。
  • 避免使用星形导入,因为这些总是强制的。

参考实现

初始实现作为 Cinder 的一部分可用。此参考实现已在 Meta 内部使用,并已被证明在启动时间(以及某些应用程序的总运行时)方面实现了 40%-70% 的改进,以及显著降低了内存占用(高达 40%),这得益于无需执行在常见流程中最终未使用的导入。

一个基于 CPython 主分支的更新的参考实现也可用。

被拒绝的想法

包装延迟异常

为了减少混淆的可能性,在执行延迟导入的过程中引发的异常可以被替换为 LazyImportError 异常(ImportError 的子类),并设置 __cause__ 为原始异常。

确保所有延迟导入错误都作为 LazyImportError 引发,可以减少意外捕获并将其误认为其他预期异常的可能性。然而,在实践中,我们遇到过一些情况,例如在测试中,失败的模块会引发 unittest.SkipTest 异常,而这也将包装在 LazyImportError 中,导致这些测试失败,因为真实的异常类型被隐藏了。这里的缺点似乎大于意外的延迟异常被错误捕获的假设情况。

按模块选择加入

使用未来导入(即 from __future__ import lazy_imports)进行按模块选择加入没有意义,因为 __future__ 导入不是功能标志,它们是用于过渡到将来将成为默认行为的。目前还不清楚延迟导入是否会作为默认导入行为才有意义,所以我们不应该用 __future__ 导入来承诺这一点。

还有其他情况,库可能希望为特定模块本地选择加入延迟导入;例如,一个大型库的延迟顶级 __init__.py,使其子组件可以作为延迟属性访问。目前,为了使功能更简单,本 PEP 选择专注于“应用程序”用例,而不处理库用例。本 PEP 中引入的底层延迟机制将来也可以用于处理此用例。

单个延迟导入的显式语法

如果延迟导入的主要目标仅仅是为了解决导入循环和前向引用,那么一种显式标记特定目标导入为延迟的语法将非常有意义。但在实践中,很难从这种方法中获得稳健的启动时间或内存使用效益,因为它需要将代码库中的大多数导入(以及第三方依赖项中的导入)转换为使用延迟导入语法。

有可能实现“浅层”延迟,即只将子系统从主模块的顶级导入显式设为延迟,然后子系统中的导入都是强制的。然而,这是极其脆弱的——只需要一个放错位置的导入就可以抵消精心构建的浅层延迟。另一方面,全局启用延迟导入提供了深入的健壮延迟,即你总是只为你使用的导入付费。

可能存在某些用例(例如,用于静态类型检查)希望单独标记的延迟导入来避免前向引用,但不需要全局延迟导入的 perf/memory 优势。由于这是一组不同的动机用例,并且需要新的语法,因此我们不希望将其包含在本 PEP 中。另一个 PEP 可以基于此实现并提出额外的语法。

启用延迟导入的环境变量

提供环境变量 opt-in 太容易滥用了。Python 用户可能会倾向于,例如,在他们的 shell 中全局设置环境变量,希望加速他们运行的所有 Python 程序。这种对未经测试程序的用法很可能导致虚假的错误报告和这些工具作者的维护负担。为了避免这种情况,我们选择根本不提供环境变量 opt-in。

删除 -L 标志

我们确实提供了 -L CLI 标志,理论上用户也可以以类似的方式滥用它,通过一个单独的 Python 程序运行,该程序使用 python somescript.pypython -m somescript(而不是通过 Python 打包工具分发)。但与环境变量相比,滥用的潜在范围要小得多,而且 -L 对于某些应用程序来说很有价值,可以通过确保从进程开始的所有导入都是延迟的来最大化启动时间效益,因此我们选择保留它。

已经存在的情况是,使用未为其设计的命令行标志(例如 -s-S-E-I)运行任意 Python 程序可能会产生意外且破坏性的结果。 -L 在这方面没有任何新意。

半延迟导入

可以强制执行导入加载器到找到模块源的程度,但然后推迟模块的实际执行和模块对象的创建。这样做的优点是,某些类别的导入错误(例如,模块名称中的简单拼写错误)将被强制捕获,而不是推迟到使用导入的名称。

缺点是,延迟导入的启动时间优势将大大降低,因为未使用的导入至少仍然需要文件系统的 stat() 调用。当启用延迟导入时,它还会引入一个可能不明显的划分——*哪些*导入错误被强制引发,哪些被延迟。

目前,基于参考实现中导入拼写错误混淆未被观察到的问题,该想法被拒绝。

半延迟导入的另一个可能动机是允许模块本身通过某个标志来控制它们是延迟导入还是强制导入。这被拒绝了,因为这需要半延迟导入,放弃了导入延迟的一些性能优势,并且因为一般来说模块不决定如何或何时导入它们,而是导入它们的模块决定。本 PEP 没有明确的理由来颠倒这种控制;相反,它只是为导入代码提供了更多做出决定的选项。

延迟动态导入

可以向 __import__() 和/或 importlib.import_module() 添加 lazy=True 或类似选项,以使它们能够执行延迟导入。本 PEP 因缺乏清晰的用例而拒绝了该想法。动态导入已经远远超出了 PEP 8 代码风格关于导入的建议,并且可以通过将它们放置在代码流程的所需点来轻松地将其实现为所需的延迟程度。这些通常不在模块顶级使用,而这是延迟导入适用的地方。

深度强制导入覆盖

提议的 importlib.eager_imports() 上下文管理器和 importlib.set_lazy_imports(excluding=...) 中的排除模块都具有浅层影响:它们仅强制应用于应用位置的强制性,而不是传递性。可能提供其中一个或两个的深度/传递性版本。本 PEP 拒绝了该想法,因为实现会很复杂(考虑到线程和异步代码),参考实现的使用经验并未表明其必要性,并且因为它妨碍了对导入惰性的局部推理。

深度覆盖可能导致令人困惑的行为,因为传递导入的模块可能从多个位置导入,其中一些使用“深度强制覆盖”,而另一些则不使用。因此,这些模块仍然可能首先被惰性导入,如果它们首先从没有覆盖的位置导入。

有了深度覆盖,就无法在本地推理给定导入是延迟还是强制的。根据本 PEP 中指定的行为,这种局部推理是可能的。

将延迟导入设为默认行为

将延迟导入设为 Python 导入的默认/唯一行为,而不是 opt-in,将带来一些长期好处,因为库作者(最终)将不再需要考虑这两种语义的可能性。

然而,由于向后不兼容性,这只能在很长一段时间内,通过 __future__ 导入来考虑。目前还不清楚延迟导入是否应该成为 Python 的默认导入语义。

本 PEP 认为 Python 社区在考虑将延迟导入作为默认行为之前需要更多经验,因此这完全留给未来的 PEP。


来源: https://github.com/python/peps/blob/main/peps/pep-0690.rst

上次修改:2025-02-01 08:55:40 GMT