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

Python 增强提案

PEP 810 – 显式惰性导入

作者:
Pablo Galindo <pablogsal at python.org>, Germán Méndez Bravo <german.mb at gmail.com>, Thomas Wouters <thomas at python.org>, Dino Viehland <dinoviehland at gmail.com>, Brittany Reynoso <brittanyrey at gmail.com>, Noah Kim <noahbkim at gmail.com>, Tim Stumbaugh <me at tjstum.com>
讨论至:
Discourse 帖子
状态:
草案
类型:
标准跟踪
创建日期:
2025 年 10 月 02 日
Python 版本:
3.15
发布历史:
2025 年 10 月 03 日

目录

摘要

此 PEP 引入了惰性导入的语法,作为一项显式语言功能

lazy import json
lazy from json import dumps

惰性导入将模块的加载和执行推迟到第一次使用导入的名称时,与“正常”导入(在导入语句处急切加载和执行模块)相反。

通过允许开发人员使用显式语法将单个导入标记为惰性,Python 程序可以减少启动时间、内存使用和不必要的工作。这对于命令行工具、测试套件和具有大型依赖项图的应用程序特别有益。

此提案保留了完整的向后兼容性:普通导入语句保持不变,惰性导入仅在明确请求的地方启用。

动机

Python 代码中的主要约定是将所有导入放在模块级别,通常在文件开头。这避免了重复,使导入依赖关系清晰,并通过每个模块只评估一次导入语句来最小化运行时开销。

这种方法的缺点是,执行 Python 的第一个模块(“主”模块)的导入通常会触发导入的级联,并乐观地加载许多可能永远不会使用的依赖项。对于具有多个子命令的命令行工具来说,这种影响尤其昂贵,因为即使是运行带有 --help 的命令也会加载数十个不必要的模块,耗费几秒钟。这个基本示例演示了仅仅为了获得有关如何运行程序的有用反馈而必须加载的内容。用户在找出他们想要的命令并“真正”调用程序时,会再次承担这种开销,效率低下。

一种相当常见的延迟导入方法是将导入移入函数(内联导入),但这种做法需要更多的实现和维护工作,并且可能被单个无意的顶级导入所规避。此外,它会混淆模块的全部依赖项。对 Python 标准库的分析表明,大约 17% 的非测试导入(在 730 个文件中近 3500 个导入)已经放在函数或方法中,专门用于延迟其执行。这表明开发人员已经在手动实现性能敏感代码中的惰性导入,但这样做需要将导入分散到整个代码库中,并且使得一眼就能看懂完整的依赖关系图变得更加困难。

标准库提供了 LazyLoader 类来解决其中一些低效率问题。它允许模块级别的导入*大致*像内联导入一样工作。许多科学 Python 库采用了类似的模式,该模式已在 SPEC 1 中形式化。还有一个第三方 lazy_loader 包,这是惰性导入的另一种实现。仅用于静态类型检查的导入是另一个潜在的未必要导入的来源,并且有类似的、不同的方法来最小化开销。此处用于延迟或删除急切导入的各种方法并未涵盖通用惰性导入机制的所有潜在用例。没有明确的标准,并且存在几个缺点,包括意想不到的运行时开销,或者更糟的运行时内省。

此提案通过一种局部、显式、受控和细粒度的设计引入了惰性导入的语法。这些特性中的每一个都对于使该功能在实践中可预测和安全至关重要。

行为是*局部*的:惰性仅适用于用 lazy 关键字标记的特定导入,它不会递归地级联到其他导入。这确保了开发人员可以通过仅查看他们面前的代码行来推断惰性的效果,而无需担心导入的模块本身是否会表现出不同的行为。 lazy import 每次使用时都是一个孤立的决定,而不是语义的全局变化。

语义是*显式*的。当一个名称被惰性导入时,导入的绑定会立即在导入模块中创建,但目标模块直到第一次访问该名称时才会被加载。在此之后,该绑定与正常导入创建的绑定无法区分。这种清晰度减少了意外,并使该功能对于可能不熟悉 Python 导入机制的开发人员来说易于访问。

惰性导入是*受控*的,这意味着惰性加载仅由导入代码本身触发。在一般情况下,如果库的作者选择将导入标记为惰性,那么该库才会遇到惰性导入。这避免了将责任转移给下游用户,并防止了库行为中的意外惊喜。由于库作者通常管理自己的导入子图,因此他们可以预测地控制惰性的应用时间和方式。

该机制也是*细粒度*的。它通过对单个导入的显式语法引入,而不是通过全局标志或隐式设置。这允许开发人员逐步采用它,从代码库中最性能敏感的区域开始。随着该功能在社区中推广,我们希望使入职体验成为可选的、渐进的和适应每个项目需求的。

惰性导入提供了几个具体的优势

  • 命令行工具通常由用户直接调用,因此延迟(尤其是启动延迟)非常明显。这些程序通常也是短暂的进程(与例如 Web 服务器相比)。使用惰性导入,只有实际到达的代码路径才会导入模块。这实际上可以将启动时间减少 50-70%,从而显著改善常见的用户体验,并在最需要快速启动的领域提高 Python 的竞争力。
  • 类型注解经常需要运行时从不使用的导入。常见的解决方法是将它们包装在 if TYPE_CHECKING: 块中 [1]。通过惰性导入,仅用于注解的导入不会产生运行时开销,从而消除了这种保护的需要,并使注解的代码库更简洁。
  • 大型应用程序通常导入数千个模块,每个模块都会创建函数和类型对象,从而产生内存成本。在长时间运行的进程中,这会显著提高基线内存使用量。惰性导入将这些成本推迟到需要模块时,从而使未使用的子系统保持未加载状态。在实际工作负载中已观察到内存节省 30-40%。

基本原理

此提案的设计以清晰性、可预测性和易于采用为中心。每个决定都是为了确保惰性导入在不向语言或其运行时引入不必要复杂性的情况下提供切实的益处。

还值得注意的是,虽然此 PEP 概述了一种特定的方法,但我们列出了该提案某些核心方面和语义的替代实现策略。如果社区对仍然保留相同核心语义的不同技术路径表示强烈偏好,或者在特定选项上存在根本分歧,我们已将为准备此提案而完成的头脑风暴包含在内作为参考。

选择引入新的 lazy 关键字反映了对显式语法的需求。惰性导入与正常导入具有不同的语义:错误和副作用发生在第一次使用时,而不是在导入语句处。这种语义差异使得惰性在导入站点本身可见至关重要,而不是隐藏在全局配置或远程模块级别声明中。 lazy 关键字提供对导入行为的局部推理,避免了搜索代码中的其他位置来理解导入是否被延迟。其余的导入语义保持不变:使用相同的导入机制、模块查找和加载机制。

另一个重要的决定是使用代理对象在模块的命名空间中表示惰性导入,而不是通过修改字典查找。早期的方法尝试将惰性嵌入字典中,但这模糊了抽象,并可能影响运行时的其他部分。字典是 Python 中的基本数据结构——字面上每个对象都建立在字典之上——并且向字典添加钩子会阻止关键优化并使整个运行时复杂化。代理方法更简单:它在第一次使用之前表现得像一个占位符,届时它会解析导入并重新绑定名称。此后,该绑定与正常导入无法区分。这使得该机制易于解释,并保持解释器其他部分不变。

库作者的兼容性也是一个关键问题。许多维护者需要一个迁移路径,允许他们同时支持 Python 的新旧版本。因此,该提案包括 __lazy_modules__ 全局变量作为过渡机制。一个模块可以声明哪些导入应被视为惰性(通过将模块名称列为字符串),在 Python 3.15 或更高版本上,这些导入将自动变为惰性,就像使用 lazy 关键字导入一样。在早期版本中,该声明被忽略,导入保持急切。这为作者提供了一个实用的桥梁,直到他们可以依赖关键字作为规范语法。

