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-04-29
Python 版本:
3.12
历史记录:
2022-05-03, 2022-05-03
决议:
Discourse 消息

目录

摘要

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

动机

常见的 Python 代码风格 推荐 将导入放在模块级别,这样就不需要在每个使用导入对象的范围内重复它们,并且可以避免在运行时重复执行导入系统带来的低效性。这意味着,导入程序的主模块通常会导致立即级联导入程序可能需要的大部分或所有模块。

考虑一个具有多个子命令的 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 1 将这种实践规范化为 lazy_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() 函数来全局启用。此函数接受两个参数,布尔型 enabledexcluding 容器。如果 enabled 为真,则从那时起将启用延迟导入。如果为假,则从那时起将禁用它们。(excluding 关键字的使用将在下面的“按模块选择性停用”部分中讨论。)

当向 Python 解释器传递 -L 标志时,一个新的 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.pyspam 模块将永远不会被导入(因为它在导入后从未被引用),"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 代码可见;它们始终在第一次引用时被解析。Python 代码永远不会看到任何存根、虚拟或thunk对象,也不会将它们放到 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__ 方法中

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 * 中的导入)将从那时起成为延迟的。

此选择退出系统旨在保持对导入延迟进行本地推理的可能性。您只需要查看一个模块的代码以及 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 中删除部分构建的模块,就像正常导入期间发生的异常一样。

由于延迟导入期间发生的错误将在比急切导入(即首次引用该名称的地方)更晚的时候发生,因此也有可能它们会意外地被异常处理程序捕获,而这些处理程序没有期望导入在它们的 try 块中运行,从而导致混淆。

缺点

此 PEP 的缺点包括

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

延迟导入语义在今天已经可以在 Python 标准库中实现甚至得到支持,因此这些缺点不是由本 PEP 重新引入的。到目前为止,一些应用程序对延迟导入的现有使用尚未证明存在问题。但是,此 PEP 可能会使延迟导入的使用更加流行,从而可能加剧这些缺点。

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

安全隐患

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

性能影响

参考实现表明,该功能对现有的真实代码库(Instagram 服务器、Meta 的几个 CLI 程序、Meta 研究人员使用的 Jupyter 笔记本)的影响可以忽略不计,同时在启动时间和内存使用方面提供了实质性改进。

参考实现表明,在 pyperformance 基准套件 上,聚合性能没有可衡量的变化

如何教授

由于该功能是选择加入的,因此初学者默认情况下不应该遇到它。有关 -L 标志和 importlib.set_lazy_imports() 的文档可以阐明延迟导入的行为。

文档还应澄清,选择加入延迟导入意味着选择加入 Python 导入的非标准语义,这可能会导致 Python 库以意外的方式崩溃。识别这些崩溃并通过选择退出解决它们(或停止使用延迟导入)的责任完全在于选择为其应用程序启用延迟导入的人,而不是库作者。Python 库没有义务支持延迟导入语义。礼貌地报告不兼容性可能对库作者有用,但他们可能选择简单地说他们的库不支持与延迟导入一起使用,这是一个有效的选择。

一些最佳实践可以解决可能出现的一些问题,并更好地利用延迟导入

  • 避免依赖于导入副作用。也许最常见的依赖于导入副作用的是注册表模式,其中某些外部注册表的填充在导入模块期间隐式地发生,通常通过装饰器。相反,应该通过显式调用来构建注册表,该调用执行发现过程以在明确指定的模块中查找装饰的函数或类。
  • 始终显式地导入所需的子模块,不要依赖于其他导入来确保模块具有其子模块作为属性。也就是说,除非在 foo/__init__.py 中存在显式的 from . import bar,否则始终执行 import foo.bar; foo.bar.Baz,而不是 import foo; foo.bar.Baz。后者仅在 foo.bar 被导入到其他地方时才会(不可靠地)工作,因为属性 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 中引入的底层延迟机制将来可以用于解决此用例。

单独延迟导入的显式语法

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

