Chapter 40: Import Semantics
导入语义要回答的问题是:一条 import 语句如何从模块名得到 module object,并把这个对象绑定到当前代码块可用的名字上。读完本章后,读者应能追踪一次导入中的对象创建、模块执行、sys.modules 状态、package 上下文、并发边界和循环导入风险。
本章以一个小 package 作为贯穿材料。它的重点不在文件系统搜索细节,搜索、finder、loader 和 ModuleSpec 的完整机制放到后续章节;这里先固定导入结果和状态变化。官方文档中,Python Language Reference: The import system 把 import 描述为“搜索模块”和“绑定名字”两个动作,本章沿用这个语义边界。
# 目录结构
# app/
# __init__.py
# config.py
# repository.py
# service.py
# app/config.py
print("execute app.config")
SETTINGS = {"region": "us-west"}
# app/repository.py
from . import config
READY = True
def region():
return config.SETTINGS["region"]
# app/service.py
from .repository import READY, region
SERVICE_READY = READY
这段材料会反复出现。观察重点是:导入 app.service 会先得到 app package object,再得到 app.repository module object,repository 执行时又访问 app.config module object。每个模块的顶层代码只在首次成功加载时执行,执行结果保存在对应 module object 的 namespace 中,后续导入主要复用这个对象。
40.1 Module and package objects
导入语义的结果对象是 module object。module object 是一个运行时状态容器,它保存模块名、全局 namespace、加载元数据和顶层代码执行后的绑定结果。普通 .py 文件、C 扩展模块、内置模块和 package 最终都通过 module object 暴露给 Python 代码;差异主要落在加载方式、元数据和是否拥有 package 搜索路径上。
module object 的核心状态在 module.__dict__ 中。模块顶层赋值会写入这个字典,函数定义会把 function object 绑定进去,类定义会把 class object 绑定进去。以 app/config.py 为例,SETTINGS = {"region": "us-west"} 执行后,app.config.__dict__["SETTINGS"] 指向这个字典对象。其它模块通过 app.config.SETTINGS 读取的也是这个 module namespace 中的绑定。
package object 是带有 package 语义的 module object。语言参考说明,包含 __path__ 属性的 module 被视为 package;常规 package 通常由含有 __init__.py 的目录触发,导入 package 时会执行这个 __init__.py,并把执行结果放入 package object 的 namespace。app 因为有 app/__init__.py,导入 app.service 前会先创建并初始化 app module object,然后通过 app.__path__ 继续搜索子模块。
package object 还承担父子模块连接点。导入 app.service 后,解释器会让 app 这个 package object 持有名为 service 的属性,属性值指向 sys.modules["app.service"] 中的 module object。这个连接让 import app.service 后可以写 app.service.SERVICE_READY。模块缓存和 package 属性共同维持“同一个限定名对应同一个导入结果”的可观察效果。
导入语句完成后还会执行名字绑定。import app.service 在当前局部 namespace 中绑定名字 app,表达式 app.service 再沿 package 属性访问到子模块。from app.service import SERVICE_READY 会把 SERVICE_READY 这个对象直接绑定到当前局部 namespace。两种写法共享同一次搜索和加载结果,但当前代码块得到的本地名字不同。
import app.service
x = app.service.SERVICE_READY
from app.service import SERVICE_READY
y = SERVICE_READY
第一段代码让当前 namespace 拥有 app 名字,第二段代码让当前 namespace 拥有 SERVICE_READY 名字。这个差异影响后续重载、测试替换和循环导入排查。排查导入问题时,应先确认当前名字绑定指向 package、submodule,还是 submodule 内部某个对象。
40.2 Module execution and sys.modules cache
模块加载的关键状态位于 sys.modules。sys.modules 是一个从完整模块名到 module object 的映射,语言参考把它定义为导入搜索的第一站。执行导入时,解释器先用完整模块名查询这个映射;命中时直接返回映射中的 module object,缺失时再进入搜索、创建和执行流程。
首次导入 app.service 的核心路径可以抽象成下面的状态流。这里省略 finder 和 loader 的内部细节,只保留本章需要的语义节点。
这条路径中最容易被忽略的是“先写入缓存,再执行顶层代码”。官方导入文档在加载伪代码和说明中明确写出,module object 会在 loader 执行模块代码前进入 sys.modules,这样模块代码在执行期间发生自引用或间接自引用时,导入系统能拿到同一个对象。这个规则给循环导入提供了收敛条件,也带来了半初始化模块的可见状态。
以 app.service 为例,首次导入会按父到子的顺序推进。app 先进入 sys.modules,执行 app/__init__.py;随后 app.repository 进入 sys.modules,执行 repository.py;repository.py 中的 from . import config 又让 app.config 进入 sys.modules 并执行 config.py。所有执行成功后,缓存中至少包含 app、app.config、app.repository、app.service 四个键。
import sys
import app.service
for name in ["app", "app.config", "app.repository", "app.service"]:
module = sys.modules[name]
print(name, module.__name__, module.__package__)
这段观察代码验证的是缓存身份。module.__name__ 保存完整模块名,module.__package__ 保存相对导入所需的 package 上下文。它们由导入系统在执行模块代码前设置,模块顶层代码随后把业务名字写入同一个 module object 的 namespace。
缓存命中改变的是执行次数。再次执行 import app.service 时,导入系统会从 sys.modules["app.service"] 返回已有对象,service.py 顶层代码不会重新执行。当前代码块仍然会发生名字绑定:import app.service 绑定 app,from app.service import SERVICE_READY 绑定 SERVICE_READY 当前指向的对象。顶层执行和本地绑定是两个阶段,应分开判断。
sys.modules 可写,这给测试和热加载带来能力,也带来身份风险。删除 sys.modules["app.service"] 后再次导入,会创建新的 module object;旧引用仍可能被其它模块、函数默认值、类属性或闭包持有。importlib.reload() 采用另一条路径:它通常复用已有 module object,并重新执行模块代码来更新 namespace。两者对 module identity 的影响不同,排查“代码已经改了但对象还是旧的”时必须先看当前引用来自哪个 module object。
加载失败时,导入系统会移除失败模块自己的缓存项,已经成功加载的依赖模块会保留。假设 app.service 顶层抛出异常,sys.modules["app.service"] 会被清理,但之前成功的 app、app.config 或 app.repository 仍可能留在缓存中。下一次导入 app.service 会重新尝试加载失败模块,依赖模块则可能直接从缓存返回。
40.3 Import lock and relative import
并发导入需要先区分两个层次:导入系统内部的初始化串行化,以及应用代码自己的共享状态保护。CPython 的 importlib._bootstrap 中存在 module-level locking,当前主线源码使用 _ModuleLock、_ModuleLockManager 和全局导入锁保护模块锁表;这属于 CPython 实现策略,公共语义层面的稳定结论是:同一个模块在初始化期间会被导入系统用锁协调,等待方通常会看到完成后的同一个 module object 或循环导入中的半初始化对象。
导入锁保护的是模块加载流程。两个线程同时首次导入 app.config 时,通常只有一个线程执行 config.py 顶层代码,另一个线程等待模块初始化完成后复用 sys.modules["app.config"]。这个设计降低了重复执行顶层代码的风险,也让 module identity 在并发导入下保持稳定。
导入锁的边界同样清楚。它不会把应用层所有初始化副作用变成事务,也不会保护不同模块顶层代码之间的业务资源。若 app.config 顶层打开文件、注册全局回调、修改外部服务状态或启动线程,这些动作仍需要应用代码自己设计幂等性和生命周期管理。导入系统只协调“模块对象加载”这件事。
相对导入处理的是另一个问题:当前模块如何从 package 上下文推导目标模块名。from . import config 中的点号表示从当前 package 开始,from ..core import settings 中的两个点号表示向上一层 package 再查找。语言参考的 package relative imports 章节规定,相对导入使用 leading dots,并且只能出现在 from ... import ... 形式中。
# app/repository.py
from . import config
# 等价语义目标:导入 app.config,并把 config 绑定到当前模块 namespace
这段代码依赖 repository.py 的 package 上下文。导入系统通常通过 __package__ 判断当前模块所属 package,缺失时可结合 __spec__ 或 __name__ 推导。正常通过 import app.repository 或 python -m app.repository 执行时,__package__ 能指向 app,相对导入可以解析到 app.config。
直接用文件路径执行 package 内部模块时,package 上下文容易断裂。例如在项目根目录执行 python app/repository.py,当前模块名变成 __main__,__package__ 通常没有可用 package 名,from . import config 缺少定位基准。稳定做法是从项目根目录使用 python -m app.repository,让模块按照可导入名称进入执行路径。
导入锁和相对导入在故障表现上常交织。并发场景下看到“部分属性缺失”时,应先判断目标模块是否处于初始化期间;相对导入失败时,应先检查当前模块的 __name__、__package__、__spec__ 和启动方式。一个偏向状态时序,一个偏向名称解析基准,排查时应分开收集证据。
40.4 Import cycle and module state
循环导入的根源是两个或多个模块在顶层执行阶段互相请求对方。由于导入系统会在执行模块代码前把 module object 放进 sys.modules,第二个模块可以拿到第一个模块的对象;但第一个模块的顶层代码尚未执行完,某些名字还没有绑定。这种对象已存在、namespace 未完整填充的阶段就是半初始化状态。
下面的例子把风险压缩到两个文件中。app/a.py 在定义 provide 前导入 b,app/b.py 又试图从 a 中导入 provide。
# app/a.py
from .b import need_a
VALUE = "a-ready"
def provide():
return VALUE
# app/b.py
from .a import provide
def need_a():
return provide()
执行 import app.a 时,导入系统先创建 app.a module object,并把它放入 sys.modules。随后执行 a.py 第一行,转去导入 app.b。app.b 执行第一行 from .a import provide 时,sys.modules["app.a"] 已经存在,但 a.py 尚未执行到 def provide(),所以 app.a.__dict__ 中还没有 provide 这个绑定。错误信息常会出现“partially initialized module”之类的提示。
半初始化并不必然导致异常。若 app/b.py 写成 from . import a,它拿到的是 app.a module object 本身;只要不在顶层立即读取 a.provide,后续等 app.a 执行完成再访问就可能成功。
# app/b.py
from . import a
def need_a():
return a.provide()
这个改法的关键在访问时机。顶层 from .a import provide 要在 b.py 执行期间立刻从 app.a.__dict__ 取出 provide;from . import a 只把 module object 绑定到 b 的 namespace,函数体内部的 a.provide() 会等函数被调用时再做属性查找。循环导入排查时,判断点应落在“顶层是否立即读取对方尚未绑定的名字”。
工程上处理循环导入有三类稳定手段。第一,把共享常量、协议类或类型定义下沉到第三个低层模块,让双方依赖共同底座。第二,把导入移动到函数内部或方法内部,推迟到双方模块初始化完成后再访问。第三,调整模块职责,让顶层只定义对象,注册、连接和启动动作放到显式入口函数中。
需要注意延迟导入的代价。函数内部导入可以打破初始化时序冲突,但它也把导入失败推迟到运行路径上,并增加局部调用的认知成本。若循环来自模块职责互相依赖,优先选择拆出共同底座;若循环只来自少数可选功能或类型检查路径,局部导入才是合适选择。
循环导入的可复盘证据来自四个对象:sys.modules 中是否已有目标模块,目标模块的 __dict__ 是否包含所需名字,当前执行栈是否处于模块顶层,错误发生在 import module 还是 from module import name。这些证据能把“找不到模块”“模块对象存在但名字未绑定”“相对导入上下文错误”分开。
40.5 Import semantics checklist
导入语义的检查顺序应从名字到对象,再从对象到状态。第一步确认导入语句的绑定目标。import app.service 绑定的是顶层名字 app,import app.service as svc 绑定的是别名 svc,from app.service import SERVICE_READY 绑定的是 SERVICE_READY 指向的对象。很多排查误判来自把“模块已加载”和“当前 namespace 已绑定某个名字”混在一起。
第二步确认完整模块名和 package 上下文。绝对导入需要完整限定名能从搜索路径抵达目标;相对导入需要当前模块拥有可用的 __package__ 或等价 package 信息。文件路径直接执行、测试运行器修改工作目录、交互环境临时改 sys.path,都会改变这个判断。先打印 __name__、__package__、__spec__,再判断相对导入解析基准。
第三步检查 sys.modules。目标模块名存在时,导入系统会优先复用缓存对象;目标键缺失时,导入系统会重新搜索并加载;目标键对应 None 时会触发导入失败语义。若怀疑旧代码、旧类或旧配置仍在生效,应比较当前引用的 module identity 和 sys.modules 中的 module identity。
import sys
import app.service
module = sys.modules.get("app.service")
print(module is app.service)
print(id(module), id(app.service))
print(sorted(name for name in module.__dict__ if name.isupper()))
这段检查把 module identity 和 namespace 内容放在一起看。module is app.service 为真时,当前 package 属性和缓存指向同一个对象;大写名字列表能显示顶层执行已经写入哪些结果。若缓存对象存在但缺少预期名字,就要继续看模块是否加载失败、是否正在循环导入中,或顶层条件分支是否跳过了绑定。
第四步区分执行失败和缓存复用。首次加载失败时,失败模块通常从缓存中移除;依赖模块和副作用导入模块可能已经保留。后续导入可能跳过依赖模块的顶层执行,只重新尝试失败模块。若错误表现受运行顺序影响,应记录首次导入的完整异常,而非只看第二次导入后的缓存状态。
第五步检查循环导入。看到 partially initialized module、cannot import name 或 attribute missing 时,应按执行时序还原:谁先进入 sys.modules,谁在顶层导入了谁,错误点需要的名字是否已经绑定。from module import name 对名字绑定时机更敏感,import module 对 module object 身份更敏感。这个差异常决定最小修复方式。
第六步检查并发边界。若多个线程同时触发首次导入,CPython 的模块锁会协调同名模块加载;若多个不同模块的顶层代码并发修改同一外部资源,应用层仍需自己的同步和幂等设计。排查并发导入时,应记录线程、目标模块名、是否同名模块、是否在顶层执行外部副作用。
第七步把导入问题落回工程结构。频繁依赖修改 sys.path、顶层启动服务、模块间互相导入具体对象、测试中手动删除 sys.modules,都会扩大导入状态面。稳定结构是:模块顶层定义对象和轻量常量,package 提供清晰边界,启动逻辑放入显式入口,循环依赖通过共同底座或延迟访问收束。
最小自检任务
阅读下面的 package 片段,判断执行 import app.a 时会发生什么,并说明 sys.modules 和 module namespace 在错误发生前处于什么状态。
# app/__init__.py
# app/a.py
from .b import call_a
VALUE = "ready"
def get_value():
return VALUE
# app/b.py
from .a import get_value
def call_a():
return get_value()
答案要点
执行 import app.a 时,导入系统会先创建 app package object,再创建 app.a module object,并把 app.a 放入 sys.modules。随后执行 a.py 第一行,导入 app.b。
执行 app.b 顶层代码时,from .a import get_value 会查询已经在 sys.modules 中的 app.a module object。此时 a.py 尚未执行到 def get_value(),app.a.__dict__ 中还没有 get_value 绑定,所以这次从模块取名字会失败。错误核心是 module object 已存在,但目标名字尚未写入 module namespace。
sys.modules 在错误发生前通常已经包含 app 和半初始化的 app.a,也可能短暂包含正在执行的 app.b。加载失败后,导入系统会清理失败模块自己的缓存项;已经成功初始化的 package 或依赖模块可能保留。具体保留形态要按首次异常发生点判断,结论应以“目标名字尚未绑定”作为主因。
把 app/b.py 改成 from . import a 并在 call_a() 内部调用 a.get_value(),可以把属性读取推迟到 app.a 初始化完成之后。这个修复成立的前提是 call_a() 不在 b.py 顶层立即执行。若顶层马上调用 call_a(),读取时机仍然落在半初始化阶段。
本章知识点总结
- 导入结果:导入语义最终返回 module object,并把结果按
import或from ... import ...的形式绑定到当前 namespace。 - 模块容器:module object 用
__dict__保存顶层代码执行后的名字绑定,函数、类、常量和导入结果都写入这个 namespace。 - 包对象:package 是带有
__path__的 module object,它提供子模块搜索路径和父子模块连接点。 - 绑定差异:
import app.service绑定顶层 package 名字,from app.service import name绑定目标对象当前指向的名字。 - 缓存入口:
sys.modules是导入搜索的第一站,命中时导入系统复用已有 module object。 - 执行时机:首次加载会先创建 module object 并写入
sys.modules,再执行模块顶层代码填充 namespace。 - 失败清理:模块加载失败时,失败模块自己的缓存项会被移除,已经成功加载的依赖模块可能保留。
- 相对导入:相对导入依赖当前模块的 package 上下文,文件路径直接执行 package 内模块容易让这个上下文缺失。
- 导入锁:CPython 使用模块级锁协调同名模块初始化,应用层顶层副作用仍需自己设计幂等性和同步边界。
- 半初始化:循环导入会暴露已经进入
sys.modules但 namespace 尚未完整填充的 module object。 - 读取时机:
from module import name会在导入阶段立即读取名字,import module可以把属性读取推迟到后续执行点。 - 循环修复:拆出共同底座、推迟局部导入、收敛顶层副作用,是处理循环导入的三种稳定方式。
- 排查顺序:先看当前名字绑定,再看完整模块名和 package 上下文,再看
sys.modules、执行失败、循环导入和并发边界。