理解 Python 的 import 机制

2024-06-18 Python

Import 语句通常是你的 Python 代码中的第一行代码,但它背后的运行机制很少有人去了解过,这也给许多 Python 开发者带来许多困扰。

本文梳理了 Python import 系统的运行机制,并对常见问题进行了归纳总结,希望对你能有所帮助。

1. 问题引入

你可以使用 import 语句来引入 Python 的模块(module),而 Python 的模块本身是一个比较宽泛的概念,它包括以下三种:

  1. 包(package),
  2. C/C++ 扩展模块(extension module),
  3. 普通模块(.py)。

例如,NumPy 是一个第三方的 package,Python 标准库中的 math 模块是一个 C 扩展模块,而你自己写的大多数 Python 代码都是普通模块。

现在问题来了,假如你写了这样一句 import:

import foo

那么,

  1. Python 去哪里寻找 foo 这个模块?
  2. 如果恰好在同一个位置,分别有一个 package、一个扩展模块和一个普通模块都叫 foo,Python 会把 import 了?

2. import 的查找目录及其优先级

Python 的 sys 模块有一个特殊的变量:sys.path,它是一个列表,存储了一系列目录,这里就是 Python 在遇到 import 语句时查找的目录。

Python 会按顺序依次尝试这些目录,并且对于每个目录,按以下优先级寻找模块:

  1. 包(package),
  2. C/C++ 扩展模块(extension module),
  3. 普通模块(.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']

可以看到:

因此,只要你要 import 的模块位于上述这些目录中的任何一个之中,就能够被成功 import 到。

4. 我 import 失败了,怎么办?

有时候,你要 import 的模块位于特殊的位置,它不在 sys.path 中,此时就会引入失败。

最常见的两种情形:

  1. 在一个模块中 import 外层目录(parent directory)中的模块。
  2. 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 机制,具体包括:

以后如果再遇到 ModuleNotFoundError: No module named 'xxx’ 的错误,那么你要做的就是检查你要 import 的这个模块能否从 sys.path 中找到。如果不能,请使用上述这些方法,告诉 Python 去哪里寻找你要 import 的模块。

6. 参考资料