【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 75 ~ 90 条(测试与调试、协作开发)读书笔记

(书基于 Python 3.7+ 语法规范)

知识点概括:

1
2
3
4
5
6
7
8
9
10
11
repr函数
unittest模块
TestCase类
mock函数与Mock类
pdb模块
breakpoint函数
tracemalloc模块
PyPI
venv工具
requirements.txt文件
warnings模块

9.测试与调试

Python语言没有编译期的静态类型检查机制,所以Python解释器无法确保程序一定能够正确运行。当然,在静态分析的过程中,可以选用类型注解帮助我们发现各种bug。

即便源代码里写着某个函数,你也未必能够保证,程序在需要调用这个函数的时候,它一定得到了定义。这种动态特征既有好处,又有风险。

程序正式发布之前,为什么不把它全面测试好呢?编译期的静态类型检查并不能完全确保程序必定能够正常运行。无论是用动态语言编程,还是用静态语言编程,我们都必须对写出来的代码做测试。

它与其他语言相比,更需要通过测试来确保代码准确无误。然而这也有好的地方:Python的动态机制虽然有风险,但若能适当加以运用,则可以帮助我们相当顺利地测试代码并调试程序。

第75条、通过repr字符串输出调试信息

调试Python程序时,我们可以通过print函数与格式字符串,或者利用内置的logging模块,相当深入地观察程序的运行情况。Python的内部状态一般都可以通过普通的属性访问到。

print函数可以把开发者传给它的值显示成便于认读的那种字符串。例如,最简单的用法是直接把字符串传给它,这样就可以打印出不带引号的内容。

1
print('foo bar')

这种写法跟下面几种写法是等效的:

  • 先把字符串传给str函数,然后把str函数返回的内容传给print;把字符串写在%操作符的右侧,让它替换操作符左侧那个格式字符串里的'%s';把表示字符串值的那个变量,按照默认格式写在f-string中,交给print去打印;
  • 调用内置的format函数;
  • 明确调用format特殊方法;
  • 明确调用str特殊方法。

如:

1
2
3
4
5
6
7
my_value = 'foo bar'
print(str(my_value))
print('%s' % my_value)
print(f'{my_value}')
print(format(my_value))
print(my_value.__format__('s'))
print(my_value.__str__())

然而问题在于,用这种方式来打印,不太容易看清这个值究竟是什么类型以及它具体是由哪些部分组成的。例如,如果按照默认方式调用print函数,那么我们无法区分打印出来的这个5,到底是数字5,还是字符串’5’。

如果要用print调试程序,那么类型的区别就很重要。所以,我们需要打印的应该是对象的repr版本。这个版本可以通过内置的repr函数获得,该函数会返回对象的可打印表示形式(printable representation),这也是对象最为清晰且易于理解的表示形式。对于大多数内置类型来说,repr返回的字符串是个有效的Python表达式。

1
2
a = '\x07'
print(repr(a))

把repr返回的值传给内置的eval函数,应该会得到一个跟原来相同的Python对象(当然,实际中eval函数的使用必须相当谨慎)。

用print调试程序的时候,应该先把要打印的值传给repr,然后将repr返回的内容传给print去打印,以明确体现出类型之间的差别。

1
2
print(repr(5)); # 5
print(repr('5')); # '5'

还有两种写法也能实现相同的效果,一种是把要打印的值放在%操作符的右边,让它替换左边的'%r'格式化字符串,另一种是在f-string中使用!r转换类型。

1
2
print('%r' % 5)
print('%r' % '5')

第76条、在TestCase子类里验证相关的行为

在Python中编写测试的最经典办法是使用内置的unittest模块。

为了定义测试用例,需要再创建一个文件,将其命名为test_utils.py或utils_test.py,命名方案可以根据你的风格来选。

1
2
3
4
5
6
7
8
9
10
11
12
13
# utils_test.py
from unittest import TestCase, main
from utils import to_str

class UtilsTestCase(TestCase):
def test_to_str_bytes(self):
self.assertEqual('hello', to_str(b'hello'))

def test_to_str_str(self):
self.assertEqual('incorrect', to_str('hello'))

if __name__ == '__main__':
main()

测试用例需要安排到TestCase的子类中。在这样的子类中,每个以test开头的方法都表示一项测试用例。如果test方法在运行过程中没有抛出任何异常(assert语句所触发的AssertionError也算异常),那么这项测试用例就是成功的,否则就是失败。其中一项测试用例失败,并不影响系统继续执行TestCase子类里的其他test方法,所以我们最后能够看到总的结果,知道其中有多少项测试用例成功,多少项失败,而不是只要遇到测试用例失败,就立刻停止整套测试。

我们还可以在test方法中指定断点,这样能够直接从此处激活调试器(debugger),以观察详细的出错原因。

TestCase类提供了一些辅助方法,可以在测试用例中做断言。例如,assertEqual方法可以确认两者是否相等,assertTrue可以确认Boolean表达式是否为True,此外还有很多以assert开头的方法(在Python解释器界面输入help(TestCase),可以查看所有的方法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# assert_test.py
from unittest import TestCase, main
from utils import to_str

class AssertTestCase(TestCase):
def test_assert_helper(self):
expected = 12
found = 2 * 5
self.assertEqual(expected, found)
def test_assert_statement(self):
expected = 12
found = 2 * 5
assert expected == found

if __name__ == '__main__':
main()

TestCase还提供了assertRaises这样一个辅助方法,它可以当作情境管理器(context manager)用在with结构中),以验证该结构的主体部分是否会抛出应有的异常。这种写法跟try/except结构相似,可以很清楚地表示出受测代码应该抛出哪种异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
# utils_error_test.py
from unittest import TestCase, main
from utils import to_str

