bigywater.tech
Python · 88

importlib:如何动态导入和重载模块

importlib 是 import 语句背后的那套 API:能按字符串导入、从任意路径加载文件、只查找不执行、重载、延迟加载。它们各自的边界比想象的要多——尤其是 reload。这篇把每个边界都做成了可运行的小实验。

reload 后 id 不变
但 from 导入的旧值、旧类的实例都不会自动更新
find_spec
只查找不执行:colorsys 前后都不在 sys.modules
0.38 → 310 ms
LazyLoader:返回时 vs 第一次访问属性时

验证一:按字符串导入

import_module 与 __import__
实测 · 本机执行输出
  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_plugin

importlib.import_module("json.decoder") 直接返回你要的那个子模块;而 __import__("json.decoder") 返回的是顶层包 json,除非传 fromlist。写插件系统、按配置加载类时,请用 import_module。相对导入要带 package=。找不到的模块抛 ModuleNotFoundError,e.name 告诉你缺的是哪个。

验证二:reload 到底更新了什么

实测 · 本机执行输出
  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,就是这个原因。

验证三:reload 不会清理被删掉的名字

实测 · 本机执行输出
  文件里已经删掉 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,可以用来做「可选依赖是否安装」的检查,不会触发模块的副作用。

验证六:reload 会重新执行模块顶层代码

实测 · 本机执行输出
  [counter.py 被执行, 第 1 次]
  [counter.py 被执行, 第 2 次]
  [counter.py 被执行, 第 3 次]

模块顶层的每一行——包括打印、注册、计数——都会在每次 reload 时再执行一次:计数器从 1 变成了 3。所以顶层带副作用的模块不适合热重载。

验证七:延迟导入

LazyLoader
实测 · 本机执行输出
  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。适合启动时不想为用不到的大依赖付出导入成本的场景。

分步演示:一次 reload 前后,哪些东西变了、哪些没变

所有值都来自上面的真实输出
importlib.reload 与延迟加载
此刻变化的名字

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 importlib、importlib.util
    • 没有验证:importlib.metadata、importlib.resources(见第 80 篇里的用法)、自定义 sys.meta_path 查找器、多线程同时导入时的锁行为
    京ICP备2026067502号-1京公网安备11010502063487号