摘要
一个给出错误结果的程序,不能靠随意改动几行来修正,而要靠调查。本课把调试作为一种实验方法来介绍:复现失效,对其原因提出假设,设计一个能证实或排除该假设的实验,并缩小缺陷所在的范围;二分排查找到缺陷所需的步数随程序规模的对数增长。本课展开讲解这一工具(用 -g 且不开优化来编译,在三种系统上配置 Visual Studio Code,以及一次真实的 GDB 会话,展示编辑器在幕后做了什么),讲解普通断点和条件断点、单步执行、调用栈和状态查看,并贯穿一个核心例子:只在数据个数为偶数时出错的中位数。最后介绍比工具更有价值的策略(精简用例、寻找最后一个正确点、用 assert 检查不变式、阅读检测器的报告),以及三个案例:一个差一错误、一个不终止的递归和一个失效的引用。
学习目标
- 配置带调试信息的编译(
-g,不开优化),并在自己的系统上从 Visual Studio Code 启动调试器。 - 使用普通断点和条件断点、单步执行(进入、跳过、跳出),以及对变量、表达式和调用栈的查看。
- 运用系统的策略(复现、精简、二分、提出并检验假设)定位给定的缺陷,并记录每一个假设。
- 把调试器与检测器和测试结合起来,根据报告定位内存错误。
调试就是调查
缺陷与失效
失效是观察到的现象:错误的结果、不终止的程序、检测器的报告。缺陷(或错误)是原因:与规格说明不符的一行代码、一个条件或一个设计决定。失效看得见;缺陷则必须去找,而且它可能离失效很远。由此可知,在看到失效的地方修正,例如因为“总是多出 2”就把结果减去 2,只是修正了症状而留下了原因,换一组数据它还会回来。
如果是这样,调试就是对原因的调查,它的方法就是实验科学的方法:
- 观察与复现。准确地描述失效(用什么输入,期望什么,得到什么),并做到想让它出现时它就出现。不能复现的缺陷无法调查,也无法确认它已被修正。
- 提出假设。一个关于原因、可能被证明为假的断言:“排序没有排好序”,“第二个中间数的下标多了一”。
- 设计实验。选定程序中的一个点,并预测:如果假设为真,在那里应当看到什么状态;如果为假,又应当看到什么状态。
- 得出结论并缩小范围。排除或证实假设;无论哪种情况,可疑区段都会缩小。如此重复,直到区段只剩一行。
然而,这个方法要求纪律:一次只改一处,把每个假设连同其结果记下来,在不知道要找什么之前不碰代码。随意改动几行直到输出看起来正确,可能掩盖失效而没有消除缺陷。
一组数据的中位数,在个数为奇数时是排序后列表的中间那个数,在个数为偶数时是中间两个数的平均值。程序测试一课表明,测试能发现函数何时出错,却不能指出它的缺陷在哪里。下面的函数在五个数据时通过测试,在四个数据时则不通过。
#include <algorithm>#include <cstddef>#include <print>#include <vector>// 一组数据的中位数。// 前置条件:values 不为空。// 后置条件:返回排序后列表的中间数,或中间两个数的平均值。double median(std::vector<double> values){ std::ranges::sort(values); const std::size_t n{values.size()}; const std::size_t middle{n / 2}; if (n % 2 == 1) { return values[middle]; } return (values[middle] + values[middle + 1]) / 2.0;}int main(){ const std::vector<double> odd{7.0, 1.0, 5.0, 3.0, 9.0}; const std::vector<double> even{7.0, 1.0, 5.0, 3.0}; std::println("{} 的中位数:{}", odd, median(odd)); std::println("{} 的中位数:{}", even, median(even));}[7, 1, 5, 3, 9] 的中位数:5[7, 1, 5, 3] 的中位数:6观察是精确的:对于 ,排序后为 ,期望得到 ,实际得到 6;有五个数据时,结果是正确的。至少可以提出三个假设:(H1)排序没有排好序;(H2)两个中间数的下标错了;(H3)加法或除法写错了。每个假设对第 17 行在执行之前的状态都有不同的预测:如果 H1 为真,values 没有排好序;如果 H2 为真,values[middle] 和 values[middle + 1] 不是 3 和 5;如果 H3 为真,两个数是 3 和 5,但计算结果仍然是 6。奇数情形能正常工作,这使 H1 不太可能成立,但“不太可能”不等于“已排除”;用调试器做的实验,就是让程序停在第 17 行并查看。
二分排查
当没有明确的假设时,还有一个不会失败的方法:二分排查。假定程序的状态在开始时正确、在结束时错误,并且在每个中间点都能判断状态是否正确(与手算的值比较,或检查一个不变式)。在可疑区段的中点检查状态:如果正确,缺陷在后一半;否则在前一半。这就是查找与排序一课中的二分查找,只是应用于程序的各行(或循环的各轮,或文件的各个版本)。
图中跟踪对十六行代码的二分排查,缺陷在第 11 行;每一道刻线是对该行之后状态的一次实验:正确(✓)或错误(✗)。
证明
开始时,十六行全都可疑:已知状态在第 1 行之前正确,在第 16 行之后错误。
第 8 行之后,状态正确:第 1 行到第 8 行被排除嫌疑。
第 12 行之后,状态已经不正确:缺陷在第 9 行到第 12 行之间。
第 10 行之后,正确:剩下第 11 行和第 12 行。
第 11 行之后,不正确:最后一个正确点和第一个不正确点相邻,缺陷就在第 11 行。
计数与二分查找相同:每次实验把区段一分为二,因此 次实验足以处理 行。对于一千行的程序,十次就够了,因为 。由此可见这个方法的优点:它的代价随规模的对数增长,而且不要求理解整个程序,只要求能够在某一点判断状态是否符合应有的样子。然而,这种判断正是困难的部分:规格说明和不变式就是评判每个中间状态的标准。
正在加载内容…
已完成
登录账户以保存您的进度