《Python编程入门》5.1 import 机制与模块搜索路径

本节从「Python 是怎么找到并加载模块」出发,讲透 import 的三种形式及差异、模块对象与名字绑定的区别、sys.modules 缓存带来的「模块只执行一次」语义,以及 sys.path 的构成与搜索顺序。重点用真实实验对比 python -m 与直接运行脚本时 sys.path[0] 的不同,解释相对导入为何会报错,并说明 __name__ 守卫的原理。

本节目标:搞清楚 import 背后到底发生了什么——Python 如何查找、加载、缓存一个模块,以及为什么同一段代码用不同方式运行会报出不同的导入错误。
适用版本:Python 3.12+(实测 3.14.6)

5.1 import 机制与模块搜索路径

上一节我们学了 一等函数、lambda 与 functools ,把函数当成普通对象传来传去。但到目前为止,所有代码都写在同一个文件里。真实程序不可能只有几百行,必须拆分到多个文件——Python 里叫模块(module),多个模块组织成包(package)。而把模块拼到一起的动作,就是 import。

import 看似简单,却是新手报错最集中的地方:ModuleNotFoundError、ImportError: cannot import name、attempted relative import with no known parent package……这些错误的根源都藏在「Python 如何查找模块」这一套机制里。本节把机制拆开,后面两节再讲包、相对导入与循环导入。

一、三种 import 形式的实际差异

Python 提供了三种导入写法,它们看起来差不多,语义却完全不同。

import math               # 形式 1:绑定名字 math
from math import sqrt     # 形式 2:绑定名字 sqrt
import math as m          # 形式 3:绑定名字 m
写法绑定了什么名字名字指向什么使用方式
import mathmath模块对象math.sqrt(2)
from math import sqrtsqrt模块里的属性(函数)sqrt(2)
import math as mm模块对象m.sqrt(2)

关键区别在名字空间。import math 只往当前名字空间里放一个名字 math,你写 math.sqrt、math.pi、math.floor 时 Python 每次都在模块对象上做属性查找。而 from math import sqrt 直接把 sqrt 这个函数对象搬进当前名字空间,math 这个名字根本不存在。

这带来两个实际影响。第一,from 写法更短,但会让读者不知道 sqrt 来自哪里——名字多了容易冲突。第二,也是更隐蔽的一点:from 拿到的是一份引用快照,下一节会详细演示。

import x as z 与 import x 的唯一区别是绑定的名字不同。它在两种场景下几乎是必须的:包名太长(import matplotlib.pyplot as plt)、或者名字会冲突(import multiprocessing as mp)。注意 from x import y as z 也是合法的,绑定的名字是 z。

二、模块对象 vs 名字绑定

先建立一个反直觉但极其重要的事实:from module import name 并不会在每次使用时都去模块里取值,它只是在导入那一刻把名字绑定到当时的对象上。

# config.py
VERSION = 1

def get_version():
    return VERSION
# demo.py
import config
from config import VERSION, get_version

print("导入时 VERSION =", VERSION)

config.VERSION = 2                 # 修改模块内的全局变量

print("改后 config.VERSION =", config.VERSION)
print("改后本地 VERSION    =", VERSION)          # 本地名字没变
print("改后 get_version()  =", get_version())    # 函数读到新值

真实输出:

导入时 VERSION = 1
改后 config.VERSION = 2
改后本地 VERSION    = 1
改后 get_version()  = 2

为什么本地 VERSION 还是 1?因为 from config import VERSION 执行时,VERSION 这个名字被绑定到整数对象 1 上。后来 config.VERSION = 2 只是让模块对象里的属性指向了新的整数 2,本地那个名字指向的仍是旧的 1。而 get_version() 函数体里写的是 return VERSION,它每次调用时都在模块自己的全局名字空间里查找,所以看到的是新值 2。

结论:模块属性可以被外部修改,但 from 导入的名字不会跟着变。这就是为什么「修改模块全局变量来配置行为」这种做法经常失效——你的代码里那份 from 副本永远是旧的。

三、sys.modules:模块只执行一次

Python 把所有已加载的模块放在一个字典 sys.modules 里。导入一个模块时,解释器先查这个字典,命中就直接复用,不会重新执行模块代码。

# greeter.py
print("[greeter.py] 正在被导入执行……")
GREETING = "hello"

def greet(name):
    return f"{GREETING}, {name}"
# demo1.py
import sys
import greeter
import greeter           # 再次 import

from greeter import greet

print("模块对象相同吗:", sys.modules["greeter"] is greeter)
print("greet:", greet("Ada"))
print("greeter 在 sys.modules:", "greeter" in sys.modules)

真实输出:

[greeter.py] 正在被导入执行……
模块对象相同吗: True
greet: hello, Ada
greeter 在 sys.modules: True

注意 [greeter.py] 正在被导入执行…… 只打印了一次。尽管写了三行 import,模块代码只执行了一次。第二次 import greeter 只是从 sys.modules 里取出已有的模块对象,连绑定动作都几乎没开销。

这个「只执行一次」的语义解释了为什么:

  • 模块顶层不适合放「需要每次都执行」的逻辑;
  • 在模块顶层做的初始化(读文件、建连接)全局只有一份,多个导入方共享;
  • 循环导入会卡住——因为第二个模块去拿第一个模块时,第一个还没执行完(5.3 详述)。

四、sys.path 的构成与顺序

那么 Python 去哪几个地方找模块?答案在 sys.path——一个有序的字符串列表,解释器从左到右依次查找,找到第一个匹配的就停止。

