《Python编程入门》5.3 循环导入、命名空间包与惰性导入

本节处理导入体系里最易踩坑的三类问题:循环导入的成因与三种真实修法(调整导入位置、TYPE_CHECKING 类型专用导入、把共享部分抽到第三个模块)、PEP 420 命名空间包的机制与适用场景,以及惰性导入的几种做法,含尚未正式发布的 3.15 PEP 810 预览。最后用 python -X importtime 看导入与启动时间的关系。

本节目标:能诊断并修复循环导入,理解 PEP 420 命名空间包与常规包的差异,掌握函数内导入、模块级 __getattr__ 等惰性导入手段,并会用 python -X importtime 量化导入开销。
适用版本:Python 3.12+(实测 3.14.6)

5.3 循环导入、命名空间包与惰性导入

前两节讲的都是「良性」的导入:5.1 import 机制与模块搜索路径 讲查找与缓存,5.2 包、__init__.py 与相对导入 讲组织与解析。但真实项目里模块之间的依赖往往不是一棵树,而是一张网——A 依赖 B、B 又依赖 A,于是出现循环导入。本节处理这类麻烦,顺带讲两个相关话题:没有 __init__.py 的命名空间包,以及为加快启动而生的惰性导入。

一、循环导入的成因

循环导入的本质,是 5.1 节那条「模块只执行一次」的规则被两个模块同时触发。看一个最小例子。

# cyc/a.py
from .b import b_func

def a_func():
    return "a"

print("a 导入完成")
# cyc/b.py
from .a import a_func

def b_func():
    return "b"

运行 python3 -m cyc.a,报错:

  File "/private/tmp/py5/cyc/b.py", line 1, in <module>
    from .a import a_func
  File "/private/tmp/py5/cyc/a.py", line 1, in <module>
    from .b import b_func
ImportError: cannot import name 'b_func' from partially initialized module 'cyc.b'
(most likely due to a circular import) (/private/tmp/py5/cyc/b.py)

读这段回溯要抓住关键词 partially initialized(部分初始化)。执行流程是这样的:

  1. 导入 cyc.a,开始执行 a.py 第一行 from .b import b_func;
  2. 触发导入 cyc.b,开始执行 b.py 第一行 from .a import a_func;
  3. 此时 cyc.a 已经在 sys.modules 里,但只执行到第一行,a_func 还没定义;
  4. 于是 from .a import a_func 找不到这个名字,报错。

关键认知:循环导入不一定总是报错。如果导入语句放的位置足够晚,或者互相依赖的名字在导入发生时已经定义好,就可能侥幸通过。这种「有时能跑、有时炸」的特性让它特别难排查。因此修法的目标不是「碰运气让它过」,而是打破运行时的循环依赖。

二、修法一:调整导入位置(函数内导入)

最直接的修法是把其中一个导入从模块顶层挪进函数体。模块顶层导入在导入期执行,函数内导入在调用时才执行,那时另一个模块早已加载完成。

# cycfix/a.py
def a_func():
    from .b import b_func   # 函数内导入,调用时才执行
    return "a -> " + b_func()
# cycfix/b.py
from .a import a_func

def b_func():
    return "b"
python3 -c "from cycfix.a import a_func; print(a_func())"
a -> b

现在 b.py 顶层导入 a_func 时,a.py 顶层没有任何导入,可以顺利执行完;等真正调用 a_func() 时,cycfix.b 早就加载好了。这个手法简单有效,但它有两个代价:每次调用都做一次名字查找(虽然 sys.modules 命中很快),以及依赖关系被藏进函数体,读代码时不容易看出模块之间的耦合。所以它适合用在少数真正循环的地方,不适合当常规风格。

三、修法二:只在类型注解里导入(TYPE_CHECKING)

Python 是动态语言,但类型注解在开发时很有价值。问题在于:如果只为了写一个类型注解而导入模块,就白白引入了运行时依赖,可能触发循环。typing.TYPE_CHECKING 就是为此设计的。

# typ/models.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:          # 仅类型检查器会执行,运行时为 False
    from .service import Service

class User:
    def __init__(self, service: "Service"):
        self.service = service
# typ/service.py
from .models import User

class Service:
    def make_user(self):
        return User(self)
python3 -c "
from typ.service import Service
s = Service()
print('运行期 TYPE_CHECKING =', __import__('typing').TYPE_CHECKING)
print(s.make_user().__class__.__name__)
"
运行期 TYPE_CHECKING = False
User

