调试

调试是当一切都失败时的最后手段。让我们退一步,考虑一下在调试之前发生的一切。

预防BUGs

根据罗布·米勒(Rob Miller)的说法,有四种防御措施可以防止错误

  • 对Bugs的第一道防线是让它们无法生存

    选择使用保证内存安全(即除了通过有效的指针(或引用)访问内存区域外,不能访问内存的任何部分)和类型安全(即不能以与其类型不一致的方式使用值)的编程语言,可以消除整类错误。例如,OCaml 类型系统可以防止程序发生缓冲区溢出和无意义操作(如将布尔值加到浮点数上),而 C 类型系统则不能。

  • 第二道预防Bugs的方法是使用能够发现它们的工具。

    有自动化源代码分析工具,如 FindBugs,可以在 Java 程序中找到许多常见类型的错误,以及用于在设备驱动程序中查找错误的 SLAM。计算机科学的一个子领域被称为形式方法,研究如何使用数学来指定和验证程序,即如何证明程序没有错误。我们将在本课程中稍后学习验证。

    社交方法,如代码审查和配对编程,也是发现错误的有用工具。IBM 在 1970 年代至 1990 年代的研究表明,代码审查可以非常有效。在一项研究中(Jones,1991),代码检查发现了 65%的已知编码错误和 25%的已知文档错误,而测试仅发现了 20%的编码错误和没有文档错误。

  • 第三个防御措施是使错误立即可见。

    出现的错误越早,诊断和修复就越容易。如果计算继续进行到错误点之后,那么进一步的计算可能会掩盖故障实际发生的位置。源代码中的断言使程序“快速失败”和“大声失败”,因此错误会立即出现,程序员会准确知道在源代码中应该查找的位置。

  • 第四个防御虫害的方法是进行广泛的测试。

    如何知道一段代码是否存在特定的错误?编写能够暴露错误的测试,然后确认您的代码不会在这些测试中失败。针对相对较小的代码片段,比如单个函数或模块,编写单元测试尤为重要,最好在开发代码的同时进行。这些测试的运行应该是自动化的,这样如果您不小心破坏了代码,您能尽快发现。(这实际上又是防御 3。)

在所有这些防御都失败之后,程序员被迫诉诸调试。

如何调试

那么你发现了一个漏洞。接下来呢?

  • 将错误提炼成一个小测试案例。调试是一项艰难的工作,但测试案例越小,你就越有可能将注意力集中在错误潜藏的代码部分上。因此,花在这种提炼上的时间可以节省时间,因为你不必重新阅读大量代码。在拥有一个小测试案例之前不要继续调试!

  • 运用科学方法。制定一个关于为什么出现错误的假设。你甚至可以把这个假设写在笔记本上,就好像你在化学实验室里一样,以便在自己的头脑中澄清它并跟踪你已经考虑过的假设。接下来,设计一个实验来证实或否定这个假设。运行你的实验并记录结果。根据你所学到的知识,重新制定你的假设。继续进行,直到你以理性、科学的方式确定了错误的原因。

  • 修复错误。修复可能只是简单地更正一个拼写错误。或者它可能揭示了一个导致您需要进行重大更改的设计缺陷。考虑是否需要将修复应用到代码库中的其他位置——例如,这是一个复制粘贴错误吗?如果是,您是否需要重构您的代码?

  • 将这个小测试用例永久地添加到您的测试套件中。您不希望错误再次潜入您的代码库。因此,通过将其作为单元测试的一部分来跟踪这个小测试用例。这样,每当您进行未来更改时,您将自动防范相同的错误。反复运行从先前错误中提炼出来的测试是回归测试的一部分。

OCaml 中的调试

以下是关于如何在 OCaml 中调试的一些建议,如果你被迫这样做的话。

  • 打印语句。插入一个打印语句来确定变量的值。假设您想知道以下函数中 x 的值是多少:

    let inc x = 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;;

如果您评估 fib 2 ,您现在将看到以下输出:

fib <-- 2
fib <-- 0
fib --> 1
fib <-- 1
fib --> 1
fib --> 2

要停止跟踪,请使用 #untrace 指令。

  • 调试器。OCaml 有一个调试工具 ocamldebug 。您可以在 OCaml 网站上找到教程。除非您将 Emacs 作为编辑器,否则您可能会发现这个工具比仅插入打印语句更难使用。

防御性编程

正如我们在调试部分讨论过的那样,防止错误的一种方法是使任何错误立即可见。这个想法与前提条件的想法相联系。

考虑这个 random_int 的规范:

(** [random_int bound] is a random integer between 0 (inclusive)
    and [bound] (exclusive).  Requires: [bound] is greater than 0
    and less than 2^30. *)

如果 random_int 的客户端传递了违反“Requires”条款的值,比如 -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 *)

第二种可能性可能对客户最具信息性,因为它使用内置函数 invalid_arg 来引发良好命名的异常 Invalid_argument 。事实上,这正是该函数的标准库实现所做的。

第一种可能性可能在您尝试调试自己的代码时最有用,而不是选择将失败的断言暴露给客户。

第三种可能性与第二种可能性仅在引发的异常名称( Failure )上有所不同。在前提涉及多个无效参数的情况下,这可能是有用的。

在这个例子中,检查前置条件在计算上是廉价的。在其他情况下,可能需要大量计算,因此函数的实现者可能更喜欢不检查前置条件,或者只检查一些廉价的近似值。

有时程序员会不必要地担心防御性编程会太昂贵——无论是在实施初始检查时花费的时间,还是在运行时检查断言时支付的成本。这些担忧往往是不合时宜的。社会为修复软件中的故障所花费的时间和金钱表明,我们都能负担得起让程序运行得慢一点的程序。

最后,实施者甚至可以选择消除前置条件,并将其重述为后置条件:

(** [random_int bound] is a random integer between 0 (inclusive)
    and [bound] (exclusive).  Raises: [Invalid_argument "bound"]
    unless [bound] is greater than 0 and less than 2^30. *)

现在,当 bound 太大或太小时,不能自由地做任何事情, random_int 必须引发异常。对于这个函数来说,这可能是最好的选择。

在这门课程中,我们不会强迫你进行防御性编程。但如果你很精明,你会开始(或继续)这样做。花费少量时间编写这些防御代码将为你节省大量调试时间,使你成为更高效的程序员。