Python 除了在解释器中实时执行,或者单个脚本执行,在复杂程序中也有必要对代码进行组织封装,这就是 Python 的模块文件,与模块相对的,称导入并使用模块的脚本为主文件。(这里主要讨论 Python 源码模块,Python 的部分内置模块并不适用)

模块例子

一个模块的简单例子如下,由一个 demo.py 文件组成,此时 demo 就是模块名。模块的内容包括其中定义的函数以及可执行的语句等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# demo.py

def fib(n):
a, b = 0, 1
while a < n:
print(a, end=' ')
a, b = b, a+b
print()

def fib2(n):
result = []
a, b = 0, 1
while a < n:
result.append(a)
a, b = b, a+b
return result

# print("hi, this is module demo")
s = 1

在解释器或者其它脚本中,使用如下语句可以导入模块

1
import demo

导入模块的过程本质上就是把这个脚本执行了一遍,并且定义了 demo 这个模块变量, 然后可以通过 demo.sdemo.fib 等访问模块中的变量和函数。

也可以完全导入模块中的符号

1
from demo import *

然后就可以直接使用模块中定义的 sfib 等标识符。

模块查找路径

解释器通过以下路径查找模块:

  • 输入脚本的目录(或未指定文件时的当前目录)
  • PYTHONPATH 环境变量(目录列表,与 shell 变量 PATH 的语法一样)。
  • 依赖于安装的默认值(按照惯例包括一个 site-packages 目录,由 site 模块处理)。

可以使用如下语句打印当前状态的所有的查找路径

1
2
import sys
print(sys.path)

可以利用这个机制添加存放在非标准路径的模块

1
2
3
import sys
#replace with your local module directory
sys.path.insert(0,'/home/user/xxx')

导入模块

导入模块的语法非常灵活,最基本的用法为

1
import demo

此时可以使用 demo 这个模块变量,模块内的所有标识符可以通过模块名访问,例如 demo.sdemo.fib1demo.fib2

但是这种写法使得比较繁琐,可以直接导入到当前作用域

1
from demo import *

此时不可以使用 demo 这个模块变量,但是模块内的所有标识符可以直接访问,例如 sfib1fib2

有时并不需要所有标识符,可以部分导入

1
from demo import fib,fib2

此时不可使用 demo 这个模块变量,但是可以使用其中的 fibfib2 变量。

标识符的导入存在冲突风险,如果不同模块中存在同名的标识符,并且被完全导入,那么后导入的会覆盖先导入的,导致未知错误。因此不建议全部导入,按需导入即可。 导入标识符时更建议使用别名,既可以规避重名冲突,也可以简化标识符,例如:

  • 对整个模块使用别名 import numpy as np
  • 导入单个标识符时使用别名 from numpy import linspace as ls

关于模块对外暴露的标识符,还有一些细节值得讨论:

  • 使用 * 导入所有标识符的语句并不会导入任何下划线开头的标识符,因为它们被视作私有的,不会自动导入
  • 可以自定义 __all__ 这个特殊变量来指定对外暴露的所有标识符列表

可以使用如下 dir(<module>) 命令可以查看模块中定义的所有标识符信息(包括内置的标识符),例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dir(demo)

'''
['__builtins__',
'__cached__',
'__doc__',
'__file__',
'__loader__',
'__name__',
'__package__',
'__spec__',
'fib',
'fib2']
'''

这里有很多特殊标识符:

  • __name__: 模块名
  • __file__: 模块文件路径
  • __doc__: 模块文档字符串
  • __spec__: 模块的导入元数据
  • __cached__: .pyc 缓存路径

解释器在一个会话中不会重复导入同一个模块,而是在导入后进行缓存,如果在导入模块之后进行修改,需要重启解释器或者强制加载才能体现修改。

1
2
3
4
import xxx
import importlib

importlib.reload(xxx)

对于 jupyter notebook,还有一种更优雅的方式

1
2
3
%load_ext autoreload
%autoreload 2
import xxx

虽然通常在 py 脚本开头导入需要的模块,但是 python 也允许延迟到必要时再导入模块,例如

1
2
3
def func():
import numpy as np
return np.array([1,2,3])

注意此时导入模块的生效范围也是局部的。

在 py 脚本中导入源代码模块(而非内置模块),python 会在源码模块目录中生成 __pycache__/ 目录,并且在其中存放 .pyc 文件, 这是对应模块生成的字节码文件,被缓存用于优化模块的导入速度。

事实上,我们也可以手动控制这个过程,例如将 py 脚本 hello.py 转换为字节码文件 __pycache__/hello.xxx.pyc 并执行(xxx 对应 Python 版本)

1
python -m py_compile ./hello.py

执行 pyc 文件和 py 文件的效果是一样的

1
2
3
python ./hello.py

python ./__pycache__/hello.cpython-312.pyc

注意:

  • 通常会将 __pycache__/ 目录和缓存文件从版本管理中排除;
  • 除了.pyc文件,还有一个可能见到的.pyd文件,这是 Python 的动态模块,本质就是 Windows 平台的动态链接库(DLL),它们通常用 C 或 C++ 直接编写,可以被 Python 直接导入使用;
  • 在修改模块后,为了确保修改生效,最好也删除缓存。

避免模块执行

在导入模块时,会依次执行模块的所有语句,例如前文中的 demo.py 文件代表的 demo 模块,导入时会自动执行一次赋值语句 s=1

一个常见的需求是:我们并不希望作为模块模块时执行特定语句,而是仅仅在当前文件作为主文件时才执行。

可以使用如下语句完成这样的效果,这里我们参考 C++ 的习惯,也定义一个 main() 函数

1
2
3
4
5
def main():
print("hi, this is module demo")

if __name__ == "__main__":
main()