# demo3.py
import sys
for i, p in enumerate(sys.path):
    print(i, repr(p))

在 macOS + Homebrew Python 3.14.6 上直接运行的真实输出:

0 '/private/tmp/py5'
1 '/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/lib/python314.zip'
2 '/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/lib/python3.14'
3 '/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/lib/python3.14/lib-dynload'
4 '/opt/homebrew/lib/python3.14/site-packages'

这些条目按来源可以分为四类:

顺序来源说明
0脚本所在目录由启动方式决定,见下一节
中段PYTHONPATH 环境变量用户自定义,按变量里的顺序插入
后段标准库目录python314.zip、python3.14/、lib-dynload/
末尾site-packages第三方库(pip / uv 装到这里)

PYTHONPATH 的插入位置可以用真实实验验证。把 /tmp/py5/extra 加进去后:

PYTHONPATH=/tmp/py5/extra python3 demo3.py

输出变成:

0 '/private/tmp/py5'
1 '/tmp/py5/extra'
2 '/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/lib/python314.zip'
...

可以看到 PYTHONPATH 的目录被插在脚本目录之后、标准库之前。这也意味着:如果 PYTHONPATH 里有一个和标准库同名的模块,它会覆盖标准库——这是「模块被遮蔽」这类诡异 bug 的来源。运行期也可以临时修改,sys.path.insert(0, "/some/dir") 能立刻让新目录参与查找,但依赖它做「hack 式导入」会让代码难以复现,不推荐。

五、python -m 与直接运行:sys.path[0] 的差异

上一节说 sys.path[0] 由启动方式决定,这是本节最重要的一处细节。先写一个只打印信息的模块:

# pkgdemo/show_path0.py
import sys
print("sys.path[0] =", repr(sys.path[0]))
print("__name__     =", repr(__name__))

用两种方式运行,结果不同:

python3 pkgdemo/show_path0.py
sys.path[0] = '/private/tmp/py5/pkgdemo'
__name__     = '__main__'
python3 -m pkgdemo.show_path0
sys.path[0] = '/private/tmp/py5'
__name__     = '__main__'

区别在 sys.path[0]:

启动方式sys.path[0]含义
python3 script.py脚本所在目录把脚本当独立文件
python3 -m pkg.mod当前工作目录(绝对路径)把模块当包的一部分
python3 -c "..."''(空串,代表 cwd)命令行片段
交互式 / stdin''REPL

这就是「为什么相对导入有时报错」的根因。 直接运行脚本时,Python 只知道「有一个叫 show_path0 的独立文件」,不知道它属于哪个包,于是 __name__ 是 __main__ 而不是 pkgdemo.show_path0。相对导入需要知道「我在哪个包里」,包信息一旦丢失,就会报错:

# app/main.py
from .util import add   # 相对导入
print("1 + 2 =", add(1, 2))

直接运行(错误):

python3 app/main.py
  File "/private/tmp/py5/app/main.py", line 1, in <module>
    from .util import add   # 相对导入
    ^^^^^^^^^^^^^^^^^^^^^
ImportError: attempted relative import with no known parent package

改用 -m 运行(成功):

python3 -m app.main
1 + 2 = 3

注意两种方式下 __name__ 都是 __main__——所以光看 __name__ 不能判断是否在包里,真正决定相对导入能否工作的是解释器有没有为这个模块建立父包信息(__package__)。-m 会建立,直接运行不会。5.2 节会把相对导入的规则完整讲透。

六、name == “main” 的完整解释

现在可以完整回答那个经典写法了。当模块被直接运行时,解释器把它的 __name__ 设为 "__main__";当它被导入时,__name__ 是模块的真实名字。

# guard.py
def main():
    print("main() 被调用")

if __name__ == "__main__":
    print("作为脚本运行,__name__ =", __name__)
    main()
else:
    print("作为模块被导入,__name__ =", __name__)

直接运行 python3 guard.py:

作为脚本运行,__name__ = __main__
main() 被调用

被导入 python3 -c "import guard":

作为模块被导入,__name__ = guard

if __name__ == "__main__": 这个守卫的价值在于:同一个文件既能当脚本执行,又能当模块被导入而不触发副作用。测试代码、演示代码、命令行入口都放在这个块里,导入方就不会莫名其妙地跑起一堆逻辑。它和 5.3 节的循环导入、以及后面 15.2 节的 console 入口点都直接相关。

小结

本节把 import 拆成了四层机制,核心结论如下:

  1. 三种形式绑定不同的名字:import x 绑定模块对象,from x import y 绑定属性对象,import x as z 只是换个名字绑定模块对象。
  2. from 导入的是引用快照:导入那一刻绑定,之后模块属性被改,本地名字不会跟着变。
  3. 模块只执行一次:sys.modules 是缓存,重复 import 只取缓存,模块顶层代码不会重跑。
  4. sys.path 有序查找:脚本目录 → PYTHONPATH → 标准库 → site-packages,靠前目录会遮蔽靠后同名模块。
  5. sys.path[0] 决定相对导入能否工作:python -m 建立父包信息,直接运行脚本不建立,这就是相对导入报错的根因。

下一节 5.2 包、__init__.py 与相对导入 会在本节机制的基础上,讲清楚包的定义、__init__.py 的作用,以及相对导入的完整规则和 __all__ 的控制能力。

阅读导航:上一节:4.3 一等函数、lambda 与 functools · 下一节:5.2 包、init.py 与相对导入 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时