2024-06-18
Python
Import 语句通常是你的 Python 代码中的第一行代码,但它背后的运行机制很少有人去了解过,这也给许多 Python 开发者带来许多困扰。
本文梳理了 Python import 系统的运行机制,并对常见问题进行了归纳总结,希望对你能有所帮助。
1. 问题引入
你可以使用 import
语句来引入 Python 的模块(module),而 Python 的模块本身是一个比较宽泛的概念,它包括以下三种:
- 包(package),
- C/C++ 扩展模块(extension module),
- 普通模块(.py)。
例如,NumPy 是一个第三方的 package,Python 标准库中的 math 模块是一个 C 扩展模块,而你自己写的大多数 Python 代码都是普通模块。
现在问题来了,假如你写了这样一句 import:
import foo
那么,
- Python 去哪里寻找
foo
这个模块? - 如果恰好在同一个位置,分别有一个 package、一个扩展模块和一个普通模块都叫 foo,Python 会把谁 import 了?
2. import 的查找目录及其优先级
Python 的 sys 模块有一个特殊的变量:sys.path
,它是一个列表,存储了一系列目录,这里就是 Python 在遇到 import 语句时查找的目录。
Python 会按顺序依次尝试这些目录,并且对于每个目录,按以下优先级寻找模块:
- 包(package),
- C/C++ 扩展模块(extension module),
- 普通模块(.py)。
找到名字符合的模块,就结束查找。
因此,在 sys.path 中排名靠前的模块,有可能覆盖(隐藏掉)同名的排名靠后的模块;同理,被实现为 package 的模块,能够覆盖(隐藏掉)同名的扩展模块和普通模块等等。
那么,sys.path
列表里都有什么呢?
3. sys.path 的构成
以我的 MacBook 为例,编写一个最简单的 Python 脚本(print_sys_path.py
),打印一下 Python sys.path
变量:
# print_sys_path.py
import sys
from pprint import pp
pp(sys.path)
输出结果:
['/Users/guoshuai/Documents/repos/python-imports',
'/Users/guoshuai/mambaforge/lib/python312.zip',
'/Users/guoshuai/mambaforge/lib/python3.12',
'/Users/guoshuai/mambaforge/lib/python3.12/lib-dynload',
'/Users/guoshuai/mambaforge/lib/python3.12/site-packages']
可以看到:
- 列表中第一个路径是当前脚本所在目录(
python-imports
这个目录); - 接下来两个是当前 Python 的标准库目录(
lib/python312.zip
和lib/python3.12
); - 接下来是当前 Python 的标准库扩展模块目录(
lib/python3.12/lib-dynload
); - 最后一个是当前 Python 的第三方模块目录(
lib/python3.12/site-packages
)。
因此,只要你要 import 的模块位于上述这些目录中的任何一个之中,就能够被成功 import 到。
4. 我 import 失败了,怎么办?
有时候,你要 import 的模块位于特殊的位置,它不在 sys.path 中,此时就会引入失败。
最常见的两种情形:
- 在一个模块中 import 外层目录(parent directory)中的模块。
- import 一个位于别处的自定义目录中的模块。
本质上来说,上面两种情况都是一样的:想要 import 的模块位于一个自定义的位置。因此,本文就以第一种情况为例介绍其解决方法。
考虑以下目录结构:
<root>
├── lib
│ └── hello.py
└── tests
└── main.py
我想要在 main.py
中引入 hello.py
中定义的对象,应该如何操作?
首先,默认情况下,当你用 Python 运行 main.py
脚本时,sys.path 只会包含 tests
目录,它无法得知 hello
这个模块在哪里。
因此,我们需要以某种方式修改 sys.path,让它增加新的查找目录,以找到 hello
模块。
4.1 直接在代码中修改 sys.path
你可以直接在代码里修改 sys.path,例如:
# tests/main.py
import sys
sys.path.append('/path/to/<root>')
import lib.hello
# 或
sys.path.append('/path/to/<root>/lib')
import hello
4.2 通过 PYTHONPATH 修改 sys.path
但上一种方式不够灵活(路径写死在代码里了)。Python 提供了一个环境变量,叫做 PYTHONPATH
,你可以通过它对 sys.path 进行灵活的修改:
$ PYTHONPATH=/path/to/my/lib python print_sys_path.py
['/Users/guoshuai/Documents/repos/python-imports',
'/path/to/my/lib',
'/Users/guoshuai/mambaforge/lib/python312.zip',
'/Users/guoshuai/mambaforge/lib/python3.12',
'/Users/guoshuai/mambaforge/lib/python3.12/lib-dynload',
'/Users/guoshuai/mambaforge/lib/python3.12/site-packages']
这里可以看到,通过 PYTHONPATH
设置的路径,被添加到了第二个位置,即 Python 标准库之前,当前脚本所在目录之后。
因此,可以通过这个环境变量,把 hello
模块所处的目录路径添加到 sys.path 中,且这种添加是仅在运行时生效的,不需要对代码进行修改:
$ PYTHONPATH=/path/to/<root> python tests/main.py
# 此时代码里是 import lib.hello
# 或
$ PYTHONPATH=/path/to/<root>/lib python tests/main.py
# 此时代码里是 import hello
4.3 通过 python -m 执行代码
Python 解释器支持一个 -m
选项,用于把模块(或 package 内的模块)当成 script 来执行。与直接运行脚本相比,它的特殊之处在于,这种方式下,是当前目录(Current Working Directory)而不是当前脚本所在目录被添加到 sys.path 中。例如,在 repos
目录执行下面的命令:
$ python -m python-imports.print_sys_path
['/Users/guoshuai/Documents/repos',
'/Users/guoshuai/mambaforge/lib/python312.zip',
'/Users/guoshuai/mambaforge/lib/python3.12',
'/Users/guoshuai/mambaforge/lib/python3.12/lib-dynload',
'/Users/guoshuai/mambaforge/lib/python3.12/site-packages'] # 注意这里没有 .py 了
这里,由于当前目录变成了 repos
,此时打印出来的 sys.path 的第一个路径也成了 repos
,而不是 print_sys_path.py
所在的 python-imports
目录了。
因此,我们可以在 <root>
目录下执行 python -m tests.main
来执行 main.py
,同时在 main.py
里写 import lib.hello
即可,因为此时 lib
所处的 <root>
目录已经被添加到了 sys.path 中。
这种方式相比上一种 PYTHONPATH
的好处是,不需要手工指定一个要添加的目录,只需要切换到这个目录执行代码即可。如果你要执行的所有代码都在同一个目录下(如这个例子所示的),那这种方法是最便捷的。
4.4 通过相对 Imports
上面讲的所有方法都属于绝对(Absolute)Imports,而 Python 支持包内的相对(Relative)Imports。这部分内容本身也比较复杂,且个人认为对于个人开发者来说,使用的场景不多,因此在这里不再赘述,有机会的话可以单独写一篇文章介绍。
5. 总结
本文主要介绍了 Python 的 import 机制,具体包括:
- Python 会按照
sys.path
列表中的顺序和优先级查找模块; - 优先级依次为包(package)、C/C++扩展模块(extension module)和普通模块(.py)。
- 如果需要引入特殊位置的模块,可以
- 直接在代码中修改 sys.path,
- 通过
PYTHONPATH
环境变量修改 sys.path, - 或者通过
python -m
执行代码。
以后如果再遇到 ModuleNotFoundError: No module named 'xxx’
的错误,那么你要做的就是检查你要 import 的这个模块能否从 sys.path 中找到。如果不能,请使用上述这些方法,告诉 Python 去哪里寻找你要 import 的模块。