最后,该功能旨在逐步采用。除非开发人员明确选择加入,否则不会发生任何更改,并且可以从少数性能敏感区域的导入开始采用。这类似于 Python 中渐进式类型化的经验:一种可以逐步引入的机制,而无需项目从第一天就开始全局承诺。值得注意的是,采用也可以从“外部到内部”进行,允许 CLI 作者引入惰性导入并加速面向用户的工具,而无需更改该工具可能使用的每个库。

其他设计决策

  • 惰性的范围是故意局部的和非递归的。惰性导入仅影响其出现的特定语句;它不会级联到其他模块或子模块。这个选择对于可预测性至关重要。当开发人员阅读代码时,他们可以逐行推断导入行为,而无需担心依赖关系图中更深处的隐藏惰性。结果是该功能功能强大,但在上下文中仍然易于理解。
  • 此外,提供一种机制来激活或禁用解释器中运行的所有代码的惰性导入(在此 PEP 中称为“全局惰性导入标志”)是有用的。虽然主要设计围绕显式 lazy import 语法,但在某些场景下——例如大型应用程序、测试环境或框架——在许多模块中始终启用惰性可以提供最大的好处。全局开关可以轻松尝试或强制执行一致的行为,同时仍然与过滤 API 结合使用以尊重排除或工具特定的配置。这确保了全局采用可以实用,而不会降低灵活性或控制力。

规范

语法

添加了一个新的软关键字 lazy。软关键字是在特定语法上下文中才具有特殊含义的上下文敏感关键字;在其他地方,它可以被用作普通标识符(例如,作为变量名)。 lazy 关键字仅在出现在 import 语句之前时才具有特殊含义。

import_name:
    | 'lazy'? 'import' dotted_as_names

import_from:
    | 'lazy'? 'from' ('.' | '...')* dotted_name 'import' import_from_targets
    | 'lazy'? 'from' ('.' | '...')+ 'import' import_from_targets

语法限制

软关键字仅允许在全局(模块)级别,*不*允许在函数、类体、try 块或 import * 内部。使用软关键字的导入语句是*潜在惰性*的。无法惰性导入的导入不受全局惰性导入标志的影响,而是始终急切的。此外,from __future__ import 语句不能是惰性的。

语法错误示例

# SyntaxError: lazy import not allowed inside functions
def foo():
    lazy import json

# SyntaxError: lazy import not allowed inside classes
class Bar:
    lazy import json

# SyntaxError: lazy import not allowed inside try/except blocks
try:
    lazy import json
except ImportError:
    pass

# SyntaxError: lazy from ... import * is not allowed
lazy from json import *

# SyntaxError: lazy from __future__ import is not allowed
lazy from __future__ import annotations

语义

当使用 lazy 关键字时,导入成为*潜在惰性*(有关高级覆盖机制,请参阅 惰性导入过滤器)。模块不会在导入语句处立即加载;相反,会创建一个惰性代理对象并将其绑定到该名称。实际模块在第一次使用该名称时加载。

使用 lazy from ... import 时,*每个导入的名称*都绑定到一个惰性代理对象。*任何*这些名称的第一次访问都会触发整个模块的加载,并*仅将该特定名称具体化*为其实际值。其他名称将保持惰性代理状态,直到它们被访问。解释器的自适应特化将在几次访问后优化掉惰性检查。

带有 lazy import 的示例

import sys

lazy import json

print('json' in sys.modules)  # False - module not loaded yet

# First use triggers loading
result = json.dumps({"hello": "world"})

print('json' in sys.modules)  # True - now loaded

带有 lazy from ... import 的示例

import sys

lazy from json import dumps, loads

print('json' in sys.modules)           # False - module not loaded yet

# First use of 'dumps' triggers loading json and reifies ONLY 'dumps'
result = dumps({"hello": "world"})

print('json' in sys.modules)           # True - module now loaded

# Accessing 'loads' now reifies it (json already loaded, no re-import)
data = loads(result)

一个模块可能包含一个 __lazy_modules__ 属性,它是一个完全限定的模块名(字符串)的序列,用于使其*潜在惰性*(就像使用了 lazy 关键字一样)。在每个 import 语句上都会检查此属性,以确定导入是否应被*潜在惰性*。当模块以这种方式惰性化时,使用该模块的 from 导入也是惰性的,但不一定是子模块的导入。

正常(非惰性)导入语句将检查全局惰性导入标志。如果设置为“all”,则所有导入都是*潜在惰性*的(除了无法惰性导入的导入,如上所述)。

示例

__lazy_modules__ = ["json"]
import json
print('json' in sys.modules)  # False
result = json.dumps({"hello": "world"})
print('json' in sys.modules)  # True

如果全局惰性导入标志设置为“none”,则没有*潜在惰性*导入会被惰性导入,其行为等同于常规导入语句:导入是*急切的*(就好像没有使用 lazy 关键字一样)。

最后,应用程序可以使用自定义过滤器函数来处理所有*潜在惰性*导入,以确定它们是否应惰性(这是高级功能,请参阅 惰性导入过滤器)。如果设置了过滤器函数,它将使用执行导入的模块名称、正在导入的模块名称以及(如果适用)fromlist 来调用。只有当过滤器函数返回 True 时,导入才保持惰性。如果没有设置惰性导入过滤器,所有*潜在惰性*导入都是惰性的。

惰性对象

惰性模块以及从模块惰性导入的名称由 types.LazyImportType 实例表示,这些实例在可用于之前会被解析(具体化)为真实对象。这种具体化通常是自动完成的(见下文),但也可以通过调用惰性对象的 resolve 方法来完成。

惰性导入机制

当导入是惰性的时,将调用 __lazy_import__ 而不是 __import____lazy_import__ 具有与 __import__ 相同的函数签名。它将模块名称添加到 sys.lazy_modules(一个完全限定的模块名集合,它们在某个时候被惰性导入(主要用于诊断和内省)),并返回该模块的 types.LazyImportType 对象。

from ... import 的实现( IMPORT_FROM 字节码实现)会检查其要从中获取的模块是否是惰性模块对象,如果是,则为每个名称返回 types.LazyImportType 而不是立即访问它。

此过程的最终结果是,惰性导入(无论如何启用)都会导致惰性对象被分配给全局变量。

惰性模块不会出现在 sys.modules 中,它们只列在 sys.lazy_modules 集合中。在正常操作下,惰性对象应该只存储在全局变量中,并且访问这些变量的常见方式(常规变量访问、模块属性)将在访问时解析惰性导入(具体化)并替换它们。

通过其他方式(如调试器)暴露惰性对象仍然是可能的。这不被认为是一个问题。

具体化

当使用惰性对象时,它需要被具体化。这意味着在程序的该点解析导入,并将惰性对象替换为具体对象。具体化在该程序点导入模块。值得注意的是,具体化仍然调用 __import__ 来解析导入,它使用导入系统的状态(例如 sys.pathsys.meta_pathsys.path_hooks__import__)在*具体化*时间,而不是在评估 lazy import 语句时。

当模块被具体化时,它会从 sys.lazy_modules 中移除(即使仍然存在其他未具体化的惰性引用)。当一个包被具体化并且包中的子模块也曾被惰性导入时,这些子模块*不会*被自动具体化,但它们*会被添加*到被具体化包的全局变量中(除非该包已将其他内容分配给子模块的名称)。

如果具体化失败(例如,由于 ImportError),惰性对象*不会*被具体化或替换。后续使用惰性对象将重试具体化。具体化期间发生的异常会像正常一样引发,但异常会通过链接得到增强,以显示惰性导入的定义位置和访问位置(即使它从触发具体化的代码传播)。这提供了清晰的调试信息。

# app.py - has a typo in the import
lazy from json import dumsp  # Typo: should be 'dumps'

print("App started successfully")
print("Processing data...")

# Error occurs here on first use
result = dumsp({"key": "value"})

