第5章 排错
5.1 排错系统
排错系统是依赖于具体系统的,因此,当你在另一个系统中工作时,可能就没法使用你很熟悉的排错系统。
作为个人的观点,我们倾向于除了为取得堆栈轨迹和一两个变量的值之外不去使用排错系统。这其中有一个重要原因:人很容易在复杂数据结构和控制流的细节中迷失方向,我们发现以单步方式遍历程序的方式,还不如努力思考,辅之以在关键位置加打印语句和检查代码。后者的效率更高。
5.2 好线索,简单错误
检查错误输出中的线索,设法推断它可能如何被产生。看看程序垮台前已经有了什么样的输出,如果可能的话,通过排错系统得到堆栈轨迹。
n 寻找熟悉的模式
n 检查最近的改动
n 不要两次犯同样的错误
n 现在排除,而不是以后
n 取得堆栈轨迹
n 键入前仔细读一读
n 把你的代码解释给别人
5.3 无线索,难办的错误
n 把错误弄成可以重现的
n 分而治之
n 研究错误的计数特性
n 显示输出,使搜索局部化
n 写自检测代码
n 写记录文件
n 画一个图
n 使用工具
n 保留记录
5.4 最后的手段
如果上面的建议都没有用,那么又该怎么办?这可能是使用一个好的排错系统,以步进方式遍历程序的时候了。如果你关于某些东西如何工作的思维模型根本就不对,以至你一直在完全错误的地方寻找问题,或者找的地方是对的,但却总也不能发现问题,那么排错系统将迫使你以另一种方式去思考。这种“思维模型”错误是最难发现的一类错误,在这里机械的帮助将很有价值。
在这种情况下,排错系统将会很有帮助,它能迫使你向另一个方向走,顺着程序的实际工作流程,而不是你头脑里设想的流程。与此类似,如果问题出在错误地理解了整个程序的结构,要想看到错误到底是什么,就必须回到开始的假设去。
5.5 不可重现的错误
应该先检查所有的变量是否都正确地进行了初始化。如果没有,它就可能取到某个具有随机性的值,是以前存入同一个存储位置的。函数的局部变量和由分配得到的存储块是C或C++里最重要的嫌疑犯。把所有变量都用已知值设置好,如果程序里用到某个随机数的种子(正常情况下它可能会被用日期和时间设置),现在也应该先把它设置为常数,例如设置为0。
如果垮台的地方看起来与任何可能出错的东西都距离很远,那么最有可能的就是错误地向某个存储位置里写进了一些东西,而又是在很久以后才用到了这个地方。有时问题出在悬空的指针,例如由于疏忽从函数里返回了一个指向局部变量的指针,而后又使用了它。返回局部变量的地址可以说是产生延迟灾难的一个秘方。
当一个程序对于某个人能工作,而对另一个人则不行时,一定是有某些东西依赖于程序的外部环境。这可能包括:程序需要读的文件、文件的权限、环境变量、命令的查找路径、各种默认的东西和启动文件等等。
5.6 排错工具
5.7 其他人的程序错误
各种工具在这里都可能很有帮助。grep一类的文本搜索程序有助于找到所有出现的名字;交叉引用程序可以帮人看清程序结构的某些思想;显示函数调用图(如果不太大的话)也很有价值;用一个排错系统,以步进方式一个一个函数地执行程序,可以帮人看清事件发生的顺序;程序的版本历史可以给人一些线索,显示出随着时间变化人们对程序做了些什么。代码中的频繁改动是个信号,常常说明对问题的理解不够,或者表示需求发生了变化,这些经常是潜在错误的根源。
最好的错误报告就是那种仅需要给基本系统提供一两行输入就能说明毛病的东西,最好再带上如何更正的说明。应该送给别人那种你希望自己也能接收到的错误报告。
第6章 测试
测试和排错常常被说成是一个阶段,实际上它们根本不是同一件事。简单地说,排错是在你已经知道程序有问题时要做的事情。而测试则是在你在认为程序能工作的情况下,为设法打败它而进行的一整套确定的系统化的试验。
测试能够说明程序中有错误,但却不能说明其中没有错误。
6.1 在编码过程中测试
1) 测试代码的边界情况。一项重要技术是边界条件测试:在写好一个小的代码片段之后,就应该检查条件所导致的分支是否正确,循环实际执行的次数是否正确等。这种工作称为边界条件测试,因为你的检查是在程序和数据的自然边界上。
2) 测试前条件和后条件。防止问题发生的另一个方法,是验证在某段代码执行前所期望的或必须满足的性质(前条件)、执行后的性质(后条件)是否成立。
3) 使用断言。C和C++在<assert.h>里提供了一种断言机制,它鼓励给程序加上前/后条件测试。断言失败将会终止程序,所以这种机制通常是保留给某些特殊情况使用的,写在这里的错误是真正不应该出现的,而且是无法恢复的。我们可能在前面程序段里的循环前面加一个断言:assert(n > 0);
4) 防御性的程序设计。有一种很有用的技术,那就是在程序里增加一些代码,专门处理所有“不可能”出现的情况,也就是处理那些从逻辑上讲不可能发生,但是或许(由于其他地方的某些失误)可能出现的情况。
5) 检查错误的返回值。一个常被忽略的防御措施是检查库函数或系统调用的返回值。对所有输入函数(例如fread或fscanf)的返回值一定要做检查,看它们是否出错。对文件打开操作(如fopen)也应该这样。如果一个读入操作或者文件打开操作失败,计算将无法正确地进行下去。
6.2 系统化测试
1) 以递增方式做测试。测试应该与程序的构造同步进行。与逐步推进的方式相比,以“大爆炸”方式先写出整个程序,然后再一古脑地做测试,这样做困难得多,通常也要花费更长时间。写出程序的一部分并测试它,加上一些代码后再进行测试,如此下去。如果你有了两个程序包,它们已经都写好并经过了测试,把它们连接起来后就应该立即测试,看它们能否在一起工作。
2) 首先测试简单的部分。递增方式同样适用于对程序性质的测试。测试应该首先集中在程序中最简单的最经常执行的部分,只有在这些部分能正确工作之后,才应该继续下去。这样,在每个步骤中你使更多的东西经过了测试,对程序基本机制能够正确工作也建立了信心。通过容易进行的测试,发现的是容易处理的错误。在每个测试中做最少的事情去发掘出下一个潜在问题。虽然错误可能是一个比一个更难触发,但是可能并不更难纠正。
3) 弄清所期望的输出。对于所有测试,都必须知道正确的答案是什么,如果你不知道,那么你做的就是白白浪费自己的时间。
4) 检验应保持不变的特征。许多程序将保持它们输入的一些特征。有些工具,如wc(计算行、词和字符数)和sum(计算某种检验和)可用于验证输出是否与输入具有同样大小、词的数目是否相同、是否以某种顺序包含了同样的字节以及其他类似的东西。另外还有一些程序可以比较文件的相等(cmp)或报告差异(diff)等。这些程序,或其他类似的东西在许多环境里都可以直接使用,也是非常值得用的。
5) 比较相互独立的实现。一个库或者程序的几个相互独立的实现应该产生同样的回答。例如,由两个编译程序产生的程序,在相同机器上的行为应该一样,至少在大部分情况下应该是这样。
6) 度量测试的覆盖面。测试的一个目标是保证程序里的每个语句在一系列测试过程中都执行过,如果不能保证程序的每一行都在测试中至少经过了一次执行,那么这个测试就不能说是完全的。完全覆盖常常很难做到,即使是不考虑那些“不可能发生”的语句。设法通过正常输入使程序运行到某个特定语句有时也是很困难的。
6.3 测试自动化
自动回归测试。自动化的最基本形式是回归测试,也就是说执行一系列测试,对某些东西的新版本与以前的版本做一个比较。在更正了一个错误之后,人们往往有一种自然的倾向,那就是只检查所做修改是否能行,但却经常忽略问题的另一面,所做的这个修改也可能破坏了其他东西。回归测试的作用就在这里,它要设法保证,除了有意做过的修改之外,程序的行为没有任何其他变化。
建立自包容测试。自包容测试带着它们需要的所有输入和输出,可以作为回归测试的一种补充。
6.4 测试台
要孤立地测试一个部件,通常必须构造出某种框架或者说是测试台,它应能提供足够的支持,并提供系统其他部分的一个界面,被测试部分将在该系统里运行。
6.5 应力测试
6.6 测试秘诀
6.7 谁来测试
由程序实现者或其他可以接触源代码的人做的测试有时也被称为白箱测试。
黑箱测试的测试者对代码的内部结构毫不知情,也无法触及。
实际用户接踵而至。新用户往往能发现新错误,因为他们会以我们无法预知的方式来探测这个程序。
测试交互式程序是特别困难的,特别是如果它们还涉及到鼠标输入等。有些测试可以用脚本来做(脚本的特性依赖于语言、环境和其他类似东西)。
最后,还应该想一想如何测试所用的测试代码本身。
6.8 测试马尔可夫程序
第一个测试集由几个很小的文件组成,用于测试边界条件,目标是保证程序对只包含几个词的输入能正确产生输出。
第二项测试检验某些必须保持的特征。对于两词前缀的情况,一次运行中输出的每个词、每个词对、以及每个三词序列都必然也出现在输入里。
第三步测试是统计性的。
最后,我们给马尔可夫程序普通的英文文本,看着它产生出很漂亮的无意义的费话。
所有这些测试都是机械化的。用一个脚本产生必需的输入数据,运行测试并且对它们计时,打印出反常的输出。这个脚本本身是可配置的,所以同一个测试能够应用到马尔可夫程序的各种版本上,每次我们对这些程序中的一个做了更改后,就重新运行所有测试,以保证所有东西都没出问题。
6.9 小结
你把开始的代码写得越好,它出现的错误也就越少,你也就越能相信所做过的测试是彻底的。在写代码的同时测试边界条件,这是去除大量可笑的小错误的最有效方法。系统化测试以一种有序方式设法探测潜在的麻烦位置。同样,毛病最可能出现在边界,这可以通过手工的或者程序的方式检查。自动进行测试是最理想的,用得越多越好,因为机器不会犯错误、不会疲劳、不会用臆想某些实际无法工作的东西能行来欺骗自己。回归测试检查一个程序是否能产生与它们过去相同的输出。在做了小改变之后就测试是一种好技术,能帮助我们将出现问题的范围局部化,因为新问题一般就出现在新代码里面。
对于测试,惟一的、最重要的规则就是必须做。