TYPE_CHECKING 在运行时恒为 False,所以 if 块里的导入根本不会执行,循环依赖被切断。类型检查器(pyright、mypy)则把它当作 True,能正常解析 Service。

用字符串形式的注解 service: "Service"(或者 3.11+ 的 from __future__ import annotations)能让 Python 在运行时不去求值这个注解。注意 3.14 已经默认延迟求值注解(PEP 649/749),所以新代码里直接写 service: Service 也不会在运行时出错——但前提是这个名字在求值时能解析到。保守起见,TYPE_CHECKING + 字符串注解的组合仍然最通用。这块细节会在 9.1 类型注解语法与 pyright / mypy 展开。

四、修法三:把共享部分抽到第三个模块

如果两个模块互相需要的是同一份数据或同一个基类,最干净的做法是把它抽到一个被双方依赖、自己不依赖任何一方的第三个模块。

# third/shared.py
SHARED = "共享常量"
# third/a.py
from .shared import SHARED

def a_func():
    return "a 用到 " + SHARED
# third/b.py
from .a import a_func
from .shared import SHARED

def b_func():
    return "b 用到 " + SHARED + " / " + a_func()
python3 -c "from third.b import b_func; print(b_func())"
b 用到 共享常量 / a 用到 共享常量

现在依赖图变成 b → a → shared 和 b → shared,是一张有向无环图,循环消失。这是三种修法里架构上最优的一种,因为它同时改善了模块的职责划分。代价是要多引入一个文件,对于「只是想共享一个常量」的小场景可能显得重。

三种修法怎么选,可以总结成一张表:

修法适用场景代价
函数内导入局部、偶发的循环依赖被藏进函数,可读性下降
TYPE_CHECKING只为类型注解而导入需要配合字符串注解或延迟求值
抽第三模块共享数据 / 基类多一个文件,需重新划分职责

五、命名空间包(PEP 420)

5.2 节说「包 = 含 __init__.py 的目录」。从 Python 3.3 起(PEP 420),还有另一种包:命名空间包(namespace package)——一个没有 __init__.py 的目录,也能被当作包导入,而且多个目录可以拼成同一个包。

构造两个目录,各自贡献 acme 包的一部分:

nsdemo/
├── part1/acme/a.py     # 无 __init__.py
└── part2/acme/b.py     # 无 __init__.py
# nsdemo/part1/acme/a.py
def hello_a():
    return "from part1"
# nsdemo/part2/acme/b.py
def hello_b():
    return "from part2"

把两个目录都放进 sys.path,然后导入同一个 acme:

PYTHONPATH=/tmp/py5/nsdemo/part1:/tmp/py5/nsdemo/part2 python3 demo_ns.py
acme.__path__: ['/tmp/py5/nsdemo/part1/acme', '/tmp/py5/nsdemo/part2/acme']
from part1
from part2

注意 acme.__path__ 里两个目录都在——这正是命名空间包的核心能力:把分散在不同位置的目录合并成一个逻辑包。它不对应任何单个文件,所以 acme.__file__ 与 acme.__spec__.origin 实测都是 None(常规包则指向 __init__.py 的路径)。

命名空间包最典型的用途是大型项目的插件拆分:不同团队各自维护一个 mycompany.plugins.xxx,发布成独立的库,用户安装后它们自动合并到同一个 mycompany.plugins 命名空间下。反过来说,如果你只是想要一个普通包,加上 __init__.py 更稳妥——它能确保包的行为符合直觉,也避免与同名命名空间包意外合并。

六、惰性导入的几种做法

包越大,import 顶层包时连带加载的子模块越多,启动越慢。**惰性导入(lazy import)**的目标是:推迟到真正用到时才加载。常见做法有三层。

做法一:函数内导入。 就是第二节展示的手法,最简单,也最常用。把「只在某个函数里用到」的重依赖放进函数体:

def render_report(data):
    import matplotlib.pyplot as plt   # 只有调用这个函数时才加载
    ...

做法二:模块级 __getattr__(PEP 562)。 当你想让 import mypkg 看起来提供了某个名字、但实际加载推迟到访问时,可以给模块定义 __getattr__。它在属性访问失败时被调用。

# lazy/heavy.py
print("[heavy] 被真正导入了(假设这里开销很大)")

def compute():
    return 42
# lazy/__init__.py
__all__ = ["compute"]

