当其他所有方法都失败时,调试是最后的手段。让我们退后一步,在调试之前思考一下所有的事情。
防范漏洞
根据 Rob Miller 的说法,有四种防范漏洞的方法:
-
防范漏洞的第一个方法是让它们变得不可能。
通过选择保证内存安全(除非通过对该内存区域有效的指针(或引用),否则内存的任何部分都不能被访问)和类型安全(任何值的使用方式都不能与其类型不一致)的编程语言,可以消除整个类的错误。例如,OCaml 类型系统可以防止程序发生缓冲区溢出和无意义操作(比如向浮点数添加布尔值),而 C 类型系统则不能。
-
防范漏洞的第二种方法是使用发现漏洞的工具。
有自动化的源代码分析工具,比如 FindBugs,它可以发现 Java 程序中许多常见的错误,还有 SLAM,它用来发现设备驱动程序中的错误。形式化方法是计算机科学(CS)的一个子领域,研究如何使用数学来指定和验证程序,即如何证明程序没有错误。我们将在本课程的后面学习验证。
代码评审和结对编程等社会化方法也是查找错误的有用工具。IBM 在20世纪 70 – 90 年代的研究表明,代码审查可以非常有效。在一项研究中 (Jones, 1991),代码审查发现了 65% 的已知编码错误和 25% 的已知文档错误,而测试只发现了 20% 的编码错误,而且没有发现文档错误。
-
防范漏洞的第三种方法是让它们立即可见。
错误出现得越早,就越容易诊断和修复。如果计算超过了错误点,那么进一步的计算可能会掩盖真正发生故障的位置。源代码中的断言 使程序“快速失败”和“明显失败”,这样错误就会立即显现,程序员就能准确地知道在源代码中的什么地方查找。
-
防范漏洞的第四种方法是广泛的测试。
如何知道一段代码是否有特定的错误?编写测试来暴露这个错误,然后确认你的代码不会通过这些测试。对于相对较小的代码段 (如单个函数或模块),在开发该代码的同时编写单元测试尤其重要。这些测试的运行应该是自动化的,这样如果你破坏了代码,可以尽快发现。(这又是防范漏洞3。)
在所有这些防范措施都失败之后,程序员不得不求助于调试。
如何调试
你发现了一个错误。接下来你将怎么做?
-
将错误提取到一个小的测试用例中。 调试是一项艰苦的工作,但是测试用例越小,你就越有可能将注意力集中在隐藏错误的代码段上。因此,将时间花在此提取上可以节省时间,因为不需要重新阅读大量代码。在有一个小的测试用例之前不要继续调试!
-
采用科学的方法。 提出一个关于错误发生原因的假设。你甚至可以把假设写在笔记本上,就像你在化学实验室里一样,在自己的头脑中澄清它,并记录已经考虑过的假设。接下来,设计一个实验来确认或否定这个假设。运行你的试验并记录结果。根据你所学到的,重新制定你的假设。继续,直到理性地、科学地确定了错误的原因。
-
修复错误。 修复可能是一个简单的错误更正。或者它可能会暴露出一个设计缺陷,导致你做出重大更改。考虑是否需要将修复程序应用到代码库中的其他位置——例如,这是一个复制粘贴错误吗?如果是这样,是否需要重构代码?
-
将小测试用例永久地添加到测试套件中。 你不希望这个错误回到代码库中。因此,通过将它作为单元测试的一部分来跟踪这个小测试用例。这样,将来任何时候进行更改时,都将自动防止出现相同的错误。反复运行从以前的错误中提炼出来的测试是回归测试的一部分。
OCaml中的调试
这里有一些关于如何在 OCaml 中调试的技巧(如果你不得不这么做的话)。
-
打印语句。插入一条打印语句以确定变量的值。假设你想知道
x
在以下函数中的值:let inc x = x + 1let inc x = x + 1
let inc x = x + 1
Just add the line below to print that value:
只需要添加下面这行代码来打印这个值:
let inc x =let () = print_int x inx + 1let inc x = let () = print_int x in x + 1
let inc x = let () = print_int x in x + 1
-
函数跟踪。 假设你想查看一个函数的递归调用和返回的跟踪。使用
#trace
指令:
# let rec fib x = if x <= 1 then 1 else fib (x - 1) + fib (x - 2);;# #trace fib;;# let rec fib x = if x <= 1 then 1 else fib (x - 1) + fib (x - 2);; # #trace fib;;# let rec fib x = if x <= 1 then 1 else fib (x - 1) + fib (x - 2);; # #trace fib;;
If you evaluate fib 2
, you will now see the following output:
如果你计算 fib 2
,你现在会看到以下输出:
fib <-- 2fib <-- 0fib --> 1fib <-- 1fib --> 1fib --> 2fib <-- 2 fib <-- 0 fib --> 1 fib <-- 1 fib --> 1 fib --> 2fib <-- 2 fib <-- 0 fib --> 1 fib <-- 1 fib --> 1 fib --> 2
要停止跟踪,请使用 #untrace
指令。
- 调试器。 OCaml 有一个调试工具
ocamldebug
。你可以在 OCaml 网站上找到 教程。除非你使用 Emacs 作为编辑器,否则你可能会发现这个工具比仅仅插入打印语句更难使用。
防范性程序设计
正如我们在前面的调试部分中所讨论的,防止错误的一种方法是使任何漏洞(或错误)立即可见。这个概念和前置条件概念相联系。
考虑一下 random_int
的规范:
(** [random_int bound] 是一个介于 0 (包括)和 [bound] (不包括)之间的随机整数。要求: [bound] 大于 0 且小于2的30次方。*)(** [random_int bound] 是一个介于 0 (包括)和 [bound] (不包括)之间的随机整数。 要求: [bound] 大于 0 且小于2的30次方。*)(** [random_int bound] 是一个介于 0 (包括)和 [bound] (不包括)之间的随机整数。 要求: [bound] 大于 0 且小于2的30次方。*)
如果 random_int
的客户端传递了一个违反 “Requires” 子句的 bound
值,例如 -1
,则 random_int
的实现可以自由地执行任何操作。当客户违反前置条件时,所有的备选都将作废。
但 random_int
能做的最有帮助的事情,是立即指出违反了前置条件。毕竟,客户端可能并不是有意违反它。
因此 random_int
的实现者最好检查是否违反了前置条件,如果违反了,就抛出异常。以下是这种防范性编程 的三种可能:
(* possibility 1 *)let random_int bound =assert (bound > 0 && bound < 1 lsl 30);(* proceed with the implementation of the function *)(* possibility 2 *)let random_int bound =if not (bound > 0 && bound < 1 lsl 30)then invalid_arg "bound";(* proceed with the implementation of the function *)(* possibility 3 *)let random_int bound =if not (bound > 0 && bound < 1 lsl 30)then failwith "bound";(* proceed with the implementation of the function *)(* possibility 1 *) let random_int bound = assert (bound > 0 && bound < 1 lsl 30); (* proceed with the implementation of the function *) (* possibility 2 *) let random_int bound = if not (bound > 0 && bound < 1 lsl 30) then invalid_arg "bound"; (* proceed with the implementation of the function *) (* possibility 3 *) let random_int bound = if not (bound > 0 && bound < 1 lsl 30) then failwith "bound"; (* proceed with the implementation of the function *)(* possibility 1 *) let random_int bound = assert (bound > 0 && bound < 1 lsl 30); (* proceed with the implementation of the function *) (* possibility 2 *) let random_int bound = if not (bound > 0 && bound < 1 lsl 30) then invalid_arg "bound"; (* proceed with the implementation of the function *) (* possibility 3 *) let random_int bound = if not (bound > 0 && bound < 1 lsl 30) then failwith "bound"; (* proceed with the implementation of the function *)
第二种可能对客户端来说信息量最大,因为它使用内置函数 invalid_arg
来引发命名良好的异常 Invalid_argument
。事实上,这正是该函数的标准库实现所做的。
第一种可能在调试自己的代码时最有用,而不是选择将失败的断言暴露给客户端。
第三种可能性与第二种可能性的区别仅在于引发的异常的名称 (Failure
)。当前置条件涉及不只一个无效参数时,它可能很有用。
在这个例子中,检查前置条件的计算成本很低。在其他情况下,它可能需要大量的计算,因此函数的实现者可能不喜欢检查前置条件,或者只检查与之近似的一些开销不大的东西。
有时,程序员会不必要地担心防御性编程的开销太大 —— 要么是在最初实现检查的时间上,要么是在检查断言时要付出的运行时成本上。这些担忧往往是多余的。社会为修复软件错误所花费的时间和金钱表明,我们都可以负担得起运行稍微慢一点的程序。
最后,实现者甚至可以选择消除前置条件,并将其重申为后置条件:
(** [random_int bound]是一个介于0(包括)和[bound](不包括)之间的随机整数。Raises: [Invalid_argument "bound"],除非[bound]大于0且小于2^30。*)(** [random_int bound]是一个介于0(包括)和[bound](不包括)之间的随机整数。 Raises: [Invalid_argument "bound"],除非[bound]大于0且小于2^30。*)(** [random_int bound]是一个介于0(包括)和[bound](不包括)之间的随机整数。 Raises: [Invalid_argument "bound"],除非[bound]大于0且小于2^30。*)
现在,当 bound
太大或太小时,random_int
不能随意执行任何操作,而是必须抛出异常。对于这个函数来说,这可能是最好的选择。
在本课程中,我们不会强制你进行防御性编程。但如果你够聪明,你还是会开始 (或继续) 做这件事。花在编写此类防范措施上的少量时间将为你节省数小时的调试时间,让你成为更高效的程序员。
注:本书是康奈尔大学 CS 3110 数据结构和函数式编程的教材。原书为英文版,在学习的过程中,根据自己的理解,翻译了一些,做一个记录,版权归原作者所有,如有侵权,请联系我删除。