Python模块和包的基本概念

Python 中的模块

什么是模块

不管使用什么编程语言,一旦一个项目达到了一定规模,其包含的函数与类就会变得非常多,这个时候如果所有的代码都堆放在一个文件内,这个文件将会变得非常混乱,不利于维护项目。几乎在编程语言出现的时候,人们就将一个大的程序拆分成多个部分来管理,以提高代码的可维护性和复用性。程序的每个部分一般存放在单独的文件中,需要用到的时候再合并起来。

Python 等很多编程语言都在此基础上提出了更强大的源代码组织方式。在 Python 中,一个源文件一般被处理为模块(module)。模块是 Python 中的一种基本的组织单位,一个模块中可以包含一些通用的变量、函数、类以及其它模块,方便管理与使用。

在实践中,一个模块常常对应一个 .py 文件,但其它类型的源文件也可以作为模块,例如一个编译的扩展模块 .pyd 、一个压缩文件 .zip 、一个动态链接库 .so 等,但本节仅讨论由 .py 文件构成的模块。

模块的另一个优点是方便共享,只需要在网络上下载他人编写的源文件就可以使用其中的代码。Python 解释器也提供了一些内置模块,例如 math 模块提供了数学计算相关的函数、time 模块提供了时间处理相关的功能、json 模块提供了序列化和反序列化 JSON 格式数据的工具,这些模块在任何 Python 环境都应该是可用的。

import语句与导入模块

文件是一个操作系统层面的概念,存在于文件系统中;而模块是一个 Python 运行时的概念,它作为 Python 对象存在于内存中。为了将一个文件加载为模块,可以使用 Python 中的 import 语句。import 语句从文件系统中读取文件内容,并在内存中创建相应的模块对象。

import 语句最基本的语法如下:

import module_name

这里的 module_name 是模块的文件名称。对于一般的 .py 文件来说,它的模块名是其不带后缀的文件名称。

假设在当前工作目录下有两个 Python 文件:ui.pydatabase.py ,前者主要负责内容显示,后者主要处理数据。在 ui.py 文件中,可以使用以下语句来导入 database.py 文件作为一个模块:

import database

执行以上语句之后,Python 解释器主要做了两件事:

  1. 根据 database 这个名称找到同一目录下的 database.py 文件,执行该文件的代码,然后构造一个模块对象,将文件中的全局变量,包括函数、类或其它模块等将作为该模块对象的属性;
  2. 然后,该模块对象会被赋值给当前命名空间中一个与这个模块同名的变量(即 database )。
$ python -i ui.py >>> database >module 'database' from '/path/to/database.py'> >>> type(database) >class 'module'>

也就是说,执行 import database 后,变量 database 就引用了 database 模块对象。可以通过模块对象的属性来访问文件中定义的成员:

# database.py def load(): ... def dump(): ... # ui.py import database database.load() database.dump()

模块对象提供了一个新的、独立的命名空间,可以防止变量名的冲突,并使代码更具可读性。

模块的路径

在检查模块时,可以发现模块包含了它对应文件的路径的信息。__file__ 是一个由 Python 解释器(更具体地说是模块加载器)在模块被导入时自动创建并赋予模块的特殊属性,它的值是该模块文件在文件系统中的路径。

__file__ 在模块的源文件中可以作为全局变量使用,它在模块的任何地方都可以直接访问。但它的值只有当脚本被执行或作为模块导入后才确定。

因此,在源代码中,可以通过操作 __file__ 路径访问某个相对位置的文件或目录。很多工程中都会在配置文件中提供类似代码:

import pathlib DIR_PATH = pathlib.Path(__file__).resolve().parent

这样只要模块和其它文件的相对位置没有改变,它们可以在不同工作目录与不同运行环境下保持一致,增强了模块的可移植性。

实际应用时,除了模块自身外,不要访问其它模块的 __file__ 属性,这是因为有些模块是内置的或从内存加载(例如某些冻结的模块),那么这些模块可能不提供 __file__ 属性。

几种导入模块的方式

除了 import 语句外,Python 实际上还提供了几种导入模块的工具:

