KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 模块与包:import 的真相与循环导入 — keel 龙骨

## 这一章解决什么问题

这一章解决什么问题

import 用了无数次,但「它到底做了什么、__init__.py 干嘛的、为什么会循环导入」多数人答不上来。这一章拆开 import 的三个步骤,讲清包的组织方式,然后正面解决循环导入——以及工程上更高级的「延迟导入」。

import 到底做了什么

import utils.helpers

这行代码做三件事:

  1. 查找:按 sys.path 的顺序找 utils/helpers.py(或包)。
  2. 执行:把模块文件从头到尾执行一遍——模块顶层有多少代码,这次就跑多少。
  3. 缓存:执行结果存进 sys.modules 字典,键是模块名。之后任何地方再 import 同一模块,直接拿缓存,不再执行。

第 2、3 条是理解一切 import 玄学的钥匙:

# 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 章讲。

自检五题

  1. 两个模块互相 from x import y 会炸,改成互相 import x 就能过——为什么?
  2. 「模块即单例」靠的是 import 三步骤里的哪一条?
  3. __init__.py 里的 __all__ 影响什么?from pkg import * 时它起什么作用?
  4. 脚本里用 open("data.csv") 读取同目录文件,为什么从别的目录启动时可能失败?用 __file__ 怎么写才稳?
  5. vars(m) 和 m.__dict__ 是什么关系?__file__ 在 REPL 里不存在时,代码怎么写才不崩?

下一章

07 章讲迭代器协议与上下文管理器——for 循环和 with 语句的底层协议,也是 yield 课的直接前置。

进入 keel 阅读