Python语法速查: 14. 测试与调优
本篇索引
(1)测试的基本概念
(2)doctest模块
(3)unittest模块
(4)调试器和pdb模块
(5)程序探查
(6)调优与优化
(1)测试的基本概念
对程序的各个部分建立测试,这个称为:单元测试(unit test),更进一步的是测试驱动编程(test-driven programming)。 简单来说,就是“先写测试、再写程序”。是否一定要为所有的函数和模块编写测试代码, 这个目前仍有争论,因为构建完备的测试集本身就是一件工作量很大的事情。而且常常会发生这样的事情: 测试还没编好,需求已经变更了。但不管如何,测试驱动的理念是一种进步,掌握自动化测试工具, 比以前没有自动化测试工具的时代,效率要提高很多倍。
● 测试时驱动开发的4步:
(1)指出需要的新特性,可以记录下来,然后为其编写一个测试。
(2)编写特性的概要代码,程序代码没有任何语法错误,但测试结果会失败。 看到测试失败是很重要的,这样就能确定测试可以失败。 这里再强调一遍:在试图让测试成功之前,先要看到它失败!
(3)为特性的概要编写虚设代码(dummy code),不用准确实现功能,只要保证测试可以通过即可。
(4)现在重写(Refactor)代码,真正实现需要的特性功能,之后要保证测试一直成功。
(2)doctest模块
函数、类或模块的第一行如果是一个字符串,这个字符串就是“文档字符串”。 使用doctest
模块可以在文档字符串中查找“交互式会话”的例子, 并使用一系列测试的形式测试这些例子给出的数据和结果。
# my_math.py def add2(x,y): """ 函数名:add(x,y) 功能:返回2个输入参数之和 >>> add2(1, 2) 3 >>> add2(1.3, 2.4) 3.7 """ return x+y
上例中,在自定义的add2()函数的文档字符串中,举了2个调用和结果的例子, doctest
模块可以从my_math.py文件中提取出add2()的示例部分, 加以测试,若测试结果和示例结果不同,则会输出错误报告。
可以编写单独的测试文件,也可以在库模块的末尾包含测试代码来测试自身,以下分别示例:
● 编写单独的测试文件:
# my_math.py def add2(x,y): ...(内容同上例) # testmymath.py (单独测试文件) import my_math import doctest nfails, ntests = doctest.testmod(my_math) # nfails和ntest分别表示失败的数量和执行的总测试数
如果所有测试都顺利通过,则不产生输出。否则将会在屏幕上输出错误报告, 如果想在屏幕上看到测试的详细输出,可使用verbose
参数:
doctest.testmod(my_math, verbose=True)
● 在库模块的末尾包含测试代码:
# my_math.py def add2(x,y): ...(内容同上例) if __name__=='__main__': import doctest, my_math doctest.testmod(my_math)
如果my_math.py文件作为主程序在解释器中运行,就会运行文档测试。否则, 如果文件是由import
加载的,测试将被忽略。 如果所有测试都顺利通过,则不产生输出。否则将会在屏幕上输出错误报告, 如果想在屏幕上看到测试的详细输出,可使用以下-v
参数:
$ python my_math.py -v
(3)unittest模块
对于更全面的程序测试,可以使用unittest
模块。如果进行单元测试, 开发人员会为程序的每个组成元素(如各个函数、方法、类和模块)编写独立的测试案例。 然后运行这些测试来验证组成更大程序的基本组件的行为是否正确。
unittest
的基本使用方法为:定义一个继承自unittest.TestCase
的类, 在这个类中,各种测试由以名称 test 开头的方法定义,在每隔测试内,可用各种断言来检查不同条件。
● TestCase实例支持以下方法和属性:
实例方法 | 说明 |
---|---|
t.setUp() | 在运行任何测试方法之前,调用它来执行设置步骤。 |
t.tearDown() | 在运行测试之后,调用它来执行清除操作。 |
t.assert_(expr [,msg]) | 如果expr 的计算结果为False,表明测试失败。 msg 是一条消息字符串,提供对失败的解释。 |
t.failUnless(expr [,msg]) | |
t.assertEqual(x, y, [,msg]) | 如果x和y不相等,则表明测试失败。msg 含义同上。 |
t.failUnlessEqual(x, y, [,msg]) | |
t.assertNotEqual(x, y, [,msg]) | 如果x和y相等,则表明测试失败。msg 含义同上。 |
t.failIfEqual(x, y, [,msg]) | |
t.assertAlmostEqual(x, y, [,places [,msg]]) | 如果数字x和y未包含在对方的places 小数位中,则表明测试失败。 检查方法是计算x和y的差,并将结果舍入到给定位数。如果结果为0,则x和y的值相近。 msg 含义同上。 |
t.failUnlessAlmostEqual(x, y, [,places [,msg]]) | |
t.assertNotAlmostEqual(x, y, [,places [,msg]]) | 如果x和y在places 小数位内无法区分大小,则表明测试失败。 msg 含义同上。 |
t.failIfAlmostEqual(x, y, [,places [,msg]]) | |
t.assertRaises(exc, callable, ...) | 如果可调用对象callable 未引发一场exc ,则表明测试失败。 剩余参数将以参数形式传递给callable 。可以使用异常元组exc 检查多个异常。 msg 含义同上。 |
t.failUnlessRaises(exc, callable, ...) | |
t.failIf(expr [,msg]) | 如果expr计算结果为True,则表明测试失败。msg 含义同上。 |
t.fail([msg]) | 表明测试失败,msg 含义同上。 |
t.failureException |
该属性设置为在测试中捕获到的最后一个异常值。用于不仅要检查是否出现异常, 还想要检查异常是否抛出了恰当的值。 |
● 单元测试的例子
如果需要编写单元测试来测试前面add2()函数的各个方面,可以创建一个独立模块 testmymath.py,如下例所示。
import my_math import unittest # 单元测试 class TestMyMathFunction(unittest.TestCase): def setUp(self): # 执行设置操作(如果有的话) pass def tearDown(self): # 执行清除操作(如果有的话) pass def testintint(self): r = my_math.add2(2,3) self.assertEqual(r, 5) def testfloatfloat(self): r = my_math.add2(1.2, 3.4) self.assertEqual(r, 4.6) def testintfloat(self): r = my_math.add2(2, 3.5) self.assertEqual(r, 5.5) # 运行unittest if __name__=='__main__': unittest.main()
要运行单元测试,只需在文件 testmymath.py 上运行Python:
$ python testmymath.py
(4)调试器和pdb模块
Python提供了一个基于命令的简单调试器,pdb模块支持:事后检查、检查栈帧、设置断点、单步调试以及计算代码。
● pdb模块的功能函数
函数 | 说明 |
---|---|
run(statement [,globals [,locals]]) | 在调试器控制下执行statement ,globals 和 locals 分别定义运行代码的全局和局部命名空间。 |
runeval(expression [,globals [,locals]]) | 在调试器控制下计算expression 字符串表达式。成功运行之后, 将返回表达式的值。globals 和 locals 含义同上。 |
runcall(function [,argument, ...]) | 在调试器内调用一个函数。function 是一个可调用对象, 其他参数可在其后的argument 部分提供。函数运行后将返回它的返回值。 |
set_trace() | 在调用该函数的位置启动调试器,这可用于将调试器断点硬编码到程序代码中。 |
post_mortem(traceback) | 对回溯对象traceback 启动事后检查。通常使用sys.exc_info() 等函数获得。 |
pm() | 使用最后一个异常的回溯进入事检查调试。 |
● 从Python交互环境启动调试器
启动调试器后,会显示一个(Pdb)
提示符:
以下为待调试文件
#testfile.py def testadd(a, b): c = a + b; return c
以下为交互环境命令行:
>>> import pdb >>> import testfile # 要调试的文件名 >>> pdb.run('testfile.testadd(1,2)') > <string>(1)() (Pdb)
● 调试器命令
可以在调试器提示符(Pdb)
下使用命令,一些命令具有长短2种形式,例如, h(elp)
表示h
和help
都是可接受的。 可用调试命令见下表:
命令 | 说明 |
---|---|
[!]statement | 在当前栈帧上下文中执行一行statement 语句。感叹号可以忽略, 但如果语句的第一个词于调试器命令类似,则必须使用它来避免歧义。如要设置全局变, 可在同一行上添加global命令作为前缀,如:global a; a = 1 |
a(rgs) | 打印当前函数的参数列表。 |
alias [name [command]] | 创建名为name 的别名来运行command 。在command 字符串中, 键入别名时子字符串'%1', '%2'等被替换为相应的参数,'%*'被替换为所有参数。 ,如果没有给定任何命令,则显示当前别名列表。command 可以很长, 甚至可以使用for循环等(但需写在一行内)。 |
b(reak) [loc [,condition]] | 在位置loc 处设置断点。loc 指定一个特定文件名和行号, 或者指定一个模块中的一个函数名称,可使用以下语法:n(当前文件中的行号); filename:n(另一个文件中的行号); function(当前模块中的函数名称); module.function(某个模块中的函数名称); 如果省略了 loc ,将打印当前的所有断点。condition 是一个表达式,
在打印断点之前,该表达式的值必须计算为True。所有断点都会被分配一个数字,
该数字将在完成此命令时作为输出打印出来。这些数字可以在一些其他调试命令中使用。
|
cl(ear) [bpnumber [bpnumber ...]] | 清除断点编号列表。如果没指明断点编号,所有断点都会被清除。 |
commands [bpnumber] |
设置在遇到bpnumber 时,将自动执行一系列调试命令。列出要执行的命令时,
只需在后续行中键入它们并使用end 来标记命令序列的结束即可。
如果包含continue 命令,在遇到断点时,程序将自动继续执行。如果省略bpnumber ,
将使用最后一个断点集。
|
condition bpnumber [condition] |
在断点上放置一个条件。condition 是一个表达式,在识别该断点之前,
该表达式的值必须计算为True。省略该条件会清除任何以前的条件。
|
c(ont(inue)) | 继续执行,直到遇到下一个断点。 |
disable [bpnumber [bpnumber ...]] | 禁用指定断点集,可以在以后用enable 命令重新启用这些断点。 |
d(own) | 将当前帧在栈跟踪中下移一层。 |
enable [bpnumber [bpnumber ...]] | 启用指定的断点集。 |
h(elp) [command] | 显示可用命令的列表。指定一个命令将返回该命令的帮助信息。 |
ignore bpnumber [count] | 忽略一个断点count 次。 |
j(ump) lineno | 设置要执行的下一行。只能用于同一执行帧中的不同语句之间移动。而且, 无法跳到某些语句中(如循环中的语句) |
l(ist) [first [,last]] | 列出源代码。如果没有参数,该命令将列出当前行前后的共11行。如果使用一个参数, 它将列出该行前后的11行。如果使用2个参数,它将列出指定范围内的行。 |
n(ext) | 执行到当前函数中的下一行,与step 命令的区别在于,
next 会将调用整体函数视为一行执行,而step 将进入那个函数执行一行。 |
p expression | 在当前上下文中计算表达式expression 的值并打印其值。 |
q(uit) | 退出调试器。 |
r(eturn) | 持续运行,直到当前函数返回值。 |
run [args] | 重新启动程序,并使用args 中的命令行参数作为 sys.args 的新设置。
所有断点和其他调试器设置都会被保留。 |
s(tep) | 执行一行源代码并停在被调用的函数内。 |
tbreak [loc [,condition]] | 设置一个临时断点,该断点将在第一次到达时删除。
loc 和condition 用法同前。
|
u(p) | 将当前帧在跟踪中上移一层。 |
unalias name | 删除指定别名 |
until | 恢复执行,直至不再控制当前执行帧,或者直至到达一个比当前行号大的行号。 例如在循环中键入 until 命令,将执行循环中的所有语句,直至循环结束。 |
w(here) | 打印栈跟踪。 |
● 从命令行进行调试
可以在命令行上调用调试调试某文件,在这种情况下,启动程序时调试器将自动启动。
$ python -m pdb testfile.py
如果用户的主目录或当前目录中包含.pdbrc
文件,那么每次调试器启动时将执行该文件, 可以使用这种方法来指定要在调试器每次启动时执行的调试命令。
(5)程序探查
profile
和cProfile
模块用于收集探查信息,两个模块的工作方式相同, 但cProfile
速度更快且更先进。程序探查可以通过以下命令行方式调用:
$ python -m cProfile testfile.py
运行该命令后,会在屏幕上打印出性能统计信息。
也可以在交互命令行中或程序代码中调用探查器(profiler),具体方法是使用run()
函数, 其语法如下:
import cProfile cProfile.run(command [,filename])
探查器会使用exec语句执行command的内容,filename是输出报告要保存到的文件, 如果忽略该参数,报告将输出到标准输出。
生成报告的部分说明如下:
抬头 | 说明 |
---|---|
primitive calls | 非递归性函数调用的数量 |
ncalls | 调用总数(包括自递归),当表示为 n/m 时,n表示实际调用数量,m表示原始调用的数量。 |
tottime | 该函数消耗的时间(不含子函数) |
percall | tototime / ncalls |
cumtime | 函数消耗的总时间 |
percall | cumtime / (primitive calls) |
filename:lineno(function) | 每个函数的位置和名称 |
通常对于普通的探查分析,cProfile
模块足够了。如果希望保存数据和进一步分析, 可使用pstats
模块,它可以进一步分析cProfile
模块输出的报告信息。
>>> import pstats >>> p = pstats.Stats('result.profile')
(6)调优与优化
● 程序运行时间测量
(1)方法一:使用Linux的time
命令
可用于简单对长时间运行的Python程序进行计时:
$ time python testfile.py
(2)方法二:在程序中直接放入统计时间代码
time模块的perf_counter()
函数可得到本进程开始起到现在的总秒数, process_time()
函数可得到本进程开始起到现在的进程运行秒数。
import time start_proc = time.process_time() # 进程时间 start_real = time.time() # UTC时间 ...... end_proc = time.process_time() end_real = time.time() print('%f Real Seconds" %(end_real - start_real)) print('%f Process Seconds" %(end_proc - start_proc))
(3)方法三:使用timeit()函数
如果想对一个特定语句进行基准测试,可以使用timeit
模块中的timeit()
函数,语法如下:
timeit(code [,setup])
其中,code
参数时希望对其基准测试的代码,setup
参数是一条语句, 用来设置执行环境。timeit()
函数会运行这条语句100万次并报告执行时间。 可以向timeit()
函数提供number=count
关键字参数来更改重复次数。
>>> from timeit import timeit >>> timeit('math.sqrt(3.0)', 'import math') 0.10910729999886826 >>> timeit('sqrt(3.0)', 'from math import sqrt') 0.0762049000004481
timeit
模块还有一个repeat()
函数,功能与timeit()
相同, 但它重复测量5次并返回一个结果列表:
>>> from timeit import repeat >>> repeat('math.sqrt(3.0)', 'import math') [0.07255980000081763, 0.07150849999925413, 0.07361959999980172, 0.0723534999997355, 0.08293649999905028]
● 内存测量
sys模块有一个getsizeof()函数,可用于分析Python对象的内存占用(以字节为单位):
>>> import sys >>> sys.getsizeof(2) 28 >>> sys.getsizeof('a') 50 >>> sys.getsizeof([1,]) 72 >>> sys.getsizeof([1,2,3]) 88 >>> sum(sys.getsizeof(x) for x in [1,2,3]) 84
对于列表、元组和字典等容器,报告的大小只是容器对象本身的大小,不是容器中包含的所有对象的累计大小。 可以像上面显示的那样使用sum()
函数来计算列表内容的总大小。
测量实际内存占用的一种辅助技术是,从操作系统的进程查看器或任务管理器检查正在运行的程序。
● 返汇编
dis
模块可用于将Python函数、方法、类反汇编为低级的解释器指令, 该模块中的dis()
函数使用示例如下:
>>> from dis import dis >>> import testfile >>> dis(testfile.testadd)
在多线程程序中,反汇编中的每行操作都采用是原子执行方式,一行不会被中间打断, 可以利用此信息来跟踪复杂的竞争条件。
● 调优策略
下面列出了一些比较典型的优化策略:
(1)尽量使用内置类型
Python内置的元组、列表、集合、字典完全是用C语言实现的,是解释器中优化程度最高的数据结构。 尽量避免构建自定义的数据结构(如:二叉搜索树、链表等)来模仿它们的功能。 标准库中的类型也是很好的选择,例如collection.deque
双端队列,用它在队列头插入项, 比在普通列表头部插入项要高效得多。
(2)能使用字典结构就不要定义新class
用户定义得类和实例时用字典构建的,因此,查找、设置、删除实例数据的速度几乎总是比直接 在字典上执行这些操作更慢。如果只是构建一个简单的数据结构来存储数据,元组和字典通常就够用了。
(3)使用__slots__
如果程序创建了自定义类的大量实例,可以考虑在类定义中使用__slots__
属性。 __slots__
有时被看作一种安全功能,因为它会限制属性名称的设置, 但它更主要的用途是性能优化。使用__slots__
的类不使用字典存储实例数据 (而是用一种更高效的内部数据结构),所以其实例使用的内存也更少、访问速度也更快。
不过,需要注意的是,将__slots__
功能添加到类中可能会无故破坏其他代码。 因为__slots__
会占用__dict__
属性,故依赖__dict__
的代码会失败。
(4)避免多次重复使用(.)运算符
使用.
在对象上查找属性时,总会涉及名称查找。对于大量使用方法或模块查找的计算, 最好首先将要执行的操作方道一个局部变量中,从而避免属性查找。例如:使用 from math import sqrt 和 sqrt(x) 要比 math.sqrt(x) 要快 1.4 倍左右。
(5)使用异常来处理不常见的情况,但是避免对常见情况使用异常
对于try
中语句块正常运行的情况,其运行速度要比前面加一个额外的if
判断要快10%左右, 但是如果经常会让程序陷入异常except
的语句块,程序就会变得非常慢。 因此,在这种情况下,用if
判断语句比较好。
另外,检查字典中是否有某个键名,用in
操作符也比用d.get(key)
要快2倍。
(6)鼓励使用函数式编程和迭代
使用:列表推导、生成器表达式、生成器、协程、闭包,这些方式会使程序执行效率大大提高, 尤其是对于大量数据处理来说,更是如此。射你用生成器编写的代码,不仅运行速度快,而且内存使用效率也高。
(7)使用装饰器和元类
装饰器和元类用于修改函数和类,可以通过多种方式使用它们来改进性能, 特别是程序拥有很多可以启动或禁用的可选功能时。