13.3 类型声明 (Type Declarations)

如果 Lisp 不是你所学的第一门编程语言,那么你也许会感到困惑,为什么这本书还没说到类型声明这件事来?毕竟,在很多流行的编程语言中,类型声明是必须要做的。

在不少编程语言里,你必须为每个变量声明类型,并且变量也只可以持有与该类型相一致的值。 这种语言被称为强类型(strongly typed) 语言。 除了给程序员们徒增了许多负担外,这种方式还限制了你能做的事情。 使用这种语言,很难写出那些需要多种类型的参数一起工作的函数,也很难定义出可以包含不同种类元素的数据结构。 当然,这种方式也有它的优势,比如无论何时当编译器碰到一个加法运算,它都能够事先知道这是一个什么类型的加法运算。如果两个参数都是整数类型,编译器可以直接在目标代码中生成一个固定 (hard-wire) 的整数加法运算。

正如 2.15 节所讲,Common Lisp 使用一种更加灵活的方式:显式类型 (manifest typing) [3] 。有类型的是值而不是变量。变量可以用于任何类型的对象。

当然,这种灵活性需要付出一定的速度作为代价。 由于 + 可以接受好几种不同类型的数,它不得不在运行时查看每个参数的类型来决定采用哪种加法运算。

在某些时候,如果我们要执行的全都是整数的加法,那么每次查看参数类型的这种做法就说不上高效了。 Common Lisp 处理这种问题的方法是:让程序员尽可能地提示编译器。 比如说,如果我们提前就能知道某个加法运算的两个参数是定长数 (fixnums) ,那么就可以对此进行声明,这样编译器就会像 C 语言的那样为我们生成一个固定的整数加法运算。

因为显式类型也可以通过声明类型来生成高效的代码,所以强类型和显式类型两种方式之间的差别并不在于运行速度。 真正的区别是,在强类型语言中,类型声明是强制性的,而显式类型则不强加这样的要求。 在 Common Lisp 中,类型声明完全是可选的。它们可以让程序运行的更快,但(除非错误)不会改变程序的行为。

全局声明以 declaim 伴随一个或多个声明的形式来实现。一个类型声明是一个列表,包含了符号 type ,后跟一个类型名,以及一个或多个变量组成。举个例子,要为一个全局变量声明类型,可以这么写:

  1. (declaim (type fixnum *count*))

在 ANSI Common Lisp 中,可以省略 type 符号,将声明简写为:

  1. (declaim (fixnum *count*))

局部声明通过 declare 完成,它接受的参数和 declaim 的一样。 声明可以放在那些创建变量的代码体之前:如 defunlambdaletdo ,诸如此类。 比如说,要把一个函数的参数声明为定长数,可以这么写:

  1. (defun poly (a b x)
  2. (declare (fixnum a b x))
  3. (+ (* a (expt x 2)) (* b x)))

在类型声明中的变量名指的就是该声明所在的上下文中的那个变量 ── 那个通过赋值可以改变它的值的变量。

你也可以通过 the 为某个表达式的值声明类型。 如果我们提前就知道 abx 是足够小的定长数,并且它们的和也是定长数的话,那么可以进行以下声明:

  1. (defun poly (a b x)
  2. (declare (fixnum a b x))
  3. (the fixnum (+ (the fixnum (* a (the fixnum (expt x 2))))
  4. (the fixnum (* b x)))))

看起来是不是很笨拙啊?幸运的是有两个原因让你很少会这样使用 the 把你的数值运算代码变得散乱不堪。其一是很容易通过宏,来帮你插入这些声明。其二是某些实现使用了特殊的技巧,即便没有类型声明的定长数运算也能足够快。

Common Lisp 中有相当多的类型 ── 恐怕有无数种类型那么多,如果考虑到你可以自己定义新的类型的话。 类型声明只在少数情况下至关重要,可以遵照以下两条规则来进行:

  1. 当函数可以接受若干不同类型的参数(但不是所有类型)时,可以对参数的类型进行声明。如果你知道一个对 + 的调用总是接受定长数类型的参数,或者一个对 aref 的调用第一个参数总是某种特定种类的数组,那么进行类型声明是值得的。
  2. 通常来说,只有对类型层级中接近底层的类型进行声明,才是值得的:将某个东西的类型声明为 fixnum 或者 simple-array 也许有用,但将某个东西的类型声明为 integer 或者 sequence 或许就没用了。