回溯显示两个位置

App started successfully
Processing data...
Traceback (most recent call last):
  File "app.py", line 2, in <module>
    lazy from json import dumsp
ImportError: lazy import of 'json.dumsp' raised an exception during resolution

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "app.py", line 8, in <module>
    result = dumsp({"key": "value"})
             ^^^^^
ImportError: cannot import name 'dumsp' from 'json'. Did you mean: 'dump'?

此异常链接清楚地显示了

  1. 惰性导入的定义位置,
  2. 模块未被急切导入,并且
  3. 触发错误的确切访问位置。

具体化*不会*在之前惰性导入的模块随后被急切导入时自动发生。具体化*不会*立即解析引用该模块的所有惰性对象(例如 lazy from 语句)。它*只*会解析正在访问的惰性对象。

访问惰性对象(来自全局变量或模块属性)会具体化该对象。

但是,调用 globals() 或访问模块的 __dict__*不会*触发具体化——它们返回模块的字典,通过该字典访问惰性对象仍然会返回惰性代理对象,这些对象在使用时需要手动具体化。可以通过调用 resolve 方法来显式解析惰性对象。在全局范围调用 dir() 不会具体化全局变量,调用 dir(mod) (通过 mod.__dir__ 的特殊处理)也不会。其他更间接的访问任意全局变量的方式(例如,检查 frame.f_globals)也*不会*具体化所有对象。

使用 globals()__dict__ 的示例

# my_module.py
import sys
lazy import json

# Calling globals() does NOT trigger reification
g = globals()
print('json' in sys.modules)  # False - still lazy
print(type(g['json']))  # <class 'LazyImport'>

# Accessing __dict__ also does NOT trigger reification
d = __dict__
print(type(d['json']))  # <class 'LazyImport'>

# Explicitly reify using the resolve() method
resolved = g['json'].resolve()
print(type(resolved))  # <class 'module'>
print('json' in sys.modules)  # True - now loaded

参考实现

参考实现可在以下网址找到: https://github.com/LazyImportsCabal/cpython/tree/lazy

一个演示可在以下网址获得(不一定与最新的 PEP 同步),用于评估目的: https://lazy-import-demo.pages.dev/

字节码和自适应特化

惰性导入是通过修改四个字节码指令实现的:IMPORT_NAMEIMPORT_FROMLOAD_GLOBALLOAD_NAME

lazy 语法在 IMPORT_NAME 指令的 oparg 中设置一个标志(oparg & 0x01)。解释器检查此标志并调用 _PyEval_LazyImportName() 而不是 _PyEval_ImportName(),从而创建一个惰性导入对象而不是立即执行导入。 IMPORT_FROM 指令检查其源是否为惰性导入(PyLazyImport_CheckExact()),并为该属性创建一个惰性对象,而不是立即访问它。

当访问惰性对象时,它必须被具体化。 LOAD_GLOBAL 指令(在函数范围内使用)和 LOAD_NAME 指令(在模块和类级别使用)都会检查正在加载的对象是否为惰性导入。如果是,它们会调用 _PyImport_LoadLazyImportTstate() 来执行实际导入并将模块存储在 sys.modules 中。

此检查在每次访问时都会产生非常小的成本。但是,Python 的自适应解释器可以在观察到惰性导入已被具体化后特化 LOAD_GLOBAL。几次执行后,LOAD_GLOBAL 变为 LOAD_GLOBAL_MODULE,它直接访问模块字典,而无需检查惰性导入。

生成的字节码示例

lazy import json  # IMPORT_NAME with flag set

生成

IMPORT_NAME              1 (json + lazy)
lazy from json import dumps  # IMPORT_NAME + IMPORT_FROM

生成

IMPORT_NAME              1 (json + lazy)
IMPORT_FROM              1 (dumps)
lazy import json
x = json  # Module-level access

生成

LOAD_NAME                0 (json)
lazy import json

def use_json():
    return json.dumps({})  # Function scope

在任何调用之前

LOAD_GLOBAL              0 (json)
LOAD_ATTR                2 (dumps)

几次调用后,LOAD_GLOBAL 特化为 LOAD_GLOBAL_MODULE

LOAD_GLOBAL_MODULE       0 (json)
LOAD_ATTR_MODULE         2 (dumps)

惰性导入过滤器

注意:这是一个高级功能。库开发人员不应调用这些函数。这些函数适用于需要在使用全局标志时对惰性导入行为进行精细控制的专业/高级用户。

此 PEP 向 sys 模块添加了以下新函数来管理惰性导入过滤器

  • sys.set_lazy_imports_filter(func) - 设置过滤器函数。如果 func=None,则移除导入过滤器。 func 参数必须具有签名:func(importer: str, name: str, fromlist: tuple[str, ...] | None) -> bool
  • sys.get_lazy_imports_filter() - 返回当前安装的过滤器函数,如果没有设置过滤器则返回 None
  • sys.set_lazy_imports(mode, /) - 用于在运行时控制惰性导入的编程 API。 mode 参数可以是 "normal" (仅尊重 lazy 关键字)、"all" (强制所有导入都潜在惰性)或 "none" (强制所有导入都急切)。

过滤器函数会为每个潜在惰性导入调用,并且必须返回 True 才能使导入惰性。这允许对哪些导入应为惰性进行精细控制,这对于排除已知具有副作用依赖项或注册模式的模块很有用。过滤器函数在惰性导入或惰性 from 导入语句执行时被调用,而不是在具体化时调用。过滤器函数可能会并发调用。

过滤机制为工具、调试器、linter 和其他生态系统实用程序提供了基础,可以利用该机制来提供更好的惰性导入体验。例如,静态分析工具可以检测具有副作用的模块并自动配置适当的过滤器。*未来*(超出此 PEP 的范围),这个基础可能会实现更好的方法来声明性地指定哪些模块可以安全地惰性导入,例如包元数据、带有惰性安全注解的类型存根或配置文件。当前的过滤器 API 设计得足够灵活,可以容纳这些未来的增强功能,而无需更改核心语言规范。

示例

import sys

def exclude_side_effect_modules(importer, name, fromlist):
    """
    Filter function to exclude modules with import-time side effects.

    Args:
        importer: Name of the module doing the import
        name: Name of the module being imported
        fromlist: Tuple of names being imported (for 'from' imports), or None

    Returns:
        True to allow lazy import, False to force eager import
    """
    # Modules known to have important import-time side effects
    side_effect_modules = {'legacy_plugin_system', 'metrics_collector'}

    if name in side_effect_modules:
        return False  # Force eager import

    return True  # Allow lazy import

# Install the filter
sys.set_lazy_imports_filter(exclude_side_effect_modules)

# These imports are checked by the filter
lazy import data_processor        # Filter returns True -> stays lazy
lazy import legacy_plugin_system  # Filter returns False -> imported eagerly

print('data_processor' in sys.modules)       # False - still lazy
print('legacy_plugin_system' in sys.modules) # True - loaded eagerly

# First use of data_processor triggers loading
result = data_processor.transform(data)
print('data_processor' in sys.modules)       # True - now loaded

全局惰性导入控制

注意:这是一个高级功能。库开发人员不应使用全局激活机制。此功能旨在供应用程序开发人员和框架作者使用,他们需要跨整个应用程序控制惰性导入。

全局惰性导入标志可以通过以下方式控制

  • -X lazy_imports=<mode> 命令行选项
  • PYTHON_LAZY_IMPORTS=<mode> 环境变量
  • sys.set_lazy_imports(mode) 函数(主要用于测试)

其中 <mode> 可以是

  • "normal" (或未设置):仅明确标记的惰性导入才是惰性的
  • "all":所有模块级导入(try 块和 import * 除外)都*潜在惰性*
  • "none":没有导入是惰性的,即使是明确用 lazy 关键字标记的。

