本节目标:能诊断并修复循环导入,理解 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(部分初始化)。执行流程是这样的:
- 导入
cyc.a,开始执行a.py第一行from .b import b_func; - 触发导入
cyc.b,开始执行b.py第一行from .a import a_func; - 此时
cyc.a已经在sys.modules里,但只执行到第一行,a_func还没定义; - 于是
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 性能剖析与优化入门 展开。
小结
本节把导入体系里最棘手的三类问题收尾,要点如下:
- 循环导入的根因是「部分初始化」:模块 A 执行到一半时去导入 B,B 又回头导入 A,此时 A 里还没有需要的名字。
- 三种修法各有取舍:函数内导入最轻、
TYPE_CHECKING适合纯类型依赖、抽第三模块架构最干净。 - 命名空间包(PEP 420)没有
__init__.py,多个目录可合并成同一个包,__file__为None;普通项目仍建议显式加__init__.py。 - 惰性导入有三种层次:函数内导入、模块级
__getattr__、以及 3.15 才有的 PEP 810lazy语法(尚未正式发布)。 python -X importtime能量化导入开销,区分self与cumulative,据此决定哪些重依赖该惰性化。
至此,模块、包与导入这一章讲完了。你已经能把代码组织成多文件、多层的包结构,并处理它们之间的依赖。下一章 6.1 类、实例与属性查找(MRO) 进入面向对象:类如何创建实例、属性如何被查找、继承链上的方法解析顺序(MRO)又是怎么回事。
阅读导航:上一节:5.2 包、init.py 与相对导入 · 下一节:6.1 类、实例与属性查找(MRO) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。