class UtilsErrorTestCase(TestCase):
def test_to_str_bad(self):
with self.assertRaises(TypeError):
to_str(object())
def test_to_str_bad_encoding(self):
with self.assertRaises(UnicodeDecodeError):
to_str(b'\xfa\xfa')
if __main__ == '__main__':
main()

如果测试用例需要使用比较复杂的逻辑,那么可以把这些逻辑定义成辅助方法放到TestCase子类里。但是必须注意,这种方法的名称不能以test开头,否则系统就会把它们当成测试用例来执行。在写辅助方法时,我们可能会用到TestCase类提供的各种assert方法,而且还经常会用fail方法来表示不应该出现的情况,也就是说,如果程序真的运行到了fail这里,那么意味着我们预设的某项前提条件没有得到满足。

TestCase类还提供了subTest辅助方法,可以让我们把相似的用例全都写在同一个test方法中,让它们成为这个用例中的子用例,这样的话,每个子用例所共用的那部分代码与逻辑只需要写一次就行。

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
# data_driven_test.py
from unittest import TestCase, main
from utils import to_str

class DataDrivenTestCase(TestCase):
def test_good(self):
good_cases = [
(b'my bytes', 'my bytes'),
('no error', b'no error'), # will fail
('other str', 'other str'),
]
for value, expected in good_cases:
with self.subTest(value):
self.assertEqual(expected, to_str(value))
def test_bad(str):
bad_cases = [
(object(), TypeError),
(b'\xfa\xfa', UnicodeDecodeError),
# ...
]
for value, exception in bad_cases:
with self.subTest(value):
with self.assertRaises(exception):
to_str(value)

if __main__ == '__main__':
main()