当全局标志设置为 "all" 时,所有模块的全局级别的所有导入都是*潜在惰性*的,*除了* try 块内的导入以及任何通配符(from ... import *)导入。

如果全局惰性导入标志设置为 "none",则没有任何*潜在惰性*导入会被惰性导入,导入过滤器永远不会被调用,其行为等同于常规 import 语句:导入是*急切的*(就好像没有使用 lazy 关键字一样)。

Python 代码可以运行 sys.set_lazy_imports() 函数来覆盖从环境或 CLI 继承的全局惰性导入标志的状态。如果应用程序需要确保所有导入都被急切评估,通过 sys.set_lazy_imports("none"),这一点尤其有用。

向后兼容性

惰性导入是*选择加入*的。除非项目明确启用了惰性(通过 lazy 语法、__lazy_modules__ 或解释器范围的开关),否则现有程序将继续按原样运行。

未更改的语义

  • 常规 importfrom ... import ... 语句保持急切,除非通过本地或全局提供的机制明确使其*潜在惰性*。
  • 动态导入 API 保持急切且未更改:__import__()importlib.import_module()
  • 导入钩子和加载器在惰性对象被具体化时,继续在标准导入协议下运行。

可观察的行为变化(仅限选择加入)

这些更改仅限于明确标记为惰性的绑定

  • 错误发生时间。原本在急切导入期间会发生的异常(例如,ImportError 或缺少成员的 AttributeError)现在发生在惰性名称的*使用*时。
    # With eager import - error at import statement
    import broken_module  # ImportError raised here
    
    # With lazy import - error deferred
    lazy import broken_module
    print("Import succeeded")
    broken_module.foo()  # ImportError raised here on use
    
  • 副作用发生时间。惰性导入模块中的导入时副作用在首次使用绑定时发生,而不是在模块导入时发生。
  • 导入顺序。由于模块是在第一次使用时导入的,模块的导入顺序可能与它们在代码中出现的顺序不同。
  • 在 ``sys.modules`` 中的存在。惰性导入的模块在第一次使用之前不会出现在 sys.modules 中。具体化之后,它必须出现在 sys.modules 中。如果其他代码在第一次使用之前急切地导入了同一个模块,那么惰性绑定会在第一次使用时解析到该现有的(惰性)模块对象。
  • 代理可见性。在第一次使用之前,绑定名称指向惰性代理。触摸该值的间接内省可能会观察到代理惰性对象表示。第一次使用后(假设模块导入成功),名称将被重新绑定到真实对象,并且与急切导入无法区分。

线程安全和具体化

具体化遵循现有的导入锁定约定。只有一个线程执行导入,并*原子地重新绑定*导入模块的全局变量为已解析对象。并发读取者之后会观察到真实对象。

惰性导入是线程安全的,并且没有特殊的自由线程考虑。一个通常在主线程中导入的模块,如果该线程触发了惰性导入的首次访问,也可能在不同的线程中被导入。这不是问题:导入锁确保线程安全,无论哪个线程执行导入。

子解释器得到支持。每个子解释器维护自己的 sys.lazy_modules 和导入状态,因此一个子解释器中的惰性导入不会影响其他解释器。

性能

惰性导入*没有可测量的性能开销*。该实现旨在对使用惰性导入的代码和不使用惰性导入的代码都具有性能中性。

运行时性能

具体化之后(假设导入成功),惰性导入*没有开销*。自适应解释器会将字节码特化(通常在 2-3 次访问后),消除任何检查。例如,LOAD_GLOBAL 变为 LOAD_GLOBAL_MODULE,它直接访问模块,与正常导入相同。

pyperformance 套件证实了该实现是性能中性的。

过滤器函数性能

过滤器函数(通过 sys.set_lazy_imports_filter() 设置)会为每个*潜在惰性*导入调用,以确定它是否应实际惰性。当没有设置过滤器时,这只是一个 NULL 检查(测试是否已注册过滤器函数),这是一个高度可预测的分支,几乎不产生任何开销。当安装了过滤器时,它会为每个潜在惰性导入调用,但这仍然*几乎没有可测量的性能成本*。为了衡量这一点,我们对导入 Python 标准库中的所有 278 个顶级可导入模块(这会传递加载 392 个总模块,包括所有子模块和依赖项)进行了基准测试,然后强制具体化所有已加载的模块以确保所有内容都已完全具体化。

请注意,这些测量结果确定了过滤器机制本身的基线开销。当然,任何执行除简单检查之外的其他工作的用户定义的过滤器函数都会增加与该工作复杂性成比例的开销。但是,我们预计在实践中,这种开销将远远小于避免不必要导入所带来的性能优势。下面的基准测试测量了过滤器分派机制的最小成本,当过滤器函数几乎不做任何事情时。

我们比较了四种不同的配置

配置 平均值 ± 标准差(毫秒) 与基线的开销
急切导入(基线) 161.2 ± 4.3 0%
惰性 + 强制急切的过滤器 161.7 ± 4.2 +0.3% ± 3.7%
惰性 + 允许惰性 + 具体化过滤器 162.0 ± 4.0 +0.5% ± 3.7%
惰性 + 无过滤器 + 具体化 161.4 ± 4.3 +0.1% ± 3.8%

四种配置

  1. 急切导入(基线):正常的 Python 导入,没有惰性机制。标准的 Python 行为。
  2. 惰性 + 强制急切的过滤器:过滤器函数对所有导入返回 False,强制急切执行,然后在脚本结束时具体化所有导入。测量纯粹的过滤器调用开销,因为每个导入都经过过滤器但急切执行。
  3. 惰性 + 允许惰性 + 具体化过滤器:过滤器函数对所有导入返回 True,允许惰性执行。所有导入都在脚本结束时具体化。测量实际惰性导入时的过滤器开销。
  4. 惰性 + 无过滤器 + 具体化:未安装过滤器,导入是惰性的,并在脚本结束时具体化。在没有过滤器的情况下惰性行为的基线。

基准测试使用了 hyperfine,测试了 278 个标准库模块。每个模块都在一个新的 Python 进程中运行。所有配置都强制导入完全相同的模块集(由急切基线加载的所有模块),以确保公平的比较。

基准测试环境使用了具有 32 个逻辑 CPU 的 CPU 隔离(0-15 个在 3200 MHz,16-31 个在 2400 MHz),性能扩展的调速器,禁用了 Turbo Boost,并且具有完全的 ASLR 随机化。开销误差条使用标准误差传播来计算 (value - baseline) / baseline,同时考虑了测量值和基线中的不确定性。

启动时间改进

惰性导入的主要性能优势是通过只加载运行时实际使用的模块来减少启动时间,而不是在启动时乐观地加载整个依赖项树。

大规模的实际部署已表明,其优势可能非常巨大,尽管这当然取决于特定的代码库和使用模式。拥有大型、相互关联的代码库的组织报告称,服务器重新加载时间、ML 训练初始化、命令行工具启动和 Jupyter notebook 加载都得到了显著缩短。由于未使用的模块保持未加载状态,因此还观察到了内存使用量的改进。

有关生产部署的详细案例研究和性能数据,请参阅

收益随代码库复杂度的增加而增加:代码库越大、关联性越强,改进就越显著。PySide 实现特别突出了具有大量初始化开销的框架如何能从选择加入的惰性加载中受益匪浅。

类型提示和工具

类型检查器和静态分析器可以将 lazy 导入视为普通导入来解析名称。在运行时,可以将仅用于注解的导入标记为 lazy,以避免启动开销。 IDE 和调试器应准备好在第一次使用前显示惰性代理,此后显示真实对象。

安全隐患

在同一环境中执行包安装的工具应确保在安装步骤之前导入所有模块(或具体化),以避免新安装的发行版覆盖它们。

这些工具可以使用 sys.set_lazy_imports()"none" 来强制急切评估,或者提供 sys.set_lazy_imports_filter() 函数来进行精细控制。

