编写程序是艺术、手艺和科学的混合体,由于程序是由人编写的,因此其中难免存在错误。幸运的是,首先就有一些技术可用于避免问题,而在问题出现时还有一些技术可用于识别和修复程序中的错误。
程序中的错误可以划分到几个类别中。最容易被发现和修复的是语法错误,因为这些错误通常都是由于输入错误造成的。更具挑战性的是逻辑错误——存在这些错误不影响程序的运行,但是程序的某些行为和我们需要的或期待的不同。通过使用TDD (由测试驱动的开发),可以防止很多这种错误的发生,在TDD中,需要添加一项新功能时,我们从为该功能编写一个测试开始——由于此时尚未添加这一功能,因此该测试会失败——之后再实现该功能。另一种错误是创建的程序具有本不该有的低劣性能,这几乎都是由于错误地选择了算法或数据结构(或同时错误选择了这两者)造成的。然而,在尝试对程序进行优化之前,我们首先应该准确地确定性能瓶颈到底在哪里——因为这有可能并不在我们认为的地方——之后我们应该细致地确定采用什么优化措施,而不是随便去改动。
调试(Debugging)
我们首先讲述Python在发现语法错误时会怎样处理,之后了解Python 在发现未处理异常时生成的回溯信息,之后讲解怎样将科学的方法用于调试。在讲述这些内容之前,我们先简要讨论一下备份与版本控制。
在对程序中的bug进行修复时,总会存在的风险是修改后的程序不仅没有去掉原有的bug,反而引入了新的bug,也就是说,修改后的程序比修改前还要差!并且,如果没有任何备份(或者虽然有但已经过期),并且没有使用版本控制,那么试图回退到修改之前存在bug的程序也是非常困难的。
定期地进行备份是程序设计中的一个关键环节——不管我们的机器、操作系统多么可靠以及发生失败的概率多么微乎其微——因为失败仍然是可能发生的。备份一般都是粗粒度的——备份文件是几个小时之前的,甚至是几天之前的。
版本控制系统允许我们在任何粒度层次上递增地保存所做的修改——每个单独的修改,或每组相关的改动,或每隔多少分钟保存一次此期间内的改动。版本控制系统允许我们对改动进行应用(比如试用bug修复),如果不能有效进行,还可以回退到应用改动之前的上一次“好的”代码版本。因此,在开始进行调试之前,最好的做法是用版本控制系统对我们的代码进行检查,以便在出问题的时候可以回退到已知的位置。
有很多可用的跨平台开源版本控制系统一本书使用Bazaar (bazaar-vcs.oig),其他流行的还包括 Mercurial (mercurial.selenic.com)、Git (git-scm.com)与 Subversion (subversion. tigris.org)。巧合的是,Bazaar与Mercurial大都是用Python编写的。这些系统都不难使用(至少对基本的使用而言),并且无论使用哪一个都有助于避免那些不必要的麻烦。
处理语法错误(Dealing with Syntax Errors)
如果我们试图运行一个包含语法错误的程序,Python就会终止执行,并打印出文件名、行号、出错行,并使用人标记在该行程序中检测出错的位置,下面是一个实例:
File "blocks.py“, line 383
if BlockOutput.save_blocks_as_svg(blocks, svg)
^
SyntaxError: invalid syntax
看到错误在哪里了么?我们忘记在if语句条件结尾处放置一个冒号。
下面给出另一个相当常见的错误实例,但是从中看不出明显的错误:
File "blocks.py", line 385
except ValueError as err:
^
SyntaxError: invalid syntax
在右面指示的行中并没有语法错误,因此行号与^标记的位置都是错误的。通常, 当面对这种我们不相信是指定行中出现的错误时,错误几乎总是出在该行的前一行。
下面给出的是Python报告出错的那一段从try到except的代码——在阅读代码后面给出的解释之前,试一下自己能否定位到其中的错误:
try:
blocks = parse(blocks)
svg = file.replace(".blk", ".svg")
if not BlockOutput.save_blocks_as_svg(blocks, svg):
print("Error: failed to save {0}".format(svg)
except ValueError as err:
发现问题出在哪里了吗?当然,这个错误不太容易发现,出在Python报错的那一行的上一行。对str.format()方法,使用了闭括号,但是print()函数缺少闭括号,也就是说,该行最后缺少一个闭括号,但Python没有意识到这一错误,直到运行到下一行 的except关键字时才发现。在一行代码中忘记最后的闭括号是相当常见的,尤其是在同时使用print()和str.fbrmat()的时候,但是错误经常在下一行才会报告出来。类似地, 如東某个列表的闭括号或某组字典的闭括号缺失,Python也会在下一个非空行报告这 一错误。从好的方面看,类似于这样的语法错误是易于修复的。
处理运行时错误(Dealing with Runtime Errors)
如果运行时发生了未处理的异常,Python就将终止执行程序,并打印出回溯信息。 下面给出一个未处理异常发生时打印出的回溯信息:
Traceback (most recent call last):
File “blocks.py", line 392, in <module>
main()
File "blocks.py”, line 381, in main
blocks = parse(blocks)
File "blocks.py", line 174, in recursive_descent_parse
return data.stack[1]
IndexError: list index out of range
像这种类型的回溯(也称为向后追踪)应该从最后一行向最前一行进行。最后一行指定了未处理异常发生在哪里,在这一行之上显示的是文件名、行号、函数名,之后跟随的是导致该异常的代码行(跨越了两行)。如果导致异常的函数被另一个函数调用,那么该调用函数的文件名、行号、函数名以及调用代码行会在上面显示岀来。如果调用函数被另外一个函数调用,那么这一过程将重复一遍,依此类推,直到调用栈的头部为止。(注意,回溯中的文件名是带路径的,但是大多数情况下,出于简洁的需要,给出的实例中都忽略了路径。)
因此,这一实例中,发生了 IndexError错误意味着data.stack是某种类型的序列,但在位置1处没有数据项。该错误发生在blocks.py程序174行的recursive_descent_parse()函数, 该函数在381行被main()函数调用。(381行函数名不同(是parse()而非 recursive_descent_parse())的原因在于parse变量被设置为几个不同函数名中的某一个,这依赖于赋予程序的命令行参数;在通常的情况下,名称总是匹配的。)对main()函数的调用是在392行进行的,程序的执行也是从该语句开始的。
尽管回溯信息初看之下让人困惑不解,但在理解了其结构之后我们会发现它是非常有用的。在上面的实例中,回溯信息告诉了我们应该去哪里寻找问题的根源,当然我们必须自己想办法去解决问题。
#下面给出另一个回溯实例:
Traceback (most recent call last):
File "blocks.py", line 392, in <module>
main()
File "blocks,py", line 383, in main
if BlockOutput.save_blocks_as_svg(blocks, svg):
File "BldckOutput.py", line 141, in save_blocks_as_svg
widths, rows = compute_widths_and_rows(cells, SCALElBY)
File "BlockOutput.py", line 95,In compute_widths_and_rows
width = len(cell.text) // cell.columns
ZeroDivisionError: integer division or modulo by zero
这里,问题出在blocks.py程序调用的BlockOutputpy模块中,这一回溯信息让我们知道问题在哪里变得明显,但并没有说明在哪里发生。95行BloekOutput.py模块的 compute_widths_and_rows()函数中,cell.columns的值明显是0 不管怎么说,这是
导致ZeroDivisionError异常前问题所在——但我们必须看前面的行来了解为什么 cell.columns会被赋予错误的值。
在某些情况下,回溯信息会显示异常发生在Python的标准库或第三方库中,尽管这可能意味着标准库中有bug,实际上几乎都是我们代码中的bug。下面给出的是 Python 3.0给出的一个这种回溯信息实例:
Traceback (most recent call last):
File "blocks.py", line 392, in <module>
main()
File "blocks.py", line 379, in main
blocks = open(file, encoding="utf8").read()
File "usr/lib/python3.0/lib/python3.0/io.py", line 278, in __new__ return open(*args, **kwargs)
File "usr/lib/python3.0/lib/python3.0/io.py", line 222, in open closefd)
File "usr/lib/python3.0/lib/python3.0/io.py", line 619, in _init__fileio._FileIO.__init__(self, name, mode, closefd)
lOError: [Errno 2] No such file or directory: 'hierarchy.blk'
结尾处的lOError错误明确地告诉了我们问题是什么,但是报告异常出现在标准库的io模块中。对这样的情况,最好的方法是向上査看回溯信息,直到初次发现其中列出的某个文件是我们自己的文件(或者是我们创建的某个模块)。因此,在这一实例 中,可以发现对自己编写的程序的第一次引用是在文件blocks.py的379行的main() 函数。看起来是我们有一个对open()的调用,但是没有将该调用放置在try ...except 块中,也没有使用with语句。
Python 3.1比Python 3.0要聪明一些,知道我们要寻找的是自己代码中的错误,而非标准库中的,因此生成的回溯信息更紧凑,更有用,比如:
Traceback (most recent call last):
File "blocks.py", line 392, in <module>
main()
File "blocks.py", line 379, in main
blocks = open(file, encoding="utf8").read()
lOError: [Errno 2] No such file or direttory: 'hierarchy.blk' File
这种回溯信息删除了所有不相关的详细信息,使得从中发现是什么问题(在底部行)以及问题出在哪里(在顶部行)变得更容易。
因此,不管回溯信息多么复杂,最后一行总是指定了未处理异常,我们必须沿着最后一行向上看,直到发现其中列出的是我们自己程序的文件或我们的某个模块。问题当然几乎总是出现在Python指定的行或前面一行。这一特定实例表明,我们应该修改blocks.py程序,以便在给定了不存在的文件名时釆取更好的处理方式。这是一个奇用性错误,该错误也应该归类到逻辑错误中,因为终止并打印回溯信息不能被视作可接受的程序行为。
实际上,作为一种好的策略,也是出于对用户的负责,我们应该总是尽量捕获所有相关的异常,标记出我们认为可能归属的特定异常,比如EnvironmentError。通常, 我们不应该使用catchalls语句,比如except:或except Exception:,尽管在程序顶层使用后者来避免崩溃可能是适当的——但只有在我们总是报告捕获的任何异常(以便异常不会不被注意地错过)时才如此。
我们捕获的并且无法从中恢复的异常应该以错误消息的形式进行报告,而不是向用户呈现回溯信息——对于外行来讲,这种信息是可怕的。对GUI程序,这一原则同样适用,不同之处在于我们通常会使用消息对话框来向用户通告问题所在。对通常无人值守运行的服务器程序,我们应该将错误消息写入服务器日志中。
Python的异常体系在设计上无法捕获所有异常,尤其是无法捕获Keyboardinterrupt 异常。因此,对于控制台程序,如果用户按下Ctrl-C:组合键,那么程序将会终止。如果我们选择捕获这一异常,存在的风险就是可能将用户锁定到他们无法终止的程序中,这是因为异常处理代码中的bug可能防止程序终止或异常传播。(当然,即使“无法中断”的程序也可以将其进程杀掉,但不是所有用户都知道方法。)因此,如果一定要捕获 Keyboardinterrupt异常,就必须极度谨慎,以尽可能少地进行保存与清理所必需的工作 之后终止程序。对不需要保存和清理的程序,最好不要捕获Keyboardinterrupt异常,而只是让程序终止。
Python 3的优点之一就是在原始字节和字符串之间进行了明显的区分,然而,当需要 str对象却传递了 bytes对象(或者相反)时,这种区分就会导致预期外的异常,比如:
Traceback (most recent call last):
File "program.py", line 918, in <module>
print(datetime.datetime.strptime(date, format))
TypeError: strptime() argument 1 must be str, not bytes
遇到类似的问题时,我们或者可以进行转换(在这里是传递date.decode("utf8")),或者认真分析了解为什么变量是一个bytes对象,而不是str对象,并在源头上修复问题。
在需要字节但传递了字符串时,错误消息不那么明显,并且Python 3.0和Python 3.1不同,比如,在Python 3.0中显示的是:
Traceback (most recent call last):
File "program.py", line 2139, in <module>
data.write(info)
TypeError: expected an object with a buffer interface
#在Python 3.1中,错误消息文本则有稍许改进:
Traceback (most recent call last):
File "program.py", line 2139, in <module>
data.write(info)
TypeError: 'str' does not have the buffer interface
在这两种情况中,问题都在于我们传递的是字符串,而需要的是bytes、bytearray 或类似对象。我们或者进行转换(这里是传递info.encode("utf8")),或者找到问题所在并修复。
Python 3.0引入了对异常链的支持---这意味着,作为对某个异常的响应而产生的另一个异常中可以包含原始异常的详细信息。当某个结链的异常未被捕获时,相应的回溯信息中不仅包含该未捕获异常的信息,还包含了导致该异常的原始异常的信息(前提是该异常进行了结链处理)。对结链异常的调试方法几乎与前面是相同的:从结尾处向前回溯,直至发现自己的代码中存在的问题。然而,这一处理过程不能只是针对最后一个异常进行, 而是异常链上的每个异常都重复这一过程,直至找到问题真正的源头。
我们可以在自己的代码中利用异常链——比如,想使用一个自定义异常类,但仍然希望底层的问题是可见的:
class InvalidDataError(Exception): pass
def process(data):
try:
i = int(data)
...
except ValueError as err:
raise InvalidDataError("lnvalid data received") from err
这里,如果int()转换失败,就会产生一个ValueError并被捕获。之后,我们产生自己的自定义异常,但通过使用from err,会创建一个结链的异常,其中包含我们自己的异常和err中的异常。如果InvalidDataError异常产生并且未被捕获,那么回溯信息类似于:
Traceback (most recent call last):
File "application.py", line 249, in process
i = int(data)
ValueError: invalid literal for int() with base 10: '17.5'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):