如果项目比较复杂或者对测试的要求比较高,可以考虑用pytest这个开源软件包(https://pytest.org/)来测试,Python开发者给pytest制作了许多特别有用的插件。

第77条、把测试前、后的准备与清理逻辑写在setUp、tearDown、setUp-Module与tearDownModule中,以防用例之间互相干扰

TestCase子类在执行其中的每个test方法之前,经常需要先把测试环境准备好,这套准备逻辑有时也叫测试装置或测试用具(test harness)。我们可以在TestCase子类中覆写setUp与tearDown方法,并把相应的准备逻辑与清理逻辑写在里面。系统在执行每个test方法之前都会先调用一遍setUp方法,并在执行完test方法之后调用一遍tearDown方法。这可以确保测试用例之间不会互相干扰,这一点,对测试工作至关重要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# environment_test.py
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase, main

class EnvironmentTest(TestCase):
def setUp(self):
self.test_dir = TemporaryDirectory()
self.test_path = Path(self.test_dir.name)
def tearDown(self):
self.test_dir.cleanup()
def test_modify_file(self):
with open(self.test_path / 'data.bin', 'w') as f:
#...
if __name__ == '__main__':
main()

当程序变得复杂后,我们就不能只依赖这种彼此隔绝的单元测试了,而是需要再写一些测试,以验证模块与模块之间能否正确地交互(这可能要用到mock等工具)。这种测试叫集成测试(integration test),它跟前面的单元测试(unit test)不同。这两种测试在Python中很重要,假如不做集成测试,那就没办法确信这些模块能够协同运作。

对于集成测试来说,测试环境的准备与清理工作可能要占用大量计算资源,并持续比较长的时间。例如,可能要先启动数据库进程,并等待该进程把索引加载进来,然后才能开始做集成测试。这些工作的延迟很高,因此不能像做单元测试时那样,写在setUp与tearDown方法中。

第78条、用Mock来模拟受测代码所依赖的复杂函数

写测试的时候还有一个常见的问题,就是某些逻辑很难从开发环境里真实地执行,或者使用起来特别慢,这样的逻辑可以通过mock函数与Mock类来模拟。

1
2
3
4
5
6
7
8
9
10
form datetime import datetime
from unittest.mock import Mock

mock = Mock(spec=get_animals)
expected = [
('Spot', datetime(2019, 6, 5, 11, 15)),
('Fluffy', datetime(2019, 6, 5, 12, 30)),
('Jojo', datetime(2019, 6, 5, 12, 45)),
]
mock.return_value = expected

只要参数的取值不影响要测试的关键行为,那就可以在验证时通过ANY忽略这个参数。对于这些不太重要的参数,我们可以放宽一些,而不应该指定得太细,因为那样必须编写大量的验证代码。

Mock类还能够模拟调用时抛出异常的情况。

1
2
3
4
5
6
class MyError(Exception):
pass

mock = Mock(spec=get_animals)
mock.side_effect = MyError('Whoops! Big problem')
result = mock(database, 'Meerkat')

要想把受测函数所调用的其他函数用mock逻辑替换掉,一种办法是给受测函数设计只能以关键字来指定的参数;另一种办法是通过unittest.mock.patch系列的方法暂时隐藏那些函数。

第79条、把受测代码所依赖的系统封装起来,以便于模拟和测试

通过Mock类实现,另一个是通过patch方法实现。可是,这两种方案都要求我们在测试的过程中重复编写很多例行代码,这会让初次阅读代码的人很难理解我们究竟要验证什么。

有一种办法可以改进代码,就是把受测函数所要使用的数据库接口封装起来,这样我们就不用像原来那样,专门把数据库连接(DatabaseConnection)当作参数传给受测函数了,而是可以将封装好的系统传过去。

Python内置的unittest.mock模块里有个Mock类,它能模拟类的实例,这种Mock对象具备与原类中的方法相对应的属性。如果在它上面调用某个方法,就会触发相应的属性。如果想把程序完整地测一遍,那么可以重构代码,在原来直接使用复杂系统的地方引入辅助函数,让程序通过这些函数来获取它要用的系统,这样我们就可以通过辅助函数注入模拟逻辑。

第80条、考虑用pdb做交互调试

Python内置的交互调试器(interactive debugger)就是这样一种工具,它可以检查程序状态,打印局部变量的值,还可以每次只执行一条Python语句(也就是单步执行)。

在其他大部分编程语言中,如果要使用调试器,那么必须先在源文件中指定断点,令程序在执行到这一行时停下来。然而Python不用这样,你可以直接在认为有问题的那行代码前加入一条指令,让程序暂停,并启动调试器,这是最简单的办法。采用这种办法来调试程序,与正常启动程序并没有什么区别。

用来触发调试器的指令就是Python内置的breakpoint函数。这个函数的效果与先引入内置的pdb模块然后运行set_trace函数的效果是一样的。

一旦运行breakpoint函数,程序会在即将执行下一条语句的地方暂停。

在(Pdb)提示符界面,我们可以输入局部变量的名称(或执行p <name>命令)来查看变量的取值,也可以调用Python内置的locals函数以观察所有的局部变量,还可以引入模块,检查全局状态,构造新的对象,或运行内置的help命令,甚至还能修改正在运行的程序里的某些部分,总之,对调试工作有帮助的操作都可以在这里执行。

另外,调试器还提供了各种特殊命令,帮我们控制程序的执行方式,并探查其执行情况。在调试界面输入help,可以看到完整的命令列表。通过下面这三条非常实用的命令,我们可以很方便地检查正在运行的这个程序:

  • where:打印出当前的执行调用栈(execution call stack),可以据此判断程序当前执行到了哪个位置,以及程序是在调用了哪些函数后才触发breakpoint断点的。
  • up:把观察点沿着执行调用栈上移一层,回到当前函数调用者处,以观察位于当前断点之上的那些层面分别有什么样的局部变量。
  • down:把观察点沿着执行调用栈下移一层。

检查完程序的运行状态后,可以通过下面这五条命令决定程序接下来应该如何执行:

  • step:执行程序里的下一行代码,并在执行完毕后把控制权交还给调试器。如果下一行代码带有函数调用操作,那么调试器就会停在受调用的那个函数开头。
  • next:执行当前函数的下一行代码,并在执行完毕后,返回交互调试界面。如果下一行代码带有函数调用操作,系统不会令调试器停在受调用的函数开头。
  • return:让程序一直运行到当前函数返回为止,然后把控制权交还给调试器。
  • continue:让程序运行到下一个断点处(那个断点可以是通过breakpoint触发的,也可以是在调试界面里设置的)。
  • quit:退出调试界面,并且让接受调试的程序也随之终止。如果已经找到了问题,那么就可以用这个命令结束调试。如果发现寻找的方向不对,或者需要先去修改程序的代码,那么也应该运行这个命令以便重新调试。

breakpoint函数可以出现在程序里的任何地方。

调试器还支持一项有用的功能,叫作事后调试(post-mortem debugging),当我们发现程序会抛出异常并崩溃后,想通过调试器看看它在抛出异常的那一刻,究竟是什么样子的。有时我们也不确定应该在哪里调用breakpoint函数,在这种情况下,尤其需要这项功能。

于是,我们用python3 -m pdb -c continue <program path>命令把这个有问题的程序放在pdb模块的控制下运行,其中-c选项里的continue命令会让pdb在启动受测程序之后立刻向前推进,直至遇到断点或出现异常为止。

还有一种办法也能触发事后调试机制,就是先在普通的Python解释器里执行受测代码,待遇到未被捕获的异常后,再引入pdb模块并调用其pm函数(通常把引入pdb模块与调用pm函数这两项操作合起来写成import pdb; pdb.pm())。

第81条、用tracemalloc来掌握内存的使用与泄漏情况

在Python的默认实现方式(也就是CPython)中,内存管理是通过引用计数(reference counting)执行的。如果指向某个对象的引用已经全部过期,那么受引用的对象就可以从内存中清除,从而给其他数据腾出空间。另外,CPython还内置了循环检测器(cycle detector),确保那些自我引用的对象也能够得到清除。

从理论上讲,这意味着Python开发者不用担心程序如何分配并释放内存的问题,因为Python系统本身以及CPython运行时环境会自动处理这些问题。但实际上,还是会有程序因为没有及时释放不再需要引用的数据而耗尽内存。想了解Python程序使用内存的情况,或找到泄漏内存的原因,是比较困难的。

第一种调试内存使用状况的办法,是用Python内置的gc模块把垃圾回收器目前知道的每个对象都列出来。虽然这样有点儿笨,但毕竟可以让我们迅速得知程序的内存使用状况。

gc.get_objects函数的缺点在于,它并没有指出这些对象究竟要如何分配。复杂的程序中,同一个类的对象可能是因为好几种不同的原因而为系统所分配的。知道对象的总数固然有意义,但更为重要的是找到分配这些对象的具体代码,这样才能查清内存泄漏的原因。

Python 3.4版本推出了一个新的内置模块,名为tracemalloc,它可以解决刚才讲的那个问题。tracemalloc能够追溯对象到分配它的位置,因此我们可以在执行受测模块之前与执行完毕之后,分别给内存使用情况做快照,并对比两份快照,以了解它们之间的区别。

1
2
3
4
import tracemalloc

tracemalloc.start(10)
time1 = tracemalloc.take_snapshot()

每一条记录都有size与count指标,用来表示这行代码所分配的对象总共占用多少内存以及这些对象的数量。通过这两项指标,我们很快就能发现占用内存较多的对象是由哪几行代码所分配的。tracemalloc模块还可以打印完整的栈追踪信息(当然了,这最多只能达到调用tracemalloc.start函数时设置的帧数)。

这样的栈追踪信息很有用处,因为它能帮我们找到程序中累计分配内存最多的函数或类。


10.协作开发

要想与其他人一起开发Python程序,你必须仔细留意自己编写代码的方式。

第82条、学会寻找由其他Python开发者所构建的模块

Python有个集中存放模块的地方,叫作Python Package Index(PyPI,网址为https://pypi.org),你可以从中安装模块,并在自己的程序里面使用。这些模块都是由Python开发者所构建并维护的,这些开发者合称Python社群。如果你面对一项自己不太熟悉的需求,那么可以去PyPI搜索一下,看看有没有哪个软件包能够帮你更快地实现目标。

要从PyPI安装软件包,可以在命令行界面执行pip命令(这是个递归的首字母缩略词,指pip installs packages)。

pip最好与内置的venv模块搭配使用,这样可以给不同的项目分别安装不同版本的软件包,以适应每个项目自身的要求。你可以自己创建软件包并发布到PyPI与其他开发者共享,或搭建私人的软件包仓库,让pip改从这里安装模块。

第83条、用虚拟环境隔离项目,并重建依赖关系

程序变得比较大、比较复杂后,可能需要依赖更多的第三方Python包,那时我们可以通过python3 -m pip命令安装pytz、numpy以及其他许多模块。

问题在于,pip会把新的软件包默认安装到全局路径之中,这样会让涉及该模块的每一个Python程序都受到影响。有些人可能觉得这不会有什么问题:如果只安装模块,而不在Python程序里面直接引入它,那这个程序就不会受模块的影响了吧?

其实问题出现在间接的依赖关系上面,也就是说,直接引入的虽然不是这个模块,但你引入的其他模块却必须依赖这个模块才能运作。例如,如果你打算直接引入Sphinx模块,那就用pip install命令安装该模块,安装好之后,可以通过pipshow查看它的依赖关系。

同一个模块在Python的全局环境中只能存在一个版本。如果某个软件包需要使用新版模块,而另一个软件包需要使用旧版模块,那么就会遇到两难的局面:不论这个模块是否升级,这两个软件包都会有一个无法运作。这种情况通常称为dependency hell。

上面提到的种种问题都可以通过venv工具解决。这个工具能够创建虚拟环境(virtual environment)。从Python 3.4开始,pip与venv模块会在安装Python时一起默认安装。

venv可以创建彼此隔绝的Python环境,我们能够把同一个软件包的不同版本分别安装到不同的环境里面,这样就不会产生冲突了。这意味着能够在同一台电脑上面给不同的项目创建各自的环境,并在里面安装它们所需要的软件包版本。为了达到这样的效果,venv工具会把这些软件包以及它们所依赖的其他软件包都专门安装到单独的目录结构里面,使得多个环境之间不会发生冲突。这种机制,也让我们可以把项目所要求的环境在其他电脑上面重新建立起来,令程序能够可靠地运行,而不会出现意外的问题。

用python3 -m pip list列出这套虚拟环境之中安装的软件包,可以看到,这些软件包与前一套环境所安装的相同。

用版本控制系统(revision control system)与他人协作时,这样一份requirements.txt文件是很有用处的,因为你在提交代码的同时,可以把新版代码所依赖的软件包通过该文件一起提交上去,这样能够确保代码与代码所依赖的软件包总是可以同步更新。然而,有一个问题必须注意,那就是Python本身的版本并不包含在requirements.txt之中,所以必须单独管理。

虚拟环境有个很容易出错的地方,就是不能直接把它移动到其他路径下面,因为它里面的一些命令(例如python3)所指向的位置都是固定写好的,其中用到了这套环境的安装路径,假如移动到别处,那么这些路径就会失效。然而,这其实并不算大问题,因为我们创建虚拟环境,不是想要移动它,而是想把它的配置记录下来,以便在其他环境里面重建。所以我们要做的,仅仅是用python3 -m pip freeze命令把旧环境依赖的软件包保存到requirements.txt文件之中,然后在新环境里面,根据这份文件重新安装这些软件包。

第84条、每一个函数、类与模块都要写docstring

Python是动态语言,因此文档特别重要。Python内建了相关的机制,支持给代码块编写文档,而且与其他一些编程语言不同,Python允许我们在程序运行的过程中,直接访问这些文档。

例如,我们可以紧跟着函数的定义(def)语句,书写一条docstring作为这个函数的文档。

1
2
3
4
5
6
def palindrome(word):
"""Return True if the given word is a palindrome."""
return word == word[::-1]

assert palindrome('tacocat')
assert not palindrome('banana')

函数的docstring可以在运行Python程序的过程中通过doc这个特殊的属性访问。

1
print(repr(palindrome.__doc__))

另外,还可以在命令行界面中,利用内置的pydoc模块在本机上启动web服务器,这台服务器能够提供当前Python解释器所能访问到的全部文档,也包括你自己编写的那些模块。

docstring可以关联到函数、类与模块上面,系统在编译并运行Python程序的过程中,会把确定这种关联的关系也当成工作的一部分。由于Python支持docstring并允许程序通过doc属性来访问docstring,我们会享受到以下三个好处:

  • 开发者能够在程序中访问文档信息,这会让交互式开发工作变得更加轻松。
  • 这些文档是按照标准的方式定义的,因此很容易就能转换成表现力更强的格式(例如HTML)。
  • Python文档不仅可以做得很漂亮,而且与其他普通的头等Python实体一样,也能够在程序里面正常地访问,这会让开发者更乐意编写这样的文档。

如果你也想像大家一样把文档写好,那就需要遵循一些与docstring有关的约定。完整的规范,可以查看PEP 257(https://www.python.org/dev/peps/pep-0257/),接下来我们重点讲述其中必须注意的几个方面。

为模块编写文档

每个模块都要有顶级的docstring,即写在源文件开头的那个字符串。字符串的首尾都要带三重引号,这样的字符串的目的主要是介绍本模块与其中的内容。

在docstring里面,第一行应是一个单句,描述本模块的用途。接下来应该另起一段,详解讲述使用这个模块的用户所要知道的一些事项。另外,凡是模块里面比较重要的类与函数,都应该在docstring中予以强调,这样的话,查看这份文档的用户就可以从这些类及函数出发来熟悉模块。

1
2
3
4
5
6
7
8
9
10
11
12
# words.py
#!/usr/bin/env python3

"""Library for finding linguistic patterns in words.

Testing how words relate to each other can be tricky sometimes!
This module provides easy ways to determine when words you've found have special properties.

Available functions:
- palindrome: Determine if a word is a palindrome.
- check_anagram: Determine if two words are anagrams.
"""

如果这个模块表示的是一个可以在命令行界面使用的工具,那么模块的docstring里面,还应该写出相关的信息并介绍该工具的用法。

为类编写文档

每个类都应该有类级别的docstring,这种文档的写法,与模块级别的docstring差不多。它的第一段,也需要用一句话来概述整个类的用途。后面的各段,可以详细讲解本类中的每一种操作。

类中比较重要的public属性与方法,同样应该在类级别的docstring里面加以强调。另外还需要说明,如果想编写子类,子类应该怎样与受保护的属性以及超类中的方法相交互。

1
2
3
4
5
6
7
8
class Player:
"""Represents a player of the game.

Subclasses may override the 'tick' method to provide custom animations for the player's movement depending on their power level, etc.
Public attributes:
- power: Unused power-ups(float between 0 and 1).
- coins: Coins found during the level(integer).
"""

为函数编写文档

每个public函数与方法都应该有docstring。它的写法与模块和类的相同,第一段也是一个句子,描述这个函数是做什么的。接下来的那段应该描述函数的行为。然后,可以各用一段来描述函数的参数与返回值。另外,如果调用者在使用这个函数接口的时候,需要处理该函数所抛出的一些异常,那么这些异常也要解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
def find_anagrams(word, dictionary):
"""Find all anagrams for a word.

This function only runs as fast as the test for membership in the 'dictionary' container.

Args:
words: String of the target word.
dictionary: collections.abc.Container with all strings that are known to be actual words.

Returns:
List of anagrams that were found. Empty if
none were found.
"""

编写docstring的时候,需要注意下面几种特殊的情况:

  • 如果函数没有参数,而且返回的是个比较简单的值,那么就不用按照上面讲的那种格式分段书写了。直接用一句话来描述整个函数可能会更好。
  • 如果函数没有返回值,那么最好是把描述返回值的那段完全省去,而不要专门写出返回None。
  • 如果函数所抛出的异常也是接口的一部分,那么应该在docstring里面详细解释每一种异常的含义,并说明函数在什么场合会抛出这样的异常。
  • 如果函数在正常使用的过程中,不会抛出异常,那么无须专门指出这一点。
  • 如果函数可以接受数量可变的位置参数或关键字参数,那么应该在解释参数的那一部分用*args**kwargs来说明这两种参数的用途。
  • 如果参数有默认值,那么文档里应该提到这些默认值。
  • 如果函数是个生成器,那么应该在docstring里面写明这个生成器在迭代过程中会产生什么样的值。
  • 如果函数是异步协程,那么应该在docstring里面解释这个协程执行到何时会暂停。

用类型注解来简化docstring

Python现在已经支持类型注解了,这种注解有许多用途,其中一项就是简化docstring,因为原本写在docstring里面的某些含义,现在可以直接通过类型注解体现出来。下面,我们给find_anagrams函数的签名加上类型注解,以指出参数与返回值的类型。

1
2
3
4
5
from typing import Container, List

def find_anagrams(word: str,
dictionary: Container[str]) -> List[str]:
# ...

有了这样的注解,我们就不用专门在docstring里面说明word参数是字符串了,因为现在的word参数后面有个冒号,冒号右侧标出了它的类型,即str。dictionary参数也一样,它的类型现在明确地标注成了collections.abc.Container。另外,返回值同样不用专门在docstring里面解释,因为声明函数时,已经在->符号右侧明确说明该函数会返回一份列表。这也意味着即便找不到与word参数的组成字母相同但排列顺序不同的词(或者说,找不到word参数的同字母异序词(anagram)),函数也依然要返回列表,当然这种情况下返回的列表是一份空白的列表,所以不用在docstring里面专门说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
def find_anagrams(word: str,
dictionary: Container[str]) -> List[str]:
"""Find all anagrams for word.
This function only runs as fast as the test for
membership in the 'dictionary' container.

Args:
word: Target word.
dictionary: All known actual words.
Returns:
Anagrams that were found.
"""
# ...

类型注解与docstring之间的这种重复现象,也会出现在实例字段、类属性与方法中。这样的类型信息最好是只写在一个地方,而不要同时写在类型注解与docstring中,因为在修改实现代码时,可能会忘记更新其中的某一处。

第85条、用包来安排模块,以提供稳固的API

项目代码变多之后,我们自然需要重新调整它的结构。可能要把大函数拆成许多个小的函数,或者通过辅助类改写某个数据结构,还有可能要把各项功能分散到多个相互依赖的模块里面。

到了一定阶段,你就会发现模块也变得多了起来,所以还得再构建一层机制以便于管理。在Python中,这可以通过包(package)来实现,包本身也是一种模块,只不过它里面还含有其他模块。

大多数情况下,把名为init.py的空白文件放在某个目录中,即可令该目录成为一个包。一旦有了init.py文件,我们就可以使用相对于该目录的路径引入包中的其他py文件了。例如,现在目录结构是这样的:

1
2
3
4
main.py
mypackage/__init__.py
mypackage/models.py
mypackage/utils.py

如果想在main.py里面引入utils模块,那么可以把包名(也就是mypackage)写在from后面,并把其中的模块名写在import后面。

1
2
# main.py
from mypackage import utils

如果有些包本身位于更大的包之中,那么就把从大包到这个包之间的各层都用圆点连起来。例如,要引入bar包中的某个模块,而bar包又位于mypackage包的foo目录之下,那么就写为from mypackage.foo.bar import ...。包在Python程序里主要有两种用途。

用包划分名称空间

包的一个用途是帮助把模块安排到不同的名称空间(namespace)里面,这样的话,即便两个模块所在的文件同名,也依然能够加以区分,因为它们所在的名称空间不同。例如,下面这个程序要从两个模块里面分别引入一个属性(本例实际引入一个函数),虽然这两个模块所在的文件都叫utils.py,但我们还是可以通过模块所属的包来区分它们。

1
2
3
4
5
# main.py
from analysis.utils import log_base2_bucket
from frontend.utils import stringify

bucket = stringify(log_base2_bucket(33))

这种写法有个问题,如果两个包里有同名的模块,或者两个模块里有同名的函数或类,那么后引入的那个会把先引入的覆盖掉。例如,假设analysis.utils与frontend.utils模块里都有函数叫作inspect,那么就没办法通过刚才那种写法来同时使用这两个函数了,因为第二条import语句会把第一条所引入的那个inspect覆盖掉,导致当前范围内只存在一个inspect。

解决办法是给import语句加上as子句,这样就能把两个inspect函数分别用不同的名称引入到当前的范围里面。

1
2
from analysis.utils import inspect as analysis_inspect
from frontend.utils import inspect as frontend_inspect

as子句不仅可以给函数起别名,而且能把整个模块都换个名称,这让我们可以清晰地区分各种命名空间里面的同名实体。

另外一种办法是从能够区分它们的那一层开始书写访问名称。例如,可以把from... import ...形式的引入语句改成import ...形式,并且用analysis.utils.inspect与frontend. utils.inspect来区分这两个函数。

1
2
import analysis.utils
import frontend.utils

这种写法完全不需要使用as子句,而且由于它采用的名字比较全,初次读到这段代码的读者也可以清楚地了解到这两个inspect函数分别是在哪个包的哪个模块里面定义的。

通过包来构建稳固的API

包的第二个用途是构建严谨而稳固的API,以便给外部的开发者使用。

如果这套API要提供给很多人使用,例如要做成开源的软件包,那么你可能想把它的功能稳定下来,以免新旧版本之间差得太大。为了做到这一点,必须隐藏软件包内部的代码结构,不要让外部的开发者依赖这套结构,只有这样,你才能重构并改善这些内部模块,而不必担心自己所做的修改会影响外部用户已经写好的那些代码。

Python允许我们通过all这个特殊的属性,决定模块或包里面有哪些内容应该当作API公布给外界。all的值是一份列表,用来描述可作为public API导出的所有内容名称。如果执行from foo import *这样的语句,那么只有all所列出名称的属性才会引入进来。若是foo里面没有all,那么就只会引入public属性。

谨慎地使用import形式的引入语句。from x import y这种形式的引入语句,是很清晰的,因为它明确指出了y来自x包或x模块。引入语句还有一种写法,就是带有通配符的from foo import 形式,这种引入语句也很有用,尤其是在交互式的Python界面之中。然而大家必须注意,这样写会造成两方面的困难:

  • 用from … import 的形式引入,会让初次读到这段代码的读者弄不清某些名称究竟来自哪里。如果模块里有许多条这样的import 语句,那我们可能必须把受引用的模块全都查找一遍,才能确定某个名字到底是从哪个模块引入的。
  • import *形式的引入语句,会把已经引入本模块的同名实体覆盖掉,从而导致奇怪的bug,因为同一个名称,在执行这条语句之前,与执行这条语句之后,可能指向两个不同的地方。

最稳妥的做法是不要采用import *的形式引入,而是通过from x import y明确指出名称y来自模块x。

如果不想让外界看到某些内容,那么可以在包目录中的init.py文件里面故意不引入这些内容,或者给这些只供本包内部使用的内容名称前面添加下划线。

假如这个包只在某个团队或某个项目内部使用,那恐怕就没必要专门通过all来指定外界能够访问到的API了。

第86条、考虑用模块级别的代码配置不同的部署环境

部署环境指的是程序运行在什么样的配置之下。每个程序至少要有一套部署环境,也就是生产环境(production environment)。我们之所以写程序,就是想让它能够在生产环境里面正常运行并产生预期的结果。

另外,我们可能还需要构建一套环境,可以方便地编写或修改程序代码,这套环境叫作开发环境(development environment),它的配置方式可能与生产环境有很大区别。例如,我们的程序可能是在单片机中开发的,但是却打算放在巨型的超级计算机上面运行。

venv这样的工具可以确保所有的环境里面安装的都是同一套Python软件包(参见第83条)。但是问题在于,软件在生产环境中运行的时候,通常需要依赖许多外部条件,而那些条件不太容易在开发环境里面重现。

要想解决这个问题,最好的办法是让程序在启动时,能够根据当前环境决定其中某些资源应该如何配置。

1
2
3
4
5
TESTING = True

import db_connection

db = db_connection.Database()

这两份文件只有一个地方不同,也就是TESTING常量的取值。程序中的其他模块可以引入main模块,并根据TESTING的值决定如何配置自己的某些属性。

如果环境配置起来特别复杂,那就不要使用TESTING这样单纯的Python常量,而是可以考虑构建专门的配置文件,并通过Python内置的configparser模块等解析工具来处理这种文件,把它们与程序代码分开维护。在与运维团队合作的时候,这一点尤其重要。

模块级别的代码不仅可以模拟外部资源,而且还有其他用途。

我们还可以通过os.environ查询环境变量,从而决定模块中的相关内容应该如何定义。

第87条、为自编的模块定义根异常,让调用者能够专门处理与此API有关的异常

给模块定义API时,要定义的不仅是其中的函数与类,而且还必须注意这些函数会不会抛出异常,因为这些异常实际上也接口的一部分。

Python语言及标准库本身有自己的异常体系,所以在开发程序的时候,开发者喜欢沿用这些内置的异常类型来报告其中的错误,而不想定义新的类型。

给模块定义根异常,可以让使用这个模块的API用户将他们自己的代码与这个模块所提供的API隔开,以便分别处理其中的错误。

API用户在处理完API所属模块有可能抛出的具体异常后,可以写一个针对模块根异常的except块,如果程序进入这个块,那就说明他使用API的方式可能有问题,例如可能忘记处理某种本来应该处理的具体异常。

API用户还可以再写一个except块以捕获整个Python体系之中的根异常,如果程序进入了那个块,那说明所调用的API可能实现得有问题。在模块的根异常下,可以设立几个门类,让具体的异常不要直接继承总的根异常,而是继承各自门类中那个分根异常,这样的话,使用这个模块的开发者,就可以只关注这几个门类,即便你修改了某个门类之下的具体异常,也不会影响到他们已经写好的那些代码。

第88条、用适当的方式打破循环依赖关系

在引入模块的时候,Python系统会按照深度优先的顺序,对模块执行以下五步:

  • 1)在sys.path里面寻找模块的位置。
  • 2)把模块的代码加载进来,并确认这些代码能够编译。
  • 3)创建相应的空白模块对象表示该模块。
  • 4)把这个模块插入sys.modules字典。
  • 5)运行模块对象之中的代码定义该模块的内容。