如何教授此内容

新的 lazy 关键字将作为语言标准的一部分进行记录。

由于此功能是选择加入的,新的 Python 用户应该能够继续像以前一样使用该语言。对于经验丰富的开发人员,我们希望他们根据具体情况利用惰性导入来获得上述各种好处(降低延迟、减少内存使用等)。对 Python 二进制文件性能感兴趣的开发人员可能会利用分析工具来了解其代码库中的导入时间开销,并将其必要的导入标记为 lazy。此外,开发人员可以标记仅用于类型注解的导入为 lazy

Python 文档将添加更多文档,包括指南、专门的“如何做”指南以及导入系统文档的更新,涵盖:使用分析工具(如 -X importtime)识别加载缓慢的模块、现有代码库的迁移策略、避免导入时副作用常见陷阱的最佳实践,以及使用惰性导入与类型注解和循环导入有效结合的模式。

以下是如何充分利用惰性导入以及避免不兼容性的指导

  • 采用惰性导入时,用户应意识到将导入推迟到使用时执行将导致副作用不会被执行。反过来,用户应警惕依赖导入时副作用的模块。导入时副作用最常见的依赖可能是注册模式,即在导入模块期间隐式地填充某个外部注册表,通常通过装饰器,但有时通过元类或 __init_subclass__ 实现。相反,对象的注册表应通过显式发现过程(例如,调用一个众所周知的函数)来构建。
    # Problematic: Plugin registers itself on import
    # my_plugin.py
    from plugin_registry import register_plugin
    
    @register_plugin("MyPlugin")
    class MyPlugin:
        pass
    
    # In main code:
    lazy import my_plugin
    # Plugin NOT registered yet - module not loaded!
    
    # Better: Explicit discovery
    # plugin_registry.py
    def discover_plugins():
        from my_plugin import MyPlugin
        register_plugin(MyPlugin)
    
    # In main code:
    plugin_registry.discover_plugins()  # Explicit loading
    
  • 始终显式导入所需的子模块。仅依赖于其他导入来确保模块具有子模块作为属性是不够的。明确地说,除非在 foo/__init__.py 中有显式的 from . import bar,否则始终使用 import foo.bar; foo.bar.Baz,而不是 import foo; foo.bar.Baz。后者(不可靠地)有效是因为属性 foo.bar 是作为在其他地方导入 foo.bar 的副作用而添加的。
  • 将导入移入函数以提高启动时间的*用户*,应该考虑将它们保留在原处,但添加 lazy 关键字。这允许他们保持依赖关系的清晰并避免重复解析导入的开销,但仍能加快程序速度。
    # Before: Inline import (repeated overhead)
    def process_data(data):
        import json  # Re-resolved on every call
        return json.dumps(data)
    
    # After: Lazy import at module level
    lazy import json
    
    def process_data(data):
        return json.dumps(data)  # Loaded once on first call
    
  • 避免使用通配符(星号)导入,因为这些始终是急切的。

常见问题

这与被拒绝的 PEP 690 有何不同?

PEP 810 采用了一种显式的、选择加入的方法,而不是 PEP 690 的隐式全局方法。关键区别是

  • 显式语法lazy import foo 清楚地标记了哪些导入是惰性的。
  • 局部范围:惰性仅影响特定的导入语句,不会级联到依赖项。
  • 更简单的实现:使用代理对象而不是修改核心字典行为。

具体化时会发生什么变化?什么保持不变?

有什么变化(时间)

  • 何时导入模块 - 推迟到第一次使用,而不是在导入语句处
  • 何时发生导入错误 - 在第一次使用时,而不是在导入时
  • 何时模块级副作用执行 - 在第一次使用时,而不是在导入时

什么保持不变(其他一切)

  • 使用的导入机制 - 相同的 __import__,相同的钩子,相同的加载器
  • 创建的模块对象 - 与急切导入的模块完全相同
  • 咨询的导入状态 - 在*具体化*时(而不是在导入语句时)的 sys.pathsys.meta_path
  • 模块属性和行为 - 具体化后完全无法区分
  • 线程安全 - 与正常导入相同的导入锁定约定

换句话说:惰性导入只改变*何时*发生某事,而不是*什么*发生。具体化后,惰性导入的模块与急切导入的模块无法区分。

惰性导入遇到错误时会发生什么?

导入错误(ImportErrorModuleNotFoundError、语法错误)会被推迟到惰性名称的第一次使用。这类似于将导入移动到函数中。错误将带有清晰的回溯,指向惰性对象的第一次访问。

该实现通过异常链提供增强的错误报告。当惰性导入在具体化期间失败时,会保留原始异常并对其进行链接,显示导入的定义位置和第一次使用的位置。

Traceback (most recent call last):
  File "test.py", line 1, in <module>
    lazy import broken_module
ImportError: lazy import of 'broken_module' raised an exception during resolution

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test.py", line 3, in <module>
    broken_module.foo()
    ^^^^^^^^^^^^^
  File "broken_module.py", line 2, in <module>
    1/0
ZeroDivisionError: division by zero

具体化期间的异常会阻止替换惰性对象,并且后续使用惰性对象将重试整个具体化。

惰性导入如何影响具有导入时副作用的模块?

副作用被推迟到第一次使用。这通常有利于性能,但可能需要更改代码以处理依赖导入时注册模式的模块。我们建议

  • 使用显式初始化函数而不是导入时副作用
  • 在需要时显式调用初始化函数
  • 避免依赖导入顺序来处理副作用

我可以使用惰性导入与 from ... import ... 语句吗?

是的,只要你不使用 from ... import *lazy import foolazy from foo import bar 都受支持。 bar 名称将被绑定到一个惰性对象,该对象在第一次使用时解析为 foo.bar

lazy from module import Class 是加载整个模块还是只加载类?

它加载*整个模块*,而不仅仅是类。这是因为 Python 的导入系统始终执行完整的模块文件——没有机制可以仅执行 .py 文件的一部分。当你第一次访问 Class 时,Python 会

  1. 加载并执行完整的 module.py 文件
  2. 从生成的模块对象中提取 Class 属性
  3. Class 绑定到你的命名空间中的名称

这与急切的 from module import Class 行为完全相同。惰性导入的唯一区别是步骤 1-3 在第一次使用时发生,而不是在导入语句处发生。

# heavy_module.py
print("Loading heavy_module")  # This ALWAYS runs when module loads

class MyClass:
    pass

class UnusedClass:
    pass  # Also gets defined, even though we don't import it

# app.py
lazy from heavy_module import MyClass

print("Import statement done")  # heavy_module not loaded yet
obj = MyClass()                  # NOW "Loading heavy_module" prints
                                 # (and UnusedClass gets defined too)

关键点:惰性导入会延迟模块加载*的时间*,而不是延迟*什么*被加载。你不能选择性地只加载模块的一部分——Python 的导入系统不支持部分模块执行。

类型注解和 TYPE_CHECKING 导入呢?

惰性导入消除了对 TYPE_CHECKING 保护的常见需求。你可以编写

lazy from collections.abc import Sequence, Mapping  # No runtime cost

def process(items: Sequence[str]) -> Mapping[str, int]:
    ...

而不是

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from collections.abc import Sequence, Mapping

def process(items: Sequence[str]) -> Mapping[str, int]:
    ...

惰性导入的性能开销是多少?

开销极小

  • 第一次使用后零开销(假设导入未失败),这得益于自适应解释器将慢路径优化掉。
  • 创建代理对象的微小一次性成本。
  • 具体化(第一次使用)的成本与常规导入相同。
  • 无持续的性能损失。

使用 pyperformance 套件进行的基准测试表明,当不使用惰性导入时,该实现是性能中性的。

我可以混合使用同一模块的惰性导入和急切导入吗?

是的。如果模块 foo 在同一个程序中被惰性导入和急切导入,那么急切导入具有优先权,并且两个绑定都解析到同一个模块对象。

