galeo's blog

In the morning after, you begin to see the light.

宏:标准控制构造

Lisp的宏系统使其始终保持在编程语言风格上的独特性。

所有的程序员应该都比较熟知这样一种观点,一种编程语言的定义可能会包含一个实现了“核心”语言功能的标准功能库——如果某个功能没有被定义在标准库中,那么其很可能已经被某个程序员在语言之上实现了。例如,C的标准库,差不多可以完全用可移植的C来实现。类似地,Java的标准开发工具包(JDK)中所提供的不断改进的类和接口的集合也是用“纯”Java编写的。

使用语言核心加一个标准库的方式来定义一种编程语言的优势在于可以使其易于理解和实现,但真正的好处在于其可表达性——因为你所认为的“语言”很大程度上其实只是一个库,很容易对其进行拓展。如果C语言中不包含你所需要做某件事情的一个函数,你可以写出这个函数,然后你就得到了一个特性稍微丰富点的C版本。类似地,在诸如Java或者Smalltalk这种几乎所有有趣部分都是用类来定义的语言当中,你可以通过定义新的类来拓展该语言,使其更加适合你正编写的程序来试图做任何事情。

Common Lisp支持所有这些扩展语言的方法,但 还提供了另一种方式。每个宏都定义了其自己的语法,它们能够决定那些被传递的S-表达式如何被转换成Lisp形式。宏作为部分语言核心,使其可以构造出新的语法,例如 WHENDOLISTLOOP 这样的控制构造以及 DEFUNDEFPARAMETER 这样的定义形式,这些新语法可以作为“标准库”的一部分而不必将其硬编码到语言核心。这已经牵涉到语言本身是如何实现的问题了,作为一名Lisp程序员,你需要更多地注意其所提供的宏这种语言扩展方式,这使得其成为更好的用于表达特定编程问题解决方案的语言。1

我们从Common Lisp所定义的几种标准控制构造宏开始Lisp宏的讨论。

WHEN & UNLESS

最基本的条件执行形式通过 IF 特殊操作符来完成:

(if condition then-form [else-form])

Some examples:

(if (> 2 3) "Yup" "Nope") ; --> "Nope"
(if (> 2 3) "Yup") ; --> NIL
(if (> 3 2) "Yup" "Nope") ; --> "Yup"

IF 中每个 then-formelse-form 都被限制为必须是单一的Lisp形式。通过特殊操作符 PROGN 可以按顺序执行任意数量的形式并返回最后一个形式的值,e.g.:

(if (spam-p current-message)
    (progn
      (file-in-spam-folder current-message)
      (update-spam-database current-message)))

为了将这种由IF加上PROGN所组成的模式进行抽象来隐藏掉细节,Common Lisp提供了一个标准宏 WHEN ,可以让你这样写:

(when (spam-p current-message)
  (file-in-spam-folder current-message)
  (update-spam-database current-message))

你可以像这样用一个宏来定义自己的WHEN:

(defmacro my-when (condition &rest body)
  `(if ,conditon (progn ,@body)))

UNLESS 取WHEN相反的条件,可以这样定义你自己的UNLESS:

(defmacro my-unless (condition &rest body)
  `(if (not ,condition) (progn ,@body)))

宏系统是直接构建在语言之中的,所以你可以写出像WHEN和UNLESS这样简单的宏来获得小而重要的清晰性,并随后通过不断的使用而无限放大。

COND

COND 用于表达多重分支条件,其基本构造为:

(cond
  (test-1 form*)
      .
      .
      .
  (test-N form*))

主体中每个元素都代表一个条件分支,并由一个列表所构成,列表中含有一个条件形式以及零或多个当该分支被选择时将被求值的形式。这些条件形式按照分支在主体中出现的顺序被依次求值,直到它们中的一个求值为真,此时分支中的其余形式将被求值,且其最后一个形式的值将作为整个COND的值返回。如果该分支在条件形式之后不含有其他形式,那么将返回该条件形式的值。

习惯上,用来表示if/else链中最后一个else子句的分支将被写成带有条件 T ,e.g.:

