第十四章:进阶议题

优质
小牛编辑
124浏览
2023-12-01

本章是选择性阅读的。本章描述了 Common Lisp 里一些更深奥的特性。Common Lisp 像是一个冰山:大部分的功能对于那些永远不需要他们的多数用户是看不见的。你或许永远不需要自己定义包 (Package)或读取宏 (read-macros),但当你需要时,有些例子可以让你参考是很有用的。

14.1 类型标识符 (Type Specifiers)

类型在 Common Lisp 里不是对象。举例来说,没有对象对应到 integer 这个类型。我们像是从 type-of 函数里所获得的,以及作为传给像是 typep 函数的参数,不是一个类型,而是一个类型标识符 (type specifier)。

一个类型标识符是一个类型的名称。最简单的类型标识符是像是 integer 的符号。这些符号形成了 Common Lisp 里的类型层级。在层级的最顶端是类型 t ── 所有的对象皆为类型 t 。而类型层级不是一棵树。从 nil 至顶端有两条路,举例来说:一条从 atom ,另一条从 listsequence

一个类型实际上只是一个对象集合。这意味着有多少类型就有多少个对象的集合:一个无穷大的数目。我们可以用原子的类型标识符 (atomic type specifiers)来表示某些集合:比如 integer 表示所有整数集合。但我们也可以建构一个复合类型标识符 (compound type specifiers)来参照到任何对象的集合。

举例来说,如果 ab 是两个类型标识符,则 (or a b) 表示分别由 ab 类型所表示的联集 (union)。也就是说,一个类型 (or a b) 的对象是类型 a 或 类型 b

如果 circular? 是一个对于 cdr 为环状的列表返回真的函数,则你可以使用适当的序列集合来表示: [1]

(or vector (and list (not (satisfies circular?))))

某些原子的类型标识符也可以出现在复合类型标识符。要表示介于 1 至 100 的整数(包含),我们可以用:

(integer 1 100)

这样的类型标识符用来表示一个有限的类型 (finite type)。

在一个复合类型标识符里,你可以通过在一个参数的位置使用 * 来留下某些未指定的信息。所以

(simple-array fixnum (* *))

描述了指定给 fixnum 使用的二维简单数组 (simple array)集合,而

(simple-array fixnum *)

描述了指定给 finxnum 使用的简单数组集合 (前者的超类型 「supertype」)。尾随的星号可以省略,所以上个例子可以写为:

(simple-array fixnum)

若一个复合类型标识符没有传入参数,你可以使用一个原子。所以 simple-array 描述了所有简单数组的集合。

如果有某些复合类型标识符你想重复使用,你可以使用 deftype 定义一个缩写。这个宏与 defmacro 相似,但会展开成一个类型标识符,而不是一个表达式。通过表达