__import__() 是 Python 标准的内置函数,它提供了对导入过程更底层的控制。import 语句实际上是调用了这个内置函数来执行导入操作的。这个函数的第一个参数 name 是一个字符串,指定要导入的模块名称,其余几个参数都和后文讨论的包相关,在此不多介绍,可以阅读 Python 官方文档

importlib 是 Python 的内置模块之一,提供了对导入模块相应工具的支持。 importlib.import_module() 函数也是 import 语句的一个实现,它的第一个参数 name 用途相同。

有些时候,可能需要使用以上函数取代 import 语句,例如:

  • 使用 import 语句时,模块名通常是硬编码在代码中的;但是有些时候需要做动态导入(例如模块名是基于配置文件或用户输入的),那么只能使用以上函数;
  • 由于 import 语句导入的模块会被绑定到同名变量上,此时模块对应的文件名也必须符合 Python 标识符的命名要求;如果需要导入的模块含有空格或以数字开头,那么也只能使用以上函数:(但是在编写模块时,不应采用非 Python 标识符作为文件名)
  • auth = __import__('2fa_auth')
  • 还有些时候需要在元编程中实现自定义导入方式,需要对导入过程更底层的控制。

除此之外,这两个函数也有一定区别:

__import__() 函数由解释器提供,而 importlib.import_module() 是纯 Python 实现的。虽然前者效率可能更好一些,但是它参数复杂,行为难以控制。Python 官方文档明确指出,在日常的编程工作中应该避免使用 __import__() 函数,如果确实有相应需求,importlib.import_module() 通常是更好的选择。后续章节会详细介绍 importlib 模块。

如果要导入多个模块,可以使用多个 import 语句,也可以在一个 import 语句中使用逗号分隔多个模块名,例如,对于 Python 内置模块 sysmathurllib ,可以使用以下语句来同时导入它们:

import sys, math, urllib

但是,PEP8 规范不建议通过一条 import 语句导入多个模块。PEP8 规范还建议所有 import 语句都应该位于文件任何全局对象的定义之前。

模块的缓存机制

当模块第一次导入时,模块对象会被添加到 sys.modules 缓存字典中,键为模块名(如 "database"),值为模块对象。每次导入模块时,解释器都会首先检查模块缓存 sys.modules 是否已经存在同名的模块对象;如果缓存中已存在该模块对象,则直接获取该对象,后续的查找和加载过程将被跳过。这样可以加快模块的解析过程。

有些时候需要重新导入模块,就要考虑模块缓存机制的影响。重新导入模块可以使用 importlib 标准库中的 reload() 函数操作模块:

>>> import importlib >>> importlib.reload(database) >module 'database' from '/path/to/database.py'>

但是请注意,在开发环境中应该避免重新加载模块。因为重新加载模块后,如果代码仍然持有对旧模块中对象的引用,那么这些对象不会被自动销毁和重新创建,使得代码中可能会同时存在同一对象的两个不同版本,增加了错误调试的难度。

别名导入

在导入模块时,模块对象会绑定在与文件名同名的变量上。有些时候,可能希望使用一个不同的名称(别名)来引用导入的模块,这可以通过在 import 语句中使用 as 关键字实现:

使用 as 给模块重新赋予一个名称的语法为:

import module_name as new_name

此时,模块就会绑定到以 new_name 为名称的变量上,可以通过该变量来访问模块中的内容,例如:

# ui.py import database as db db.open() # using name 'db' to access module

但要注意,执行 import module_name as new_name 时,仍然是根据名称 module_name 查找并加载模块(或从缓存获取);但是加载得到的模块对象被赋值给变量 new_name ,而不是 module_name。 模块本身的真实名称(在 sys.modules 中的键名)仍然是 "module_name"

别名有两个用途,一是避免变量的名称冲突。例如有些模块的名称和全局变量名重复了,为了不因为名称冲突而导致变量覆盖,就可以在导入时给模块起一个别名。

别名另外一个比较常见的用法是为一些比较长的模块名起一个比较短的名称,使其更方便使用。对于一些常用的第三方模块,它们几乎都有约定的别名。

导入特定成员

如果只对模块中的某个或某些特定属性(如函数、类或变量)感兴趣,而不是整个模块对象,可以使用 from 关键字引导的 import 语句,它的语法为:

