2466 字
约 8 分钟
14
S10-A16 · 模块导入的副作用:为什么一 import 就跑起来

一个脚本 import utils 之后,屏幕上冒出了"连接数据库成功"——因为 utils.py 的末尾写了一行 db = connect()。在单文件脚本里这很方便,而在被十处引用之后,它意味着"任何 import 都会建立连接",包括测试与静态分析。

Python 的 import 不是"声明",而是执行:模块顶层代码在被导入时运行一次。这个设计与很多语言不同:Java/Go 的 import 只做名字绑定,C 的 #include 只做文本拼接,而 Python 的 import 会真的运行代码。所以"导入"在 Python 里是一个动作,不是一条声明。
这个设计带来了灵活性,也让"副作用"成为最常见的隐式耦合。


1. import 做四件事

阶段 行为
查找 按 sys.path 找模块
编译 生成 .pyc(如有变更)
执行 运行顶层代码(只一次)
绑定 把名字放进 sys.modules

第三项是关键:顶层代码被执行一次,结果被缓存。所以"导入慢"通常是"顶层做了重活",而"导入有副作用"是"顶层做了有后果的事"。

第四项的缓存也解释了一个现象:同一个模块被导入两次不会执行两次第三项与第四项结合还有个后果:初始化失败会被"记住"吗?不会——如果顶层抛异常,模块不会被放进 sys.modules,于是下一次导入会再执行一遍,再抛一次。这也是"为什么报错出现了两次"的常见解释。
,于是"改代码后没生效"往往是因为缓存与路径问题,而不是代码没改。

2. 顶层不该做的五件事

不该做 后果
建立连接/打开文件 导入即占用资源
读环境变量并校验 测试环境无法导入
执行耗时计算 导入变慢,处处受影响
修改全局状态(logging.basicConfig) 与调用方冲突
启动线程/定时器 重复启动或无法停止

第一项与第五项最危险:它们让"导入"变成"启动",于是测试时一 import 就占资源、一 import 就起线程。

第三项(耗时计算)在数据项目里很常见:顶层加载模型或读大文件,于是"导入这个包要 8 秒"。这类问题在本地开发时被容忍,在 CI 上会叠加成几分钟。
第四项在库代码里尤其不该:basicConfig 是全局的,库一调用就把应用方的日志配置改掉了。

// 反例:顶层副作用
db = connect(os.environ["DB_URL"])          # 导入即连接
logging.basicConfig(level="INFO")            # 覆盖调用方配置
threading.Thread(target=heartbeat, daemon=True).start()   # 导入即起线程

// 正例:延迟到显式调用
def get_db():
    global _db
    if _db is None:
        _db = connect(os.environ["DB_URL"])
    return _db

3. __main__ 保护与它的局限

写法 作用
if __name__ == "__main__": 仅直接运行时执行
函数封装 更彻底
延迟初始化 与初始化时机解耦

第一项保护的是"入口逻辑",不保护顶层副作用:如果 connect() 写在 if 之前,它照样在导入时执行。

所以正确做法是"把副作用移进函数",而不是"给副作用加个 if"。第二项(函数封装)还有一个附带好处:依赖从"隐式"变成"显式参数",于是测试可以传入假对象,而不必改环境变量。这比"延迟初始化"更彻底。
这是最常见的误解。

// 不彻底:副作用仍在顶层
db = connect(URL)                     # 导入即执行
if __name__ == "__main__":
    main()

// 彻底:副作用进函数
def main():
    db = connect(URL)                 # 只有调用时才执行
    ...
if __name__ == "__main__":
    main()

4. 循环导入的三种表现

表现 成因
ImportError: cannot import name X 部分初始化
AttributeError: module has no attribute 同上
时好时坏 导入顺序不同

第三项最烦:同一个环,从不同入口启动时有时成功。原因是"哪个模块先被导入"决定了"环在哪一步断掉"。

第二种(函数内 import)要克制使用:它把"模块依赖"藏进了函数体,于是依赖图上看不出来,而每次调用都要查一次 sys.modules(代价小但非零)。
解法有三种:把共享部分抽到第三个模块(最优)、在函数内 import(应急)、合并模块(承认划分不当)。

// 应急:函数内 import 打破环(有代价:每次调用都查 sys.modules)
def render():
    from . import templates      # 延迟导入,打断顶层环
    return templates.render(...)
// 更优:把两者共用的东西放到 common.py,双方只依赖 common