可以针对“浅层”延迟,其中只有从主模块导入子系统的顶层导入被显式地延迟,但是子系统内的导入都是急切的。然而,这是极其脆弱的——只需要一个放置错误的导入就可以破坏精心构建的浅层延迟。另一方面,全局启用延迟导入提供了深入的稳健延迟,在其中你始终只为使用的导入付费。

可能有一些用例(例如用于静态类型),其中希望单独标记延迟导入以避免前向引用,但不需要全局延迟导入的性能/内存优势。由于这是一组不同的动机用例,并且需要新的语法,我们更愿意不在此 PEP 中包含它。另一个 PEP 可以在此实现的基础上构建,并提出额外的语法。

启用延迟导入的环境变量

提供环境变量选择加入很容易被滥用。对于 Python 用户来说,将环境变量全局设置到他们的 shell 中似乎很诱人,希望加快他们运行的所有 Python 程序的速度。这种对未经测试的程序的使用可能会导致虚假错误报告,并给这些工具的作者带来维护负担。为了避免这种情况,我们选择完全不提供环境变量选择加入。

删除 -L 标志

我们确实提供了 -L CLI 标志,理论上可以通过类似的方式被最终用户滥用,最终用户运行一个使用 python somescript.pypython -m somescript 运行的单个 Python 程序(而不是通过 Python 打包工具分发)。但是,使用 -L 的潜在滥用范围远小于环境变量,并且 -L 对于某些应用程序来说非常有价值,可以最大程度地提高启动时间优势,通过确保从进程开始的所有导入都将是延迟的,因此我们选择保留它。

使用它们不打算使用的命令行标志运行任意 Python 程序(例如 -s-S-E-I)已经会导致意想不到的和破坏性的结果。在这方面,-L 并不新鲜。

半延迟导入

可以急切地运行导入加载程序以找到模块源,但随后延迟实际执行模块和创建模块对象。这样做的好处是,某些类型的导入错误(例如模块名称中的简单拼写错误)将被急切地捕获,而不是延迟到使用导入的名称。

缺点是延迟导入的启动时间优势会大大降低,因为未使用的导入仍然需要一个文件系统 stat() 调用,至少。它还会在启用延迟导入时,在哪些导入错误被急切地引发以及哪些被延迟之间引入一个可能不明显的分割。

目前,由于参考实现中尚未观察到关于导入拼写错误的混淆问题,因此拒绝了这个想法。一般来说,延迟导入不会永远延迟,错误会在足够短的时间内出现,以便被捕获和修复(除非导入实际上没有被使用)。

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

延迟动态导入

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

深度优先导入覆盖

提议的 importlib.eager_imports() 上下文管理器和 importlib.set_lazy_imports(excluding=...) 中的排除模块都具有浅层效应:它们只对应用它们的位点强制立即执行,而不是传递性地执行。可以提供一个或两个的深度/传递版本。这个想法在该 PEP 中被拒绝,因为实现将很复杂(需要考虑线程和异步代码),对参考实现的经验表明它并非必要,而且因为它会阻止对导入延迟进行本地推理。

深度覆盖可能会导致令人困惑的行为,因为传递性导入的模块可能从多个位置导入,其中一些使用“深度立即覆盖”,而另一些则没有。因此,这些模块可能仍然最初是延迟导入的,如果它们首先是从没有覆盖的位点导入的。

使用深度覆盖,无法对给定导入是延迟还是立即执行进行本地推理。使用本 PEP 中指定的行为,这种本地推理是可能的。

将延迟导入设为默认行为

将延迟导入设置为 Python 导入的默认/唯一行为,而不是选择加入,会有一些长期的好处,因为库作者(最终)不再需要考虑两种语义的可能性。

但是,向后不兼容性意味着这只能在很长一段时间内考虑,并使用 __future__ 导入。目前还不清楚延迟导入是否应该成为 Python 的默认导入语义。

该 PEP 认为 Python 社区需要更多使用延迟导入的经验才能考虑将其设置为默认行为,因此这完全留给未来可能的 PEP。


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

最后修改:2023-09-09 17:39:29 GMT