from module_name import member

如果只需要用到模块中的少数成员,使用 from ... import ... 导入成员后,可以直接使用该成员,无需再作为模块中的属性访问。

# ui.py from database import load data = load()

执行 from module_name import member 时,Python 解释器主要完成以下工作:

  1. Python 解释器仍然会加载 module_name 模块(如果尚未加载)并缓存到 sys.modules
  2. 解释器会从 module_name 模块对象的命名空间中查找名为 member 的属性;
  3. 找到后,该属性所引用的对象被直接导入到当前模块的命名空间中,并绑定到同名变量中。此时,模块 module_name 本身并没有被绑定到当前命名空间的任何变量,也就无法直接访问到该模块。

import ... 语句类似,from ... import ... 也可以同时导入一个模块的多个成员,此时成员间使用逗号分隔,例如:

from database import load, dump, Session

这种合并的书写方式是 PEP8 推荐的。

from 语句中,也可以使用 as 为导入的特定成员指定别名:

# ui.py from database import load as load_data data = load_data()

这样,导入的仍然是 database 模块中的函数 load ,但在 ui.py 文件中,它将被绑定到 load_data 变量中。

在一条 import ... 语句导入多个模块或 from ... import 语句导入多个成员时,每个模块或成员都可以使用 as 起一个别名,也可以各使用 as 给这些模块起一个名称,同样只要使用逗号区分各个模块即可。

例如,以下导入语句:

# ui.py from database import load, convert as convert_data, dump

只有成员 convert 以别名 convert_data 导入,其余两个成员还是依照原名导入。可以采用以下书写方式,将导入的每个成员分行书写,这样看起来更清晰:

# ui.py from database import ( load, convert as convert_data, dump )

在导入模块的成员时,有一种特别的通配符导入的语法:如果使用一个星号 * 表示导入的成员,即

from module_name import *

那么模块中所有的公开成员(即所有非下划线开头的全局成员)都会被导入,并绑定到当前的作用域中。

和其它导入语句不同的是,通配符导入只能在全局作用域下使用,不能在函数或类中使用。

通常不推荐这么写。虽然导入所有成员看起来很方便,但是这样既污染命名空间,也不利于阅读代码时快速追踪某个名称的来源。一般来说,要么导入模块后通过模块访问成员,要么仅导入确定会用到的成员。

有一种方法可以稍微改善这种导入方式的弊端:可以在模块中定义一个 __all__ 变量。这个变量是一个字符串列表,里面存储的字符串定义了当使用通配符导入语句时,哪些公共名称(变量、函数、类等)会被导入到当前的命名空间中。换句话说,__all__ 充当了模块公共 API 的一个明确声明。

例如,假设某一个模块的内容如下:

import csv open_ = open from os import open DB_PATH = r'...' def load(): ... def dump(): ... class Session: ...

这里想对外提供的 API 只有 loaddump 这两个函数和 Session 类,其余全局变量包括导入的模块只是为了实现 API 而具有的,就可以定义以下 __all__ 变量:

__all__ = ['load', 'dump', 'Session']
为模块提供文档

当编写完成模块后,为其编写一份简短的说明文档也是必要的。和函数以及类一样,Python 的模块也支持文档字符串(docstring)。

在模块文件( .py 文件)的最顶端,其第一个语句如果是一个字符串,则这个字符串会作为模块的 docstring ,并保存在模块对象的 __doc__ 属性中。docstring 不仅可以通过 help() 函数查看,IDE 也会在光标悬浮在模块符号时展示其文档:

虽然如何编写 docstring 没有明确的标准,不过大部分模块的 docstring 都由以下部分组成:

  • 第一行是简洁的摘要,说明模块的用途和目的;
  • 在摘要行之后,空一行,然后是更详细的解释;
  • 接下来可以提及模块定义的重要的类或函数,或者展示该模块的基本用法示例等;
  • 虽然像作者、版本等信息通常在工程的配置文件中管理,但有时也会在模块文档字符串的末尾看到,或者通过模块级变量如 __author____version__ 定义。

模块的查找

上文在导入模块时,要求脚本和被导入的模块位于相同的目录。这不是必须的,最重要的是理解模块是如何查找的。