循环依赖之所以会出错,原因在于,执行完第4步之后,这个模块已经位于sys.modules之中了,然而它的内容这个时候可能还没有得到定义,要等到执行完第5步,才能齐备。可是,Python系统在执行import语句的时候,如果发现要引入的模块已经出现在了sys.modules里面(也就是说,那个模块已经执行完了前4步),那么就会继续执行import的下一条语句,而不会顾及模块之中的内容是否得到了定义。

要解决这种问题,最好的办法是重构代码,把prefs数据结构放在依赖体系的最底层,把它单独放在一个工具模块里面,app与dialog模块就可以分别引入这个工具模块,而不用像原来那样,彼此依赖对方。予以划分,有时必须重构大量的代码,才能解开两个模块之间的相互依赖关系,让它们都去依赖第三个模块。

除了这种解法之外,还有三个办法,也能够解除循环依赖关系。

  • 第一个办法是,调整import语句的位置。调整import语句的位置,可能会让代码变得容易出错,因为有时只要稍微改动这条import语句的位置,整个模块就没办法使用了。
  • 把模块划分成引入-配置-运行这样三个环节。循环引入问题的第二个解决办法是,尽量缩减引入时所要执行的操作。我们可以让模块只把函数、类与常量定义出来,而不真的去执行操作,这样的话,Python程序在引入本模块的时候,就不会由于操作其他模块而出错了。我们可以把本模块里面,需要用到其他模块的那种操作放在configure函数中,等到本模块彻底引入完毕后,再去调用。configure函数会访问其他模块中的相关属性,以便将本模块的状态配置好。这个函数是在该模块与它所要使用的那个模块都已经彻底引入后才调用的(也就是说,这两个模块都把各自的第5步执行完了),因此,其中涉及的所有属性全都定义过了。
  • 动态引入。第三个办法比前两个都简单,也就是把import语句从模块级别下移到函数或方法里面,这样就可以解除循环依赖关系了。这种import语句并不会在程序启动并初始化本模块时执行,而是等到相关函数真正运行的时候才得以触发,因此又叫作动态引入(dynamic import)。