如何将现有代码迁移到使用惰性导入?

迁移是渐进的

  1. 使用分析工具识别加载缓慢的模块。
  2. lazy 关键字添加到并非立即需要的导入。
  3. 测试副作用时序的变化是否会破坏功能。
  4. 使用 __lazy_modules__ 以兼容旧版 Python。

星号导入(from module import *)怎么样?

通配符(星号)导入不能是惰性的——它们始终是急切的。这是因为在加载模块之前无法确定要导入的名称集。将 lazy 关键字与星号导入一起使用将是语法错误。如果全局启用了惰性导入,星号导入仍将是急切的。

惰性导入如何与导入钩子和自定义加载器进行交互?

导入钩子和加载器正常工作。当使用惰性对象时,标准导入协议将运行,包括在具体化时间已有的任何自定义钩子或加载器。

多线程环境中会发生什么?

惰性导入的具体化是线程安全的。只有一个线程会执行实际导入,并且绑定会原子地更新。其他线程将看到惰性代理或最终解析的对象。

我可以在不使用它的情况下强制具体化惰性导入吗?

是的,可以通过调用其 resolve() 方法来解析单个惰性对象。

为什么不使用 importlib.util.LazyLoader

LazyLoader 有显著的限制

  • 为每个惰性导入需要冗长的设置代码。
  • from ... import 语句配合不佳。
  • 比专用语法更不清晰和不标准。

这会破坏 isortblack 等工具吗?

linters、formatters 和其他工具需要更新才能识别 lazy 关键字,但更改应该很小,因为导入结构保持不变。关键字出现在开头,易于解析。

我如何知道一个库是否与惰性导入兼容?

大多数库应该能很好地与惰性导入一起工作。可能存在问题的库

  • 具有关键导入时副作用的库(注册、猴子补丁)。
  • 期望特定导入顺序的库。
  • 在导入期间修改全局状态的库。

如有疑问,请用您的具体用例测试惰性导入。

如果我全局启用惰性导入模式,而某个库工作不正常怎么办?

*注意:这是一个高级功能。* 您可以使用惰性导入过滤器来排除已知具有问题副作用的特定模块。

import sys

def my_filter(importer, name, fromlist):
    # Don't lazily import modules known to have side effects
    if name in {'problematic_module', 'another_module'}:
        return False  # Import eagerly
    return True  # Allow lazy import

sys.set_lazy_imports_filter(my_filter)

过滤器函数接收导入模块名、被导入模块名和 fromlist(如果使用 from ... import)。返回 False 会强制急切导入。

或者,将全局模式设置为 "none"(通过 -X lazy_imports=none)以关闭所有惰性导入进行调试。

我可以在函数内部使用惰性导入吗?

不,lazy 关键字只允许在模块级别。对于函数级别的惰性加载,请使用传统的内联导入,或使用 lazy 将导入移至模块级别。

向前兼容旧版 Python 怎么样?

使用 __lazy_modules__ 进行兼容性处理

# Works on Python 3.15+ as lazy, eager on older versions
__lazy_modules__ = ['expensive_module', 'expensive_module_2']
import expensive_module
from expensive_module_2 import MyClass

__lazy_modules__ 属性是模块名字符串的列表。当执行导入语句时,Python 会检查要导入的模块名是否出现在 __lazy_modules__ 中。如果出现,该导入将被视为具有 lazy 关键字(成为*潜在惰性*)。在不支持惰性导入的 Python 3.15 之前的版本上,__lazy_modules__ 属性将被忽略,导入将像往常一样急切地继续。

这提供了一个迁移路径,直到您可以依赖 lazy 关键字。为了获得最大的可预测性,建议在任何导入之前定义一次 __lazy_modules__。但由于它在每次导入时都会被检查,所以它可以在 import 语句之间进行修改。

显式惰性导入如何与 PEP 649 和 PEP 749 交互?

Python 3.14 实现了注释的延迟评估,如 PEP 649PEP 749 所指定。如果注释不是字符串化的,它就是一个将在稍后时间评估的表达式。只有在访问注释时才会解析它。在下面的示例中,fake_typing 模块仅在用户检查 __annotations__ 字典时才会被加载。如果用户使用 annotationlib.get_annotations()getattr 来访问注释,fake_typing 模块也会被加载。

lazy from fake_typing import MyFakeType
def foo(x: MyFakeType):
  pass
print(foo.__annotations__)  # Triggers loading the fake_typing module

惰性导入如何与 dir()getattr() 和模块内省进行交互?

通过正常属性访问或getattr()访问延迟导入会触发对已访问属性的具现化。在模块上调用dir()将在mod.__dir__中进行特殊处理,以避免具现化。

lazy import json

# Before any access
# json not in sys.modules

# Any of these trigger reification:
dumps_func = json.dumps
dumps_func = getattr(json, 'dumps')
# Now json is in sys.modules

惰性导入是否适用于循环导入?

延迟导入不会自动解决循环导入问题。如果两个模块存在循环依赖,延迟导入可能**仅在**循环引用在模块初始化期间不被访问时有所帮助。但是,如果任一模块在导入时访问另一个模块,您仍然会收到错误。

可以正常工作的示例(函数中的延迟访问)

# user_model.py
lazy import post_model

class User:
    def get_posts(self):
        # OK - post_model accessed inside function, not during import
        return post_model.Post.get_by_user(self.name)

# post_model.py
lazy import user_model

class Post:
    @staticmethod
    def get_by_user(username):
        return f"Posts by {username}"

这之所以可行,是因为两个模块在模块级别都没有相互访问——访问发生在稍后调用get_posts()时。

失败的示例(导入时访问)

# module_a.py
lazy import module_b

result = module_b.get_value()  # Error! Accessing during import

def func():
    return "A"

# module_b.py
lazy import module_a

result = module_a.func()  # Circular dependency error here

def get_value():
    return "B"

这会失败,因为module_a在导入时尝试访问module_b,而module_b随后尝试在module_a完全初始化之前访问module_a

最佳实践仍然是避免在代码设计中出现循环导入。

惰性导入会影响我热路径的性能吗?

首次使用后(前提是导入成功),由于自适应解释器的存在,延迟导入的开销为。解释器会专门化字节码(例如,LOAD_GLOBAL变为LOAD_GLOBAL_MODULE),从而消除了后续访问中的延迟检查。这意味着一旦延迟导入被具现化,访问它的速度与正常导入一样快。

lazy import json

def use_json():
    return json.dumps({"test": 1})

# First call triggers reification
use_json()

# After 2-3 calls, bytecode is specialized
use_json()
use_json()

您可以使用dis.dis(use_json, adaptive=True)观察专门化。

=== Before specialization ===
LOAD_GLOBAL              0 (json)
LOAD_ATTR                2 (dumps)

=== After 3 calls (specialized) ===
LOAD_GLOBAL_MODULE       0 (json)
LOAD_ATTR_MODULE         2 (dumps)

专门化的LOAD_GLOBAL_MODULELOAD_ATTR_MODULE指令是优化的快速路径,在检查延迟导入时没有开销。

sys.modules 呢?惰性导入何时会出现在那里?

直到被具现化(首次使用)之前,延迟导入的模块会出现在sys.modules中。一旦具现化,它就会像任何急切导入一样出现在sys.modules中。

import sys
lazy import json

print('json' in sys.modules)  # False

result = json.dumps({"key": "value"})  # First use

print('json' in sys.modules)  # True

lazy from __future__ import feature 有效吗?

否,未来导入不能是延迟的,因为它们是解析器/编译器指令。从技术上讲,运行时行为可以是延迟的,但没有实际价值。

你为什么选择 lazy 作为关键字名称?

不是“为什么”…要记住!:)

延迟的想法

以下想法已在考虑之中,但为优先交付稳定、可用的核心功能而被故意推迟。一旦我们有了延迟导入的实际使用经验,这些想法可能会在未来的增强中被考虑。