还有一个"跨模块状态"的坑:在 a.py 里 from b import counter,然后 counter += 1,改的只是 a 里的名字,b.counter 没变。这类错误在"共享计数器/配置"上很常见,而它看起来完全正常。

// 反例:from-import 后重新赋值,改的是本地名
from config import settings
settings = {"debug": True}          # 只改了本地名,config.settings 未变

// 正例:改内容而不是改引用;或 import 模块本身
import config
config.settings["debug"] = True     # 改的是同一个对象

5. 让副作用可见的做法

做法 效果
顶层只放定义 一眼看出
显式初始化函数 调用处可见
导入耗时测量 发现"重"模块
静态检查(如 pyflakes) 发现未使用与循环

第三项最直接:用 python -X importtime -c "import yourpackage" 看每个模块的导入耗时第四项(静态检查)能抓一类隐蔽问题:"导入了但没用到"的模块仍在执行副作用。所以删掉未使用的 import 不只是整洁问题,它能去掉一部分意外行为。
,能立刻发现"某个模块顶层在做重活"。

// 导入耗时:按累计时间排序,看谁最重
python -X importtime -c "import mypkg" 2>&1 | sort -t'|' -k2 -n -r | head -15
// 典型症状:某个模块 800ms,而它只是"读了个配置文件"

想让"顶层无副作用"成为团队规则,最省力的落地方式是加一条检查:所有模块顶层只允许"定义、常量、import",不允许"调用"。

// 简易 AST 检查:禁止顶层出现函数调用(除少数白名单)
import ast, sys
ALLOW = {"dataclass", "field", "namedtuple", "Enum"}
def check(path):
    tree = ast.parse(open(path).read())
    for node in tree.body:
        if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
            fn = ast.unparse(node.value.func)
            if fn.split(".")[-1] not in ALLOW:
                print(f"{path}: 顶层调用 {fn}(应移入函数)")
check(sys.argv[1])

6. 排错:五种"import 出的问题"

现象 成因 处理方向
导入即连数据库 顶层副作用 延迟到函数
测试无法导入 顶层读环境变量 延迟 + 默认值
循环导入报错 顶层互相引用 抽公共/函数内导入
改代码不生效 pyc 或路径混淆 清缓存、查 sys.path
日志配置被改 库内 basicConfig 库不配置日志

第五项(日志被改)的排查方式是"打印 handler 列表":看有几个 handler、谁加的。库正确做法是只 getLogger(__name__),不配置。
第四项在同名模块上最常见:两个包里都有 utils.py,导入到的是 sys.path 里靠前的那个。现象是"我改的这个文件没生效",而实际改的是另一个。

还有一个"性能"上的经验值:导入耗时超过 300ms 的模块应当在文档里标注。因为它会被每一个"顺手 import"的地方带出来,而 CI 里的测试会累积成几分钟。

// 导入耗时的分级(团队约定)
< 50ms    正常
50–300ms  可接受,但不要出现在"常用工具"模块里
> 300ms   需标注,且应当改为延迟加载
// 用 -X importtime 定期体检,把 >300ms 的列出来

7. 边界:什么情况顶层副作用可接受

场景 为什么 做法
一次性脚本(不 import) 无复用 随意
入口模块(main.py) 就是启动点 可执行
明确的初始化模块(bootstrap.py) 语义就是"执行" 可执行,但命名要清楚
常量定义 无副作用 可接受

判断口径一句话:这个模块会不会被别的地方 import。最后一条与"包结构"有关的边界:"初始化模块"应当显式命名(如 bootstrap.py、init_db.py),让人一眼知道"导它等于执行它"。名字模糊(如 common.py)的模块里放副作用,是最坏的组合。
会,顶层就不能有副作用;不会(纯入口),它在顶层做什么都行。


8. 自测

  1. import 的四个阶段中,哪一步导致"导入即执行"?为什么重复导入不会执行两次?
  2. 五件顶层不该做的事里,哪两件让"导入"变成"启动"?为什么库不该调用 basicConfig?
  3. if __name__ == "__main__" 保护的是什么?为什么它不能消除顶层副作用?
  4. 循环导入"时好时坏"的原因是什么?三种解法里哪种最优?
  5. 怎么用 -X importtime 找"重"模块?它的典型症状是什么?
  6. 判断"顶层副作用能否接受"的那个问题是什么?
S10-A16 · 模块导入的副作用:为什么一 import 就跑起来
https://www.clxhxhhr.top/posts/6504/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。