这篇文章将较为深入的梳理 Python 的模块与包管理机制,它是我们开发大型 Python 应用必须掌握的重要技能之一。
模块与包
模块(module)是包含 Python 定义和语句的文件,其文件名是模块名加后缀名 .py 。模块是 Python 代码的一种组织单位。各模块具有独立的命名空间,可包含任意 Python 对象。模块可通过 importing 操作被加载到 Python 中。
Python 只有一种 模块对象类型,即 types.ModuleType 类型,所有模块都属于该类型,无论模块是用 Python、C 还是别的语言实现。
为了帮助组织模块并提供名称层次结构,Python 还引入了 包(package)的概念。包是一种可包含子模块或递归地包含子包的 Python module。可以把包看成是文件系统中的目录,并把模块看成是目录中的文件(需要注意,这只是一个概念上的类比,因为包和模块不是必须来自文件系统)。
要注意的一个重点概念是所有包都是模块,但并非所有模块都是包。 或者换句话说,包只是一种特殊的模块。特别地,任何具有 __path__ 属性的模块都会被当作是包。所有模块都有自己的名字,子包名与其父包名会以点号分隔,与 Python 的标准属性访问语法一致。
Python 定义了两种类型的包,常规包(regular package)和命名空间包(namespace package)。 常规包是传统的包类型,通常以一个包含 __init__.py 文件的目录形式实现。当一个常规包被导入时,这个 __init__.py 文件会隐式地被执行,它所定义的对象会被绑定到该包命名空间中的名称(另外该文件中所导入的对象也会被绑定到包的命名空间中,参见后文描述)。
例如,导入 parent.one 将隐式地执行 parent/__init__.py 和 parent/one/__init__.py。 后续导入 parent.two 或 parent.three 则将分别执行 parent/two/__init__.py 和 parent/three/__init__.py。
命名空间包则是一种仅被用作子包的容器的 package。命名空间包可以没有实体表示物,具体而言就是不同于常规包,因为它们没有 __init__.py 文件。命名空间包是由多个 portion 构成,每个 portion 为父包的一个子包。各个 portion 可能处于文件系统、网络或者 Python 在导入期间可以搜索的其他地方。而且命名空间包并不一定会直接对应到文件系统中的对象;它们有可能是无实体表示的虚拟模块。
实际测试包与模块
假设在当前目录下有如下代码:
1 | . |
根据上面的定义,parent 其实就是一个常规包,而 parent_mod 则是 parent 包下的一个模块。我们将通过 import 语句来分别导入 parent 包和 parent_mod 模块:
1 | # python |
- 可以看到,无论是 parent 包还是 parent_mod 模块,在 Python 内部,都是属于模块类型(<class ‘module’>)
- parent 包的确存在
__path__属性,而 parent_mod 则不包含__path__属性
导入机制
接下来我们将讨论 module 的导入机制,这里的 module 可以是 python 子模块,也可以是包(或者有时也会称为父模块),对于当前讨论来说两者没有差别。
一个 module 内的 Python 代码通过 importing 操作就能够访问另一个模块内的代码。import 语句是唤起导入机制的最常用方式,但不是唯一的方式。importlib.import_module() 以及内置的 __import__() 等函数也可以被用来唤起导入机制。
import 语句 结合了两个操作;它先搜索指定名称的模块,然后将搜索结果绑定到当前作用域中的名称:
import 语句的搜索操作被定义为对__import__()函数的调用(带有适当的参数)__import__()的返回值会被用于执行 import 语句的名称绑定操作
对 __import__() 的直接调用将仅执行模块搜索以及在找到时的模块创建操作。只有 import 语句会执行名称绑定操作。
importlib 模块则提供了一个丰富的 API 用来与导入系统进行交互,例如 importlib.import_module() 提供了相比内置的 __import__() 更推荐、更简单的 API 用来唤起导入机制。importlib.import_module() 常用来动态地确定要导入模块的应用提供支持。
当一个模块首次被导入时,Python 会搜索该模块,如果找到就创建一个 module 对象并初始化它。 如果指定名称的模块未找到,则会引发 ModuleNotFoundError。 当唤起导入机制时,Python 会实现多种策略来搜索指定名称的模块。
模块搜索与加载
为了开始搜索,Python 需要被导入模块的完整限定名称(对模块而言,完整限定名称为标示该模块的以点号分隔的整个路径),例如 foo.bar.baz。 在这种情况下,Python 会先尝试导入 foo,然后是 foo.bar,最后是 foo.bar.baz。 如果这些导入中的任何一个失败,都会引发 ModuleNotFoundError。
在导入搜索期间首先会被检查的地方是 sys.modules,这个映射起到缓存之前导入的所有模块的作用, 因此如果之前导入过 foo.bar.baz,则 sys.modules 将包含 foo, foo.bar 和 foo.bar.baz 条目。每个键的值就是相应的模块对象。
1 | import parent.parent_mod |
- 在导入期间,会在 sys.modules 查找模块名称,如存在则其关联的值就是需要导入的模块,导入过程完成
- 如果找不到指定模块名称,Python 将继续搜索该模块
如果指定名称的模块在 sys.modules 找不到,则将唤起 Python 的导入协议以查找和加载该模块。此协议由两个概念性模块构成,即查找器(finder)和加载器(loader)。查找器的任务是确定是否能使用其所知的策略找到该名称的模块。查找器并不真正加载模块。如果它们能找到指定名称的模块,会返回一个 模块规格说明,这是对模块导入相关信息的封装,供后续导入机制用于在加载模块时使用。
导入机制被设计为可扩展,其中的基本机制是 导入钩子:
- 元钩子在导入过程开始时被调用,此时任何其他导入过程尚未发生(sys.modules 缓存查找除外),这允许元钩子重载
sys.path过程、冻结模块甚至内置模块。元钩子的注册是通过向sys.meta_path添加新的查找器对象 - 导入路径钩子是作为
sys.path(或package.__path__) 过程的一部分,在遇到它们所关联的路径项的时候被调用。导入路径钩子的注册是通过向sys.path_hooks添加新的可调用对象。
Python 的默认 sys.meta_path 具有三种元路径查找器,一种知道如何导入内置模块,一种知道如何导入冻结模块,还有一种知道如何导入来自 import path 的模块 (即 path based finder)。
sys.path 是一个由字符串组成的列表,用于指定模块的搜索路径。初始化自环境变量 PYTHONPATH,再加上一条与安装有关的默认路径。当前工作目录由一个空字符串表示,可以看到,sys.path 默认包含了当前工作目录:
1 | sys.path |
而 __path__ 属性则是一个(可能为空的)枚举将用于查找包的子模块的位置的字符串 sequence。 根据定义,如果一个模块具有 __path__ 属性,它就是一个 package。包的 __path__ 属性会在导入其子包期间被使用。在导入机制内部,它的功能与 sys.path 基本相同,即在导入期间提供一个模拟搜索位置列表。
基于路径的查找器(PathFinder)自身并不知道如何进行导入。它只是遍历单独的路径条目,将它们各自关联到某个知道如何处理特定类型路径的 路径条目查找器。默认的路径条目查找器集合实现了在文件系统中查找模块的所有语义,可处理多种特殊文件类型例如 Python 源码 (.py 文件),Python 字节码 (.pyc 文件) 以及共享库 (例如 .so 文件)。
当一个 模块说明 被找到时,导入机制将在加载该模块时使用它(及其所包含的加载器)。模块加载器提供关键的加载功能:模块执行。如果模块是一个 Python 模块(而非内置模块或动态加载的扩展),加载器应该在**模块的全局命名空间 (module.dict)**中执行模块的代码,从而填充模块的命名空间。
相对导入
import module_name 的形式称为绝对导入,它是基于 sys.path 进行模块路径搜索的。可以使用绝对导入来引用同级包的子模块。例如,如果 sound.filters.vocoder 模块需要使用 sound.effects 包中的 echo 模块,它可以使用 from sound.effects import echo。
Python 也支持相对导入。相对导入是基于当前模块所属包的名称进行的,相对导入使用前缀点号。一个前缀点号表示相对导入从当前包开始。两个或更多前缀点号表示对当前包的上级包的相对导入,第一个点号之后的每个点号代表一级。
绝对导入可以使用 import <> 或 from <> import <> 语法,但相对导入只能使用第二种形式;其中的原因在于:
1 | import XXX.YYY.ZZZ |
应当提供 XXX.YYY.ZZZ 作为可用表达式,但 .moduleY 不是一个有效的表达式。
子模块
当使用任意机制 (例如 importlib API, import 及 import-from 语句或者内置的 import()) 加载一个子模块时,父模块的命名空间中会添加一个对子模块对象的绑定。
例如假设 parent/__init__.py 中增加了如下代码:
1 | from .parent_mod import parent_func |
那么执行如下代码将把 parent_mod、parent_func 的名称绑定添加到 spam 模块中:
1 | import parent |
import parent导入 parent 包,执行其对应的__init__.py__init__.py中的from .parent_mod import parent_func导入了 parent_mod 模块,并且将该模块中的parent_func函数绑定到当前的作用域中,因此可以访问parent.parent_func- 同时又因为导入了
parent_mod模块,因此会在父包parent中增加对parent_mod的绑定,因此可以访问parent.parent_mod
import 语句与 from import
上文较为详细地解释了 Python 的导入机制。接下来再来总结下 import 语句与 from import 语句两种形式的区别与联系。
基本的 import 语句(非 from 形式)会分两步执行:
- 查找一个模块,如果有必要还会加载并初始化模块
- 在局部命名空间中为 import 语句发生位置所处的作用域定义一个或多个名称
如果成功获取到请求的模块,则可以通过以下三种方式一之在局部命名空间中使用它:
- 模块名后使用 as 时,直接把 as 后的名称与导入模块绑定。
- 如果没有指定其他名称,且被导入的模块为最高层级模块,则模块的名称将被绑定到局部命名空间作为对所导入模块的引用。
- 如果被导入的模块 不是最高层级模块,则包含该模块的最高层级包的名称将被绑定到局部命名空间作为对该最高层级包的引用。所导入的模块必须使用其完整限定名称来访问而不能直接访问
from 形式使用的过程略微繁复一些:
- 查找 from 子句中指定的模块,如有必要还会加载并初始化模块;
- 对于 import 子句中指定的每个标识符:
- 检查已导入模块中是否有该名称的属性
- 如果没有,尝试导入指定名称的子模块,然后再次检查被导入模块是否有该属性
- 如果未找到该属性,则引发 ImportError。
- 否则的话,将对该值的引用存入局部命名空间,如果有 as 子句则使用其指定的名称,否则使用该属性的名称
1 | import foo # foo 被导入并且被局部绑定 |
一定要区分导入和绑定的区别,导入意味着该模块的代码被执行,而绑定则表示可以在当前模块的命名空间中使用对应的标识符。如下代码验证了,虽然 foo 包被导入,到时却无法直接使用 foo:
1 | from foo.bar import baz |
注意,这个例子要和上文所列举的 子模块导入 仔细区分:
子模块导入的例子是直接导入父模块,而父模块的__init__.py继续导入了子模块,因此我们可以直接通过父模块标识符来继续访问子模块- 而这个例子虽然父子模块都被导入,但是只有子模块标识符被绑定当前作用域中,因此当前作用域只能访问子模块标识符
使用 from package import item 时,item 可以是包的子模块(或子包),也可以是包中定义的函数、类或变量等其他名称。相反,使用 import item.subitem.subsubitem 句法时,除最后一项外,每个 item 都必须是包;最后一项可以是模块或包,但不能是上一项中定义的类、函数或变量。
当使用 from 语句指定要导入哪个模块时,你不必指定模块的绝对名称。当一个模块或包被包含在另一个包之中时,可以在同一个最高层级包中进行相对导入,而不必提及包名称。一个前缀点号表示是执行导入的模块所在的当前包,两个点号表示上溯一个包层级。 三个点号表示上溯两级,依此类推:
- 如果你执行
from . import mod时所处位置为 pkg 包内的一个模块,则最终你将导入pkg.mod - 如果你执行
from ..subpkg2 import mod时所处位置为pkg.subpkg1则你将导入pkg.subpkg2.mod
需要注意的是,相对导入是基于当前模块所属包的名称进行的。由于主模块(即直接运行的脚本)没有所属包,因此那些打算作为 Python 应用程序主模块使用的模块,必须始终使用绝对导入。
all 属性
如果标识符列表改为一个星号 (‘*’),则在模块中定义的全部公有名称都将按 import 语句所在的作用域被绑定到局部命名空间。一个模块所定义的 公有名称 是由在模块的命名空间中检测一个名为 __all__ 的变量来确定的:
- 如果有定义,它必须是一个字符串列表,其中的项为该模块所定义或导入的名称。 在
__all__中所给出的名称都会被视为公有并且应当存在 - 如果
__all__没有被定义,则公有名称的集合将包含在模块的命名空间中找到的所有不以下划线字符 (‘_’) 打头的名称:这包括由__init__.py中定义的名称以及显式加载的子模块/标识符 __all__应当包括整个公有 API。它的目标是避免意外地导出不属于 API 的一部分的项(例如在模块内部被导入和使用的库模块)。
通配符形式的导入 from module import * 仅在模块层级上被允许。尝试在类或函数定义中使用它将引发 SyntaxError。
接下来看一个例子,假设存在如下结构:
1 | # tree all/ |
all/__init__.py 的内容如下:
1 | def all_func(): |
1 | import all |
1 | >> from all import * |
- 直接
import all包,可以访问all.all_func或者all._all_func - 使用
from all import *时,当前命名空间只能导入得到all_func - 由于没有在
__int__.py中显示导入子模块,因此无论哪种形式,默认都不会导入子模块
接下来修改 all/__init__.py 的内容,增加显示导入子模块(这里使用相对导入):
1 | from . import sub1 |
1 | import all |
1 | from all import * |
可以看到此时就可以访问对应的子模块了(此时没有显式设置 __all__),接下来我们显式设置 __all__ 属性,增加如下代码:
1 | __all__ = ["all_func", "sub1"] |
1 | import all |
1 | from all import * |
- 可以看到,
__all__对直接的 import 语句没有影响(import module 后可以在 module 的命名空间看到相关的属性) - 但
__all__可以对from import *语句控制哪些名称会被导入到当前命名空间中
最后再看一个更复杂的例子,此时 all/__init__.py 的内容如下所示:
1 | def all_func(): |
1 | import all |
1 | from all import * |
通过对比 import all 和 from all import * 的结果,我们可以将 __all__ 属性理解为对某个模块导出名称的限制,仅对 from ... import * 语句有效。