替代语法和人体工程学改进

已提出几种替代的语法形式,以改善人体工程学。

  • 仅类型导入:可以添加一种专门用于类型注解的导入语法(类似于其他上下文中的type关键字),例如type from collections.abc import Sequence。这比使用lazy进行仅类型导入更清楚地表达了意图,并会向读者表明该导入在运行时永远不会被使用。然而,由于lazy导入已经解决了类型注解的运行时成本问题,我们倾向于从更简单、更通用的机制开始,并在收集使用数据后评估专用语法是否能带来足够大的价值。
  • 块状语法:将多个延迟导入分组到一个块中,例如
    as lazy:
        import foo
        from bar import baz
    

    这可以减少在标记许多导入为延迟时出现的重复。然而,这需要引入一个全新的语句形式(as lazy:块),这与Python现有的语法模式不符。尚不清楚这会如何与其他语言特性交互,或者类似的块级修饰符的先例是什么。这种方法也使得在扫描代码时更不清楚特定导入是否是延迟的,因为您必须查看周围的上下文而不是导入行本身。

虽然这些替代方案可以在某些上下文中提供不同的人体工程学,但它们有相似的缺点:它们需要引入新的语句形式或以不明显的方式重载现有语法,并且它们为许多其他潜在的类似语法模式打开了大门,这将极大地扩展语言。我们倾向于从显式的lazy import语法开始,并在考虑其他语法变体之前收集实际反馈。任何未来的人体工程学改进都应基于实际使用模式而不是推测性的好处来评估。

if TYPE_CHECKING 块的自动惰性导入

未来的增强功能可能会自动将if TYPE_CHECKING:块内的所有导入视为延迟的。

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from foo import Bar  # Could be automatically lazy

然而,这需要进行重大的更改才能在编译时实现,因为TYPE_CHECKING目前只是一个运行时变量。编译器需要对这种模式有特殊的了解,类似于如何处理from __future__ import语句。此外,需要将TYPE_CHECKING设为内置的才能可靠地工作。由于lazy导入已经解决了仅类型导入的运行时成本问题,我们倾向于从显式语法开始,并评估此优化是否能带来足够大的价值。

模块级惰性导入模式

模块级别的声明,使该模块中的所有导入默认都是延迟的。

from __future__ import lazy_imports
import foo  # Automatically lazy

这一点被讨论过但被推迟了,因为它引发了几个问题。使用from __future__ import意味着这将在未来的Python版本中成为默认行为,这一点尚不清楚,也未计划。它也引发了关于这种模式如何与全局标志交互以及过渡路径将是什么样的问题。当前的显式语法和__lazy_modules__为初始采用提供了足够的控制。

用于惰性安全声明的模块元数据

未来的增强功能可以允许包在其元数据中声明它们是否适合延迟导入(例如,没有导入时副作用)。这可以由过滤机制或静态分析工具使用。当前的过滤API旨在适应此类未来的添加,而无需更改核心语言规范。

备选实现思路

在开发此PEP期间,我们考虑了以下一些替代设计决策。虽然当前的提议代表了我们认为在简洁性、性能和可维护性方面最好的平衡,但这些替代方案提供了不同的权衡,实现者可以考虑或用于未来的改进。

利用 dict 的子类

而不是更新内部dict对象以直接添加支持延迟导入所需的字段,我们可以创建一个dict对象的子类,专门用于启用延迟导入。但这仍然是一个有泄漏的抽象——像dict.__getitem__这样的方法可以直接调用,并且会影响解释器中全局查找的性能。

替代关键字名称

对于本PEP,我们决定提出lazy作为显式关键字,因为它对于那些已经专注于优化导入开销的人来说感觉最熟悉。我们还考虑了各种其他选项来支持显式延迟导入。最引人注目的替代方案是deferdelay

被拒绝的想法

将新行为设为默认

import默认更改为延迟导入超出了本PEP的范围。从关于PEP 690的讨论中可以清楚地看出,这是一个相当有争议的想法,尽管也许一旦我们广泛使用延迟导入,就可以重新考虑。

不允许在 with 块内进行惰性导入

本PEP的早期版本曾提议禁止在with块内使用lazy import语句,类似于对try块的限制。担心某些上下文管理器(如contextlib.suppress(ImportError))可能以令人困惑的方式抑制导入错误,当与延迟导入结合使用时。

然而,这一限制被否决了,因为with语句具有比try/except块更广泛的语义。虽然try/except明确用于捕获异常,但with块通常用于资源管理、临时状态更改或作用域——这些上下文中的延迟导入效果很好。lazy import语法足够明确,以至于在with块内编写它的开发人员是在做出有意识的选择,这符合Python的“自愿成年人”原则。对于真正有问题的案例,例如with suppress(ImportError): lazy import foo,静态分析工具和linter更适合捕获这些模式,而不是硬性的语言限制。

在全局标志下强制在 with 块中进行急切导入

另一个被否决的想法是,即使全局延迟导入标志设置为"all"with块内的导入也保持急切。理由是谨慎:由于with语句可能会影响导入的行为(例如,通过修改sys.path或抑制异常),强制导入保持急切可能会导致细微的错误。然而,这将产生不一致的行为,其中lazy importwith块中显式允许,但当全局标志启用时,正常导入保持急切。这种显式和隐式延迟之间的不一致令人困惑且难以解释。

更简单、更一致的规则是,全局标志会影响显式lazy import语法允许的任何地方的导入。这避免了三种不同的规则集(显式语法、全局标志行为和过滤机制),而是提供了两种:显式语法规则与全局标志影响的内容相匹配,过滤机制为边缘情况提供了后门。对于需要精细控制的用户,过滤机制(sys.set_lazy_imports_filter())已经提供了一种排除特定导入或模式的方法。此外,没有反向操作:如果全局标志强制with块中的导入急切,但用户希望它们延迟,则没有办法覆盖它,从而产生不对称性。

总之:with块中的导入行为一致,无论是通过显式lazy import标记还是通过全局标志隐式标记,都创建了一个易于解释和推理的简单规则。

修改 dict 对象

最初关于延迟导入的PEP(PEP 690)严重依赖于修改内部dict对象来支持延迟导入。我们认识到这个数据结构经过了高度优化,在整个代码库中被大量使用,并且对性能非常敏感。由于这个数据结构的重要性以及希望将延迟导入的实现与可能对此功能不感兴趣的用户隔离开来,我们决定投入一种替代方法。

字典是Python的基础数据结构。每个对象的属性都存储在dict中,并且dict在运行时被用于命名空间、关键字参数等。向dict添加任何类型的钩子或特殊行为以支持延迟导入将会

  1. 阻止关键解释器优化,包括未来的JIT编译。
  2. 给必须保持简单快速的数据结构增加复杂性。
  3. 影响Python的每一个部分,而不仅仅是导入行为。
  4. 违反关注点分离——哈希表不应该知道导入系统。

过去违反这一原则(保持核心抽象的清洁)的决定给CPython生态系统带来了巨大的痛苦,使得优化变得困难,并引入了细微的错误。

使 lazy 导入在不加载模块的情况下找到模块

Python的import机制将查找模块和加载模块分开,并且延迟导入的实现理论上可以只推迟加载部分。然而,由于几个关键原因,这种方法被拒绝了。

大部分性能提升来自于跳过查找阶段。这个问题在NFS支持的文件系统和分布式存储上尤其突出,因为每次stat()调用都会产生网络延迟。在这些类型的环境中,stat()调用可能需要几十到几百毫秒,具体取决于网络状况。当有几十个导入,每个导入都进行多次文件系统检查并遍历sys.path时,在执行任何Python代码之前花费在查找模块上的时间可能会非常可观。在一些测量中,查找占了总导入时间的大部分。只跳过加载阶段将使大部分性能问题得不到解决。