sys.path 是一个包含字符串的列表,其中每个字符串表示一个搜索模块的路径:

>>> import sys >>> sys.path ['', '/usr/lib/python39.zip', '/usr/lib/python3.9', '/usr/lib/python3.9/lib-dynload', '/usr/local/lib/python3.9/dist-packages', '/usr/lib/python3/dist-packages']

sys.path 的构成通常包括:

  • 第一个路径通常是入口脚本所在的目录(如果直接在交互式解释器中运行,那么是当前工作目录)
  • Python 安装时确定的内置模块的路径
  • pip 等工具安装第三方库的目录
  • 如果设置了 PYTHONPATH 环境变量,其中指定的目录也会被添加到 sys.path 中。

Python 会按照 sys.path 列表中目录的顺序进行查找,一旦在某个目录中找到了匹配的模块文件或包,查找过程即停止。如果在以上目录中都没能找到需要的模块,则会引发 ModuleNotFoundError 异常,它是 ImportError 异常的子类。

在 Python 运行时,可以手动修改 sys.path 列表,从而动态地影响模块的搜索路径。例如,可以使用以下代码将某个开发目录添加到搜索路径中,从而可以导入其中的模块:

sys.path.insert(0, '/home/repository/python')

这个方法虽然可以改善一些情况下找不到模块的问题,但是这样做既麻烦,也会降低代码的可移植性和可预测性。Python 已经在 sys.path 中为第三方模块预留了位置,更好的方法是将模块安装或链接到指定的位置,这在后续章节会介绍。

Python中的包

什么是包

虽然模块的存在很大程度上解决了源代码混乱的问题,但是随着项目规模的增长,模块的管理又成为了新的问题。为了更好地组织模块,一般来说可以将一些实现相同或相似功能的模块整理到一起,通过(package)来统一管理模块。

在 Python 中,包是一种特殊的模块,一个包可以视为多个模块的集合。在操作系统层面,包通常对应一个目录(文件夹)。由于目录可以包含文件或其它目录,因此一个包可以包含若干模块和子包。而模块(通常对应单个文件)在组织结构上一般是末端单元,不能直接包含其它包或模块。包可以将相关的模块分组存放在不同的目录下,使得项目结构更清晰、更有条理,易于理解和维护。

简单地说,模块是代码组织的基本单元,而包是组织模块的单元。

例如,对于以上工程,随着项目规模的增长,现在 database 中包含了 SQL 和 CSV 两种实现,那么就可以以如下方式组织这些文件:

ui.py database ├─ csv.py └─ sql.py

此时,database 就成为了一个包。但是注意,这样的包对应的是一个目录,而目录本身没有内容,因此这个包本身只是作为容纳其它模块的容器,这样的包称为命名空间包(namespace package)。

命名空间包的概念在 PEP 420 中提出。

在 Python 3.3 之前的版本,一个目录必须包含 __init__.py 文件才能被 Python 识别为一个包。__init__.py 文件的作用是为包提供内容,使得包本身能定义一些成员。具有 __init__.py 文件的包称为常规包(regular package)。

判断一个包是命名空间包还是常规包的方式是检查包的 __file__ 属性是否为空。对于常规包,其 __file__ 属性指向包中的 __init__.py 文件。

接下来详细介绍包的导入和 __init__.py 文件的使用。

包的导入

既然包本身也是一个模块,那么可以像导入模块一样使用 import 语句导入包:

import package_name

和导入模块一样,Python 解释器会首先检查 sys.modules 中是否已有 "package_name" 对应的包。若不在缓存中,则在 sys.path 各路径中查找名为 package_name 的模块或包。

对于这种简单的 import 语句,Python 解释器并不会区分 package_name 到底是模块名还是包名,因此在项目中应该为模块和包选择唯一的名称,否则同时拥有同名的模块文件和包目录会导致代码难以理解和维护。

如果找到了 package_name 目录,并且该目录中没有 __init__.py 文件,对于现代 Python (3.3+) ,package_name 仍然可以被导入为一个包对象,只是没有对应的代码可以执行,包对象也就不会包含额外的属性。