def __getattr__(name):
    if name == "compute":
        import importlib
        heavy = importlib.import_module(".heavy", __name__)
        globals()["compute"] = heavy.compute   # 缓存,避免每次触发
        return heavy.compute
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
python3 demo_lazy.py
导入 lazy 后,heavy 是否已加载: False
[heavy] 被真正导入了(假设这里开销很大)
访问 lazy.compute -> <function compute at 0x10a223480>
访问后 heavy 是否已加载: True

import lazy 时 heavy 没有加载;只有访问 lazy.compute 才触发导入,而且导入后把函数写回 globals() 做缓存,下次访问不再经过 __getattr__。

做法三:PEP 810 显式惰性导入(Python 3.15 预览)。 这是把惰性导入变成语言级语法的工作。PEP 810 已被接受(状态 Final),目标版本是 3.15,语法是在 import 前加一个软关键字 lazy:

import sys
lazy import json

print('json' in sys.modules)   # False,还没加载
result = json.dumps({"hello": "world"})   # 首次使用才触发加载
print('json' in sys.modules)   # True

lazy from json import dumps, loads 也可以,每个名字绑定到一个惰性代理对象,首次访问才真正加载模块。

必须强调:3.15 尚未正式发布。 截至目前 Python 3.15 仍处于 3.15.0rc3,lazy 语法在 3.14.6 这类稳定版上会直接语法报错,本节这段代码无法在本机运行,属于「预览」性质。另外 lazy 是软关键字,只能用在模块顶层,不能放进函数、类或 try 块。等 3.15 正式发布后再以官方文档为准。

七、导入性能与启动时间

把导入开销量化,最方便的工具是 python -X importtime。它让解释器在每次导入模块后打印一行耗时。

# imp_time.py
import json
import decimal
python3 -X importtime imp_time.py

真实输出(节选,单位微秒):

import time: self [us] | cumulative | imported package
import time:      1436 |       1436 |       _json
import time:      1199 |       2635 |     json.scanner
import time:      2055 |       4689 |   json.decoder
import time:      1225 |       1225 |   json.encoder
import time:      1307 |       7220 | json
import time:      1489 |       1489 |     numbers
import time:      5935 |       7424 |   _decimal
import time:      1034 |       8457 | decimal

三列的含义是:

列含义
self [us]这个模块自身代码的执行耗时(不含它导入的子模块)
cumulative含所有子模块的累计耗时
imported package模块全名,缩进反映依赖层级

json 自身只花了约 1307 微秒,但累计 7220 微秒,差额是它拖进来的 _json、json.scanner 等。decimal 的 _decimal 是 C 扩展,单次就花了 5935 微秒。

想知道「导入一个包到底值不值」,还可以用 time.perf_counter() 在进程内直接测:import decimal 在本机实测约 5.82 ms。

对命令行工具、Serverless 冷启动这类「进程寿命很短」的场景,几百毫秒的导入开销会直接体现在响应时间上。这时惰性导入就是有效的优化手段:把只在少数命令里用到的大依赖推迟到实际调用时。更系统的性能剖析会在 18.2 性能剖析与优化入门 展开。

小结

本节把导入体系里最棘手的三类问题收尾,要点如下:

  1. 循环导入的根因是「部分初始化」:模块 A 执行到一半时去导入 B,B 又回头导入 A,此时 A 里还没有需要的名字。
  2. 三种修法各有取舍:函数内导入最轻、TYPE_CHECKING 适合纯类型依赖、抽第三模块架构最干净。
  3. 命名空间包(PEP 420)没有 __init__.py,多个目录可合并成同一个包,__file__ 为 None;普通项目仍建议显式加 __init__.py。
  4. 惰性导入有三种层次:函数内导入、模块级 __getattr__、以及 3.15 才有的 PEP 810 lazy 语法(尚未正式发布)。
  5. python -X importtime 能量化导入开销,区分 self 与 cumulative,据此决定哪些重依赖该惰性化。

至此,模块、包与导入这一章讲完了。你已经能把代码组织成多文件、多层的包结构,并处理它们之间的依赖。下一章 6.1 类、实例与属性查找(MRO) 进入面向对象:类如何创建实例、属性如何被查找、继承链上的方法解析顺序(MRO)又是怎么回事。

阅读导航:上一节:5.2 包、init.py 与相对导入 · 下一节:6.1 类、实例与属性查找(MRO) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时