更关键的是,将查找与加载分开会在错误处理方面带来两方面最坏的情况。来自导入机制的一些异常(例如,来自缺失模块的ImportError、路径解析失败、ModuleNotFoundError)将在lazy import语句处引发,而另一些异常(例如,SyntaxError、循环导入的ImportError、来自from module import name的属性错误)将在稍后首次使用时引发。这种分割既令人困惑又不可预测:开发人员需要了解内部导入机制才能知道何时会发生哪些错误。当前的设计更简单:通过完全延迟导入,所有与导入相关的错误都发生在首次使用时,使得行为一致且可预测。

此外,还存在技术限制:查找模块并不保证导入会成功,甚至不保证不会引发ImportError。在包中查找模块需要加载这些包,因此它只能帮助延迟加载包层次结构的一个级别。因为在模块中“查找”属性需要加载它们,这将为from package import modulefrom module import function之间创建难以解释的差异。

lazy 关键字放在 from 导入的中间

虽然我们发现from foo lazy import bar对于新的显式语法来说是一个非常直观的位置,但我们很快了解到,将lazy关键字放在这里在Python中已经是语法上允许的。这是因为from . lazy import bar是合法的语法(因为空格不重要)。

lazy 关键字放在 import 语句的末尾

我们讨论了将lazy附加到import语句的末尾,例如import foo lazyfrom foo import bar, baz lazy,但最终决定这种方法清晰度较低。例如,如果在单个语句中导入了多个模块,则不清楚lazy绑定是否适用于所有导入的对象,或者是否只适用于部分项。

添加显式 eager 关键字

由于我们不改变默认行为,并且我们不希望鼓励使用全局标志,所以现在考虑为常见、默认情况添加多余的语法还为时过早。这会造成关于默认是什么,或者何时需要eager关键字,或者它是否会影响显式急切导入的模块的延迟导入,从而导致过多的混淆。

允许过滤器即使在全局禁用的情况下也能强制惰性导入

由于延迟导入允许某些先前会失败的循环导入形式,并且这是有意且可取的(尤其对于与类型相关的导入),因此有人建议添加一种方法来覆盖全局禁用并强制特定导入为延迟的,例如,即使延迟导入被全局禁用,也可以调用延迟导入过滤器。

这种方法可能会引入一个复杂的“覆盖”系统层级结构,使得代码更难分析和理解。此外,这可能需要额外的复杂性来引入更细粒度的系统,以便随着延迟导入的使用而启用或禁用特定导入。全局禁用预计不会被普遍使用,而更多地是作为调试和选择性测试的工具,供那些希望严格控制其对延迟导入依赖的用户使用。我们认为,对于在更新包以采用延迟导入的包维护者来说,决定支持在全局禁用延迟导入的情况下运行是合理的。

这可能意味着,随着越来越多的包同时拥抱类型提示和延迟导入,全局禁用将变得大部分未被使用且无法使用。过去也发生过类似的事情,涉及到其他全局标志,考虑到该标志的低成本,这似乎是可以接受的。并且,在对实际使用模式有了更清晰的认识后,以后添加更具体的重新启用机制比移除一个仓促添加的不太合适的机制更容易。

使用以下划线前缀的名称用于高级功能

全局激活和过滤函数(sys.set_lazy_importssys.set_lazy_imports_filtersys.get_lazy_imports_filter)可以通过使用下划线前缀(例如,sys._set_lazy_imports_filter)标记为“私有”或“高级”。这一点被否决了,因为通过文档将它们标记为高级功能就足够了。这些函数对于高级用户,特别是大型部署的运算符来说,具有合法的用例。提供官方机制可以防止与CPython主线的分歧。全局模式被故意记录为面向运行大型集群的运算符的高级功能,而不是面向日常用户或库。Python有高级功能的先例,这些功能在没有下划线前缀的情况下仍然是公共API——例如,gc.disable()gc.get_objects()gc.set_threshold()是高级功能,如果使用不当可能会导致问题,但它们没有下划线前缀。

使用装饰器语法进行惰性导入

可以使用基于装饰器的语法来标记导入为延迟的。

@lazy
import json

@lazy
from foo import bar

这种方法被否决了,因为它引入了太多的未解问题和复杂性。Python中的装饰器设计用于包装和转换可调用对象(函数、类、方法),而不是语句。允许在导入语句上使用装饰器将为许多其他潜在的语句装饰器(@cached@traced@deprecated等)打开大门,从而在我们不想探索的方式中极大地扩展了语言的语法。此外,这引发了这些装饰器来自哪里_的问题:它们需要被导入或内置,这为与导入相关的装饰器创建了一个引导问题。这比聚焦的lazy import语法更具推测性和通用性。

使用上下文管理器而不是新的软关键字

提出了向后兼容的语法,例如以上下文管理器的形式。

with lazy_imports(...):
    import json

这将取代对__lazy_modules__的需求,并允许库在旧版本的Python中使用现有的延迟导入实现之一。然而,添加具有此类效果的魔术with语句将是对Python和with语句本身的一个重大改变,并且将其与此提议中的延迟导入实现相结合并不容易。添加对现有延迟导入器的标准库支持而不更改实现相当于现状,并且不能解决这些现有解决方案的性能和可用性问题。

globals() 返回代理 dict

一种替代在globals()上具现化或暴露延迟对象的方法是返回一个代理字典,该字典在通过代理访问延迟对象时会自动具现化它们。这似乎能兼顾两者之长:globals()立即返回而没有具现化成本,但通过结果访问项将自动解析延迟导入。

然而,这种方法与globals()的实际使用方式根本不兼容。许多标准库函数和内置函数期望globals()返回一个真实的dict对象,而不是一个代理。

  • exec(code, globals())需要一个真实的dict。
  • eval(expr, globals())需要一个真实的dict。
  • 检查type(globals()) is dict的函数将会中断。
  • 字典方法如.update()将需要特殊处理。
  • 由于每次访问时的间接性,性能会受到影响。

代理需要如此透明,以至于在几乎所有情况下都与真实dict无法区分,而这非常难以正确实现。任何偏离真实dict行为的地方都可能成为细微错误的来源。

在访问 __dict__globals() 时自动具体化

对于globals()mod.__dict__如何处理延迟导入,考虑了三种选择。

  1. 调用globals()mod.__dict__会遍历并解析所有延迟对象,然后返回。
  2. 调用globals()mod.__dict__返回带有延迟对象的字典(已选择)。
  3. 调用globals()返回带有延迟对象的字典,但mod.__dict__会具现化所有内容。

我们选择了选项2:globals()__dict__都返回原始命名空间字典,而不会触发具现化。这提供了一个干净、可预测的模型,其中低级内省API不会触发副作用。

globals()__dict__表现相同会产生对称性和简单的思维模型:两者都暴露原始命名空间视图。低级内省API不应自动触发导入,这会令人惊讶且可能成本高昂。在标准库中实现延迟导入(例如traceback模块)的实际经验表明,对__dict__访问的自动具现化很麻烦,并且迫使内省代码加载它只是在检查的模块。

选项1(始终具现化)被否决了,因为它会使globals()__dict__的访问变得出乎意料地昂贵,并阻止内省模块的延迟状态。选项3最初是为了“保护”外部代码不看到延迟对象,但实际使用表明这带来的问题比解决的更多,特别是对于需要内省模块而不触发副作用的标准库代码。

致谢

我们要感谢Paul Ganssle、Yury Selivanov、Łukasz Langa、Lysandros Nikolaou、Pradyun Gedam、Mark Shannon、Hana Joo和Python Google团队、Meta的Python团队、HRT的Python团队、Bloomberg Python团队、科学Python社区,以及所有参与PEP 690初步讨论的人,以及许多其他提供宝贵反馈和见解的人,这些反馈和见解帮助塑造了本PEP。

脚注


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

最后修改:2025-10-15 16:08:56 GMT