Python 导入机制与包工程:从 import 到打包
更新时间:2026-09-02。本文回答:
import到底做了什么?为什么改了代码不重启就不生效?循环导入为什么时好时坏?为什么自己写个json.py会让整个项目崩? 这些问题的答案都藏在 CPython 的导入系统里。本文配套 demo 全部在服务器(CentOS 7,Python 3.6.8)实测,代码见文末「代码位置」。
一、import 的本质:一次「带缓存的模块加载」
很多人把 import x 理解成「把 x 的代码粘过来」,这在 Python 里是错的。import 更准确的语义是:确保模块对象存在于 sys.modules,并把名字绑定到当前作用域。它分三步:
- 查缓存:看
sys.modules这个全局字典里有没有该模块。有就直接用(不重新执行文件)。 - 查找 + 编译 + 执行:没有就按
sys.meta_path的查找器(finder)在sys.path各目录里定位文件,编译成字节码(必要时写__pycache__/*.pyc),然后从上到下执行模块顶层代码,边执行边往模块对象上挂属性。 - 登记 + 绑定:把模块对象存进
sys.modules,再把名字绑到当前命名空间。
关键点:模块顶层代码在「首次被加载」时只执行一次。之后无论 import 多少次,都只是从 sys.modules 取同一个对象。
这张图解释了三个高频困惑:
- 「改了代码为什么不生效?」 因为常驻进程(Web 服务、Jupyter、REPL)里模块早在启动时就加载进
sys.modules了,再import也是取缓存。要么重启,要么importlib.reload(x)。 - 「模块顶层代码居然会执行?」 会。
import不是声明,是可执行语句——顶层的print、连接数据库、读文件都会在导入时跑一遍。所以别在模块顶层放有副作用的重操作。 - 「循环导入为什么有时能过有时崩?」 因为模块在「执行顶层代码」的中途就被登记进
sys.modules了,此时它是个半初始化模块(见第四节)。
二、实测 demo1:sys.modules 缓存与重载
下面用服务器真实输出证明「只加载一次 + 缓存命中 + 可强制重载」(代码 d1_modules_cache/):
== 第一次 import mymod ==
[mymod] 顶层代码执行:模块正在被加载(只会看到我一次)
import 后 mymod.VALUE = 42
== 第二次 import mymod(不会重新执行模块顶层代码)==
sys.modules 里有 mymod 吗? True
sys.modules['mymod'] 就是 mymod 吗? True
== 手工从 sys.modules 删除后再 import(会重新加载)==
[mymod] 顶层代码执行:模块正在被加载(只会看到我一次)
重新加载后 mymod.VALUE = 42
== importlib.reload 官方重载方式 ==
[mymod] 顶层代码执行:模块正在被加载(只会看到我一次)
reload 后 mymod.hello() = hello from mymod第二次 import mymod 没有任何 [mymod] 输出,证明文件没被重新执行;sys.modules["mymod"] is mymod 为 True,证明拿到的是同一个对象。删掉缓存或 importlib.reload 后顶层代码才再次执行。
工程提示:
reload不会替换已经from x import f绑定出去的旧函数引用,热更场景要小心;生产环境正确做法仍是重启进程。
三、sys.path:查找顺序与「同名遮蔽」陷阱
模块从哪找?答案是 sys.path 这个目录列表,从前到后,先找到先用。一个典型顺序(服务器实测 d4_path_shadow/show_path.py):
[0] /tmp/.../d4_path_shadow # 脚本所在目录(最优先)
[1] /usr/lib64/python36.zip # 标准库(打包形式)
[2] /usr/lib64/python3.6 # 标准库源码
[3] /usr/lib64/python3.6/lib-dynload # C 扩展(.so)
[4] /usr/lib64/python3.6/site-packages # 第三方包
[5] /usr/local/lib/python3.6/site-packagessys.path[0] 通常是被运行脚本所在目录(交互模式下是当前工作目录,即空串)。这带来一个经典事故:你在项目里建了一个和标准库/第三方库同名的文件,就会把真的遮蔽掉。
实测 d4_path_shadow/run_shadow.py:在 trap/ 目录放一个假的 json.py,把该目录插到 sys.path[0] 并清掉缓存后重新导入:
== 正常情况:标准库 json ==
json 来自: /usr/lib64/python3.6/json/__init__.py
json.dumps([1,2]): [1, 2]
== 把陷阱目录插到 sys.path[0],清掉缓存再 import ==
json 来自: /tmp/.../d4_path_shadow/trap/json.py
json2.dumps(): 我是假的 json.dumps(标准库被遮蔽了!)铁律:永远不要用 json.py、requests.py、email.py、types.py、string.py 这类标准库同名给文件命名。 排查这类 bug 最快的办法是打印 模块名.__file__ 看它到底从哪加载的——这和本站排查动态库「加载了哪个 .so」时 ldd / /proc/<pid>/maps 的思路完全同源(见 ELF 与动态链接)。
__pycache__/x.cpython-36.pyc 则是字节码缓存:源文件 mtime 没变就直接读 pyc,省掉编译。它是缓存不是真相,删了会自动重建,所以可以放心 find . -name __pycache__ -exec rm -rf {} +。
四、循环导入:半初始化模块引发的血案
循环导入(a 导 b、b 又导 a)本身不一定错,错的是在模块加载期(顶层)就去访问对方还没定义好的名字。看坏示范 d2_circular_bad/:
pkga.py顶层import pkgb,之后才定义func_a;pkgb.py顶层import pkga,并在顶层就print(..., pkga.func_a())。
执行 import pkga:pkga 开始加载 → 跑到 import pkgb → pkgb 开始加载 → 跑到 import pkga,此时 sys.modules['pkga'] 已存在(半初始化,func_a 还没执行到)→ pkgb 顶层立刻调 pkga.func_a():
Traceback (most recent call last):
File "<string>", line 1, in <module>
File ".../d2_circular_bad/pkga.py", line 3, in <module>
import pkgb
File ".../d2_circular_bad/pkgb.py", line 12, in <module>
print("pkgb 顶层调用 ->", pkga.func_a())
AttributeError: module 'pkga' has no attribute 'func_a'注意报错不是「cannot import」而是「module has no attribute」——这正是半初始化模块的指纹:模块对象在,但你要的属性那一行还没执行到。
三种正确解法(按推荐度):
- 延迟导入(lazy import):把
import挪进函数体,等运行时(所有模块都加载完)才导入。最小改动、最常用。 - 重构依赖方向:把双方都依赖的东西抽到第三个模块
common.py,从根上消除环——这才是架构正解。 - 调整导入顺序 / 只在顶层导模块、不调函数:脆弱,不推荐。
五、包、init.py 与相对导入
一个**包(package)**就是带 __init__.py 的目录(Python 3.3+ 无此文件也能作为「命名空间包」,但显式写更清晰、兼容性最好)。__init__.py 在包被导入时执行,常用来暴露公共 API(from .x import f)或定义 __all__、__version__。
包内推荐绝对导入(from app.b import func_b)或显式相对导入(from . import b、from ..sib import x)。相对导入的关键约束:它依赖模块的 __package__,只有「作为包的一部分被导入」时才成立。实测 d3_relative_fix/:
- 正确做法
main_d3.py(在包外,模块内用延迟相对导入):
a.use_b(): a.use_b -> func_b -> func_a
b.use_a(): b.use_a -> func_a -> func_a
OK:包内相对导入 + 延迟导入,循环依赖不再报错- 反面:把含顶层相对导入的模块当独立脚本直接跑
python3 app/toprel.py:
Traceback (most recent call last):
File "app/toprel.py", line 3, in <module>
from . import b # 顶层相对导入
ImportError: cannot import name 'b'直接运行时 __package__ 为空,相对导入找不到「上一级包」。正确运行方式是 python3 -m app.toprel(以模块身份跑,-m 会把当前目录加进 sys.path 并设置好包上下文),或从包外 from app import toprel 导入使用(实测 toprel.top_use() 输出 toprel -> func_b -> func_a)。经验法则:包内模块永远用 -m 或被导入,不要 python 路径/文件.py 直接跑。
六、打包发布:从模块到可安装的包
工程化的最后一步是把包发到 PyPI 或内网源。最小结构:
mypkg/
pyproject.toml # 构建配置与元数据(PEP 621,现代标准)
mypkg/
__init__.py # __version__ = "1.0.0"
core.pypyproject.toml 声明项目名、版本、依赖、Python 版本要求;用 python -m build 产出 wheel(.whl,本质是 zip,安装即解压到 site-packages)和 sdist(.tar.gz)。安装到虚拟环境后,包就出现在 sys.path 的 site-packages 里。虚拟环境(python3 -m venv .venv)的本质就是一份独立的 sys.path/site-packages + 独立解释器软链,让不同项目的依赖互不污染——这与本站 ELF/ABI 讲「动态库搜索路径 LD_LIBRARY_PATH/rpath 隔离」是同一种工程智慧:把依赖查找路径显式化、隔离化。
七、与本站主线的衔接
| Python 导入机制 | 本站对应 | 共同的工程直觉 |
|---|---|---|
sys.modules 缓存单例 | 进程地址空间 | 全局表登记、按需加载、只初始化一次 |
sys.path 查找顺序 | 动态链接 LD_LIBRARY_PATH/rpath | 搜索路径优先级决定「加载谁」 |
同名遮蔽 json.py | ldd//proc/<pid>/maps 查错库 | 打印 __file__ 确认真实来源 |
__pycache__/*.pyc | 编译期 vs 运行期 | 缓存编译产物,mtime 失效重建 |
| 包/虚拟环境隔离 | 容器/独立部署 | 依赖路径隔离,避免相互污染 |
| 顶层副作用导入 | GIL 与多进程 | 导入即执行,理解加载期语义 |
八、常见坑速查
- 改代码不生效:常驻进程取了
sys.modules缓存;重启或importlib.reload。 ImportError: cannot import name 'X':要么循环导入(X 尚未定义),要么相对导入脱离了包上下文(用-m运行)。AttributeError: module 'x' has no attribute 'y'且伴随循环导入:半初始化模块,把顶层访问挪进函数。- 项目里
json.py/requests.py导致奇怪报错:同名遮蔽,改名并打印模块.__file__确认。 python a/b.py直接跑包内文件报相对导入错:改用python -m a.b。- 提交了
__pycache__:加进.gitignore,它是可再生产物。
代码位置
本文所有 demo 位于仓库 demos/python-expert/import-mechanism/,均在服务器(CentOS 7,Python 3.6.8)实测通过:
d1_modules_cache/(mymod.py+main.py):sys.modules 缓存、删除缓存与importlib.reload。d2_circular_bad/(pkga.py+pkgb.py):循环导入在加载期抛AttributeError的坏示范。d3_relative_fix/(app/包 +main_d3.py+app/toprel.py):延迟相对导入正解、直接运行包内模块报ImportError的反例。d4_path_shadow/(show_path.py+trap/json.py+run_shadow.py):sys.path 顺序与标准库遮蔽。run_all.sh:一键复现全部输出(bash run_all.sh,建议export PYTHONIOENCODING=utf-8)。
动手练习
- 把
d1_modules_cache/main.py里的import mymod改成from mymod import VALUE,再del sys.modules['mymod']后观察VALUE会不会变——理解「绑定出去的名字不受 reload 影响」。 - 在
d2_circular_bad/里把pkgb.py顶层那行print(..., pkga.func_a())挪进func_b()体内,重新import pkga,验证环被「延迟」解开。 - 在任意目录建一个
email.py(空文件),同目录开python3执行import email,再print(email.__file__),亲眼看到标准库被遮蔽。 - 给
d3_relative_fix/app/加一个__version__ = "0.1.0",在__init__.py里from .core import ...暴露 API,体会__init__.py作为「包门面」的作用。
一句话总结
import 是一次「查 sys.modules 缓存 → 按 sys.path 查找 → 编译并执行顶层代码 → 登记绑定」的带缓存加载;理解了半初始化模块就能治循环导入,理解了查找顺序就能避开同名遮蔽,用包 + 相对导入 + 虚拟环境 + pyproject 就完成了从脚本到工程的跨越。
深入:回到 Python 进阶总纲;并发模型见 GIL、多进程与 asyncio,对象与内存见 CPython 内部机制,底层加载同源直觉见 ELF 与动态链接。