Python基本原理:模块、包与导入系统
本文收录于Python基本原理系列。
模块
平时我们说“导入一个模块”,听起来像是把一个.py文件导入进来。这个说法对于写代码当然没问题,但是从Python运行时的角度来看,模块并不是文件,而是一个对象。
如果你现在还没有读到后面关于“一切皆对象”的文章,可以先把对象理解成“运行时确实存在的一个东西”。至于为什么函数、模块、类型也都是对象,后面讲到Python对象模型时会更自然地展开。
更准确地说,一个.py文件被导入时,Python会为它创建一个模块对象,然后在这个模块对象的全局命名空间中执行文件里的代码。
可以用下面的代码验证这一点:
1import sys
2
3module = sys.modules["__main__"]
4
5print(type(module))
6print(module.__dict__ is globals())
7
8# 运行结果
9# <class 'module'>
10# True
在名字绑定与命名空间中已经提到过,使用python main.py执行一个文件时,这个文件也会被视作一个模块,只不过它的模块名固定为__main__。模块对象的.__dict__就是这个模块的全局命名空间,所以模块顶层定义的变量、函数、类,最终都会保存在这里。
例如下面这个文件:
1# foo.py
2
3a = 1
4
5
6def func():
7 return a + 1
当foo.py被导入之后,Python会创建一个名为foo的模块对象,然后在foo.__dict__中按顺序执行文件里的顶层语句。这里的“执行”不要狭义地理解成“有输出才叫执行”,赋值语句、函数定义语句、类定义语句也都是会执行的。
例如a = 1执行之后,会在模块命名空间中绑定名字a;def func(): ...执行之后,会构造一个函数对象并把它绑定到名字func。只不过函数体内部的return a + 1不会在定义函数时执行,而是等到调用func()时才执行。
所以最终得到的效果类似于:
1foo.__dict__["a"] = 1
2foo.__dict__["func"] = <function func>
当然,真实过程不是直接执行这两行伪代码,但从命名空间的角度看,这个理解足够自洽。
模块对象上还会有一些特殊属性:
__name__:模块名。__file__:模块对应的源代码文件路径。__package__:模块所属的包,主要用于相对导入。__spec__:模块的导入说明,记录了加载器、来源、是否是包等信息。
这些特殊属性大多由导入系统自动填写。平时最常见的是__name__,也就是经典的:
1if __name__ == "__main__":
2 main()
它的意思是:如果当前模块是程序入口模块,而不是被其它模块导入进来的模块,就执行main()。
import做了什么
如果只看表面,import foo的意思是“导入foo模块”。但如果把这个过程拆开,它大致做了下面几件事:
- 检查
sys.modules中有没有foo。 - 如果没有,调用查找器(Finder)寻找
foo对应的模块说明(ModuleSpec)。 - 根据ModuleSpec中的加载器(Loader)创建模块对象。
- 把模块对象放入
sys.modules。 - 由加载器在模块对象的命名空间中执行模块代码。
- 在当前命名空间中绑定名字
foo。
第一步就是后面会重点讲的缓存机制:如果sys.modules["foo"]已经存在,导入系统通常会直接复用这个模块对象,不会重新查找和执行模块。
如果没有缓存,导入系统会开始“找模块”。这个过程不是简单粗暴地遍历sys.path,而是先遍历sys.meta_path中的查找器。每个查找器都有机会根据模块名返回一个ModuleSpec对象,ModuleSpec中记录了这个模块从哪里来、应该用哪个Loader加载、它是不是一个包等信息。
可以简单看一下内置的查找器:
1import sys
2
3for finder in sys.meta_path:
4 print(finder)
对于最常见的.py文件导入,真正会去sys.path中找文件的是其中一个路径查找器。也就是说,sys.path不是整个导入机制的全部,而是路径查找器工作时使用的搜索路径。
找到ModuleSpec之后,Loader会负责创建模块对象并执行模块代码。对于源码模块来说,执行模块代码的过程大致就是:读取源文件,编译成代码对象,然后以模块对象的.__dict__作为全局命名空间执行这个代码对象。
注意,最后一步才是import foo这条语句对当前模块造成的直接影响。也就是说,import foo并不是把foo.py里的所有变量复制到当前模块,而是把当前模块中的名字foo绑定到一个模块对象上。
所以对于下面的代码:
1import math
2
3print(math.sqrt(4))
真正发生的是:当前模块的全局命名空间中多了一个名字math,它绑定到math这个模块对象。之后访问math.sqrt,本质上就是在math模块对象的命名空间中查找sqrt。
如果换成from math import sqrt,情况就不一样了:
1from math import sqrt
2
3print(sqrt(4))
这条语句依然会导入math模块,但是它不会在当前命名空间中绑定math这个名字,而是把当前命名空间中的名字sqrt直接绑定到math.__dict__["sqrt"]对应的对象上。
所以:
1import math
2from math import sqrt
3
4print("math" in globals())
5print("sqrt" in globals())
6
7# 运行结果
8# True
9# True
这两种导入方式的区别,不在于是否“真的导入了模块”,而在于导入之后在当前命名空间里绑定了什么名字。
注意
from math import *则是把math模块中允许导出的名字批量绑定到当前命名空间。它会让当前命名空间突然多出一堆不明显的名字,因此除了交互式探索和少数专门设计过的模块之外,不建议使用。
sys.modules缓存
如果一个模块被多次导入,它的顶层代码会执行几次呢?
可以准备两个文件:
1# foo.py
2
3print("foo is running")
4
5a = 1
1# main.py
2
3import foo
4import foo
5
6print(foo.a)
运行结果是:
1foo is running
21
虽然写了两次import foo,但是foo.py中的print只执行了一次。原因就在于sys.modules。
sys.modules是一个字典,用于保存已经加载过的模块对象。第一次执行import foo时,Python会创建模块对象、执行模块代码,并把这个模块对象保存到sys.modules["foo"]中。第二次执行import foo时,Python发现sys.modules里已经有foo了,就直接复用这个模块对象,不会重新执行foo.py。
可以直接验证:
1import sys
2import foo
3
4print(sys.modules["foo"] is foo)
5
6# 运行结果
7# True
这也解释了一个常见现象:如果一个模块在导入阶段就修改了全局状态,那么这种修改通常只会发生一次。
1# counter.py
2
3count = 0
4count += 1
5print(count)
1# main.py
2
3import counter
4import counter
5
6print("in main:", counter.count)
7
8# 运行结果
9# 1
10# in main: 1
第二次导入时,counter.py不会重新执行,所以count += 1也不会再次发生。
提示
可以使用
importlib.reload(module)强制重新执行某个模块的代码。但是这并不会创建一个全新的世界:已有对象的引用关系、其它模块中通过from module import name绑定出去的名字,都可能不会按你期待的方式自动更新。因此它更适合调试和交互式环境,不适合当作常规业务逻辑。
导入路径
那么Python根据模块名查找源代码时,到底会去哪里找呢?
答案主要是sys.path。
1import sys
2
3for path in sys.path:
4 print(path)
sys.path是一个列表,里面保存了一组搜索路径。执行import foo时,Python会依次在这些路径下查找foo.py、foo包、扩展模块等。只要找到符合要求的模块,就停止继续查找。
通常情况下,sys.path里会包含:
- 当前脚本所在目录,或者交互式环境下的当前工作目录。
- 环境变量
PYTHONPATH指定的目录。 - Python安装环境中的标准库目录。
- 第三方包安装目录,也就是常说的
site-packages。
所以,如果你的项目中有一个文件叫random.py,然后又在同目录下写:
1import random
Python可能导入的不是标准库里的random,而是你自己写的random.py。这就是初学者非常容易遇到的模块命名冲突。
可以用下面的方式检查某个模块究竟来自哪里:
1import random
2
3print(random.__file__)
如果输出的是你项目目录下的random.py,那就说明你把标准库模块挡住了。
包
当代码越来越多时,把所有模块都放在同一个目录下很快就会变得混乱。于是Python提供了包的概念,用来组织多个模块。
最常见的包就是一个带有__init__.py的目录:
1my_pkg/
2 __init__.py
3 foo.py
4 bar.py
此时my_pkg是一个包,my_pkg.foo和my_pkg.bar是这个包下的子模块。
但是注意,包本身也是模块对象。也就是说,执行:
1import my_pkg
Python同样会创建一个模块对象,只不过这个模块对象对应的是my_pkg/__init__.py。因此,__init__.py并不是什么神秘文件,它就是包对象的初始化代码。
可以在__init__.py中写:
1# my_pkg/__init__.py
2
3print("my_pkg is running")
4
5value = 1
然后:
1import my_pkg
2
3print(my_pkg.value)
4
5# 运行结果
6# my_pkg is running
7# 1
既然包也是模块对象,那么它同样会出现在sys.modules中:
1import sys
2import my_pkg
3
4print(sys.modules["my_pkg"] is my_pkg)
5
6# 运行结果
7# True
包和普通模块最大的区别在于,包对象通常会有一个__path__属性。这个属性告诉导入系统:如果要继续导入这个包下面的子模块,应该去哪些目录里找。
1import my_pkg
2
3print(my_pkg.__path__)
当执行:
1import my_pkg.foo
Python会先导入my_pkg这个包,再根据my_pkg.__path__去查找子模块foo。导入成功之后,sys.modules中会同时出现:
1sys.modules["my_pkg"]
2sys.modules["my_pkg.foo"]
它们是两个不同的模块对象,只是名字上存在层级关系。
注意
Python 3支持没有
__init__.py的命名空间包。命名空间包适合多个目录共同组成同一个包的场景,例如大型插件系统。日常项目里还是建议保留__init__.py,它能让包边界更明确,也更容易理解导入行为。
绝对导入与相对导入
在包内部导入其它模块时,可以使用绝对导入,也可以使用相对导入。
假设项目结构如下:
1project/
2 my_pkg/
3 __init__.py
4 foo.py
5 bar.py
如果bar.py想导入同一个包下的foo.py,可以写绝对导入:
1# my_pkg/bar.py
2
3from my_pkg import foo
也可以写相对导入:
1# my_pkg/bar.py
2
3from . import foo
其中.表示当前包,..表示上一级包。相对导入的好处是包内部结构移动时比较方便,缺点是它必须知道当前模块属于哪个包。
这个“属于哪个包”的信息,主要来自模块的__package__属性。
当你使用下面的方式运行模块时:
1python -m my_pkg.bar
Python知道bar是my_pkg这个包里的模块,因此bar.py中的__package__会被设置为my_pkg,相对导入就可以正常工作。
但是如果你直接运行:
1python my_pkg/bar.py
bar.py会被当作入口模块__main__来执行,而不是以my_pkg.bar这个名字导入。这个时候Python很难从模块名中推断出它属于my_pkg包,相对导入就容易失败:
1ImportError: attempted relative import with no known parent package
这里的no known parent package意思是:导入系统不知道当前模块属于哪个包,所以没法解释.到底表示谁。
除此之外,相对导入还有另一个常见错误:
1ImportError: attempted relative import beyond top-level package
这个错误的意思是:导入系统知道当前模块属于哪个包,但是你用的点太多,已经试图导入到顶层包之外了。
例如:
1project/
2 my_pkg/
3 __init__.py
4 sub_pkg/
5 __init__.py
6 bar.py
如果bar.py是以my_pkg.sub_pkg.bar这个名字导入的,那么它所属的包就是my_pkg.sub_pkg。
这时:
1from . import something # 在my_pkg.sub_pkg中找
2from .. import something # 回到my_pkg中找
3from ... import something # 超出my_pkg,报错
相对导入只能在当前顶层包内部移动,不能用一堆点一路退到项目根目录之外。也就是说,它的参照物不是文件系统里的任意目录,而是当前模块的包名。
这也是为什么包内部模块通常不建议直接用文件路径运行,而应该使用python -m package.module运行。
从模块系统的角度看,这两种运行方式的差别非常大:
python my_pkg/bar.py:把bar.py当作入口脚本执行,模块名是__main__。python -m my_pkg.bar:先按导入系统找到my_pkg.bar,再把它作为入口模块执行。
前者更像“运行一个文件”,后者更像“运行一个模块”。如果代码已经组织成包,后者通常更符合Python导入系统的设计。
循环导入
导入系统中另一个常见问题是循环导入。
例如:
1# a.py
2
3from b import func_b
4
5
6def func_a():
7 return "a"
1# b.py
2
3from a import func_a
4
5
6def func_b():
7 return "b"
现在执行import a,Python会开始执行a.py。执行到from b import func_b时,它会转而导入b.py。而b.py执行到from a import func_a时,又会回来导入a.py。
问题在于,此时a.py还没有执行到def func_a(): ...,所以a模块对象虽然已经放进了sys.modules,但它的命名空间里还没有func_a。于是就可能出现类似下面的错误:
1ImportError: cannot import name 'func_a' from partially initialized module 'a'
这里的partially initialized module说得非常准确:模块对象已经创建了,也已经放进sys.modules了,但是模块代码还没执行完,所以它处于“初始化了一半”的状态。
解决循环导入的关键不是记住某个技巧,而是调整依赖关系。两个模块如果必须在导入阶段互相依赖,通常说明它们的职责边界有点拧。
常见处理方式有三种:
- 把共同依赖的部分提取到第三个模块中。
- 把某个导入移动到函数内部,让它延迟到真正调用时再发生。
- 只导入模块本身,减少在导入阶段立即访问对方内部名字的机会。
例如:
1import b
2
3
4def func_a():
5 return b.func_b()
这种写法并不会从根本上消除所有循环导入问题,但它至少把“访问func_b”这件事推迟到了func_a被调用的时候,避免在模块初始化阶段立刻要求对方已经准备好。
总结
模块是一个对象,包也是一个对象。.py文件和目录只是导入系统寻找代码的来源,真正参与运行的是模块对象以及模块对象背后的命名空间。
import语句做的事情可以概括为:先检查sys.modules缓存,再通过Finder和Loader找到并加载模块,创建模块对象,执行模块代码,把模块对象缓存起来,最后在当前命名空间中绑定一个名字。
理解这一点之后,很多看起来零散的问题都会变得统一:为什么重复导入不会重复执行,为什么模块命名会挡住标准库,为什么相对导入需要包上下文,为什么循环导入会出现“初始化了一半”的模块。
导入系统看起来是在处理文件和目录,本质上仍然是在处理对象、命名空间和名字绑定。