cover

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执行之后,会在模块命名空间中绑定名字adef 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模块”。但如果把这个过程拆开,它大致做了下面几件事:

  1. 检查sys.modules中有没有foo
  2. 如果没有,调用查找器(Finder)寻找foo对应的模块说明(ModuleSpec)。
  3. 根据ModuleSpec中的加载器(Loader)创建模块对象。
  4. 把模块对象放入sys.modules
  5. 由加载器在模块对象的命名空间中执行模块代码。
  6. 在当前命名空间中绑定名字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.pyfoo包、扩展模块等。只要找到符合要求的模块,就停止继续查找。

通常情况下,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.foomy_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知道barmy_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找到并加载模块,创建模块对象,执行模块代码,把模块对象缓存起来,最后在当前命名空间中绑定一个名字。

理解这一点之后,很多看起来零散的问题都会变得统一:为什么重复导入不会重复执行,为什么模块命名会挡住标准库,为什么相对导入需要包上下文,为什么循环导入会出现“初始化了一半”的模块。

导入系统看起来是在处理文件和目录,本质上仍然是在处理对象、命名空间和名字绑定。