在调试程序时,大多数情况下,你会花费大部分时间试图找出错误实际所在的位置。一旦找到问题,剩下的步骤(修复问题并验证问题是否已修复)相比之下通常微不足道。
在本课中,我们将开始探索如何查找错误。
通过代码审查发现问题
假设你发现了一个问题,并且你想要找出这个问题的具体原因。在许多情况下(特别是在较小的程序中),我们可以根据错误的性质和程序的结构大致推断出问题可能出现的位置。
考虑以下程序片段:
int main()
{
getNames(); // 让用户输入一些名字
sortNames(); // 按字母顺序对它们进行排序
printNames(); // 打印排序后的名字列表
return 0;
}
如果你期望这个程序按字母顺序打印名字,但它却以相反的顺序打印,那么问题可能出在sortNames
函数中。在能够将问题缩小到特定函数的情况下,你可能只需查看代码就能发现这个问题。
然而,随着程序变得越来越复杂,通过代码审查发现问题也变得越来越复杂。
代码审查的局限性
首先,需要查看的代码量大大增加。查看一个拥有数千行代码的程序中的每一行代码可能需要花费很长时间(更不用说这还极其枯燥)。其次,代码本身的复杂性也增加了,可能会出错的地方也更多。第三,代码的行为可能不会给你提供太多关于问题出在哪里的线索。如果你编写了一个输出股票推荐的程序,但它实际上什么也没输出,你可能就不太清楚该从哪里开始寻找问题。
最后,错误可能是由于错误的假设造成的。几乎不可能通过视觉检查发现由错误假设引起的错误,因为你很可能会在检查代码时做出同样的错误假设,从而没有注意到错误。那么,如果我们无法通过代码审查找到问题,我们该如何找到它呢?
通过运行程序发现问题
幸运的是,如果我们无法通过代码审查发现问题,我们还有另一种途径:我们可以观察程序运行时的行为,并尝试根据此诊断问题。这种方法可以概括为:
- 弄清楚如何重现问题
- 运行程序并收集信息以缩小问题所在范围
- 重复上一步,直到找到问题
重现问题的重要性
找到问题的第一步也是最重要的一步是能够重现问题。重现问题意味着以一种一致的方式使问题出现。原因很简单:除非你能观察到问题的发生,否则很难找到问题。
回到我们的冰块分配器类比 —— 假设有一天你的朋友告诉你,你的冰块分配器坏了。你去看它时,它却正常工作。你该如何诊断问题呢?这将非常困难。然而,如果你能看到冰块分配器不工作的实际问题,那么你就可以更有效地开始诊断它为什么无法工作了。
问题重现的策略
如果软件问题很明显(例如,程序每次运行时都在同一个地方崩溃),那么重现问题可能很容易。然而,有时重现问题可能要困难得多。问题可能只在某些电脑上出现,或者在特定情况下出现(例如,当用户输入某些内容时)。在这种情况下,生成一套重现步骤可能会有所帮助。重现步骤是一系列清晰、精确的步骤,可以按照这些步骤使问题以较高的可预测性再次出现。
定位问题的具体位置
一旦我们能够合理地重现问题,下一步就是弄清楚问题在代码的哪里。根据问题的性质,这可能很容易,也可能很困难。为了举例说明,假设我们不知道问题到底在哪里。我们该如何找到它呢?
使用高低游戏策略
一个类比在这里会对我们很有帮助。让我们来玩一个高低游戏。我会让你猜一个 1 到 10 之间的数字。对于你每次的猜测,我会告诉你每个猜测是太高、太低还是正确。这个游戏的一个实例可能如下所示:
你:5
我:太低了
你:8
我:太高了
你:6
我:太低了
你:7
我:正确
应用二分查找策略
我们可以使用类似的过程来调试程序。在最坏的情况下,我们可能根本不知道错误在哪里。然而,我们知道问题一定出现在程序开始执行和程序表现出第一个我们可以观察到的错误症状之间的代码中。
例如:
- 如果在程序的某个时刻,我们可以证明问题尚未发生,这就像收到了一个"太低"的高低结果
- 如果在程序的某个时刻我们可以观察到与问题相关的错误行为,那么这就像是收到了一个"太高"的高低结果
- 有时我们也可以直接排除整个代码段
最终,通过足够的猜测和一些好的技巧,我们可以锁定导致问题的确切行!如果我们做出了任何错误的假设,这将帮助我们发现它们在哪里。当你排除了所有其他可能性时,剩下的一定是导致问题的原因。