Python 学习笔记——6.模块和包
Python 除了在解释器中实时执行,或者单个脚本执行,在复杂程序中也有必要对代码进行组织封装,这就是 Python 的模块文件,与模块相对的,称导入并使用模块的脚本为主文件。(这里主要讨论 Python 源码模块,Python 的部分内置模块并不适用)
模块例子
一个模块的简单例子如下,由一个 demo.py
文件组成,此时
demo
就是模块名。模块的内容包括其中定义的函数以及可执行的语句等。
1 | # demo.py |
在解释器或者其它脚本中,使用如下语句可以导入模块 1
import demo
导入模块的过程本质上就是把这个脚本执行了一遍,并且定义了
demo
这个模块变量, 然后可以通过 demo.s
和
demo.fib
等访问模块中的变量和函数。
也可以完全导入模块中的符号 1
from demo import *
然后就可以直接使用模块中定义的 s
和 fib
等标识符。
模块查找路径
解释器通过以下路径查找模块:
- 输入脚本的目录(或未指定文件时的当前目录)
- PYTHONPATH 环境变量(目录列表,与 shell 变量 PATH 的语法一样)。
- 依赖于安装的默认值(按照惯例包括一个 site-packages 目录,由 site 模块处理)。
可以使用如下语句打印当前状态的所有的查找路径
1 | import sys |
可以利用这个机制添加存放在非标准路径的模块 1
2
3import sys
#replace with your local module directory
sys.path.insert(0,'/home/user/xxx')
导入模块
导入模块的语法非常灵活,最基本的用法为 1
import demo
此时可以使用 demo
这个模块变量,模块内的所有标识符可以通过模块名访问,例如
demo.s
、demo.fib1
和
demo.fib2
。
但是这种写法使得比较繁琐,可以直接导入到当前作用域 1
from demo import *
此时不可以使用 demo
这个模块变量,但是模块内的所有标识符可以直接访问,例如
s
、fib1
和 fib2
。
有时并不需要所有标识符,可以部分导入 1
from demo import fib,fib2
此时不可使用 demo
这个模块变量,但是可以使用其中的
fib
、fib2
变量。
标识符的导入存在冲突风险,如果不同模块中存在同名的标识符,并且被完全导入,那么后导入的会覆盖先导入的,导致未知错误。因此不建议全部导入,按需导入即可。 导入标识符时更建议使用别名,既可以规避重名冲突,也可以简化标识符,例如:
- 对整个模块使用别名
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
14dir(demo)
'''
['__builtins__',
'__cached__',
'__doc__',
'__file__',
'__loader__',
'__name__',
'__package__',
'__spec__',
'fib',
'fib2']
'''
这里有很多特殊标识符:
__name__
: 模块名__file__
: 模块文件路径__doc__
: 模块文档字符串__spec__
: 模块的导入元数据__cached__
: .pyc 缓存路径
解释器在一个会话中不会重复导入同一个模块,而是在导入后进行缓存,如果在导入模块之后进行修改,需要重启解释器或者强制加载才能体现修改。
1
2
3
4import xxx
import importlib
importlib.reload(xxx)
对于 jupyter notebook,还有一种更优雅的方式 1
2
3%load_ext autoreload
%autoreload 2
import xxx
虽然通常在 py 脚本开头导入需要的模块,但是 python
也允许延迟到必要时再导入模块,例如 1
2
3def 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
3python ./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 | def main(): |
此时,如果通过解释器导入模块,或者在脚本中导入模块,都不会显示欢迎信息,但是在命令行直接执行这个脚本,就会显示欢迎信息
1
2python demo.py
# hi, this is module demo
注意:使用命令行选项 -m
选项时也会执行,因为此时仍然是入口脚本 1
2python -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
10Demo/
├── Ada/
├── a1.py
├── a2.py
└── __init__.py
├── Bob/
├── base.py
└── __init__.py
├── core.py
└── __init__.py
对应的是一个名为 Demo
的包,含有 Demo.Ada
和 Demo.Bob
两个子包,包含 Demo.core
和
Demo.Ada.a1
等四模块,下面以此为例,列举常见的包导入操作。
import
命令可以直接导入包或子包 1
2
3import Demo
# run Demo/__init__.py
需要特别注意的是:包在 pip/conda 安装时的名称可能和
import
时使用的名称不一致,常见的变体规则是把-
改成_
。
在导入子包时,如果父包尚未导入,也会被自动导入 1
2
3
4import Demo.Ada
# run Demo/__init__.py
# run Demo/Ada/__init__.py
还可以直接导入某个子包中的模块 1
2
3
4
5import 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
5from Demo.Ada import a1
# run Demo/__init__.py
# run Demo/Ada/__init__.py
# run Demo/Ada/a1.py
可以直接使用 a1
模块。
再例如导入某个子包 1
2
3
4from Demo import Ada
# run Demo/__init__.py
# run Demo/Ada/__init__.py
注意这里不能使用 1
from Demo import Demo.Ada
可以看一个结构非常简单的包:d2l, 1
2
3
4
5
6d2l
├── __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
8projectroot/
├── 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
3from 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 文件作为模块使用。