(cond (a (do-x))
      (b (do-y))
      (t (do-z)))

AND, OR & NOT

三个布尔逻辑操作符 ANDORNOT 经常用在使用IF、WHEN、UNLESS和COND形式编写条件语句时。注意,这里 NOT 是一个函数,不是宏。

AND和OR实现了对任意数量子表达式的逻辑合取和析取操作,并被定义成宏以便支持“短路”特性,即它们仅以从左到右的顺序对用于检测整体真值的必要数量的子表达式进行求值。一些例子:

(not nil) ; --> T
(not (= 1 1)) ; --> NIL
(and (= 1 2) (= 3 3)) ; --> NIL
(or (= 1 2) (= 3 3)) ; --> T

循环

Lisp的25个特殊操作符中没有一个能够支持结构化循环,所有的Lisp循环控制构造都是构建在一对提供原生goto机制的特殊操作符之上的宏。2Lisp的循环宏构建在以那两个特殊操作符为基础的一组分层抽象之上。

DO提供了一种基本的结构化循环构造,DOLIST和DOTIMES提供了两种易用却不通用的构造,当然你可以在DO之上继续构建自定义的循环构造。

DOLIST & DOTIMES

DOLISTDOTIMES 分别用于常见的在列表元素上的循环和计数循环,二者都只提供一个循环变量。

DOLIST 在一个列表的元素上循环操作,使用一个依次持有列表中所有后继元素的变量来执行循环体,其基本形式如下:

(dolist (var list-form)
  body-form*)

循环开始时, list-form 被求值一次产生一个列表,然后循环体在列表的每一项上求值一次,使用变量 var 保存当前项的值,例如:

