13.2 编译 (Compilation)

有五个参数可以控制代码的编译方式: speed (速度)代表编译器产生代码的速度; compilation-speed (编译速度)代表程序被编译的速度; safety (安全) 代表要对目标代码进行错误检查的数量; space (空间)代表目标代码的大小和内存需求量;最后, debug (调试)代表为了调试而保留的信息量。

Note

交互与解释 (INTERACTIVE VS. INTERPRETED)

Lisp 是一种交互式语言 (Interactive Language),但是交互式的语言不必都是解释型的。早期的 Lisp 都通过解释器实现,因此认为 Lisp 的特质都依赖于它是被解释的想法就这么产生了。但这种想法是错误的:Common Lisp 既是编译型语言,又是解释型语言。

至少有两种 Common Lisp 实现甚至都不包含解释器。在这些实现中,输入到顶层的表达式在求值前会被编译。因此,把顶层叫做解释器的这种说法,不仅是落伍的,甚至还是错误的。

编译参数不是真正的变量。它们在声明中被分配从 0 (最不重要) 到 3 (最重要) 的权值。如果一个主要的瓶颈发生在某个函数的内层循环中,我们或许可以添加如下的声明:

  1. (defun bottleneck (...)
  2. (do (...)
  3. (...)
  4. (do (...)
  5. (...)
  6. (declare (optimize (speed 3) (safety 0)))
  7. ...)))

一般情况下,应该在代码写完并且经过完善测试之后,才考虑加上那么一句声明。

要让代码在任何情况下都尽可能地快,可以使用如下声明:

  1. (declaim (optimize (speed 3)
  2. (compilation-speed 0)
  3. (safety 0)
  4. (debug 0)))

考虑到前面提到的瓶颈规则 [1] ,这种苛刻的做法可能并没有什么必要。

另一类特别重要的优化就是由 Lisp 编译器完成的尾递归优化。当 speed (速度)的权值最大时,所有支持尾递归优化的编译器都将保证对代码进行这种优化。

如果在一个调用返回时调用者中没有残余的计算,该调用就被称为尾递归。下面的代码返回列表的长度:

  1. (defun length/r (lst)
  2. (if (null lst)
  3. 0
  4. (1+ (length/r (cdr lst)))))

这个递归调用不是尾递归,因为当它返回以后,它的值必须传给 1+ 。相反,这是一个尾递归的版本,

  1. (defun length/rt (lst)
  2. (labels ((len (lst acc)
  3. (if (null lst)
  4. acc
  5. (len (cdr lst) (1+ acc)))))
  6. (len lst 0)))

更准确地说,局部函数 len 是尾递归调用,因为当它返回时,调用函数已经没什么事情可做了。 和 length/r 不同的是,它不是在递归回溯的时候构建返回值,而是在递归调用的过程中积累返回值。 在函数的最后一次递归调用结束之后, acc 参数就可以作为函数的结果值被返回。

出色的编译器能够将一个尾递归编译成一个跳转 (goto),因此也能将一个尾递归函数编译成一个循环。在典型的机器语言代码中,当第一次执行到表示 len 的指令片段时,栈上会有信息指示在返回时要做些什么。由于在递归调用后没有残余的计算,该信息对第二层调用仍然有效:第二层调用返回后我们要做的仅仅就是从第一层调用返回。 因此,当进行第二层调用时,我们只需给参数设置新的值,然后跳转到函数的起始处继续执行就可以了,没有必要进行真正的函数调用。

另一个利用函数调用抽象,却没有开销的方法是使函数内联编译。对于那些调用开销比函数体的执行代价还高的小型函数来说,这种技术非常有价值。例如,以下代码用于判断列表是否仅有一个元素:

  1. (declaim (inline single?))
  2. (defun single? (lst)
  3. (and (consp lst) (null (cdr lst))))

因为这个函数是在全局被声明为内联的,引用了 single? 的函数在编译后将不需要真正的函数调用。 [2] 如果我们定义一个调用它的函数,

  1. (defun foo (x)
  2. (single? (bar x)))

foo 被编译后, single? 函数体中的代码将会被编译进 foo 的函数体,就好像我们直接写以下代码一样:

  1. (defun foo (x)
  2. (let ((lst (bar x)))
  3. (and (consp lst) (null (cdr lst)))))

内联编译有两个限制: 首先,递归函数不能内联。 其次,如果一个内联函数被重新定义,我们就必须重新编译调用它的任何函数,否则调用仍然使用原来的定义。

在一些早期的 Lisp 方言中,有时候会使用宏( 10.2 节)来避免函数调用。这种做法在 Common Lisp 中通常是没有必要的。

不同 Lisp 编译器的优化方式千差万别。 如果你想了解你的编译器为某个函数生成的代码,试着调用 disassemble 函数:它接受一个函数或者函数名,并显示该函数编译后的形式。 即便你看到的东西是完全无法理解的,你仍然可以使用 disassemble 来判断声明是否起效果:编译函数的两个版本,一个使用优化声明,另一个不使用优化声明,然后观察由 disassemble 显示的两组代码之间是否有差异。 同样的技巧也可以用于检验函数是否被内联编译。 不论情况如何,都请优先考虑使用编译参数,而不是手动调优的方式来优化代码。