类型声明对内容复杂的对象特别重要,这包括数组、结构和对象实例。这些声明可以在两个方面提升效率:除了可以让编译器来决定函数参数的类型以外,它们也使得这些对象可以在内存中更高效地表示。

如果对数组元素的类型一无所知的话,这些元素在内存中就不得不用一块指针来表示。但假如预先就知道数组包含的元素仅仅是 ── 比方说 ── 双精度浮点数 (double-floats),那么这个数组就可以用一组实际的双精度浮点数来表示。这样数组将占用更少的空间,因为我们不再需要额外的指针指向每一个双精度浮点数;同时,对数组元素的访问也将更快,因为我们不必沿着指针去读取和写元素。

../_images/Figure-13.1.png

图 13.1:指定元素类型的效果

你可以通过 make-array:element-type 参数指定数组包含值的种类。这样的数组被称为特化数组(specialized array)。 图 13.1 为我们展示了如下代码在多数实现上求值后发生的事情:

  1. (setf x (vector 1.234d0 2.345d0 3.456d0)
  2. y (make-array 3 :element-type 'double-float)
  3. (aref y 0) 1.234d0
  4. (aref y 1) 2.345d0
  5. (aref y 2)3.456d0))

图 13.1 中的每一个矩形方格代表内存中的一个字 (a word of memory)。这两个数组都由未特别指明长度的头部 (header) 以及后续 三个元素的某种表示构成。对于 x 来说,每个元素都由一个指针表示。此时每个指针碰巧都指向双精度浮点数,但实际上我们可以存储任何类型的对象到这个向量中。对 y 来说,每个元素实际上都是双精度浮点数。 y 更快而且占用更少空间,但意味着它的元素只能是双精度浮点数。

注意我们使用 aref 来引用 y 的元素。一个特化的向量不再是一个简单向量,因此我们不再能够通过 svref 来引用它的元素。

除了在创建数组时指定元素的类型,你还应该在使用数组的代码中声明数组的维度以及它的元素类型。一个完整的向量声明如下:

  1. (declare (type (vector fixnum 20) v))

以上代码声明了一个仅含有定长数,并且长度固定为 20 的向量。

  1. (setf a (make-array '(1000 1000)
  2. :element-type 'single-float
  3. :initial-element 1.0s0))
  4. (defun sum-elts (a)
  5. (declare (type (simple-array single-float (1000 1000))
  6. a))
  7. (let ((sum 0.0s0))
  8. (declare (type single-float sum))
  9. (dotimes (r 1000)
  10. (dotimes (c 1000)
  11. (incf sum (aref a r c))))
  12. sum))

图 13.2 对数组元素求和

最为通用的数组声明形式由数组类型以及紧接其后的元素类型和一个维度列表构成:

  1. (declare (type (simple-array fixnum (4 4)) ar))

图 13.2 展示了如何创建一个 1000×1000 的单精度浮点数数组,以及如何编写一个将该数组元素相加的函数。数组以行主序 (row-major order)存储,遍历时也应尽可能按此顺序进行。

我们将用 time 来比较 sum-elts 在有声明和无声明两种情况下的性能。 time 宏显示表达式求值所花费时间的某种度量(取决于实现)。对被编译的函数求取时间才是有意义的。在某个实现中,如果我们以获取最快速代码的编译参数编译 sum-elts ,它将在不到半秒的时间内返回:

  1. > (time (sum-elts a))
  2. User Run Time = 0.43 seconds
  3. 1000000.0

如果我们把 sum-elts 中的类型声明去掉并重新编译它,同样的计算将花费超过5秒的时间:

  1. > (time (sum-elts a))
  2. User Run Time = 5.17 seconds
  3. 1000000.0

类型声明的重要性 ── 特别是对数组和数来说 ── 怎么强调都不过分。上面的例子中,仅仅两行代码就可以让 sum-elts 变快 12 倍。