如果目录中包含 __init__.py 文件,那么 Python 解释器会执行该包的 __init__.py 文件(如果是首次导入该包)。和模块的导入一样,__init__.py 文件在一个新的命名空间中执行,这个命名空间最终构成了包对象。该文件中定义的任何变量、函数或类都会成为包对象的属性。换句话说,导入常规包其实是导入其中的 __init__.py 模块。

例如,在以上示例中创建 database/__init__.py 文件,并添加如下代码:

# __init__.py from enum import Enum class EngineChioce(str, Enum): CSV = 'csv' SQLITE = 'sqlite' MYSQL = 'mysql' def load_from(engine): ...

那么执行 import database 后,这个文件就构成了 database 包对象:

$ python -i ui.py >>> database >module 'database' from '/path/to/database/__init__.py'> >>> dir(database) ['EngineChioce', 'Enum', '__builtins__', ..., '__package__', '__path__', '__spec__', 'load_from']

包对象通常额外拥有一个名为 __path__ 的特殊属性,该属性是一个列表,指明了这个包可能的位置,从而便于定位包中的模块或子包。

当执行 import package_name 时,仅仅是 package_name 这个包本身被导入,并执行可能存在的 __init__.py 文件。package_name 目录下的其它模块或子包并没有被导入,通常无法直接通过 package_name.module_name 访问子模块,除非在 package_name/__init__.py 中明确导入了这个模块。

为了访问包中的模块,需要使用以下形式的 import 语句:

import package_name.module_name

当执行这样的语句时:

  1. Python 会确保路径上的每一个包(在此例仅有 package_name)都被导入。这意味着 package_name__init__.py(如果存在)会被执行(仅首次),并且每一个包对象会被添加到 sys.modules
  2. 当子模块 module_name 或子包被成功导入后,它会成为其父包 package_name 对象的一个属性。
  3. 对于 import package_name.module_name 语句,在当前命名空间中,只有最顶层的包名(即 package_name)会被绑定到一个变量上,该变量指向 package_name 包对象。

也就是说,在 import package_name.module_name 之后,需要通过 package_name.module_name 来访问 module_name 模块对象。

例如,假设有如下层级关系:

ui.py database ├─ csv.py └─ sql ├─ connect.py └─ dialects ├─ sqlite.py └─ mysql.py

为了访问 sqlite.py 中的某个成员,需要使用以下方式:

# ui.py import database.sql.dialects.sqlite database.sql.dialects.sqlite.JSON

在以上代码中,import 语句所涉及的 3 个包和 1 个模块都会被导入,并且按照层级关系各自成为父包的属性,但是只有 database 包会被绑定到一个变量上,因此访问 sqlite.py 中的某个成员要从 database 开始。

也可以这么理解:既然通过 database.sql.dialects.sqlite 这个名称导入模块,那么也要通过这个名称访问模块,两者是一致的。这个名称称为模块的限定名称(qualified name),并作为模块的 __name__ 属性添加在 sys.modules 中。

如果在导入包中的模块时使用了 as 子句,即:

import package_name.module_name as new_name

这时情况有所不同:as 后面的名称(此处为 new_name )将直接表示路径中最末端的模块或包(即 package_name.module_name 模块对象本身)。这条语句不会再为包 package_name 创建同名变量。也就是说,以下两条语句作用类似:

import database.sql.dialects.sqlite as sqlite # similar import database.sql.dialects.sqlite sqlite = database.sql.dialects.sqlite del database

如果使用的是 from ... import ... 语句,那么同样也可以导入包或模块的成员。例如,对于以上的 sqlite.py 模块,仅需导入 JSON 成员,可以使用以下代码:

from database.sql.dialects.sqlite import JSON

from 后面的名称指向的是一个包时,后面的 import 语句可以导入包中的模块,例如以下两种导入方式是相同的:

import database.sql.dialects.sqlite as sqlite # same from database.sql.dialects import sqlite

也就是说,在使用 from ... import ... 语句允许将模块视为包的成员导入,不管这个包是否明确导入了其中的模块作为成员。

最后,from ... import ... 语句可以使用 as 给导入的模块或模块中的成员取一个别名,这就没什么好说的了。

执行一个包

通常情况下,Python 包用于管理多个模块和可能的子包,并通过 import 语句来使用包中的模块。

