静态分析器是帮助您检查代码而无需实际运行代码的工具。最基本的静态分析器形式是您常用编辑器中的语法高亮显示器。如果您需要编译代码(例如,在 C++ 中),您的编译器(例如 LLVM)也可能提供一些静态分析器功能,以警告您潜在的问题(例如,在 C++ 中将赋值符“=
”误用为相等符“==
”)。在 Python 中,我们有一些工具可以识别潜在的错误或指出违反编码标准的地方。
完成本教程后,您将了解其中一些工具。具体来说,
- Pylint、Flake8 和 mypy 工具能做什么?
- 什么是编码风格违规?
- 我们如何使用类型提示来帮助分析器识别潜在错误?
使用我的新书《Python for Machine Learning》启动您的项目,其中包括逐步教程和所有示例的Python 源代码文件。
让我们开始吧。
Python 中的静态分析器
图片来源:Skylar Kang。保留部分权利
概述
本教程分为三个部分;它们是
- Pylint 简介
- Flake8 简介
- mypy 简介
Pylint
Lint 是很久以前为 C 创建的静态分析器的名称。Pylint 借鉴了它的名字,是使用最广泛的静态分析器之一。它作为 Python 包提供,我们可以使用 pip
安装它
1 |
$ pip install pylint |
然后我们的系统中就可以使用 pylint
命令了。
Pylint 可以检查一个脚本或整个目录。例如,如果我们将以下脚本保存为 lenet5-notworking.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import numpy as np import h5py import tensorflow as tf from tensorflow.keras.datasets import mnist from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, Dense, AveragePooling2D, Dropout, Flatten from tensorflow.keras.utils import to_categorical from tensorflow.keras.callbacks import EarlyStopping # 加载 MNIST 数字 (X_train, Y_train), (X_test, Y_test) = mnist.load_data() # 将数据重塑为 (n_samples, height, width, n_channel) X_train = np.expand_dims(X_train, axis=3).astype("float32") X_test = np.expand_dims(X_test, axis=3).astype("float32") # 对输出进行独热编码 y_train = to_categorical(y_train) y_test = to_categorical(y_test) # LeNet5 模型 def createmodel(activation): model = Sequential([ Conv2D(6, (5,5), input_shape=(28,28,1), padding="same", activation=activation), AveragePooling2D((2,2), strides=2), Conv2D(16, (5,5), activation=activation), AveragePooling2D((2,2), strides=2), Conv2D(120, (5,5), activation=activation), Flatten(), Dense(84, activation=activation), Dense(10, activation="softmax") ]) return model # 训练模型 model = createmodel(tanh) model.compile(loss="categorical_crossentropy", optimizer="adam", metrics=["accuracy"]) earlystopping = EarlyStopping(monitor="val_loss", patience=4, restore_best_weights=True) model.fit(X_train, y_train, validation_data=(X_test, y_test), epochs=100, batch_size=32, callbacks=[earlystopping]) # 评估模型 print(model.evaluate(X_test, y_test, verbose=0)) model.save("lenet5.h5") |
我们可以要求 Pylint 在运行代码之前告诉我们代码的质量如何
1 |
$ pylint lenet5-notworking.py |
输出如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
************* 模块 lenet5-notworking lenet5-notworking.py:39:0: C0301: 行过长 (115/100) (line-too-long) lenet5-notworking.py:1:0: C0103: 模块名称 "lenet5-notworking" 不符合蛇形命名风格 (invalid-name) lenet5-notworking.py:1:0: C0114: 缺少模块文档字符串 (missing-module-docstring) lenet5-notworking.py:4:0: E0611: 模块 'LazyLoader' 中没有名称 'datasets' (no-name-in-module) lenet5-notworking.py:5:0: E0611: 模块 'LazyLoader' 中没有名称 'models' (no-name-in-module) lenet5-notworking.py:6:0: E0611: 模块 'LazyLoader' 中没有名称 'layers' (no-name-in-module) lenet5-notworking.py:7:0: E0611: 模块 'LazyLoader' 中没有名称 'utils' (no-name-in-module) lenet5-notworking.py:8:0: E0611: 模块 'LazyLoader' 中没有名称 'callbacks' (no-name-in-module) lenet5-notworking.py:18:25: E0601: 在赋值前使用变量 'y_train' (used-before-assignment) lenet5-notworking.py:19:24: E0601: 在赋值前使用变量 'y_test' (used-before-assignment) lenet5-notworking.py:23:4: W0621: 从外部范围重新定义名称 'model' (第 36 行) (redefined-outer-name) lenet5-notworking.py:22:0: C0116: 缺少函数或方法文档字符串 (missing-function-docstring) lenet5-notworking.py:36:20: E0602: 未定义变量 'tanh' (undefined-variable) lenet5-notworking.py:2:0: W0611: 未使用的导入 h5py (unused-import) lenet5-notworking.py:3:0: W0611: 未使用的 tensorflow 作为 tf 导入 (unused-import) lenet5-notworking.py:6:0: W0611: 未使用的 Dropout 从 tensorflow.keras.layers 导入 (unused-import) ------------------------------------- 您的代码得分为 -11.82/10 |
如果您向 Pylint 提供模块的根目录,模块的所有组件都将由 Pylint 检查。在这种情况下,您将在每一行的开头看到不同文件的路径。
这里有几点需要注意。首先,Pylint 的抱怨分为不同的类别。最常见的是关于约定(即风格问题)、警告(即代码可能以与您预期不一致的方式运行)和错误(即代码可能无法运行并抛出异常)。它们由 E0601 等代码标识,其中第一个字母是类别。
Pylint 可能会给出误报。在上面的示例中,我们看到 Pylint 将从 tensorflow.keras.datasets
导入标记为错误。这是由 Tensorflow 包中的优化引起的,当我们导入 Tensorflow 时,Python 不会扫描和加载所有内容,而是创建一个 LazyLoader 来帮助只加载大型包的必要部分。这大大节省了程序启动时间,但也使 Pylint 感到困惑,因为我们似乎导入了不存在的东西。
此外,Pylint 的主要功能之一是帮助我们将代码与 PEP8 编码风格对齐。例如,当我们定义一个没有文档字符串的函数时,即使代码没有做错任何事情,Pylint 也会抱怨我们没有遵循编码约定。
但 Pylint 最重要的用途是帮助我们识别潜在问题。例如,我们将 y_train
拼写错误为大写 Y
的 Y_train
。Pylint 会告诉我们正在使用一个未赋值的变量。它没有直接告诉我们哪里出了问题,但它确实指出了正确的位置来校对我们的代码。同样,当我们在第 23 行定义变量 model
时,Pylint 告诉我们外部作用域中存在一个同名变量。因此,稍后对 model
的引用可能与我们所想的不同。同样,未使用的导入可能仅仅是因为我们拼错了模块的名称。
所有这些都是 Pylint 提供的提示。我们仍然需要自行判断来更正代码(或忽略 Pylint 的抱怨)。
但是,如果您知道 Pylint 应该停止抱怨什么,您可以请求忽略这些。例如,我们知道 import
语句是正确的,所以我们可以这样调用 Pylint
1 |
$ pylint -d E0611 lenet5-notworking.py |
现在,Pylint 将忽略所有代码为 E0611 的错误。您可以通过逗号分隔列表禁用多个代码,例如,
1 |
$ pylint -d E0611,C0301 lenet5-notworking.py |
如果您只想在代码的特定行或特定部分禁用某些问题,您可以在代码中添加特殊注释,如下所示
1 2 3 4 5 |
... from tensorflow.keras.datasets import mnist # pylint: disable=no-name-in-module from tensorflow.keras.models import Sequential # pylint: disable=E0611 from tensorflow.keras.layers import Conv2D, Dense, AveragePooling2D, Dropout, Flatten from tensorflow.keras.utils import to_categorical |
魔法关键字 pylint:
将引入 Pylint 特定的指令。代码 E0611 和名称 no-name-in-module
是相同的。在上面的示例中,Pylint 将抱怨最后两个导入语句,而不是前两个,因为有这些特殊注释。
Flake8
Flake8 工具实际上是 PyFlakes、McCabe 和 pycodestyle 的包装器。当您使用以下命令安装 flake8 时
1 |
$ pip install flake8 |
您将安装所有这些依赖项。
与 Pylint 类似,安装此软件包后,我们就可以使用 flake8
命令,并且可以传入脚本或目录进行分析。但 Flake8 的重点倾向于编码风格。因此,对于上面相同的代码,我们将看到以下输出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
$ flake8 lenet5-notworking.py lenet5-notworking.py:2:1: F401 'h5py' 导入但未使用 lenet5-notworking.py:3:1: F401 'tensorflow as tf' 导入但未使用 lenet5-notworking.py:6:1: F401 'tensorflow.keras.layers.Dropout' 导入但未使用 lenet5-notworking.py:6:80: E501 行过长 (85 > 79 个字符) lenet5-notworking.py:18:26: F821 未定义名称 'y_train' lenet5-notworking.py:19:25: F821 未定义名称 'y_test' lenet5-notworking.py:22:1: E302 预期 2 空行,发现 1 lenet5-notworking.py:24:21: E231 逗号 ',' 后缺少空格 lenet5-notworking.py:24:41: E231 逗号 ',' 后缺少空格 lenet5-notworking.py:24:44: E231 逗号 ',' 后缺少空格 lenet5-notworking.py:24:80: E501 行过长 (87 > 79 个字符) lenet5-notworking.py:25:28: E231 逗号 ',' 后缺少空格 lenet5-notworking.py:26:22: E231 逗号 ',' 后缺少空格 lenet5-notworking.py:27:28: E231 逗号 ',' 后缺少空格 lenet5-notworking.py:28:23: E231 逗号 ',' 后缺少空格 lenet5-notworking.py:36:1: E305 类或函数定义后预期有 2 空行,发现 1 lenet5-notworking.py:36:21: F821 未定义名称 'tanh' lenet5-notworking.py:37:80: E501 行过长 (86 > 79 个字符) lenet5-notworking.py:38:80: E501 行过长 (88 > 79 个字符) lenet5-notworking.py:39:80: E501 行过长 (115 > 79 个字符) |
以字母 E 开头的错误代码来自 pycodestyle,以字母 F 开头的错误代码来自 PyFlakes。我们可以看到它抱怨编码风格问题,例如使用 (5,5)
时逗号后没有空格。我们还可以看到它能够识别在赋值前使用变量的情况。但它没有捕获一些代码异味,例如函数 createmodel()
重复使用了在外部作用域中已经定义的变量 model
。
想开始学习机器学习 Python 吗?
立即参加我为期7天的免费电子邮件速成课程(附示例代码)。
点击注册,同时获得该课程的免费PDF电子书版本。
与 Pylint 类似,我们也可以要求 Flake8 忽略一些抱怨。例如,
1 |
flake8 --ignore E501,E231 lenet5-notworking.py |
这些行将不会在输出中打印
1 2 3 4 5 6 7 8 |
lenet5-notworking.py:2:1: F401 'h5py' 导入但未使用 lenet5-notworking.py:3:1: F401 'tensorflow as tf' 导入但未使用 lenet5-notworking.py:6:1: F401 'tensorflow.keras.layers.Dropout' 导入但未使用 lenet5-notworking.py:18:26: F821 未定义名称 'y_train' lenet5-notworking.py:19:25: F821 未定义名称 'y_test' lenet5-notworking.py:22:1: E302 预期 2 空行,发现 1 lenet5-notworking.py:36:1: E305 类或函数定义后预期有 2 空行,发现 1 lenet5-notworking.py:36:21: F821 未定义名称 'tanh' |
我们也可以使用魔法注释来禁用一些抱怨,例如,
1 2 3 4 |
... import tensorflow as tf # noqa: F401 from tensorflow.keras.datasets import mnist from tensorflow.keras.models import Sequential |
Flake8 将查找注释 # noqa:
以跳过这些特定行上的某些抱怨。
Mypy
Python 不是一种类型语言,因此与 C 或 Java 不同,您不需要在使用前声明某些函数或变量的类型。但最近,Python 引入了类型提示符号,因此我们可以指定函数或变量预期的类型,而无需像类型语言那样强制其符合性。
想开始学习机器学习 Python 吗?
立即参加我为期7天的免费电子邮件速成课程(附示例代码)。
点击注册,同时获得该课程的免费PDF电子书版本。
在 Python 中使用类型提示的最大好处之一是为静态分析器提供额外的检查信息。Mypy 是能够理解类型提示的工具。即使没有类型提示,Mypy 仍然可以提供类似于 Pylint 和 Flake8 的抱怨。
我们可以从 PyPI 安装 Mypy
1 |
$ pip install mypy |
然后可以将上面的示例提供给 mypy
命令
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$ mypy lenet5-notworking.py lenet5-notworking.py:2: 错误: 跳过分析 "h5py": 模块已安装,但缺少库存根或 py.typed 标记 lenet5-notworking.py:2: 注意: 请参阅 https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports lenet5-notworking.py:3: 错误: 跳过分析 "tensorflow": 模块已安装,但缺少库存根或 py.typed 标记 lenet5-notworking.py:4: 错误: 跳过分析 "tensorflow.keras.datasets": 模块已安装,但缺少库存根或 py.typed 标记 lenet5-notworking.py:5: 错误: 跳过分析 "tensorflow.keras.models": 模块已安装,但缺少库存根或 py.typed 标记 lenet5-notworking.py:6: 错误: 跳过分析 "tensorflow.keras.layers": 模块已安装,但缺少库存根或 py.typed 标记 lenet5-notworking.py:7: 错误: 跳过分析 "tensorflow.keras.utils": 模块已安装,但缺少库存根或 py.typed 标记 lenet5-notworking.py:8: 错误: 跳过分析 "tensorflow.keras.callbacks": 模块已安装,但缺少库存根或 py.typed 标记 lenet5-notworking.py:18: 错误: 无法确定 "y_train" 的类型 lenet5-notworking.py:19: 错误: 无法确定 "y_test" 的类型 lenet5-notworking.py:36: 错误:名称 "tanh" 未定义 在 1 个文件中发现 10 个错误(检查了 1 个源文件) |
我们看到了与上面的 Pylint 类似的错误,尽管有时不如 Pylint 精确(例如,变量y_train
的问题)。然而,我们在上面看到 mypy 的一个特点:它期望我们使用的所有库都带有一个存根,以便可以进行类型检查。这是因为类型提示是可选的。如果库中的代码没有提供类型提示,代码仍然可以工作,但 mypy 无法验证。一些库有可用的类型存根,使 mypy 可以更好地检查它们。
我们再考虑一个例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import h5py def dumphdf5(filename: str) -> int: """打开一个 HDF5 文件并打印所有存储的数据集和属性 参数 filename: HDF5 文件名 返回 HDF5 文件中找到的数据集数量 """ count: int = 0 def recur_dump(obj) -> None: print(f"{obj.name} ({type(obj).__name__})") if obj.attrs.keys(): print("\tAttribs:") for key in obj.attrs.keys(): print(f"\t\t{key}: {obj.attrs[key]}") if isinstance(obj, h5py.Group): # Group has key-value pairs for key, value in obj.items(): recur_dump(value) elif isinstance(obj, h5py.Dataset): count += 1 print(obj[()]) with h5py.File(filename) as obj: recur_dump(obj) print(f"{count} dataset found") with open("my_model.h5") as fp: dumphdf5(fp) |
这个程序应该加载一个 HDF5 文件(例如 Keras 模型),并打印其中存储的所有属性和数据。我们使用了h5py
模块(它没有类型存根,因此 mypy 无法识别它使用的类型),但我们为我们定义的函数dumphdf5()
添加了类型提示。这个函数期望一个 HDF5 文件的文件名,并打印其中存储的所有内容。最后,将返回存储的数据集数量。
当我们将此脚本保存为dumphdf5.py
并将其传递给 mypy 时,我们将看到以下内容
1 2 3 4 5 6 |
$ mypy dumphdf5.py dumphdf5.py:1: error: Skipping analyzing "h5py": module is installed, but missing library stubs or py.typed marker dumphdf5.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports dumphdf5.py:3: error: Missing return statement dumphdf5.py:33: error: Argument 1 to "dumphdf5" has incompatible type "TextIO"; expected "str" Found 3 errors in 1 file (checked 1 source file) |
我们误用了我们的函数,将一个已打开的文件对象传递给了dumphdf5()
,而不是仅仅传递文件名(作为字符串)。Mypy 可以识别这个错误。我们还声明了函数应该返回一个整数,但我们没有在函数中添加返回语句。
然而,这段代码中还有一个 mypy 没有识别出的错误。即,在内部函数recur_dump()
中使用变量count
应该声明为nonlocal
,因为它是在作用域外定义的。这个错误可以被 Pylint 和 Flake8 捕捉到,但 mypy 却漏掉了。
以下是没有更多错误的完整、已更正的代码。请注意,我们在第一行添加了神奇注释“# type: ignore
”,以消除 mypy 的类型存根警告
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import h5py # type: ignore def dumphdf5(filename: str) -> int: """打开一个 HDF5 文件并打印所有存储的数据集和属性 参数 filename: HDF5 文件名 返回 HDF5 文件中找到的数据集数量 """ count: int = 0 def recur_dump(obj) -> None: nonlocal count print(f"{obj.name} ({type(obj).__name__})") if obj.attrs.keys(): print("\tAttribs:") for key in obj.attrs.keys(): print(f"\t\t{key}: {obj.attrs[key]}") if isinstance(obj, h5py.Group): # Group has key-value pairs for key, value in obj.items(): recur_dump(value) elif isinstance(obj, h5py.Dataset): count += 1 print(obj[()]) with h5py.File(filename) as obj: recur_dump(obj) print(f"{count} dataset found") return count dumphdf5("my_model.h5") |
总之,我们上面介绍的这三种工具可以相互补充。您可以考虑运行所有这些工具来查找代码中可能存在的任何错误或改进编码风格。每种工具都允许通过命令行或配置文件进行一些配置,以根据您的需求进行自定义(例如,一行代码多长才算太长而值得发出警告?)。使用静态分析器也是帮助您提高编程技能的一种方式。
延伸阅读
如果您想深入了解,本节提供了更多关于该主题的资源。
文章
- PEP8,https://peps.pythonlang.cn/pep-0008/
- Google Python 风格指南,https://ggdocs.cn/styleguide/pyguide.html
软件包
- Pylint 用户手册,https://pylint.pycqa.org/en/latest/index.html
- Flake8,https://flake8.pycqa.org/en/latest/
- mypy,https://mypy.readthedocs.io/en/stable/
总结
在本教程中,您已经了解了一些常见的静态分析器如何帮助您编写更好的 Python 代码。具体来说,您学习了
- 三种工具的优缺点:Pylint、Flake8 和 mypy
- 如何自定义这些工具的行为
- 如何理解这些分析器提出的问题
不错