(dolist (x '(1 2 3)) (print x)) ; --> NIL

如果想在列表结束之前终止一个DOLIST循环,使用 RETURN :

(dolist (x '(1 2 3))
  (print x)
  (if (evenp x)
      (return)))

DOTIMES 用于计数循环,基本形式:

(dotimes (var count-form)
  body-form*)

count-form 必须要能求值为一个整数,每次循环, var 所保持的整数依次为从0到比那个数小1的每一个后继整数,例如:

(dotimes (i 4)
  (print i))

也可以使用RETURN来提前终止循环。

DOLIST和DOTIMES的循环体中可以包含任何类型的表达式,所以可以嵌套循环:

; 打印从1*1=1到20*20=400的乘法表
(dotimes (x 20)
  (dotimes (y 20)
    (format t "~3d " (* (1+ x) (1+ y))))
  (format t "~%"))

DO

DO 允许绑定任意数量的变量,并且变量值在每次循环中的改变方式是完全可控的,也可以定义测试条件来决定何时终止循环,并可提供一个形式,用于在循环结束时进行求值来为DO表达式整体生成一个返回值。其基本形式:

(do (variable-definition*)
    (end-test-form result-form*)
  statement*)

每一个 variable-definition 引入了一个将存在于循环体作用域之内的变量。单一变量定义的完整形式时一个三元素列表:

(var init-form step-form)

init-form 将在循环开始时被求值并将结果绑定到变量 var 上。循环的每一个后续迭代开始之前, step-form 将被求值,并把新值赋给 varstep-form 是可选的,如果没有给出,那么变量在迭代过程中保持其值不变,除非在循环体中为其赋予新值。和 LET 中的变量定义一样,如果 init-form 没有给出,变量将被绑定到 NIL 。和LET的情形一样,你可以将一个只含有名字的列表简化成一个简单的变量名来使用。3

每次迭代开始并且所有循环变量都被赋予新值后, end-test-form 会被求值,只要其值不为真,迭代就会继续,依次求值所有的 statement

end-test-form 求值为真时, result-form 将被求值,且最后一个形式的值将被作为DO表达式的结果返回。

每一次迭代中,所有变量的 step-form (步长形式)将在分配任何值给变量之前被求值,所以可以在步长形式里引用其他任何循环变量。4例如下面的循环:

; 计算第11个斐波那契数
(do ((n 0 (1+ n)) ; first var
     (cur 0 next) ; second var
     (next 1 (+ cur next))) ; third var
    ((= 10 n) cur)) ; end-test-form result-form

其步长形式 (1+ n)next(1+ cur next) 均使用 ncurnext 的旧值来求值,当所有步长形式都被求值后,其对应的变量才被赋予步长形式求解到的新值。

由于DO循环可以同时推进多个变量,所以往往不需要一个循环体;在只是把循环用作控制结构时,可能会省略结果形式,这种灵活性正式DO表达式有点儿难懂的原因。理解DO表达式的最佳方式是记住其基本模板:

(do (variable-definition*)
    (end-test-form result-form*)
  statement*)

该模板中的6个括号时DO结构本身所必需的:

  1. 一对括号来围住变量声明。
  2. 一对用来围住终止测试形式和结果形式。
  3. 最后一对围住整个表达式。

DO中的其他形式需要它们自己的括号,例如变量定义总是列表形式,而测试形式通常是一个函数调用。

DO循环的框架将总是一致的,下面的例子:

(do ((i 0 (1+ i)))
    ((>= i 4))
  (print i))

其中的结果形式被省略了,此例最好用DOTIMES来写5:

(dotimes (i 4) (print i))

没有循环体的斐波那契数计算循环:

(do ((n 0 (1+ n))
     (cur 0 next)
     (next 1 (1+ cur next)))
    ((= 10 n) cur))

一个不绑定变量的DO循环:

(do ()
    ((> (get-universal-time) *some-future-date*))
  (format t "Waiting~%")
  (sleep 60))

在当前时间小于一个全局变量值的时候,它保持循环,每分钟打印一个"Waiting"。注意,就算没有循环变量,仍需要有那个空变量列表。

LOOP

某些少量的循环用法,例如在多种数据结构上的循环:列表、向量、哈希表和包,或者在循环时以多种方式来聚集值:收集、计数、求和、最小化和最大化,LOOP宏可以容易地来做其中的一件或同时几件。

LOOP宏有两大类——简化的和扩展的。

简化的版本就是一个不绑定任何变量的无限循环,基本形式像这样:

(loop
  body-form*)

主体形式在每次循环时都将被求值,循环不停地进行,直到使用RETURN来终止。例如,前面的例子可以这样写:

(loop
   (when (> (get-universal-time) *some-future-date*)
     (return))
   (format t "Waiting~%")
   (sleep 60))

复杂的循环构造,例如一个DO循环将数字1到10收集到一个列表中:

(do ((nums nil)
     (i 1 (1+ i)))
    ((> i 10) (nreverse nums))
  (push i nums))
; (1 2 3 4 5 6 7 8 9 10)

它的LOOP版本:

(loop for i from 1 to 10 collecting i)

下面是一些关于LOOP简单用法的举例:

  1. 对前十个平方数求和

    (loop for x from 1 to 10 summing (expt x 2)) ; --> 385
    
  2. 统计一个字符串中元音字母的个数

    (loop for x across "the quick brown fox jumps over the lazy dog"
         counting (find x "aeiou")) ; --> 11
    
  3. 计算第11个斐波那契数,类似之前DO循环版本

    (loop for i below 10
       and a = 0 then b
       and b = 1 then (+ b a)
       finally (return a))
    

符号 across and below collecting counting finally for from summing then to 等都是一些循环关键字,它们的存在表明当前正在使用扩展的LOOP。6

参考来源

Footnotes:

1

出于某些原因,对于很多没有实际使用过Lisp宏的人而言,他们可以为了解决编程问题而日复一日地去创建新的函数抽象或定义新的类的层次,却被Lisp宏这种可以定义新的句法抽象的思想给吓到了。

2

这两个操作符是 TAGBODYGO

3

e.g., '(list-name) .

4

DO的变体 DO* ,会在求值后续变量的步长形式之前为每个变量赋值。

5

另一好处是其宏展开将可以包含允许编译器生成更有效代码的类型声明。

6

循环关键字不是关键字符号。

Comments powered by Disqus