然而,有时候希望能够直接“执行”一个包,就像执行一个普通的 Python 脚本一样。Python 命令行工具有一个选项 -m 就是为了实现这个目的而存在的。

当用户在命令行中输入 python -m package_path 时,Python 解释器会尝试寻找 package_path 包中的 __main__.py 文件,然后执行其中的 __main__.py 文件。请注意:这个文件是 __main__.py 而不是标识为常规包的 __init__.py 文件。

虽然如果包不包含 __main__.py 文件时,确实也会执行 __init__.py 文件的代码,但从语义上来说,__init__.py 的主要目的是初始化包(例如,定义包级别的变量、导入子模块等)而 __main__.py 提供了一个更清晰、更专门的机制来定义包的执行入口,它只在显式地通过 -m 执行包时才会被调用。

__main__.py 文件内部,__name__ 变量的值总是 '__main__' ,因此不需要编写 if __name__ == '__main__' 测试条件。这两个机制其实是类似的,以下是简要的对比:

  • if __name__ == '__main__' 是一个通用机制,用于判断一个 .py 文件是作为主程序运行还是被导入。它的作用是将测试为真的代码作为模块执行的入口点。
  • __main__.py 是一个特定文件,当一个包被命令行选项 -m 执行时,Python 会寻找并执行这个文件。它的作用是将文件的代码作为执行的入口点。

这两种机制分别使得模块和包既可以作为可导入的代码单元使用,也可以作为可直接执行的应用程序使用,提高了 Python 脚本的灵活性。更深入的讨论可以参考 __main__ — Top-level code environment — Python 3 documentation

绝对导入与相对导入

以上讨论的所有导入方式(例如 import package_name.module_namefrom package_name.module_name import member)都属于绝对导入(absolute import)。 所谓绝对导入,是指从 sys.path 中的某个顶级路径开始,通过一个完整的、确定的路径字符串来查找并加载模块或包。路径中的每一部分都直接对应文件系统中的目录名或文件名(省略 .py 后缀)。

相对导入(relative import)是基于当前模块的位置来定位其它模块的导入方式,它只能用于 from ... import ... 的导入语句。相对导入的模块限定名以前导的点 . 来指示目标模块相对于当前模块的位置:

  • 以单个点 . 开头的限定名,表示从当前模块所在的包开始寻找模块;
  • 以两个点 .. 开头的限定名,表示从当前模块所在的包的父包开始寻找模块。
  • 以三个点 ... 开头表示从当前包的父包更上一级寻找,以此类推。

相对导入的当前行为是在 PEP 328 中正式引入和规范的。早期 Python 版本中没有明确区分显式或隐式相对导入(可能解释为相对位置也可能不是)。PEP 328 强调了使用显式相对导入解决对应的歧义。

例如,假设有以下目录结构:

ui.py database ├─ conf.py └─ sql ├─ __init__.py ├─ connect.py └─ dialects ├─ sqlite.py └─ mysql.py

对于 sql/__init__.py ,如果要导入 sqlite.py 模块,那么可以使用以下导入命令:

from .dialects import sqlite

这样一定导入的是该模块所处包的 dialect 子包的 sqlite.py 模块,而不是 sys.path 的其它位置符合要求的模块。

再如,如果要导入 sqlite.py 模块的某个成员,那么也是类似的:

from .dialects.sqlite import JSON

如果要导入 conf.py 模块的某个成员,它位于该包的上一级包,需要使用两个前导的点:

from ..conf import DB_CONFIG

最后,如果要导入当前包的 connect.py 模块,可以使用以下导入命令:

from . import connect

当一个包内部的模块需要引用同包内的其它模块或子包时,相对导入非常有用。模块间的相对位置关系通常比其绝对路径更为稳定和易于确定。

例如,如果代码的入口文件是 ui.py ,以上导入使用绝对导入就要表示为如下形式:

from database.sql.dialects import sqlite from database.sql.dialects.sqlite import JSON from database.conf import DB_CONFIG from database.sql import connect

但是这样一来,如果包的名称发生改变,或者整个包被移动到另一个包中,那么包内模块的绝对路径也会随之改变,这会导致基于绝对路径的内部导入失效。相对导入则不受此类外部路径变化的影响。


