程序的源代码应该易于人类阅读。让程序正确运行只是其目的的一半。如果没有适当的代码注释,自己(包括未来的你)很难理解代码背后的原理和意图。这也会使代码无法维护。在Python中,有多种方法可以为代码添加描述,使其更易读或意图更明确。在下文中,我们将了解如何正确使用注释、文档字符串和类型提示来使我们的代码更易于理解。完成本教程后,您将了解到:
- 如何在Python中正确使用注释
- 在某些情况下,字符串字面量或文档字符串可以替代注释
- 什么是Python中的类型提示,它们如何帮助我们更好地理解代码
用我的新书 Python for Machine Learning 快速启动您的项目,其中包括分步教程和所有示例的Python源代码文件。
让我们开始吧。
Python代码中的注释、文档字符串和类型提示。照片来自Rhythm Goyal。保留部分权利。
概述
本教程分为三个部分:
- 为Python代码添加注释
- 使用文档字符串
- 在Python代码中使用类型提示
为Python代码添加注释
几乎所有编程语言都有专门的注释语法。注释会被编译器或解释器忽略,因此它们对程序流程或逻辑没有影响。但有了注释,代码更容易阅读。
在 C++ 等语言中,我们可以使用双斜杠(//
)添加“行内注释”,或使用/*
和*/
enclosed 的注释块。然而,在Python中,我们只有“行内”版本,它们由井号(#
)引入。
为每一行代码写注释很容易,但这通常是浪费。当人们阅读源代码时,注释很容易吸引他们的注意力,因此过多的注释会分散阅读。例如,以下内容是不必要的且分散注意力的:
1 2 3 4 |
import datetime timestamp = datetime.datetime.now() # 获取当前日期和时间 x = 0 # 将x初始化为零 |
像这样的注释只是在重复代码的功能。除非代码晦涩难懂,否则这些注释不会为代码增加任何价值。下面的例子可能是一个特例,其中“ppf”(百分点函数)的名称不如“CDF”(累积分布函数)这个术语广为人知。
1 2 3 |
import scipy.stats z_alpha = scipy.stats.norm.ppf(0.975) # 调用标准正态分布的逆累积分布函数 |
好的注释应该说明我们为什么要这样做。我们来看下面的例子:
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 |
def adadelta(objective, derivative, bounds, n_iter, rho, ep=1e-3): # 生成一个初始点 solution = bounds[:, 0] + rand(len(bounds)) * (bounds[:, 1] - bounds[:, 0]) # 用于存储每个变量的平均平方梯度和 # 平均参数更新的列表 sq_grad_avg = [0.0 for _ in range(bounds.shape[0])] sq_para_avg = [0.0 for _ in range(bounds.shape[0])] # 运行梯度下降 for it in range(n_iter): gradient = derivative(solution[0], solution[1]) # 更新平方偏导数的移动平均值 for i in range(gradient.shape[0]): sg = gradient[i]**2.0 sq_grad_avg[i] = (sq_grad_avg[i] * rho) + (sg * (1.0-rho)) # 一次构建一个变量的解 new_solution = list() for i in range(solution.shape[0]): # 计算此变量的步长 alpha = (ep + sqrt(sq_para_avg[i])) / (ep + sqrt(sq_grad_avg[i])) # 计算变化量并更新平方变化量的移动平均值 change = alpha * gradient[i] sq_para_avg[i] = (sq_para_avg[i] * rho) + (change**2.0 * (1.0-rho)) # 计算此变量的新位置,并将其存储为新解 value = solution[i] - change new_solution.append(value) # 评估候选点 solution = asarray(new_solution) solution_eval = objective(solution[0], solution[1]) # 报告进度 print('>%d f(%s) = %.5f' % (it, solution, solution_eval)) return [solution, solution_eval] |
上面的函数实现了 AdaDelta 算法。在第一行,当我们给变量solution
赋值时,我们没有写“在 bounds[:,0] 和 bounds[:,1] 之间随机插值”这样的注释,因为这只是在重复代码。我们说这一行的意图是“生成一个初始点”。同样,对于函数中的其他注释,我们将一个 for 循环标记为梯度下降算法,而不是简单地说迭代一定次数。
在编写注释或修改代码时,一个要记住的重要问题是确保注释准确地描述了代码。如果它们相互矛盾,会令读者感到困惑。那么,我们是否应该将上面示例的第一行注释写成“将初始解设置为下界”,而代码显然是随机化初始解,反之亦然?如果这就是您打算做的,那么您应该同时更新注释和代码。
一个例外是“待办”注释。不时地,当我们有一个改进代码的想法但还没有修改时,我们可能会在代码中添加待办注释。我们也可以用它来标记未完成的实现。例如:
1 2 3 4 5 6 7 8 |
# TODO 用 Tensorflow 替换下面的 Keras 代码 from keras.models import Sequential 从 keras.layers 导入 Conv2D model = Sequential() model.add(Conv2D(1, (3,3), strides=(2, 2), input_shape=(8, 8, 1))) model.summary() ... |
这是一个常见的做法,许多 IDE 会在找到关键字TODO
时以不同的方式突出显示注释块。但是,它应该是临时的,我们不应该滥用它作为问题跟踪系统。
总而言之,一些常见的代码注释“最佳实践”如下:
- 注释不应重述代码,而应解释代码
- 注释不应引起混淆,而应消除混淆
- 在不易理解的代码上添加注释;例如,说明非惯用的语法用法,命名正在使用的算法,或解释意图或假设
- 注释应简洁明了
- 在注释中保持一致的风格和语言使用
- 始终优先编写需要额外注释的优秀代码
使用文档字符串
在 C++ 中,我们可能会写一大段注释,例如:
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 |
TcpSocketBase::~TcpSocketBase (void) { NS_LOG_FUNCTION (this); m_node = nullptr; if (m_endPoint != nullptr) { NS_ASSERT (m_tcp != nullptr); /* * Bind后,将分配一个Ipv4Endpoint并设置为m_endPoint,并将 * DestroyCallback设置为TcpSocketBase::Destroy。如果我们调用 * m_tcp->DeAllocate,它将销毁其Ipv4EndpointDemux::DeAllocate, * 这反过来又销毁我的m_endPoint,并最终调用 * TcpSocketBase::Destroy将m_node、m_endPoint和m_tcp置为null。 */ NS_ASSERT (m_endPoint != nullptr); m_tcp->DeAllocate (m_endPoint); NS_ASSERT (m_endPoint == nullptr); } if (m_endPoint6 != nullptr) { NS_ASSERT (m_tcp != nullptr); NS_ASSERT (m_endPoint6 != nullptr); m_tcp->DeAllocate (m_endPoint6); NS_ASSERT (m_endPoint6 == nullptr); } m_tcp = 0; CancelAllTimers (); } |
但在Python中,我们没有等同于/*
和*/
的定界符,但我们可以使用以下方式编写多行注释:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
async def main(indir): # 扫描目录中的文件并将列表填充 filepaths = [] for path, dirs, files in os.walk(indir): for basename in files: filepath = os.path.join(path, basename) filepaths.append(filepath) """创建4个进程的“处理池”并运行asyncio。 进程将执行工作函数 并发地,每个文件路径作为参数 """ loop = asyncio.get_running_loop() with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor: futures = [loop.run_in_executor(executor, func, f) for f in filepaths] for fut in asyncio.as_completed(futures): try: filepath = await fut print(filepath) except Exception as exc: print("failed one job") |
这样做是有效的,因为Python支持使用三引号("""
)跨越多行声明字符串字面量。而代码中的字符串字面量只是一个没有影响的声明字符串。因此,它在功能上与注释没有区别。
我们想要使用字符串字面量的一个原因是注释掉大块代码。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
from sklearn.linear_model import LogisticRegression from sklearn.datasets import make_classification """ X, y = make_classification(n_samples=5000, n_features=2, n_informative=2, n_redundant=0, n_repeated=0, n_classes=2, n_clusters_per_class=1, weights=[0.01, 0.05, 0.94], class_sep=0.8, random_state=0) """ import pickle with open("dataset.pickle", "wb") as fp: X, y = pickle.load(fp) clf = LogisticRegression(random_state=0).fit(X, y) ... |
上面的代码是我们在机器学习问题中通过实验可能会开发出来的示例代码。虽然我们在开始时是随机生成了一个数据集(上面调用了make_classification()
),但我们可能希望稍后切换到不同的数据集并重复相同的过程(例如,上面的 pickle 部分)。我们不必删除代码块,可以直接注释掉那些行,以便稍后存储代码。虽然它对于最终代码来说不是最佳的,但在开发解决方案时很方便。
Python 中函数下的第一个字符串字面量如果作为注释,则具有特殊用途。在这种情况下,该字符串字面量被称为函数的“文档字符串”(docstring)。例如:
1 2 3 4 5 6 7 8 9 10 |
def square(x): """计算值的平方 参数 x (int 或 float): 数值 返回 int 或 float: x 的平方 """ return x * x |
我们可以看到函数下的第一行是字符串字面量,它与注释的作用相同。它使代码更具可读性,但同时我们也可以从代码中检索它。
1 2 |
print("函数名:", square.__name__) print("文档字符串:", square.__doc__) |
1 2 3 4 5 6 7 8 |
函数名: square 文档字符串: 计算值的平方 参数 x (int 或 float): 数值 返回 int 或 float: x 的平方 |
由于文档字符串的特殊地位,关于如何编写一个合适的文档字符串存在一些约定。
在 C++ 中,我们可以使用 Doxygen 从注释生成代码文档,同样,我们有 Javadoc 用于 Java 代码。Python 中最接近的匹配是 Sphinx 的“autodoc”工具或 pdoc。两者都将尝试解析文档字符串以自动生成文档。
没有标准的编写文档字符串的方法,但通常,我们期望它们能解释函数(或类或模块)的用途以及参数和返回值。一种常见的风格如上所示,这是 Google 所提倡的。另一种风格来自 NumPy。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def square(x): """计算值的平方 参数 ---------- x : int 或 float 数值 返回 ------- int 或 float `x` 的平方 """ return x * x |
autodoc 等工具可以解析这些文档字符串并生成 API 文档。但即使这不是目的,拥有一个描述函数性质、函数参数和返回值数据类型的文档字符串肯定能使您的代码更容易阅读。这尤其适用于 Python,与 C++ 或 Java 不同,Python 是一种 **“鸭子类型”** 语言,其中变量和函数参数没有声明特定的类型。我们可以利用文档字符串来明确说明数据类型的假设,以便人们能够更容易地理解或使用您的函数。
想开始学习机器学习 Python 吗?
立即参加我为期7天的免费电子邮件速成课程(附示例代码)。
点击注册,同时获得该课程的免费PDF电子书版本。
在 Python 代码中使用类型提示
自 Python 3.5 以来,允许使用类型提示语法。顾名思义,它的目的是提示类型,仅此而已。因此,即使它看起来是将 Python 引入 Java,也不意味着要限制变量中存储的数据。上面的示例可以用类型提示重写:
1 2 |
def square(x: int) -> int: return x * x |
在函数中,参数后面可以跟上 : type
语法来明确说明预期的类型。函数的返回值由冒号之前的 -> type
语法标识。实际上,也可以为变量声明类型提示,例如:
1 2 3 |
def square(x: int) -> int: value: int = x * x return value |
类型提示的好处是双重的:我们可以用它来消除一些需要明确描述所用数据类型的注释。我们还可以帮助静态分析器更好地理解我们的代码,从而帮助它们识别代码中的潜在问题。
有时类型可能很复杂,因此 Python 在其标准库中提供了 typing
模块来帮助清理语法。例如,我们可以使用 Union[int,float]
来表示 int
类型或 float
类型,使用 List[str]
来表示一个列表,其中每个元素都是字符串,并使用 Any
来表示任何类型。如下所示:
1 2 3 4 5 6 7 |
from typing import Any, Union, List def square(x: Union[int, float]) -> Union[int, float]: return x * x def append(x: List[Any], y: Any) -> None: x.append(y) |
但是,重要的是要记住,类型提示仅仅是提示。它不对代码施加任何限制。因此,以下代码虽然会让读者感到困惑,但完全没问题:
1 2 |
n: int = 3.5 n = "赋值为字符串" |
使用类型提示可以提高代码的可读性。然而,类型提示最重要的好处是允许静态分析器(如 mypy)告诉我们代码是否存在潜在的错误。如果您使用 mypy 处理上面的代码行,我们会看到以下错误:
1 2 3 |
test.py:1: error: Incompatible types in assignment (expression has type "float", variable has type "int") test.py:2: error: Incompatible types in assignment (expression has type "str", variable has type "int") 在 1 个文件中找到 2 个错误(已检查 1 个源文件) |
静态分析器的使用将在另一篇文章中介绍。
为了说明注释、文档字符串和类型提示的用法,下面是一个定义生成器函数的示例,该函数在固定宽度窗口上对 pandas DataFrame 进行采样。这对于训练 LSTM 网络非常有用,其中应提供连续的几个时间步。在下面的函数中,我们从一个随机行开始,然后提取随后的几行。只要我们能够成功获取一个完整的窗口,我们就将其作为样本。一旦收集到足够的样本来构成一个批次,该批次就会被分派。
您应该会发现,如果我们能为函数参数提供类型提示,它会更清晰,这样我们就能知道,例如,data
是一个 pandas DataFrame。但我们在文档字符串中进一步说明,它应该有一个 datetime 索引。我们用注释来描述从输入数据中提取行窗口的算法,以及内部 while 循环中“if”块的意图。这样,代码就更容易理解,也更容易维护或修改以供他用。
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 44 |
from typing import List, Tuple, Generator import pandas as pd import numpy as np TrainingSampleGenerator = Generator[Tuple[np.ndarray,np.ndarray], None, None] def lstm_gen(data: pd.DataFrame, timesteps: int, batch_size: int) -> TrainingSampleGenerator: """为 LSTM 训练生成随机样本的生成器 参数 data: 具有按时间顺序排列的 datetime 索引的 DataFrame, 从中抽取样本 timesteps: 每个样本的时间步数,数据将 从该长度的窗口生成 batch_size: 每个批次的样本数 产生 ndarray, ndarray: 从输入数据中以随机窗口抽取的 (X,Y) 训练样本 从输入数据中以随机窗口抽取的 (X,Y) 训练样本 """ input_columns = [c for c in data.columns if c != "target"] batch: List[Tuple[pd.DataFrame, pd.Series]] = [] while True: # 选择一个开始时间和一个安全值 while True: # 从数据中的随机点开始并截取一个窗口 row = data["target"].sample() starttime = row.index[0] window: pd.DataFrame = data[starttime:].iloc[:timesteps] # 如果我们到达了 DataFrame 的末尾,我们就无法获得一个完整的 # 窗口,我们必须重新开始 if len(window) == timesteps: break # 提取输入和输出 y = window["target"] X = window[input_columns] batch.append((X, y)) # 如果积累了足够一个批次的数据,则分派 if len(batch) == batch_size: X, y = zip(*batch) yield np.array(X).astype("float32"), np.array(y).astype("float32") batch = [] |
延伸阅读
如果您想深入了解,本节提供了更多关于该主题的资源。
文章
- 编写代码注释的最佳实践,https://stackoverflow.blog/2021/12/23/best-practices-for-writing-code-comments/
- PEP483,类型提示理论,https://pythonlang.cn/dev/peps/pep-0483/
- Google Python 风格指南,https://ggdocs.cn/styleguide/pyguide.html
软件
- Sphinx 文档,https://sphinx-doc.cn/en/master/index.html
- Sphinx 的 Napoleon 模块,https://sphinxcontrib-napoleon.readthedocs.io/en/latest/index.html
- pdoc,https://pdoc.dev/
- typing 模块,https://docs.pythonlang.cn/3/library/typing.html
总结
在本教程中,您已经了解了我们应该如何在 Python 中使用注释、文档字符串和类型提示。具体来说,您现在知道:
- 如何写一个好而有用的注释
- 使用文档字符串解释函数的约定
- 如何使用类型提示来解决 Python 鸭子类型在可读性方面的弱点
请找一位母语为英语的人士审阅/编辑评论。