当什么事都没发生时 (When Noting Happens)

不是所有的 bug 都会打断求值过程。另一个常见并可能更危险的情况是,当 Lisp 好像不鸟你一样。通常这是程序进入无穷循环的徵兆。

如果你怀疑你进入了无穷循环,解决方法是中止执行,并跳出中断循环。

如果循环是用迭代写成的代码,Lisp 会开心地执行到天荒地老。但若是用递归写成的代码(没有做尾递归优化),你最终会获得一个信息,信息说 Lisp 把栈的空间给用光了:

  1. > (defun blow-stack () (1+ (blow-stack)))
  2. BLOW-STACK
  3. > (blow-stack)
  4. Error: Stack Overflow

在这两个情况里,如果你怀疑进入了无穷循环,解决办法是中断执行,并跳出由于中断所产生的中断循环。

有时候程序在处理一个非常庞大的问题时,就算没有进入无穷循环,也会把栈的空间用光。虽然这很少见。通常把栈空间用光是编程错误的徵兆。

递归函数最常见的错误是忘记了基本用例 (base case)。用英语来描述递归,通常会忽略基本用例。不严谨地说,我们可能说“obj 是列表的成员,如果它是列表的第一个元素,或是剩余列表的成员” 严格上来讲,应该添加一句“若列表为空,则 obj 不是列表的成员”。不然我们描述的就是个无穷递归了。

在 Common Lisp 里,如果给入 nil 作为参数, carcdr 皆返回 nil

  1. > (car nil)
  2. NIL
  3. > (cdr nil)
  4. NIL

所以若我们在 member 函数里忽略了基本用例:

  1. (defun our-member (obj lst)
  2. (if (eql (car lst) obj)
  3. lst
  4. (our-member obj (cdr lst))))

要是我们找的对象不在列表里的话,则会陷入无穷循环。当我们到达列表底端而无所获时,递归调用会等价于:

  1. (our-member obj nil)

在正确的定义中(第十六页「译注: 2.7 节」),基本用例在此时会停止递归,并返回 nil 。但在上面错误的定义里,函数愚昧地寻找 nilcar ,是 nil ,并将 nil 拿去跟我们寻找的对象比较。除非我们要找的对象刚好是 nil ,不然函数会继续在 nilcdr 里寻找,刚好也是 nil ── 整个过程又重来了。

如果一个无穷循环的起因不是那么直观,可能可以通过看看追踪或回溯来诊断出来。无穷循环有两种。简单发现的那种是依赖程序结构的那种。一个追踪或回溯会即刻演示出,我们的 our-member 究竟哪里出错了。

比较难发现的那种,是因为数据结构有缺陷才发生的无穷循环。如果你无意中创建了环状结构(见 199页「12.3 节」,遍历结构的代码可能会掉入无穷循环里。这些 bug 很难发现,因为不在后面不会发生,看起来像没有错误的代码一样。最佳的解决办法是预防,如同 199 页所描述的:避免使用破坏性操作,直到程序已经正常工作,且你已准备好要调优代码来获得效率。

如果 Lisp 有不鸟你的倾向,也有可能是等待你完成输入什么。在多数系统里,按下回车是没有效果的,直到你输入了一个完整的表达式。这个方法的好事是它允许你输入多行的表达式。坏事是如果你无意中少了一个闭括号,或是一个闭引号,Lisp 会一直等你,直到你真正完成输入完整的表达式:

  1. > (format t "for example ~A~% 'this)

这里我们在控制字符串的最后忽略了闭引号。在此时按下回车是没用的,因为 Lisp 认为我们还在输入一个字符串。

在某些实现里,你可以回到上一行,并插入闭引号。在不允许你回到前行的系统,最佳办法通常是中断执行,并从中断循环回到顶层。