此时,如果通过解释器导入模块,或者在脚本中导入模块,都不会显示欢迎信息,但是在命令行直接执行这个脚本,就会显示欢迎信息

1
2
python demo.py
# hi, this is module demo

注意:使用命令行选项 -m 选项时也会执行,因为此时仍然是入口脚本

1
2
python -m demo
# hi, this is module demo

原理是:入口脚本的 __name__ 值为 "__main__",在导入的模块中,__name__ 值为模块名。

pip 或者 conda 下载的 Python 源码都是以包为单位的。

包的结构与导入

模块对应的是一个单独的 py 文件,与之相对的,Python 的包则对应了一个文件夹结构,在包的顶级目录下含有特殊的 __init__.py 文件,以及其它若干 py 文件或文件夹, 对于 py 文件,视作一个个模块,对于含有 __init__.py 文件的子文件夹,则视作一个子包。

import 命令在导入模块会执行对应的 py 脚本,在导入包时会执行包的 __init__.py 脚本。

考虑如下的文件结构

1
2
3
4
5
6
7
8
9
10
Demo/
├── Ada/
├── a1.py
├── a2.py
└── __init__.py
├── Bob/
├── base.py
└── __init__.py
├── core.py
└── __init__.py

对应的是一个名为 Demo 的包,含有 Demo.AdaDemo.Bob 两个子包,包含 Demo.coreDemo.Ada.a1 等四模块,下面以此为例,列举常见的包导入操作。

import 命令可以直接导入包或子包

1
2
3
import Demo

# run Demo/__init__.py

需要特别注意的是:包在 pip/conda 安装时的名称可能和 import 时使用的名称不一致,常见的变体规则是把-改成_

在导入子包时,如果父包尚未导入,也会被自动导入

1
2
3
4
import Demo.Ada

# run Demo/__init__.py
# run Demo/Ada/__init__.py

还可以直接导入某个子包中的模块

1
2
3
4
5
import Demo.Ada.a1

# run Demo/__init__.py
# run Demo/Ada/__init__.py
# run Demo/Ada/a1.py

使用 from <A> import <B> 语句则过于灵活了,它会尝试定义 <B> = <A>.<B>。 例如导入某个子包中的模块

1
2
3
4
5
from Demo.Ada import a1

# run Demo/__init__.py
# run Demo/Ada/__init__.py
# run Demo/Ada/a1.py

可以直接使用 a1 模块。

再例如导入某个子包

1
2
3
4
from Demo import Ada

# run Demo/__init__.py
# run Demo/Ada/__init__.py

注意这里不能使用

1
from Demo import Demo.Ada

可以看一个结构非常简单的包:d2l

1
2
3
4
5
6
d2l
├── __init__.py
├── jax.py
├── mxnet.py
├── tensorflow.py
└── torch.py

其中的 __init__.py 内容很简单

1
2
3
4
5
6
7
8
9
10
11
12
"""Saved source code for "Dive into Deep Learing" (https://d2l.ai).

Please import d2l by one of the following ways:

from d2l import mxnet as d2l # Use MXNet as the backend
from d2l import torch as d2l # Use PyTorch as the backend
from d2l import tensorflow as d2l # Use TensorFlow as the backend
from d2l import paddle as d2l # Use Paddle as the backend

"""

__version__ = "2.0.0"

剩下几个 py 文件分别对应不同后端。

创建自定义的包

下面是一个简单的 mypackage 包示例,主要结构如下

1
2
3
4
5
6
7
8
projectroot/
├── mypackage/
├── __init__.py
├── module1.py
└── module2.py
├── pyproject.toml
├── README.md
└── .gitignore

两个模块文件中的内容为

1
2
3
4
5
6
7
# mypackage/module1.py
def add(a, b):
return a + b

# mypackage/module2.py
def mul(a, b):
return a * b

通常在包的__init__.py中导入模块中的标识符,使得使用者只需要导入包,无需考虑包中的各个模块细节

1
2
3
4
5
# mypackage/__init__.py
from .module1 import add
from .module2 import mul

__all__ = ['add', 'mul']

说明:

  • 这里使用了特殊的相对导入命令,这个命令只在包中有效;
  • __all__ 并不是必要的,因为默认情况下 __all__ 就包括所有不以下划线开头的全局标识符。

使用例如

1
2
3
from mypackage import add, mul

print(add(1, 2)) # 3

为了支持 pip 安装,我们还需要提供包的信息,早期的方式为提供 setup.py,目前推荐使用 pyproject.toml,放在 mypackage 的同级目录下,例如

1
2
3
4
5
6
7
8
9
10
11
[project]
name = "mypackage"
version = "0.1.0"
authors = [{ name = "Your Name", email = "your@email.com" }]
description = "My simple math package"
requires-python = ">=3.7"
readme = "README.md"

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

此时,这个包就可以支持 pip 安装,例如在包根目录下执行(最好在venv虚拟环境下进行)

1
pip install .

加上-e选项则是开发模式,pip 不会复制包的源码,而是建立一个引用关系,使得对源码修改后不需要重复安装

1
pip install -e .

安装过程中会在生成 mypackage 的同级目录下生成 mypackage.egg-info/build/ 两个临时文件夹,存放包元数据和编程生成的中间过程文件。

如果除了包的源码文件夹,在项目目录下还有其它文件夹,可以在 pyproject.toml 中指定搜索路径,例如

1
2
[tool.setuptools.packages.find]
where = ["src"]

补充

在默认情况下,一个 ipynb 文件具有复杂格式,并不能像一个 py 脚本一样被直接导入和使用,可以通过安装 import-ipynb 这个工具来临时支持对 ipynb 文件的导入

1
pip install import-ipynb

当然,并不建议将 ipynb 文件作为模块使用。