(deftype proseq ()
        '(or vector (and list (not (satisfies circular?)))))

我们定义了 proseq 作为一个新的原子类型标识符:

> (typep #(1 2) 'proseq)
T

如果你定义一个接受参数的类型标识符,参数会被视为 Lisp 形式(即没有被求值),与 defmacro 一样。所以

(deftype multiple-of (n)
  `(and integer (satisfies (lambda (x)
                             (zerop (mod x ,n))))))

(译注: 注意上面代码是使用反引号 ` )

定义了 (multiple-of n) 当成所有 n 的倍数的标识符:

> (type 12 '(multiple-of 4))
T

类型标识符会被直译 (interpreted),因此很慢,所以通常你最好定义一个函数来处理这类的测试。

14.2 二进制流 (Binary Streams)

第 7 章曾提及的流有二进制流 (binary streams)以及字符流 (character streams)。一个二进制流是一个整数的来源及/或终点,而不是字符。你通过指定一个整数的子类型来创建一个二进制流 ── 当你打开流时,通常是用 unsigned-byte ── 来作为 :element-type 的参数。

关于二进制流的 I/O 函数仅有两个, read-byte 以及 write-byte 。所以下面是如何定义复制一个文件的函数:

(defun copy-file (from to)
  (with-open-file (in from :direction :input
                           :element-type 'unsigned-byte)
    (with-open-file (out to :direction :output
                            :element-type 'unsigned-byte)
      (do ((i (read-byte in nil -1)
              (read-byte in nil -1)))
          ((minusp i))
        (declare (fixnum i))
        (write-byte i out)))))

仅通过指定 unsigned-byte:element-type ,你让操作系统选择一个字节 (byte)的长度。举例来说,如果你明确地想要读写 7 比特的整数,你可以使用:

(unsigned-byte 7)

来传给 :element-type

14.3 读取宏 (Read-Macros)

7.5 节介绍过宏字符 (macro character)的概念,一个对于 read 有特别意义的字符。每一个这样的字符,都有一个相关联的函数,这函数告诉 read 当遇到这个字符时该怎么处理。你可以变更某个已存在宏字符所相关联的函数,或是自己定义新的宏字符。

函数 set-macro-character 提供了一种方式来定义读取宏 (read-macros)。它接受一个字符及一个函数,因此当 read 碰到该字符时,它返回调用传入函数后的结果。

Lisp 中最古老的读取宏之一是 ' ,即 quote 。我们可以定义成:

(set-macro-character #\'
        #'(lambda (stream char)
                (list (quote quote) (read stream t nil t))))

read 在一个普通的语境下遇到 ' 时,它会返回在当前流和字符上调用这个函数的结果。(这个函数忽略了第二个参数,第二个参数永远是引用字符。)所以当 read 看到 'a 时,会返回 (quote a)

译注: read 函数接受的参数 (read &optional stream eof-error eof-value recursive)

现在我们明白了 read 最后一个参数的用途。它表示无论 read 调用是否在另一个 read 里。传给 read 的参数在几乎所有的读取宏里皆相同:传入参数有流 (stream);接着是第二个参数, t ,说明了 read 若读入的东西是 end-of-file 时,应不应该报错;第三个参数说明了不报错时要返回什么,因此在这里也就不重要了;而第四个参数 t 说明了这个 read 调用是递归的。

(译注:困惑的话可以看看 read 的定义 )

你可以(通过使用 make-dispatch-macro-character )来定义你自己的派发宏字符(dispatching macro character),但由于 # 已经是一个宏字符,所以你也可以直接使用。六个 # 打头的组合特别保留给你使用: #!#?##[##]#{#}

你可以通过调用 set-dispatch-macro-character 定义新的派发宏字符组合,与 set-macro-character 类似,除了它接受两个字符参数外。下面的代码定义了 #? 作为返回一个整数列表的读取宏。

(set-dispatch-macro-character #\# #\?
  #'(lambda (stream char1 char2)
      (list 'quote
            (let ((lst nil))
              (dotimes (i (+ (read stream t nil t) 1))
                (push i lst))
              (nreverse lst)))))

现在 #?n 会被读取成一个含有整数 0n 的列表。举例来说:

> #?7
(1 2 3 4 5 6 7)

除了简单的宏字符,最常定义的宏字符是列表分隔符 (list delimiters)。另一个保留给用户的字符组是 #{ 。以下我们定义了一种更复杂的左括号:

(set-macro-character #\} (get-macro-character #\)))

(set-dispatch-macro-character #\# #\{
  #'(lambda (stream char1 char2)
      (let ((accum nil)
            (pair (read-delimited-list #\} stream t)))
        (do ((i (car pair) (+ i 1)))
            ((> i (cadr pair))
             (list 'quote (nreverse accum)))
          (push i accum)))))

这定义了一个这样形式 #{x y} 的表达式,使得这样的表达式被读取为所有介于 xy 之间的整数列表,包含 xy

> #{2 7}
(2 3 4 4 5 6 7)

函数 read-delimited-list 正是为了这样的读取宏而生的。它的第一个参数是被视为列表结束的字符。为了使 } 被识别为分隔符,必须先给它这个角色,所以程序在开始的地方调用了 set-macro-character

如果你想要在定义一个读取宏的文件里使用该读取宏,则读取宏的定义应要包在一个 eval-when 表达式里,来确保它在编译期会被求值。不然它的定义会被编译,但不会被求值,直到编译文件被载入时才会被求值。

14.4 包 (Packages)

一个包是一个将名字映对到符号的 Lisp 对象。当前的包总是存在全局变量 *package* 里。当 Common Lisp 启动时,当前的包会是 *common-lisp-user* ,通常称为用户包 (user package)。函数 package-name 返回包的名字,而 find-package 返回一个给定名称的包:

> (package-name *package*)
"COMMON-LISP-USER"
> (find-package "COMMON-LISP-USER")
#<Package "COMMON-LISP-USER" 4CD15E>

通常一个符号在读入时就被 interned 至当前的包里面了。函数 symbol-package 接受一个符号并返回该符号被 interned 的包。

(symbol-package 'sym)
#<Package "COMMON-LISP-USER" 4CD15E>

有趣的是,这个表达式返回它该返回的值,因为表达式在可以被求值前必须先被读入,而读取这个表达式导致 sym 被 interned。为了之后的用途,让我们给 sym 一个值:

> (setf sym 99)
99

现在我们可以创建及切换至一个新的包:

> (setf *package* (make-package 'mine
                                :use '(common-lisp)))
#<Package "MINE" 63390E>

现在应该会听到诡异的背景音乐,因为我们来到一个不一样的世界了: 在这里 sym 不再是本来的 sym 了。

MINE> sym
Error: SYM has no value

为什么会这样?因为上面我们设为 99 的 symmine 里的 sym 是两个不同的符号。 [2] 要在用户包之外参照到原来的 sym ,我们必须把包的名字加上两个冒号作为前缀:

MINE> common-lisp-user::sym
99

所以有着相同打印名称的不同符号能够在不同的包内共存。可以有一个 symcommon-lisp-user 包,而另一个 symmine 包,而他们会是不一样的符号。这就是包存在的意义。如果你在分开的包内写你的程序,你大可放心选择函数与变量的名字,而不用担心某人使用了同样的名字。即便是他们使用了同样的名字,也不会是相同的符号。

包也提供了信息隐藏的手段。程序应通过函数与变量的名字来参照它们。如果你不让一个名字在你的包之外可见的话,那么另一个包中的代码就无法使用或者修改这个名字所参照的对象。

通常使用两个冒号作为包的前缀也是很差的风格。这么做你就违反了包本应提供的模块性。如果你不得不使用一个双冒号来参照到一个符号,这是因为某人根本不想让你用。

通常我们应该只参照被输出 ( exported )的符号。如果我们回到用户包里,并输出一个被 interned 的符号,

MINE> (in-package common-lisp-user)
#<Package "COMMON-LISP-USER" 4CD15E>
> (export 'bar)
T
> (setf bar 5)
5

我们使这个符号对于其它的包是可视的。现在当我们回到 mine ,我们可以仅使用单冒号来参照到 bar ,因为他是一个公开可用的名字:

> (in-package mine)
#<Package "MINE" 63390E>
MINE> common-lisp-user:bar
5

通过把 bar 输入 ( import )至 mine 包,我们就能进一步让 mineuser 包可以共享 bar 这个符号:

MINE> (import 'common-lisp-user:bar)
T
MINE> bar
5

在输入 bar 之后,我们根本不需要用任何包的限定符 (package qualifier),就能参照它了。这两个包现在共享了同样的符号;不可能会有一个独立的 mine:bar 了。

要是已经有一个了怎么办?在这种情况下, import 调用会产生一个错误,如下面我们试着输入 sym 时便知:

MINE> (import 'common-lisp-user::sym)
Error: SYM is already present in MINE.

在此之前,当我们试着在 mine 包里对 sym 进行了一次不成功的求值,我们使 sym 被 interned 至 mine 包里。而因为它没有值,所以产生了一个错误,但输入符号名的后果就是使这个符号被 intern 进这个包。所以现在当我们试着输入 symmine 包里,已经有一个相同名称的符号了。

另一个方法来获得别的包内符号的存取权是使用( use )它:

MINE> (use-package 'common-lisp-user)
T

现在所有由用户包 (译注: common-lisp-user 包)所输出的符号,可以不需要使用任何限定符在 mine 包里使用。(如果 sym 已经被用户包输出了,这个调用也会产生一个错误。)

含有自带操作符及变量名字的包叫做 common-lisp 。由于我们将这个包的名字在创建 mine 包时作为 make-package:use 参数,所有的 Common Lisp 自带的名字在 mine 里都是可视的:

MINE> #'cons
#<Compiled-Function CONS 462A3E>

在编译后的代码中, 通常不会像这样在顶层进行包的操作。更常见的是包的调用会包含在源文件里。通常,只要把 in-packagedefpackage 放在源文件的开头就可以了,正如 137 页所示。

这种由包所提供的模块性实际上有点奇怪。我们不是对象的模块 (modules),而是名字的模块。

每一个使用了 common-lisp 的包,都可以存取 cons ,因为 common-lisp 包里有一个叫这个名字的函数。但这会导致一个名字为 cons 的变量也会在每个使用了 common-lisp 包里是可视的。如果包使你困惑,这就是主要的原因;因为包不是基于对象而是基于名字。

14.5 Loop 宏 (The Loop Facility)

loop 宏最初是设计来帮助无经验的 Lisp 用户来写出迭代的代码。与其撰写 Lisp 代码,你用一种更接近英语的形式来表达你的程序,然后这个形式被翻译成 Lisp。不幸的是, loop 比原先设计者预期的更接近英语:你可以在简单的情况下使用它,而不需了解它是如何工作的,但想在抽象层面上理解它几乎是不可能的。

如果你是曾经计划某天要理解 loop 怎么工作的许多 Lisp 程序员之一,有一些好消息与坏消息。好消息是你并不孤单:几乎没有人理解它。坏消息是你永远不会理解它,因为 ANSI 标准实际上并没有给出它行为的正式规范。

这个宏唯一的实际定义是它的实现方式,而唯一可以理解它(如果有人可以理解的话)的方法是通过实例。ANSI 标准讨论 loop 的章节大部分由例子组成,而我们将会使用同样的方式来介绍相关的基础概念。

第一个关于 loop 宏我们要注意到的是语法 ( syntax )。一个 loop 表达式不是包含子表达式而是子句 (clauses)。這些子句不是由括号分隔出来;而是每种都有一个不同的语法。在这个方面上, loop 与传统的 Algol-like 语言相似。但其它 loop 独特的特性,使得它与 Algol 不同,也就是在 loop 宏里调换子句的顺序与会发生的事情没有太大的关联。

一个 loop 表达式的求值分为三个阶段,而一个给定的子句可以替多于一个的阶段贡献代码。这些阶段如下:

  1. 序幕 (Prologue)。 被求值一次来做为迭代过程的序幕。包括了将变量设至它们的初始值。
  2. 主体 (Body) 每一次迭代时都会被求值。
  3. 闭幕 (Epilogue) 当迭代结束时被求值。决定了 loop 表达式的返回值(可能返回多个值)。

我们会看几个 loop 子句的例子,并考虑何种代码会贡献至何个阶段。

举例来说,最简单的 loop 表达式,我们可能会看到像是下列的代码:

> (loop for x from 0 to 9
        do (princ x))
0123456789
NIL

这个 loop 表达式印出从 09 的整数,并返回 nil 。第一个子句,

for x from 0 to 9

贡献代码至前两个阶段,导致 x 在序幕中被设为 0 ,在主体开头与 9 来做比较,在主体结尾被递增。第二个子句,

do (princ x)

贡献代码给主体。

一个更通用的 for 子句说明了起始与更新的形式 (initial and update form)。停止迭代可以被像是 whileuntil 子句来控制。

> (loop for x = 8 then (/ x 2)
        until (< x 1)
        do (princ x))
8421
NIL

你可以使用 and 来创建复合的 for 子句,同时初始及更新两个变量:

> (loop for x from 1 to 4
        and y from 1 to 4
        do (princ (list x y)))
(1 1)(2 2)(3 3)(4 4)
NIL

要不然有多重 for 子句时,变量会被循序更新。

另一件在迭代代码通常会做的事是累积某种值。举例来说:

> (loop for x in '(1 2 3 4)
        collect (1+ x))
(2 3 4 5)

for 子句使用 in 而不是 from ,导致变量被设为一个列表的后续元素,而不是连续的整数。

在这个情况里, collect 子句贡献代码至三个阶段。在序幕,一個匿名累加器 (anonymous accumulator)設為 nil ;在主体裡, (1+ x) 被累加至這個累加器,而在闭幕时返回累加器的值。

这是返回一个特定值的第一个例子。有用来明确指定返回值的子句,但没有这些子句时,一个 collect 子句决定了返回值。所以我们在这里所做的其实是重复了 mapcar

loop 最常见的用途大概是蒐集调用一个函数数次的结果:

> (loop for x from 1 to 5
        collect (random 10))
(3 8 6 5 0)

这里我们获得了一个含五个随机数的列表。这跟我们定义过的 map-int 情况类似 (105 页「译注: 6.4 小节。」)。如果我们有了 loop ,为什么还需要 map-int ?另一个人也可以说,如果我们有了 map-int ,为什么还需要 loop

一个 collect 子句也可以累积值到一个有名字的变量上。下面的函数接受一个数字的列表并返回偶数与奇数列表:

(defun even/odd (ns)
  (loop for n in ns
        if (evenp n)
           collect n into evens
           else collect n into odds
        finally (return (values evens odds))))

一个 finally 子句贡献代码至闭幕。在这个情况它指定了返回值。

一个 sum 子句和一个 collect 子句类似,但 sum 子句累积一个数字,而不是一个列表。要获得 1n 的和,我们可以写:

(defun sum (n)
  (loop for x from 1 to n
        sum x))

loop 更进一步的细节在附录 D 讨论,从 325 页开始。举个例子,图 14.1 包含了先前章节的两个迭代函数,而图 14.2 演示了将同样的函数翻译成 loop

(defun most (fn lst)
  (if (null lst)
      (values nil nil)
      (let* ((wins (car lst))
             (max (funcall fn wins)))
        (dolist (obj (cdr lst))
          (let ((score (funcall fn obj)))
            (when (> score max)
              (setf wins obj
                    max  score))))
        (values wins max))))

(defun num-year (n)
  (if (< n 0)
      (do* ((y (- yzero 1) (- y 1))
            (d (- (year-days y)) (- d (year-days y))))
           ((<= d n) (values y (- n d))))
      (do* ((y yzero (+ y 1))
            (prev 0 d)
            (d (year-days y) (+ d (year-days y))))
           ((> d n) (values y (- n prev))))))

图 14.1 不使用 loop 的迭代函数

(defun most (fn lst)
  (if (null lst)
      (values nil nil)
      (loop with wins = (car lst)
            with max = (funcall fn wins)
            for obj in (cdr lst)
            for score = (funcall fn obj)
            when (> score max)
                 (do (setf wins obj
                           max score)
            finally (return (values wins max))))))

(defun num-year (n)
  (if (< n 0)
      (loop for y downfrom (- yzero 1)
            until (<= d n)
            sum (- (year-days y)) into d
            finally (return (values (+ y 1) (- n d))))
      (loop with prev = 0
            for y from yzero
            until (> d n)
            do (setf prev d)
            sum (year-days y) into d
            finally (return (values (- y 1)
                                    (- n prev))))))

图 14.2 使用 loop 的迭代函数

一个 loop 的子句可以参照到由另一个子句所设置的变量。举例来说,在 even/odd 的定义里面, finally 子句参照到由两个 collect 子句所创建的变量。这些变量之间的关系,是 loop 定义最含糊不清的地方。考虑下列两个表达式:

(loop for y = 0 then z
      for x from 1 to 5
      sum 1 into z
      finally (return y z))

(loop for x from 1 to 5
      for y = 0 then z
      sum 1 into z
      finally (return y z))

它们看起来够简单 ── 每一个有四个子句。但它们返回同样的值吗?它们返回的值多少?你若试着在标准中想找答案将徒劳无功。每一个 loop 子句本身是够简单的。但它们组合起来的方式是极为复杂的 ── 而最终,甚至标准里也没有明确定义。

由于这类原因,使用 loop 是不推荐的。推荐 loop 的理由,你最多可以说,在像是图 14.2 这般经典的例子中, loop 让代码看起来更容易理解。

14.6 状况 (Conditions)

在 Common Lisp 里,状况 (condition)包括了错误以及其它可能在执行期发生的情况。当一个状况被捕捉时 (signalled),相应的处理程序 (handler)会被调用。处理错误状况的缺省处理程序通常会调用一个中断循环 (break-loop)。但 Common Lisp 提供了多样的操作符来捕捉及处理错误。要覆写缺省的处理程序,甚至是自己写一个新的处理程序也是有可能的。

多数的程序员不会直接处理状况。然而有许多更抽象的操作符使用了状况,而要了解这些操作符,知道背后的原理是很有用的。

Common lisp 有数个操作符用来捕捉错误。最基本的是 error 。一个调用它的方法是给入你会给 format 的相同参数:

> (error "Your report uses ~A as a verb." 'status)
Error: Your report uses STATUS as a verb
                         Options: :abort, :backtrace
>>

如上所示,除非这样的状况被处理好了,不然执行就会被打断。

用来捕捉错误的更抽象操作符包括了 ecasecheck-type 以及 assert 。前者与 case 相似,要是没有键值匹配时会捕捉一个错误:

> (ecase 1 (2 3) (4 5))
Error: No applicable clause
                         Options: :abort, :backtrace
>>

普通的 case 在没有键值匹配时会返回 nil ,但由于利用这个返回值是很差的编码风格,你或许会在当你没有 otherwise 子句时使用 ecase

check-type 宏接受一个位置,一个类型名以及一个选择性字符串,并在该位置的值不是预期的类型时,捕捉一个可修正的错误 (correctable error)。一个可修正错误的处理程序会给我们一个机会来提供一个新的值:

> (let ((x '(a b c)))
                (check-type (car x) integer "an integer")
                x)
Error: The value of (CAR X), A, should be an integer.
Options: :abort, :backtrace, :continue
>> :continue
New value of (CAR X)? 99
(99 B C)
>

在这个例子里, (car x) 被设为我们提供的新值,并重新执行,返回了要是 (car x) 本来就包含我们所提供的值所会返回的结果。

这个宏是用更通用的 assert 所定义的, assert 接受一个测试表达式以及一个有着一个或多个位置的列表,伴随着你可能传给 error 的参数:

> (let ((sandwich '(ham on rye)))
    (assert (eql (car sandwich) 'chicken)
            ((car sandwich))
            "I wanted a ~A sandwich." 'chicken)
    sandwich)
Error: I wanted a CHICKEN sandwich.
Options: :abort, :backtrace, :continue
>> :continue
New value of (CAR SANDWICH)? 'chicken
(CHICKEN ON RYE)

要建立新的处理程序也是可能的,但大多数程序员只会间接的利用这个可能性,通过使用像是 ignore-errors 的宏。如果它的参数没产生错误时像在 progn 里求值一样,但要是在求值过程中,不管什么参数报错,执行是不会被打断的。取而代之的是, ignore-errors 表达式会直接返回两个值: nil 以及捕捉到的状况。

举例来说,如果在某个时候,你想要用户能够输入一个表达式,但你不想要在输入是语法上不合时中断执行,你可以这样写:

(defun user-input (prompt)
  (format t prompt)
  (let ((str (read-line)))
    (or (ignore-errors (read-from-string str))
        nil)))

若输入包含语法错误时,这个函数仅返回 nil :

> (user-input "Please type an expression")
Please type an expression> #%@#+!!
NIL

脚注

[1]虽然标准没有提到这件事,你可以假定 and 以及 or 类型标示符仅考虑它们所要考虑的参数,与 orand 宏类似。
[2]某些 Common Lisp 实现,当我们不在用户包下时,会在顶层提示符前打印包的名字。