当然了,一般来说,还是应该尽量避免动态引入,因为import语句毕竟是有开销的,如果它出现在需要频繁执行的循环体里面,那么这种开销会更大。另外,由于动态引入会推迟代码的执行时机,有可能让你的程序在启动了很久之后,突然因为在动态引入其他模块的过程中发生SyntaxError等错误而崩溃(如何避免此类问题)。动态引入虽然有这些缺点,但总比那种大幅度修改整个程序结构的办法好。

第89条、重构时考虑通过warnings提醒开发者API已经发生变化

我们经常需要更新API,以实现早前没有预料到的新需求。如果API很小,而且与上游及下游之间的依赖关系也不复杂,那么修改起来就比较简单。在这种情况下,只需要某位开发者把API本身与调用这些API的代码都改好,一起提交到代码库即可。

代码库变大之后,调用这个API的地方也会变多,而且可能来自好几个项目,所以在修改API的时候,不太容易保证那些地方也能够同步更新。我们必须想办法通知写那些代码的合作者这个API的用法已经变了,让他们尽快重构代码以适配新版的API。

warnings模块让我们以编程的手段提醒其他开发者注意:代码所依赖的底层库已经发生变化,请尽快做出相应修改。warnings模块发出的是警告,而不是那种带有Error字样的异常(exception),异常主要针对计算机而言,目标是让程序能够自动处理相关的错误,而警告则是写给开发者的,目标是与他们沟通,告诉对方应该如何正确地使用这个API。

