KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 模块与包:import 的真相与循环导入 — keel 龙骨
## 这一章解决什么问题
这一章解决什么问题
import 用了无数次,但「它到底做了什么、__init__.py 干嘛的、为什么会循环导入」多数人答不上来。这一章拆开 import 的三个步骤,讲清包的组织方式,然后正面解决循环导入——以及工程上更高级的「延迟导入」。
import 到底做了什么
import utils.helpers
这行代码做三件事:
- 查找:按
sys.path的顺序找utils/helpers.py(或包)。 - 执行:把模块文件从头到尾执行一遍——模块顶层有多少代码,这次就跑多少。
- 缓存:执行结果存进
sys.modules字典,键是模块名。之后任何地方再 import 同一模块,直接拿缓存,不再执行。
第 2、3 条是理解一切 import 玄学的钥匙:
- 「为什么 import 时打印的东西出现了」——因为模块顶层代码被执行了。副作用代码放顶层要克制。
- 「为什么模块级单例是安全的」——因为模块只会被执行一次,缓存保证了全进程一份。01 章
lru_cache、06 章的「模块即单例」都是这个机制的红利。
# config.py —— 天然的进程级单例
settings = {"env": "dev"} # 第一次 import 时创建,之后共享
# 任何模块里
from config import settings # 拿到的都是同一个 dict
from x import y 和 import x 的区别只在绑定名字:前者把 x.y 绑到当前命名空间的 y 上,执行成本完全一样——整个模块还是会被执行。不存在「from 导入更省」的说法。
包与 __init__.py
包是带 __init__.py 的目录:
myapp/
├── __init__.py
├── config.py
└── services/
├── __init__.py
└── task.py
__init__.py 有两个角色:
1. 标记包(虽然 3.3+ 有命名空间包,但工程上始终写上它)。
2. 包的「门面」——它自己是个模块,import 包时被执行。在这里做公开 API 收口:
# myapp/services/__init__.py
from myapp.services.task import TaskService, TaskStatus
__all__ = ["TaskService", "TaskStatus"]
于是调用方可以写干净的 from myapp.services import TaskService,不必知道 TaskService 住在哪个文件里。重构时把实现挪文件,门面不改,调用方零感知——这是包内自由重构的关键手段。
相对导入(from .task import TaskService)只在包内部可用,脚本直接运行含相对导入的文件会报错——顶层入口永远用绝对导入。
循环导入:病因与三种解法
病因:模块 A 的顶层 import B,B 的顶层又 import A。import A 时先执行 A,执行到一半去执行 B,B 又要 A——此时 A 在 sys.modules 里只执行了一半,B 拿到的是个残缺模块,访问还没定义的名字就炸:
ImportError: cannot import name 'X' from partially initialized module 'A'
解法一(首选):重新划层,让依赖单向。 循环导入几乎总是设计问题——两个模块互相需要,说明它们共享的东西应该下沉成第三个模块。A→C←B,循环消失。
解法二:把 import 挪进函数(延迟导入)。
# a.py
def run():
from b import helper # 调用时才导入,此刻 a 已执行完毕
helper()
函数体在调用时才执行,那时两个模块都已完整。缺点:每次调用有一次(极小的)缓存查找开销,且依赖关系从文件头消失,要用注释说明。
解法三:只 import 模块,不 import 名字。
# b.py
import a # ✅ 只拿模块对象
def process():
return a.helper() # 调用时才访问 a.helper,那时早定义好了
import a 只要求模块对象存在(半成品也行),a.helper 的解析推迟到调用时。而 from a import helper 要求那一刻 helper 已定义——这就是「import 模块名能过、import 名字会炸」的原因。按这条规律能诊断绝大多数循环导入报错。
延迟导入的工程价值
除了破循环,延迟导入还有两个正当用途:
1. 启动提速:CLI 工具把重型依赖挪进子命令的函数里,--help 秒出。
2. 可选依赖:某功能依赖的可选库没装也不影响主程序:
def export_excel(rows):
try:
import openpyxl # 仅导出时才需要
except ImportError:
raise RuntimeError("install openpyxl to use excel export")
...
后续课讲「按需加载重型模块」时,用的就是这招——import 语句是普通语句,它出现的位置就是你的加载策略。
if __name__ == "__main__" 的模块视角
00 章从「脚本 vs 库」讲过 __name__。现在用 import 的知识补全:模块被执行时 __name__ 是模块名;被当作入口直接跑时是 "__main__"。所以这段代码的含义是「只有直接运行我这个文件时才执行,被 import 时跳过」——测试代码、示例用法放这里,永远不会污染 import 它的人。
模块的内置变量:除了 __name__,还有 __file__ 这把定位钥匙
__name__ 只是解释器给每个模块自动挂的一组「双下划线属性」之一。理解它们,能少写很多「为什么路径对不上」「为什么在 A 机器能跑、B 机器报错」的玄学 bug。最常用、也最容易被忽视的是 __file__。
__file__:模块住在哪
__file__ 是模块源文件的路径。它解决的是「相对路径相对谁」这个经典坑:脚本里裸写的相对路径默认相对『当前工作目录』——而当前工作目录是启动 python 的那个目录,跟你的源文件在哪毫无关系。一旦别人从别的目录调用你的脚本,相对路径立刻失效。
# reader.py 与 data.csv 放在同一目录
from pathlib import Path
BASE = Path(__file__).resolve().parent # 模块所在目录,跟 CWD 无关
DATA = BASE / "data.csv" # 无论谁、从哪启动都能找到
with open(DATA, encoding="utf-8") as f:
...
Path(__file__).resolve().parent 比老式的 os.path.dirname(__file__) 更现代;resolve() 会展开符号链接、归一化 ..。凡是「读取同目录下的配置 / 模板 / 数据」,都用这招,不要裸写 "data.csv"。
注意:__file__ 在交互式 REPL、python -c "..."、以及某些打包 / zip 导入场景下不存在。依赖它之前先 hasattr(sys.modules[__name__], "__file__") 判一下,否则这些场景会 AttributeError 崩。
其余几个:一张表看清
| 属性 | 含义 | 什么时候有用 |
|---|---|---|
__name__ |
模块名;入口文件是 "__main__" |
区分「被运行」还是「被导入」(见上) |
__file__ |
源文件路径 | 定位同目录资源、构建绝对路径 |
__package__ |
所属包名 | 相对导入的上下文依据 |
__doc__ |
模块文档字符串(文件顶部首个字符串字面量) | 自动文档、help() |
__spec__ |
import 系统给的「装载凭证」(ModuleSpec) |
调试 import、看模块从哪加载 |
__cached__ |
对应的 .pyc 缓存路径 |
排查缓存 / 编译问题 |
__loader__ |
实际加载该模块的 loader 对象 | 写自定义导入钩子时 |
__all__ |
from m import * 的名单(见上节) |
控制包的公开 API |
__builtins__ |
本模块可用的内置命名空间 | 理解「内置函数从哪来」 |
要点:dir(m) 列出模块里所有名字(含上面这些和你自己定义的);vars(m) 等于 m.__dict__,就是模块自己命名空间的字典。想看某个模块到底暴露了什么,dir() 永远是第一反应。
模块内按字符串找函数还可用 globals() / locals(),那属于反射与动态分发的话题,放在拔高课 06 章讲。
自检五题
- 两个模块互相
from x import y会炸,改成互相import x就能过——为什么? - 「模块即单例」靠的是 import 三步骤里的哪一条?
__init__.py里的__all__影响什么?from pkg import *时它起什么作用?- 脚本里用
open("data.csv")读取同目录文件,为什么从别的目录启动时可能失败?用__file__怎么写才稳? vars(m)和m.__dict__是什么关系?__file__在 REPL 里不存在时,代码怎么写才不崩?
下一章
07 章讲迭代器协议与上下文管理器——for 循环和 with 语句的底层协议,也是 yield 课的直接前置。