理解相对导入的关键在于认识到:Python 在执行导入时,必须将相对导入转换为绝对导入,然后才执行实际的导入操作。在这个转换过程中,模块需要知道自己所属的包。这是通过 __package__ 属性实现的,这个属性是包的完整限定名,在构造模块对象时由解释器提供,细节可以参考 PEP 366

执行相对导入的模块,其 __package__ 属性必须非空,这样当解释器遇到相对导入语句时,它会结合当前模块的 __package__ 值来构建一个绝对导入路径。以上面的相对导入语句

# database/sql/__init__.py from .dialect import sqlite

这个文件本身的 __package__"database.sql" ,那么它可以和 .dialect 名称拼接成完整的限定名称 "database.sql.dialect" ,从而可以正确地解析为绝对导入的方式。

请注意:Python 正在逐步弃用 __package__ 而采用一套更完善的模块定位方式,但这不是本节讨论的话题。

如果一个脚本不属于任何包(例如,它是作为顶层脚本直接执行的),它的 __package__ 属性通常是 None 或空字符串,那么相对路径拼接后还是相对路径,无法转换为绝对导入方式,这种情况下尝试相对导入会导致以下错误:

ImportError: attempted relative import with no known parent package

这是一个典型的错误。因此,相对导入只能在被 Python 视为“包”一部分的模块中使用

组织模块和包

虽然 Python 提供了模块和包这一强大的功能,但是如何将代码组织为合理的模块和包的结构也是需要慎重考虑的问题。这主要是软件工程的问题,本节只讨论一些 Python 相关的问题。

首先,每个模块都应该有一个明确的职责或关注点,使其符合单一职责原则(SRP)。不要创建过于扁平的结构(所有 .py 文件都在一个目录下),如果一个模块的功能太过松散,可以考虑将其拆分成更小的、更专注的模块。

如果想将一个模块进一步细分,但是对外不想让接口也变得更细,那么 Python 社区有一种常见的解决方案:

假设有一个模块 algorithm.py ,包含了一些常用的算法,现在需要将这些算法细分为队列相关算法、树相关算法和图相关算法,那么可以将 algorithm 变成一个包,每个部分变为模块,形成如下结构:

algorithm ├─ __init__.py ├─ graph.py ├─ heap.py ├─ tree.py └─ utils.py

然后,在各个模块中通过 __all__ 变量明确的公共 API ,最后在 __init__.py 中导入子模块的特定成员或子模块本身,将它们“提升”到包的顶层命名空间:

from .heap import * from .tree import * from .graph import * from .utils import is_list_ordered

通过这种方式,可以将多个模块合并成一个单一的逻辑命名空间,同时便于内层模块进一步的细分。但也不应创建过深的嵌套结构,一般来说,2-3 层的包嵌套对于大多数项目是足够的。如果嵌套过深,可能意味着某个子包的功能被过度设计了。


在导入模块时,应该避免循环导入,即模块 A 导入模块 B,而模块 B 又导入模块 A 。循环导入通常会导致 ImportError ,而且这也紧密耦合的常见标志。如果发生了循环导入,应该考虑重新设计模块职责或接口抽象等方法来解决。

对于不同顶层包之间的导入,或者从标准库、第三方库导入,应始终使用绝对导入。在同一个包内部,模块之间的导入推荐使用相对导入。这使得依赖关系非常清晰。

另外 PEP8 对各个模块或包的导入顺序也有一定要求:

  1. __future__ 指令必须出现在最前面,这是 Python 的语法要求的;
  2. 优先导入内置模块,如 osrethreading
  3. 一些需要用到的第三方模块或包;
  4. 项目内部或与项目相关的私有模块或包。

不过这个任务可以通过 isort 之类的工具完成。

参考资料/延伸阅读

The import statement — Simple statements — Python 3 documentation

Modules — Data model — Python 3 documentation

以下资料可能超过了本文讨论的范围:

The import system — Python 3 documentation

importlib — The Python Standard Library — Python 3 documentation

The initialization of the sys.path module search path — Python 3 documentation

京ICP备2021034974号
contact me by hello@frozencandles.fun