1
warning.warn('...')

warnings.warn函数提供了一个名为stacklevel的参数,让我们可以根据栈的深度指出真正触发这条警告的那个位置,而不是调用warnings.warn函数的字面位置。

负责这段代码的开发者,如果已经意识到自己应该按照新的方式来使用你所提供的API,那么他就可以通过warnings模块的simplefilter与filterwarnings函数暂时忽略警告(详细的用法,参见https://docs.python.org/3/library/warnings)。

程序部署到生产环境之后,就没有必要让警告变成错误了,因为那样可能导致程序在关键时刻崩溃。比较好的办法是,通过Python内置的logging模块将警告信息重新定向到日志系统。

如果你设计的API会发出警告,那么应该为此编写测试,确保下游开发者在使用API的过程中,能够在适当的时机收到正确的警告信息。

第90条、考虑通过typing做静态分析,以消除bug

只有文档可能还不够,有时我们还是会把API用错,导致程序出现bug。所以,最好能有一套机制来验证调用者使用API的方式是否正确,如果我们把自己的API发布出去,那么这套机制还能帮助其他开发者检查他们的代码有没有恰当地使用这套API。许多编程语言通过编译期的类型检查来实现这种验证,这确实能够消除某些bug。

Python以前主要关注的是动态特性,所以没有提供编译期的类型安全机制。但是最近,Python开始引入一套特殊的写法,让我们可以通过内置的typing模块给变量、类中的字段、函数及方法添加类型信息。这些类型提示(type hint)信息可以实现渐进的类型判定机制(gradual typing),让我们在开发项目的过程中,把能够在编译期明确指定类型的地方逐渐确定下来。

给Python程序的代码添加类型信息之后,我们就可以运行静态分析(staticanalysis)工具,分析这些代码里面是否存在极有可能出现bug的地方。Python内置的typing模块本身并不实现类型检查功能,它只是一套可以公开使用的代码库,其中定义了相关的类型(也包括泛型类型),我们可以用这些类型来注解 Python代码,并利用其他工具根据这些类型判断受注解的代码有没有正确地得到使用。

Python解释器有许多种不同的实现方案,例如CPython、PyPy等,与之类似,与typing模块相搭配的Python静态分析工具,也有很多方案。笔者编写本书的时候,比较流行的是mypy(https://github.com/python/mypy)、pytype(https://github.com/google/pytype)、pyright(https://github.com/microsoft/pyright)与pyre(https://pyre-check.org)。

如使用mypy:

1
2
def substract(a: int, b: int) -> int:
return a - b

Python的动态机制有个好处,就是可以实现泛型,进而把需要操作的数据当成duck type使用。也就是说,只要用户给出支持这种操作的数据即可,不用管这份数据究竟是什么类型,这样我们就能够让函数接受各种各样的数据,而不用针对每一种数据都专门编写对应的版本,这可以避免重复代码并简化测试工作。

我们可以利用typing模块给函数所涉及的泛型做注解,从而通过静态手段把程序运行时可能发生的错误提前探查出来。

1
2
3
4
5
6
7
from typing import Optional

def get_or_default(value: Optional[int]m
default: int) -> int:
if value is not None:
return value
return value

typing模块还提供了许多选项,可以给代码做注解,详情参见https://docs.python.org/3.8/library/typing。然而特别需要注意的是,异常并不包括在内。Python与Java不同,Java里面有一种异常叫作受检异常(checkedexception),如果API宣称自己可能抛出这种异常,那么使用API的开发者必须明确做出应对。Python在这一方面更像C#,它们都不把异常当作接口定义中的一部分,因此,如果想确保自己的API能够正确地抛出异常,或确保自己在使用别人的API时能够正确地捕获异常,那么必须编写相关的测试。

使用typing模块的过程中,经常会碰到这样一个问题,那就是我们在编写类型注解的时候,需要用到当前还没有定义出来的类型,这叫作提前引用(forwardreference)。

还有一种更好的办法是,通过from __future__ import annotations来引入类型注解功能,这种办法是从Python 3.7版本开始支持的,到了Python 4,将会成为默认的方式。这样写,会让Python系统在运行程序的时候,完全忽略类型注解里面提到的值,于是就解决了提前引用的问题,而且程序在启动时的性能也会提升。

1
2
3
4
5
6
7
8
9
10
11
12
from __future__ import annotations

class FirstClass:
def __init__(self, value: SecondClass) -> None:
self.value = value

class SecondClass:
def __init__(self, value: int) -> None:
self.value = value

second = SecondClass(5)
first = FirstClass(second)

我们已经了解了类型提示信息的用法及其潜在好处,现在必须提醒大家注意,怎样合理地使用这些注解。下面是几条原则:

  • 如果刚开始写代码的时候,就想着如何添加类型注解,那可能会拖慢编程速度。所以我们通常应该先把代码本身写出来,然后编写测试,最后才考虑在必要的地方添加类型信息。类型提示信息最能发挥作用的地方,是在项目与项目衔接处。例如,如果有很多个项目都要使用你的API,那么这种API就很有必要做类型注解,因为这些信息可以跟集成测试(参见第77条)与警告互补,以确保API的调用者不会发生误用,并督促调用者在你更新API之后,及时修改他们的代码中与新版API不符的地方。
  • 如果有些代码比较复杂,或者特别容易出错,那么即便不属于API,也仍然值得添加类型提示信息。但是要注意,没必要给所有的代码都添上类型注解,因为到了一定程度之后,再添加这种信息,就不会给项目带来太大的好处了。
  • 如果有可能的话,应该把静态分析这一环节纳入自动构建流程与测试系统中,以确保提交上去的每份代码都会经受相关的检查。另外,检查类型信息所用的配置方案,应该放在代码库里面维护,以保证其他的合作者使用的也是这套规则。
  • 每添加一批类型注解,就应该把静态分析工具运行一遍,这样可以及时发现问题并加以解决。假如把整个项目全都注解完之后,再实施类型检查,那么类型分析工具就有可能打印出极多的错误信息,让你不知道应该先处理哪一条才好,有时甚至会让你想要放弃类型注解。

最后必须注意,还有许多场合是不需要写类型注解的,比如小型程序、临时代码、遗留项目以及原型等。没必要花时间给这些代码添加类型注解,因为这样做好处很少。