importlib 是 import 语句背后的那套 API:能按字符串导入、从任意路径加载文件、只查找不执行、重载、延迟加载。它们各自的边界比想象的要多——尤其是 reload。这篇把每个边界都做成了可运行的小实验。
importlib.import_module('json.decoder') → json.decoder
__import__('json.decoder') 返回的是顶层包: json | fromlist=['x'] 之后返回: json.decoder
相对导入需要 package 参数: json.encoder
不存在的模块 → ModuleNotFoundError: No module named 'no_such_plugin' | e.name = no_such_pluginimportlib.import_module("json.decoder") 直接返回你要的那个子模块;而 __import__("json.decoder") 返回的是顶层包 json,除非传 fromlist。写插件系统、按配置加载类时,请用 import_module。相对导入要带 package=。找不到的模块抛 ModuleNotFoundError,e.name 告诉你缺的是哪个。
reload 前后 id(cfg) 相同? True | cfg.VALUE = 2 | cfg.NEW = only in v2 但先前 from cfg import VALUE 的那个名字还是: 1 (旧值) 旧的类对象 old_K is cfg.K? False | 旧实例 isinstance(k_inst, cfg.K)? False ← reload 之后旧实例不再是新类的实例
reload 不会创建新模块对象:它把模块的代码在同一个字典里重新执行一遍。所以 cfg.VALUE 变成 2,新增的 NEW 出现了。但是:①之前用 from cfg import VALUE 拿走的名字是「当时的值」,不会跟着变;②类被重新定义了,cfg.K 是一个新的类对象,旧实例不再是新类的实例(isinstance 为 False)——热重载后出现「类型对不上」的诡异 bug,就是这个原因。
文件里已经删掉 B, reload 后 hasattr(cfg2, 'B') = True (旧名字残留)
文件里已经删掉 B,重新执行只会覆盖 A,字典里旧的 B 依然在。所以热重载之后模块的状态可能是「新代码 + 旧残留」的混合体,不等于重启进程。
module_from_spec 之后, 代码执行了吗? False | 已经在 sys.modules 里吗? False exec_module 之后: mod.NAME = 我是插件 | mod.run() = run 被调用 | 在 sys.modules 里吗? False 文件名 my-plugin.py 里有连字符, 用普通 import 写不出来, 用这种方式没问题
三步走:spec_from_file_location 造 spec → module_from_spec 造空模块(此时代码没执行、也不在 sys.modules)→ exec_module 才执行。这种方式不依赖 sys.path,文件名带连字符(my-plugin.py)也可以;而且默认不会把模块放进 sys.modules,需要的话自己放进去。
find_spec('json' ) → /opt/homebrew/Cellar/python@3.14/3.14.7/Frameworks/Python.framework/Versions/3.14/lib/python3.14/json/__init__.py
find_spec('os.path' ) → frozen
find_spec('cfg' ) → <工作目录>/cfg.py
find_spec('no_such_plugin' ) → None
find_spec('numpy_not_installed' ) → None
colorsys 还没导入过。find_spec 之前 in sys.modules = False, 之后 = False (只查找, 不执行)find_spec 返回模块的位置(spec),不执行任何模块代码:对还没导入的 colorsys,调用前后都不在 sys.modules 里。找不到时返回 None,可以用来做「可选依赖是否安装」的检查,不会触发模块的副作用。
[counter.py 被执行, 第 1 次] [counter.py 被执行, 第 2 次] [counter.py 被执行, 第 3 次]
模块顶层的每一行——包括打印、注册、计数——都会在每次 reload 时再执行一次:计数器从 1 变成了 3。所以顶层带副作用的模块不适合热重载。
lazy('heavy') 返回用时 0.38 ms, 模块顶层代码执行了吗? (上面没有出现 [heavy ...] 就是没有)
[heavy 模块顶层代码开始执行]
第一次访问 h.VALUE = 42, 用时 310.2 ms ← 这时才真正执行了模块用 importlib.util.LazyLoader 包装后,lazy("heavy") 只花了 0.38 ms 就返回,模块里那句 sleep(0.3) 没有执行;第一次访问 h.VALUE 才真正执行了模块,耗时约 310 ms。适合启动时不想为用不到的大依赖付出导入成本的场景。
importlib、importlib.utilimportlib.metadata、importlib.resources(见第 80 篇里的用法)、自定义 sys.meta_path 查找器、多线程同时导入时的锁行为