跳至内容
Topos Uranos

摘要

一个给出错误结果的程序,不能靠随意改动几行来修正,而要靠调查。本课把调试作为一种实验方法来介绍:复现失效,对其原因提出假设,设计一个能证实或排除该假设的实验,并缩小缺陷所在的范围;二分排查找到缺陷所需的步数随程序规模的对数增长。本课展开讲解这一工具(用 -g 且不开优化来编译,在三种系统上配置 Visual Studio Code,以及一次真实的 GDB 会话,展示编辑器在幕后做了什么),讲解普通断点和条件断点、单步执行、调用栈和状态查看,并贯穿一个核心例子:只在数据个数为偶数时出错的中位数。最后介绍比工具更有价值的策略(精简用例、寻找最后一个正确点、用 assert 检查不变式、阅读检测器的报告),以及三个案例:一个差一错误、一个不终止的递归和一个失效的引用。

学习目标

  1. 配置带调试信息的编译(-g,不开优化),并在自己的系统上从 Visual Studio Code 启动调试器。
  2. 使用普通断点和条件断点、单步执行(进入、跳过、跳出),以及对变量、表达式和调用栈的查看。
  3. 运用系统的策略(复现、精简、二分、提出并检验假设)定位给定的缺陷,并记录每一个假设。
  4. 把调试器与检测器和测试结合起来,根据报告定位内存错误。

调试就是调查

缺陷与失效

失效是观察到的现象:错误的结果、不终止的程序、检测器的报告。缺陷(或错误)是原因:与规格说明不符的一行代码、一个条件或一个设计决定。失效看得见;缺陷则必须去找,而且它可能离失效很远。由此可知,在看到失效的地方修正,例如因为“总是多出 2”就把结果减去 2,只是修正了症状而留下了原因,换一组数据它还会回来。

如果是这样,调试就是对原因的调查,它的方法就是实验科学的方法:

  1. 观察与复现。准确地描述失效(用什么输入,期望什么,得到什么),并做到想让它出现时它就出现。不能复现的缺陷无法调查,也无法确认它已被修正。
  2. 提出假设。一个关于原因、可能被证明为假的断言:“排序没有排好序”,“第二个中间数的下标多了一”。
  3. 设计实验。选定程序中的一个点,并预测:如果假设为真,在那里应当看到什么状态;如果为假,又应当看到什么状态。
  4. 得出结论并缩小范围。排除或证实假设;无论哪种情况,可疑区段都会缩小。如此重复,直到区段只剩一行。

然而,这个方法要求纪律:一次只改一处,把每个假设连同其结果记下来,在不知道要找什么之前不碰代码。随意改动几行直到输出看起来正确,可能掩盖失效而没有消除缺陷。

例题数据个数为偶数时的中位数

一组数据的中位数,在个数为奇数时是排序后列表的中间那个数,在个数为偶数时是中间两个数的平均值。程序测试一课表明,测试能发现函数何时出错,却不能指出它的缺陷在哪里。下面的函数在五个数据时通过测试,在四个数据时则不通过。

C++ 程序
#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

观察是精确的:对于 {7,1,5,3}\{7, 1, 5, 3\},排序后为 {1,3,5,7}\{1, 3, 5, 7\},期望得到 (3+5)/2=4(3 + 5)/2 = 4,实际得到 6;有五个数据时,结果是正确的。至少可以提出三个假设:(H1)排序没有排好序;(H2)两个中间数的下标错了;(H3)加法或除法写错了。每个假设对第 17 行在执行之前的状态都有不同的预测:如果 H1 为真,values 没有排好序;如果 H2 为真,values[middle] 和 values[middle + 1] 不是 3 和 5;如果 H3 为真,两个数是 3 和 5,但计算结果仍然是 6。奇数情形能正常工作,这使 H1 不太可能成立,但“不太可能”不等于“已排除”;用调试器做的实验,就是让程序停在第 17 行并查看。

二分排查

当没有明确的假设时,还有一个不会失败的方法:二分排查。假定程序的状态在开始时正确、在结束时错误,并且在每个中间点都能判断状态是否正确(与手算的值比较,或检查一个不变式)。在可疑区段的中点检查状态:如果正确,缺陷在后一半;否则在前一半。这就是查找与排序一课中的二分查找,只是应用于程序的各行(或循环的各轮,或文件的各个版本)。

图中跟踪对十六行代码的二分排查,缺陷在第 11 行;每一道刻线是对该行之后状态的一次实验:正确(✓)或错误(✗)。

证明

  1. 开始时,十六行全都可疑:已知状态在第 1 行之前正确,在第 16 行之后错误。

  2. 第 8 行之后,状态正确:第 1 行到第 8 行被排除嫌疑。

  3. 第 12 行之后,状态已经不正确:缺陷在第 9 行到第 12 行之间。

  4. 第 10 行之后,正确:剩下第 11 行和第 12 行。

  5. 第 11 行之后,不正确:最后一个正确点和第一个不正确点相邻,缺陷就在第 11 行。

计数与二分查找相同:每次实验把区段一分为二,因此 kk 次实验足以处理 2k2^k 行。对于一千行的程序,十次就够了,因为 210=10242^{10} = 1024。由此可见这个方法的优点:它的代价随规模的对数增长,而且不要求理解整个程序,只要求能够在某一点判断状态是否符合应有的样子。然而,这种判断正是困难的部分:规格说明和不变式就是评判每个中间状态的标准。