File "application.py", line 288, in <module>
print(process(line))
File "application.py" line 283, in process
raise InvalidDataError("lnvalid data received") from err
__main__.InvalidDataError: Invalid data received
在底部,我们的自定义异常与文本解释了问题是什么,其上的相应行展示了异常 产生的位置(283行),以及该异常在哪里被捕获(288行)。但我们也可以进一步地回溯到异常链中,其中给出了关于特定异常的详细信息,表明了是哪一行导致了该异常 (249行)。关于异常链的详细原理和更多信息,可以参考PEP 3134。
科学的调试(Scientific Debugging)
如果程序可以运行,但程序行为和期待的或需要的不一致,就说明程序中存在一个bug—必须清除的逻辑错误。清除这类错误的最好方法是首先使用TDD (测试驱动的开发)来防止发生这一类错误,然而,总会有些bug没有避免,因此,即便使用 TDD,调试也仍然是必须学习和掌握的技能。
在这一小节中,我们将简要介绍一种调试方法,该方法基于科学的方法。这里对该方法进行了足够详细的解释,以至于看起来对“简单的” bug来说,这种方法未免过于繁琐。然而,通过有意识地遵循这一过程,我们可以避免“随机"调试浪费的时间,并且,过一段时间之后,我们会将该过程内置于思维和开发过程中,并可以下意识地遵循该流程,从而非常快。
为清除一个bug,我们必须釆取如下一些步骤。
(1) 再现 bug。
(2) 定位 bug。
(3) 修复 bug。
(4) 对修复进行测试。
有时候,重现bug是容易的----每次运行时bug总是会出现;有时候则是困难的 —bug间歇性地发作。不管哪种情况,我们都应该尽量减少该bug的依赖性,也就是说, 找到最少的输入与最小的处理量,并使其仍能产生该bug。
—旦可以重现bug,我们就拥有了数据(输入数据与选项)和错误结果(这是必要的, 借助于这些信息,我们才能应用科学方法来发现并修复bug)。该方法有以下3个步骤。
(1) 设想一个解释(某种假设)可以合乎情理地导致该bug。
(2) 创建实验过程来测试该假设。
(3) 运行实验。
运行实验应该有助于定位bug,也应该有助于找到解决方案。(稍后将介绍如何创建并运行实验。)一旦确定了怎样清除bug—并且通过版本控制系统对代码进行了检査,以便在必要的时候进行修复——就可以编写修复代码了。
修复代码准备好后必须对其进行测试。自然,测试的目的是看其试图修复的bug 是否可以有效清除。仅有这个是不够的,虽然修复可能解决我们所关注的bug,但是修复代码本身也可能引入其他bug,并影响程序的其他方面。因此,除了对bug修复本身进行测试外,还必须运行程序的小测试用例,以便确信bug修复并没有引入其他任何副作用。
有些bug有特定的结构,因此,修复了某个bug的同时,总是应该再想一下,是否程序或其模块中的其他位置处也存在类似的bug。如果有,就检査一下,看看自己是否已经有了可以揭示该bug的测试用例。如果没有这样的测试用例,就添加;如果已经揭示了某些bug,就应该像前面描述的那样对其进行修复。
在对调试过程有了较好的整体了解之后,下面将集中讲述如何创建并运行实验, 以便对假设进行测试。我们首先从试图隔离bug开始。依赖于程序和bug本身的特性, 我们可以编写测试用例对程序进行实验。比如,先提供可以被程序正确处理的数据, 之后逐渐地对提供的数据进行改变,以便准确地发现在哪个位置处理失败。在知道了问题所在之后(不管是通过测试还是推理)。我们就可以对假设进行测试了。
应该进行怎样的假设呢?当然,最初的假设可以很简单。比如,怀疑在使用特定的输入数据或选项时,某个特定的函数或方法会返回错误数据。之后,如果这一假设是正确的,就可以对其进行细化,使其更加具体——比如,识别出函数(我们认为该函数在特定的情况下会进行错误运算)中某个特定的语句或套件。
要对假设进行测试,需要检査函数接受的参数及其本地变量的值,还有函数的返回值(就在其返回之前)。之后,可以使用已知会导致错误的数据运行程序,并检査可疑函数。如果进入函数的参数不是我们所期待的,那么问题可能出现在调用栈中更远的位置,因此应该再一次开始这一过程,不过这一次针对的函数是调用前面可疑函数的函数。但是,如果所有的输入参数总是有效的,就必须査看局部变量和返回值。如果这些也总是对的,就需要提出新的假设,因为可疑函数的行为是正确的。如果返回值是错的,就必须对该函数进行进一步的研究。
在实践中,怎样进行实验,也就是说,怎样对假设(假定某个特定函数有错误行为) 进行测试?有一种方法是在思维中"执行"一下函数一对很多小函数和实践中的大函数, 这是可能的,并且有一个额外的好处是有助于熟悉函数的行为。充其量,这可以导向一个改进的或更特定的假设一比如,某个特定的语句或套件是问题所在。为了正确进行实验, 我们必须对程序进行监测,以便了解函数被调用时会发生什么。
有两种方法可以对程序进行监控:一种是侵入性的方法,即插入print()语句;另 一种(通常的)是非侵入性的方法,即使用调试器。这两种方法的目的一致,也同样有效,不过有些程序员有很强的使用偏好。我们将简要地描述这两种方法,从print()语句的使用开始。
使用print()语句时,可以在函数的开始部分放置一条print()语句,并打印出函数的参数。之后,就在每条return语句之前(如果没有return语句,就在函数的末尾) 添加一条print(locals(), "\n“)语句,其中,内置的locals()函数会返回一个字典,该字典的键是本地变量的名称,值则是本地变量的值。当然,我们也可以只是简单地打印出我们特殊关注的变量。注意到上面的语句中我们添加了额外的一个换行——在第一个 print()语句中我们应该也这样做,这样的好处是在每组变量之间有一个空白行,有助于清晰描述(直接插入print()语句的一种替代方法是使用某种类型的logging decorator)。
如果在运行程序时发现参数是对的,但返回值是错的,就可以确定已经定位了 bug 的源头,并可以对该函数进行进一步的研究。如果仔细査看该函数仍然无法发现问题所在, 我们可以简单地在函数中间插入一条新的print(locals(),"\n")语句。再次运行程序之后,就应该知道问题是出在函数的前一半还是后一半,并在出问题的那一半的中间再次插入一条 print(locals(),"\n")语句,重复这一过程,直到发现导致错误的具体语句。这可以非常快捷地让我们找到问题所在一大多数情况下,定位问题就已经解决了问题的一半。
python3 statistics.py sum.dat
> statistics.py(73)calculate_median()
-> numbers = sorted(numbers)
(Pdb) s
> statistics.py(74)calculate_median()
-> middle = len(numbers) // 2
(Pdb)
> statistics.py(75)calculate_median()
-> median = numbers[middle]
(Pdb)
> statistics.py(76)calculate_median()
-> if len(numbers) % 2 == 0:
(Pdb)
> statistics.py(78)calculate_median()
-> return median
(Pdb) p middle, median, numbers
(8, 5.0, [-17.0, -9.5, 0.0, 1.0, 3.0, 4.0, 4.0, 5.0, 5.0, 5.0, 5.5,6.0, 7.0, 7.0, 8.0, 9.0, 17.0])
(Pdb) c
添加print()语句的一种替代方法是使用调试器。Python有两个标准的调试器。一个是作为模块(pdb)提供的,该模块可以在控制台中交互式地使用一比如python3 -m pdb my_program.py。(当然,在 Windows 平台上,python3 也可能是类似于 C:\Python31\ python.exe。)然而,使用该模块最容易的方法是在程序本身添加import pdb语句,并将 pdb.set_trace()语句作为第—条语句添加到我们要检査的函数中。程序运行时,pdb会在 pdb.set_trace()调用后立即终止,并允许我们逐步调试该程序、设置断点、检査变量等。
下面给出一个程序运行实例,该程序中使用了 import pdb语句进行监控,并将 pdb.set_trace()添加为calculate_median()函数的第一条语句。(黑体部分是我们输入的, 当然Enter键并未显示出来。)
要将命令提交给pdb,需要在(Pdb)提示符下输入命令名,并按Enter键。如果我们只是按Enter键,就重复执行前面执行的最后一条命令。因此,这里我们输入s (这意味着step,也就是执行展示的语句),之后重复执行该命令(只是简单地按Enter 键),以便逐步执行calculate_median()函数的语句。一旦到达返回语句,就使用p(print) 命令打印岀我们感兴趣的值。最后,使用c (continue)命令执行到最后。这个小实例展示了 pdb的一些特点,当然,该模块具有的功能要比这里展示的多得多。
在被监控的程序上使用pdb (比如这里所做的)要比在未被监控的程序上使用容易一些。由于这需要添加一条import语句,并需要调用pdb.set_trace(),因此看起来使用pdb与使用print()语句一样具有一定的侵入性(尽管该模块确实也提供了有用的功能,比如断点)。
另一个标准的调试器是IDLE,像pdb一样,也支持单步调试、断点以及对变量的检查等。
调试Python程序并不比调试任何其他程序难——并且要比调试编译型程序简单, 因为Python程序在修改后不需要进行编译。并且,如果我们能细心地使用科学方法, 通常可以相当直接地定位bug (尽管对其进行修复是另一件事)。当然,理想情况下, 我们在最初就应该尽可能多地避免出现bug。除了我们自己对程序设计深入思考并仔细编写代码之外,防止出现bug的途径是使用TDD,这一主题将在下一节进行介绍。
以上内容部分摘自视频课程05后端编程Python-19调试、测试和性能调优(上),更多实操示例请参照视频讲解。跟着张员外讲编程,学习更轻松,不花钱还能学习真本领。