第二章:欢迎来到 Lisp

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

本章的目的是让你尽快开始编程。本章结束时,你会掌握足够多的 Common Lisp 知识来开始写程序。

2.1 形式 (Form)

人可以通过实践来学习一件事,这对于 Lisp 来说特别有效,因为 Lisp 是一门交互式的语言。任何 Lisp 系统都含有一个交互式的前端,叫做顶层(toplevel)。你在顶层输入 Lisp 表达式,而系统会显示它们的值。

Lisp 通常会打印一个提示符告诉你,它正在等待你的输入。许多 Common Lisp 的实现用 > 作为顶层提示符。本书也沿用这个符号。

一个最简单的 Lisp 表达式是整数。如果我们在提示符后面输入 1

> 1
1
>

系统会打印出它的值,接着打印出另一个提示符,告诉你它在等待更多的输入。

在这个情况里,打印的值与输入的值相同。数字 1 称之为对自身求值。当我们输入需要做某些计算来求值的表达式时,生活变得更加有趣了。举例来说,如果我们想把两个数相加,我们输入像是:

> (+ 2 3)
5

在表达式 (+ 2 3) 里, + 称为操作符,而数字 23 称为实参。

在日常生活中,我们会把表达式写作 2 + 3 ,但在 Lisp 里,我们把 + 操作符写在前面,接着写实参,再把整个表达式用一对括号包起来: (+ 2 3) 。这称为前序表达式。一开始可能觉得这样写表达式有点怪,但事实上这种表示法是 Lisp 最美妙的东西之一。

举例来说,我们想把三个数加起来,用日常生活的表示法,要写两次 + 号,

2 + 3 + 4

而在 Lisp 里,只需要增加一个实参:

(+ 2 3 4)

日常生活中用 + 时,它必须有两个实参,一个在左,一个在右。前序表示法的灵活性代表着,在 Lisp 里, + 可以接受任意数量的实参,包含了没有实参:

> (+)
0
> (+ 2)
2
> (+ 2 3)
5
> (+ 2 3 4)
9
> (+ 2 3 4 5)
14

由于操作符可接受不定数量的实参,我们需要用括号来标明表达式的开始与结束。

表达式可以嵌套。即表达式里的实参,可以是另一个复杂的表达式:

> (/ (- 7 1) (- 4 2))
3

上面的表达式用中文来说是, (七减一) 除以 (四减二) 。

Lisp 表示法另一个美丽的地方是:它就是如此简单。所有的 Lisp 表达式,要么是 1 这样的数原子,要么是包在括号里,由零个或多个表达式所构成的列表。以下是合法的 Lisp 表达式:

2 (+ 2 3) (+ 2 3 4) (/ (- 7 1) (- 4 2))

稍后我们将理解到,所有的 Lisp 程序都采用这种形式。而像是 C 这种语言,有着更复杂的语法:算术表达式采用中序表示法;函数调用采用某种前序表示法,实参用逗号隔开;表达式用分号隔开;而一段程序用大括号隔开。

在 Lisp 里,我们用单一的表示法,来表达所有的概念。

2.2 求值 (Evaluation)

上一小节中,我们在顶层输入表达式,然后 Lisp 显示它们的值。在这节里我们深入理解一下表达式是如何被求值的。

在 Lisp 里, + 是函数,然而如 (+ 2 3) 的表达式,是函数调用。

当 Lisp 对函数调用求值时,它做下列两个步骤:

  1. 首先从左至右对实参求值。在这个例子当中,实参对自身求值,所以实参的值分别是 23
  2. 实参的值传入以操作符命名的函数。在这个例子当中,将 23 传给 + 函数,返回 5

如果实参本身是函数调用的话,上述规则同样适用。以下是当 (/ (- 7 1) (- 4 2)) 表达式被求值时的情形:

  1. Lisp 对 (- 7 1) 求值: 7 求值为 71 求值为 1 ,它们被传给函数 - ,返回 6
  2. Lisp 对 (- 4 2) 求值: 4 求值为 42 求值为 2 ,它们被传给函数 - ,返回 2
  3. 数值 62 被传入函数 / ,返回 3

但不是所有的 Common Lisp 操作符都是函数,不过大部分是。函数调用都是这么求值。由左至右对实参求值,将它们的数值传入函数,来返回整个表达式的值。这称为 Common Lisp 的求值规则。

Note

逃离麻烦

如果你试着输入 Lisp 不能理解的东西,它会打印一个错误讯息,接着带你到一种叫做中断循环(break loop)的顶层。 中断循环给予有经验的程序员一个机会,来找出错误的原因,不过最初你只会想知道如何从中断循环中跳出。 如何返回顶层取决于你所使用的 Common Lisp 实现。在这个假定的实现环境中,输入 :abort 跳出:

> (/ 1 0)
Error: Division by zero
      Options: :abort, :backtrace
>> :abort
>

附录 A 演示了如何调试 Lisp 程序,并给出一些常见的错误例子。

一个不遵守 Common Lisp 求值规则的操作符是 quotequote 是一个特殊的操作符,意味着它自己有一套特别的求值规则。这个规则就是:什么也不做。 quote 操作符接受一个实参,并完封不动地返回它。

> (quote (+ 3 5))
(+ 3 5)

为了方便起见,Common Lisp 定义 ' 作为 quote 的缩写。你可以在任何的表达式前,贴上一个 ' ,与调用 quote 是同样的效果:

> '(+ 3 5)
(+ 3 5)

使用缩写 ' 比使用整个 quote 表达式更常见。

Lisp 提供 quote 作为一种保护表达式不被求值的方式。下一节将解释为什么这种保护很有用。

2.3 数据 (Data)

Lisp 提供了所有在其他语言找的到的,以及其他语言所找不到的数据类型。一个我们已经使用过的类型是整数(integer),整数用一系列的数字来表示,比如: 256 。另一个 Common Lisp 与多数语言有关,并很常见的数据类型是字符串(string),字符串用一系列被双引号包住的字符串表示,比如: "ora et labora" [3] 。整数与字符串一样,都是对自身求值的。

[3]“ora et labora” 是拉丁文,意思是祷告与工作。

有两个通常在别的语言所找不到的 Lisp 数据类型是符号(symbol)与列表(lists),符号是英语的单词 (words)。无论你怎么输入,通常会被转换为大写:

> 'Artichoke
ARTICHOKE

符号(通常)不对自身求值,所以要是想引用符号,应该像上例那样用 ' 引用它。

列表是由被括号包住的零个或多个元素来表示。元素可以是任何类型,包含列表本身。使用列表必须要引用,不然 Lisp 会以为这是个函数调用:

> '(my 3 "Sons")
(MY 3 "Sons")
> '(the list (a b c) has 3 elements)
(THE LIST (A B C) HAS 3 ELEMENTS)

注意引号保护了整个表达式(包含内部的子表达式)被求值。

你可以调用 list 来创建列表。由于 list 是函数,所以它的实参会被求值。这里我们看一个在函数 list 调用里面,调用 + 函数的例子:

> (list 'my (+ 2 1) "Sons")
(MY 3 "Sons")

我们现在来到领悟 Lisp 最卓越特性的地方之一。Lisp的程序是用列表来表示的。如果实参的优雅与弹性不能说服你 Lisp 表示法是无价的工具,这里应该能使你信服。这代表着 Lisp 程序可以写出 Lisp 代码。 Lisp 程序员可以(并且经常)写出能为自己写程序的程序。

不过得到第 10 章,我们才来考虑这种程序,但现在了解到列表和表达式的关系是非常重要的,而不是被它们搞混。这也就是为什么我们需要 quote 。如果一个列表被引用了,则求值规则对列表自身来求值;如果没有被引用,则列表被视为是代码,依求值规则对列表求值后,返回它的值。

> (list '(+ 2 1) (+ 2 1))
((+ 2 1) 3)

这里第一个实参被引用了,所以产生一个列表。第二个实参没有被引用,视为函数调用,经求值后得到一个数字。

在 Common Lisp 里有两种方法来表示空列表。你可以用一对不包括任何东西的括号来表示,或用符号 nil 来表示空表。你用哪种表示法来表示空表都没关系,但它们都会被显示为 nil

> ()
NIL
> nil
NIL

你不需要引用 nil (但引用也无妨),因为 nil 是对自身求值的。

2.4 列表操作 (List Operations)

用函数 cons 来构造列表。如果传入的第二个实参是列表,则返回由两个实参所构成的新列表,新列表为第一个实参加上第二个实参:

> (cons 'a '(b c d))
(A B C D)

可以通过把新元素建立在空表之上,来构造一个新列表。上一节所看到的函数 list ,不过就是一个把几个元素加到 nil 上的快捷方式:

> (cons 'a (cons 'b nil))
(A B)
> (list 'a 'b)
(A B)

取出列表元素的基本函数是 carcdr 。对列表取 car 返回第一个元素,而对列表取 cdr 返回第一个元素之后的所有元素:

> (car '(a b c))
A
> (cdr '(a b c))
(B C)

你可以把 carcdr 混合使用来取得列表中的任何元素。如果我们想要取得第三个元素,我们可以:

> (car (cdr (cdr '(a b c d))))
C

不过,你可以用更简单的 third 来做到同样的事情:

> (third '(a b c d))
C

2.5 真与假 (Truth)

在 Common Lisp 里,符号 t 是表示逻辑 的缺省值。与 nil 相同, t 也是对自身求值的。如果实参是一个列表,则函数 listp 返回

> (listp '(a b c))
T

函数的返回值将会被解释成逻辑 或逻辑 时,则称此函数为谓词(predicate)。在 Common Lisp 里,谓词的名字通常以 p 结尾。

逻辑 在 Common Lisp 里,用 nil ,即空表来表示。如果我们传给 listp 的实参不是列表,则返回 nil

> (listp 27)
NIL

由于 nil 在 Common Lisp 里扮演两个角色,如果实参是一个空表,则函数 null 返回

> (null nil)
T

而如果实参是逻辑 ,则函数 not 返回

> (not nil)
T

nullnot 做的是一样的事情。

在 Common Lisp 里,最简单的条件式是 if 。通常接受三个实参:一个 test 表达式,一个 then 表达式和一个 else 表达式。若 test 表达式求值为逻辑 ,则对 then 表达式求值,并返回这个值。若 test 表达式求值为逻辑 ,则对 else 表达式求值,并返回这个值:

> (if (listp '(a b c))
      (+ 1 2)
      (+ 5 6))
3
> (if (listp 27)
      (+ 1 2)
      (+ 5 6))
11

quote 相同, if 是特殊的操作符。不能用函数来实现,因为实参在函数调用时永远会被求值,而 if 的特点是,只有最后两个实参的其中一个会被求值。 if 的最后一个实参是选择性的。如果忽略它的话,缺省值是 nil

> (if (listp 27)
     (+ 1 2))
NIL

虽然 t 是逻辑 的缺省表示法,任何非 nil 的东西,在逻辑的上下文里通通被视为

> (if 27 1 2)
1

逻辑操作符 andor 与条件式类似。两者都接受任意数量的实参,但仅对能影响返回值的几个实参求值。如果所有的实参都为 (即非 nil ),那么 and 会返回最后一个实参的值:

> (and t (+ 1 2))
3

如果其中一个实参为 ,那之后的所有实参都不会被求值。 or 也是如此,只要碰到一个为 的实参,就停止对之后所有的实参求值。

以上这两个操作符称为。宏和特殊的操作符一样,可以绕过一般的求值规则。第十章解释了如何编写你自己的宏。

2.6 函数 (Functions)

你可以用 defun 来定义新函数。通常接受三个以上的实参:一个名字,一组用列表表示的实参,以及一个或多个组成函数体的表达式。我们可能会这样定义 third

> (defun our-third (x)
   (car (cdr (cdr x))))
OUR-THIRD

第一个实参说明此函数的名称将是 our-third 。第二个实参,一个列表 (x) ,说明这个函数会接受一个形参: x 。这样使用的占位符符号叫做变量。当变量代表了传入函数的实参时,如这里的 x ,又被叫做形参

定义的剩余部分, (car (cdr (cdr x))) ,即所谓的函数主体。它告诉 Lisp 该怎么计算此函数的返回值。所以调用一个 our-third 函数,对于我们作为实参传入的任何 x ,会返回 (car (cdr (cdr x)))

> (our-third '(a b c d))
C

既然我们已经讨论过了变量,理解符号是什么就更简单了。符号是变量的名字,符号本身就是以对象的方式存在。这也是为什么符号,必须像列表一样被引用。列表必须被引用,不然会被视为代码。符号必须要被引用,不然会被当作变量。

你可以把函数定义想成广义版的 Lisp 表达式。下面的表达式测试 14 的和是否大于 3

> (> (+ 1 4) 3)
T

通过将这些数字替换为变量,我们可以写个函数,测试任两数之和是否大于第三个数:

> (defun sum-greater (x y z)
   (> (+ x y) z))
SUM-GREATER
> (sum-greater 1 4 3)
T

Lisp 不对程序、过程以及函数作区别。函数做了所有的事情(事实上,函数是语言的主要部分)。如果你想要把你的函数之一作为主函数(main function),可以这么做,但平常你就能在顶层中调用任何函数。这表示当你编程时,你可以把程序拆分成一小块一小块地来做调试。

2.7 递归 (Recursion)

上一节我们所定义的函数,调用了别的函数来帮它们做事。比如 sum-greater 调用了 +> 。函数可以调用任何函数,包括自己。自己调用自己的函数是递归的。 Common Lisp 函数 member ,测试某个东西是否为列表的成员。下面是定义成递归函数的简化版:

> (defun our-member (obj lst)
   (if (null lst)
       nil
   (if (eql (car lst) obj)
       lst
       (our-member obj (cdr lst)))))
OUR-MEMBER

谓词 eql 测试它的两个实参是否相等;此外,这个定义的所有东西我们之前都学过了。下面是运行的情形:

> (our-member 'b '(a b c))
(B C)
> (our-member 'z '(a b c))
NIL

下面是 our-member 的定义对应到英语的描述。为了知道一个对象 obj 是否为列表 lst 的成员,我们

  1. 首先检查 lst 列表是否为空列表。如果是空列表,那 obj 一定不是它的成员,结束。
  2. 否则,若 obj 是列表的第一个元素时,则它是列表的成员。
  3. 不然只有当 obj 是列表其余部分的元素时,它才是列表的成员。

当你想要了解递归函数是怎么工作时,把它翻成这样的叙述有助于你理解。

起初,许多人觉得递归函数很难理解。大部分的理解难处,来自于对函数使用了错误的比喻。人们倾向于把函数理解为某种机器。原物料像实参一样抵达;某些工作委派给其它函数;最后组装起来的成品,被作为返回值运送出去。如果我们用这种比喻来理解函数,那递归就自相矛盾了。机器怎可以把工作委派给自己?它已经在忙碌中了。

较好的比喻是,把函数想成一个处理的过程。在过程里,递归是在自然不过的事情了。日常生活中我们经常看到递归的过程。举例来说,假设一个历史学家,对欧洲历史上的人口变化感兴趣。研究文献的过程很可能是:

  1. 取得一个文献的复本
  2. 寻找关于人口变化的资讯
  3. 如果这份文献提到其它可能有用的文献,研究它们。

过程是很容易理解的,而且它是递归的,因为第三个步骤可能带出一个或多个同样的过程。

所以,别把 our-member 想成是一种测试某个东西是否为列表成员的机器。而是把它想成是,决定某个东西是否为列表成员的规则。如果我们从这个角度来考虑函数,那么递归的矛盾就不复存在了。

2.8 阅读 Lisp (Reading Lisp)

上一节我们所定义的 our-member 以五个括号结尾。更复杂的函数定义更可能以七、八个括号结尾。刚学 Lisp 的人看到这么多括号会感到气馁。这叫人怎么读这样的程序,更不用说编了?怎么知道哪个括号该跟哪个匹配?

答案是,你不需要这么做。 Lisp 程序员用缩排来阅读及编写程序,而不是括号。当他们在写程序时,他们让文字编辑器显示哪个括号该与哪个匹配。任何好的文字编辑器,特别是 Lisp 系统自带的,都应该能做到括号匹配(paren-matching)。在这种编辑器中,当你输入一个括号时,编辑器指出与其匹配的那一个。如果你的编辑器不能匹配括号,别用了,想想如何让它做到,因为没有这个功能,你根本不可能编 Lisp 程序 [1]

有了好的编辑器之后,括号匹配不再会是问题。而且由于 Lisp 缩排有通用的惯例,阅读程序也不是个问题。因为所有人都使用一样的习惯,你可以忽略那些括号,通过缩排来阅读程序。

任何有经验的 Lisp 黑客,会发现如果是这样的 our-member 的定义很难阅读:

(defun our-member (obj lst) (if (null lst) nil (if
(eql (car lst) obj) lst (our-member obj (cdr lst)))))

但如果程序适当地缩排时,他就没有问题了。可以忽略大部分的括号而仍能读懂它:

defun our-member (obj lst)
 if null lst
    nil
    if eql (car lst) obj
       lst
       our-member obj (cdr lst)

事实上,这是你在纸上写 Lisp 程序的实用方法。等输入程序至计算机的时候,可以利用编辑器匹配括号的功能。

2.9 输入输出 (Input and Output)

到目前为止,我们已经利用顶层偷偷使用了 I/O 。对实际的交互程序来说,这似乎还是不太够。在这一节,我们来看几个输入输出的函数。

最普遍的 Common Lisp 输出函数是 format 。接受两个或两个以上的实参,第一个实参决定输出要打印到哪里,第二个实参是字符串模版,而剩余的实参,通常是要插入到字符串模版,用打印表示法(printed representation)所表示的对象。下面是一个典型的例子:

> (format t "~A plus ~A equals ~A. ~%" 2 3 (+ 2 3))
2 plus 3 equals 5.
NIL

注意到有两个东西被打印出来。第一行是 format 印出来的。第二行是调用 format 函数的返回值,就像平常顶层会打印出来的一样。通常像 format 这种函数不会直接在顶层调用,而是在程序内部里使用,所以返回值不会被看到。

format 的第一个实参 t ,表示输出被送到缺省的地方去。通常是顶层。第二个实参是一个用作输出模版的字符串。在这字符串里,每一个 ~A 表示了被填入的位置,而 ~% 表示一个换行。这些被填入的位置依序由后面的实参填入。

标准的输入函数是 read 。当没有实参时,会读取缺省的位置,通常是顶层。下面这个函数,提示使用者输入,并返回任何输入的东西:

(defun askem (string)
 (format t "~A" string)
 (read))

它的行为如下:

> (askem "How old are you?")
How old are you?29

29

记住 read 会一直永远等在这里,直到你输入了某些东西,并且(通常要)按下回车。因此,不打印明确的提示信息是很不明智的,程序会给人已经死机的印象,但其实它是在等待输入。

第二件关于 read 所需要知道的事是,它很强大: read 是一个完整的 Lisp 解析器(parser)。不仅是可以读入字符,然后当作字符串返回它们。它解析它所读入的东西,并返回产生出来的 Lisp 对象。在上述的例子,它返回一个数字。

askem 的定义虽然很短,但体现出一些我们在之前的函数没看过的东西。函数主体可以有不只一个表达式。函数主体可以有任意数量的表达式。当函数被调用时,会依序求值,函数会返回最后一个的值。

在之前的每一节中,我们坚持所谓“纯粹的” Lisp ── 即没有副作用的 Lisp 。副作用是指,表达式被求值后,对外部世界的状态做了某些改变。当我们对一个如 (+ 1 2) 这样纯粹的 Lisp 表达式求值时,没有产生副作用。它只返回一个值。但当我们调用 format 时,它不仅返回值,还印出了某些东西。这就是一种副作用。

当我们想要写没有副作用的程序时,则定义多个表达式的函数主体就没有意义了。最后一个表达式的值,会被当成函数的返回值,而之前表达式的值都被舍弃了。如果这些表达式没有副作用,你没有任何理由告诉 Lisp ,为什么要去对它们求值。

2.10 变量 (Variables)

let 是一个最常用的 Common Lisp 的操作符之一,它让你引入新的局部变量(local variable):

> (let ((x 1) (y 2))
     (+ x y))
3

一个 let 表达式有两个部分。第一个部分是一组创建新变量的指令,指令的形式为 (variable expression) 。每一个变量会被赋予相对应表达式的值。上述的例子中,我们创造了两个变量, xy ,分别被赋予初始值 12 。这些变量只在 let 的函数体内有效。

一组变量与数值之后,是一个有表达式的函数体,表达式依序被求值。但这个例子里,只有一个表达式,调用 + 函数。最后一个表达式的求值结果作为 let 的返回值。以下是一个用 let 所写的,更有选择性的 askem 函数:

(defun ask-number ()
 (format t "Please enter a number. ")
 (let ((val (read)))
   (if (numberp val)
       val
       (ask-number))))

这个函数创建了变量 val 来储存 read 所返回的对象。因为它知道该如何处理这个对象,函数可以先观察你的输入,再决定是否返回它。你可能猜到了, numberp 是一个谓词,测试它的实参是否为数字。

如果使用者不是输入一个数字, ask-number 会持续调用自己。最后得到一个只接受数字的函数:

> (ask-number)
Please enter a number. a
Please enter a number. (ho hum)
Please enter a number. 52
52

我们已经看过的这些变量都叫做局部变量。它们只在特定的上下文里有效。另外还有一种变量叫做全局变量(global variable),是在任何地方都是可视的。 [2]

你可以给 defparameter 传入符号和值,来创建一个全局变量:

> (defparameter *glob* 99)
*GLOB*

全局变量在任何地方都可以存取,除了在定义了相同名字的区域变量的表达式里。为了避免这种情形发生,通常我们在给全局变量命名时,以星号作开始与结束。刚才我们创造的变量可以念作 “星-glob-星” (star-glob-star)。

你也可以用 defconstant 来定义一个全局的常量:

(defconstant limit (+ *glob* 1))

我们不需要给常量一个独一无二的名字,因为如果有相同名字存在,就会有错误产生 (error)。如果你想要检查某些符号,是否为一个全局变量或常量,使用 boundp 函数:

> (boundp '*glob*)
T

2.11 赋值 (Assignment)

在 Common Lisp 里,最普遍的赋值操作符(assignment operator)是 setf 。可以用来给全局或局部变量赋值:

> (setf *glob* 98)
98
> (let ((n 10))
   (setf n 2)
   n)
2

如果 setf 的第一个实参是符号(symbol),且符号不是某个局部变量的名字,则 setf 把这个符号设为全局变量:

> (setf x (list 'a 'b 'c))
(A B C)

也就是说,通过赋值,你可以隐式地创建全局变量。 不过,一般来说,还是使用 defparameter 明确地创建全局变量比较好。

你不仅可以给变量赋值。传入 setf 的第一个实参,还可以是表达式或变量名。在这种情况下,第二个实参的值被插入至第一个实参所引用的位置:

> (setf (car x) 'n)
N
> x
(N B C)

setf 的第一个实参几乎可以是任何引用到特定位置的表达式。所有这样的操作符在附录 D 中被标注为 “可设置的”(“settable”)。你可以给 setf 传入(偶数)个实参。一个这样的表达式

(setf a 'b
      c 'd
      e 'f)

等同于依序调用三个单独的 setf 函数:

(setf a 'b)
(setf c 'd)
(setf e 'f)

2.12 函数式编程 (Functional Programming)

函数式编程意味着撰写利用返回值而工作的程序,而不是修改东西。它是 Lisp 的主流范式。大部分 Lisp 的内置函数被调用是为了取得返回值,而不是副作用。

举例来说,函数 remove 接受一个对象和一个列表,返回不含这个对象的新列表:

> (setf lst '(c a r a t))
(C A R A T)
> (remove 'a lst)
(C R T)

为什么不干脆说 remove 从列表里移除一个对象?因为它不是这么做的。原来的表没有被改变:

> lst
(C A R A T)

若你真的想从列表里移除某些东西怎么办?在 Lisp 通常你这么做,把这个列表当作实参,传入某个函数,并使用 setf 来处理返回值。要移除所有在列表 xa ,我们可以说:

(setf x (remove 'a x))

函数式编程本质上意味着避免使用如 setf 的函数。起初可能觉得这根本不可能,更遑论去做了。怎么可以只凭返回值来建立程序?

完全不用到副作用是很不方便的。然而,随着你进一步阅读,会惊讶地发现需要用到副作用的地方很少。副作用用得越少,你就更上一层楼。

函数式编程最重要的优点之一是,它允许交互式测试(interactive testing)。在纯函数式的程序里,你可以测试每个你写的函数。如果它返回你预期的值,你可以有信心它是对的。这额外的信心,集结起来,会产生巨大的差别。当你改动了程序里的任何一个地方,会得到即时的改变。而这种即时的改变,使我们有一种新的编程风格。类比于电话与信件,让我们有一种新的通讯方式。

2.13 迭代 (Iteration)

当我们想重复做一些事情时,迭代比递归来得更自然。典型的例子是用迭代来产生某种表格。这个函数

(defun show-squares (start end)
  (do ((i start (+ i 1)))
      ((> i end) 'done)
    (format t "~A ~A~%" i (* i i))))

列印从 startend 之间的整数的平方:

> (show-squares 2 5)
2 4
3 9
4 16
5 25
DONE

do 宏是 Common Lisp 里最基本的迭代操作符。和 let 类似, do 可以创建变量,而第一个实参是一组变量的规格说明列表。每个元素可以是以下的形式

(variable initial update)

其中 variable 是一个符号, initialupdate 是表达式。最初每个变量会被赋予 initial 表达式的值;每一次迭代时,会被赋予 update 表达式的值。在 show-squares 函数里, do 只创建了一个变量 i 。第一次迭代时, i 被赋与 start 的值,在接下来的迭代里, i 的值每次增加 1

第二个传给 do 的实参可包含一个或多个表达式。第一个表达式用来测试迭代是否结束。在上面的例子中,测试表达式是 (> i end) 。接下来在列表中的表达式会依序被求值,直到迭代结束。而最后一个值会被当作 do 的返回值来返回。所以 show-squares 总是返回 done

do 的剩余参数组成了循环的函数体。在每次迭代时,函数体会依序被求值。在每次迭代过程里,变量被更新,检查终止测试条件,接着(若测试失败)求值函数体。

作为对比,以下是递归版本的 show-squares

(defun show-squares (i end)
   (if (> i end)
     'done
     (progn
       (format t "~A ~A~%" i (* i i))
       (show-squares (+ i 1) end))))

唯一的新东西是 prognprogn 接受任意数量的表达式,依序求值,并返回最后一个表达式的值。

为了处理某些特殊情况, Common Lisp 有更简单的迭代操作符。举例来说,要遍历列表的元素,你可能会使用 dolist 。以下函数返回列表的长度:

(defun our-length (lst)
  (let ((len 0))
    (dolist (obj lst)
      (setf len (+ len 1)))
    len))

这里 dolist 接受这样形式的实参(variable expression),跟着一个具有表达式的函数主体。函数主体会被求值,而变量相继与表达式所返回的列表元素绑定。因此上面的循环说,对于列表 lst 里的每一个 obj ,递增 len 。很显然这个函数的递归版本是:

(defun our-length (lst)
 (if (null lst)
     0
     (+ (our-length (cdr lst)) 1)))

也就是说,如果列表是空表,则长度为 0 ;否则长度就是对列表取 cdr 的长度加一。递归版本的 our-length 比较易懂,但由于它不是尾递归(tail-recursive)的形式 (见 13.2 节),效率不是那么高。

2.14 函数作为对象 (Functions as Objects)

函数在 Lisp 里,和符号、字符串或列表一样,是稀松平常的对象。如果我们把函数的名字传给 function ,它会返回相关联的对象。和 quote 类似, function 是一个特殊操作符,所以我们无需引用(quote)它的实参:

> (function +)
#<Compiled-Function + 17BA4E>

这看起来很奇怪的返回值,是在典型的 Common Lisp 实现里,函数可能的打印表示法。

到目前为止,我们仅讨论过,不管是 Lisp 打印它们,还是我们输入它们,看起来都是一样的对象。但这个惯例对函数不适用。一个像是 + 的内置函数 ,在内部可能是一段机器语言代码(machine language code)。每个 Common Lisp 实现,可以选择任何它喜欢的外部表示法(external representation)。

如同我们可以用 ' 作为 quote 的缩写,也可以用 #' 作为 function 的缩写:

> #'+
#<Compiled-Function + 17BA4E>

这个缩写称之为升引号(sharp-quote)。

和别种对象类似,可以把函数当作实参传入。有个接受函数作为实参的函数是 applyapply 接受一个函数和实参列表,并返回把传入函数应用在实参列表的结果:

> (apply #'+ '(1 2 3))
6
> (+ 1 2 3)
6

apply 可以接受任意数量的实参,只要最后一个实参是列表即可:

> (apply #'+ 1 2 '(3 4 5))
15

函数 funcall 做的是一样的事情,但不需要把实参包装成列表。

> (funcall #'+ 1 2 3)
6

Note

什么是 lambda

lambda 表达式里的 lambda 不是一个操作符。而只是个符号。 在早期的 Lisp 方言里, lambda 存在的原因是:由于函数在内部是用列表来表示, 因此辨别列表与函数的方法,就是检查第一个元素是否为 lambda

在 Common Lisp 里,你可以用列表来表达函数, 函数在内部会被表示成独特的函数对象。因此不再需要 lambda 了。 如果需要把函数记为

((x) (+ x 100))

而不是

(lambda (x) (+ x 100))

也是可以的。

但 Lisp 程序员习惯用符号 lambda ,来撰写函数, 因此 Common Lisp 为了传统,而保留了 lambda

defun 宏,创建一个函数并给函数命名。但函数不需要有名字,而且我们不需要 defun 来定义他们。和大多数的 Lisp 对象一样,我们可以直接引用函数。

要直接引用整数,我们使用一系列的数字;要直接引用一个函数,我们使用所谓的lambda 表达式。一个 lambda 表达式是一个列表,列表包含符号 lambda ,接着是形参列表,以及由零个或多个表达式所组成的函数体。

下面的 lambda 表达式,表示一个接受两个数字并返回两者之和的函数:

(lambda (x y)
 (+ x y))

列表 (x y) 是形参列表,跟在它后面的是函数主体。

一个 lambda 表达式可以作为函数名。和普通的函数名称一样, lambda 表达式也可以是函数调用的第一个元素,

> ((lambda (x) (+ x 100)) 1)
101

而通过在 lambda 表达式前面贴上 #' ,我们得到对应的函数,

> (funcall #'(lambda (x) (+ x 100))
          1)

lambda 表示法除上述用途以外,还允许我们使用匿名函数。

2.15 类型 (Types)

Lisp 处理类型的方法非常灵活。在很多语言里,变量是有类型的,得声明变量的类型才能使用它。在 Common Lisp 里,数值才有类型,而变量没有。你可以想像每个对象,都贴有一个标明其类型的标签。这种方法叫做显式类型manifest typing)。你不需要声明变量的类型,因为变量可以存放任何类型的对象。

虽然从来不需要声明类型,但出于效率的考量,你可能会想要声明变量的类型。类型声明在第 13.3 节时讨论。

Common Lisp 的内置类型,组成了一个类别的层级。对象总是不止属于一个类型。举例来说,数字 27 的类型,依普遍性的增加排序,依序是 fixnumintegerrationalrealnumberatomt 类型。(数值类型将在第 9 章讨论。)类型 t 是所有类型的基类(supertype)。所以每个对象都属于 t 类型。

函数 typep 接受一个对象和一个类型,然后判定对象是否为该类型,是的话就返回真:

> (typep 27 'integer)
T

我们会在遇到各式内置类型时来讨论它们。

2.16 展望 (Looking Forward)

本章仅谈到 Lisp 的表面。然而,一种非比寻常的语言形象开始出现了。首先,这个语言用单一的语法,来表达所有的程序结构。语法基于列表,列表是一种 Lisp 对象。函数本身也是 Lisp 对象,函数能用列表来表示。而 Lisp 本身就是 Lisp 程序。几乎所有你定义的函数,与内置的 Lisp 函数没有任何区别。

如果你对这些概念还不太了解,不用担心。 Lisp 介绍了这么多新颖的概念,在你能驾驭它们之前,得花时间去熟悉它们。不过至少要了解一件事:在这些概念当中,有着优雅到令人吃惊的概念。

Richard Gabriel 曾经半开玩笑的说, C 是拿来写 Unix 的语言。我们也可以说, Lisp 是拿来写 Lisp 的语言。但这是两种不同的论述。一个可以用自己编写的语言和一种适合编写某些特定类型应用的语言,是有着本质上的不同。这开创了新的编程方法:你不但在语言之中编程,还把语言改善成适合程序的语言。如果你想了解 Lisp 编程的本质,理解这个概念是个好的开始。

Chapter 2 总结 (Summary)

  1. Lisp 是一种交互式语言。如果你在顶层输入一个表达式, Lisp 会显示它的值。
  2. Lisp 程序由表达式组成。表达式可以是原子,或一个由操作符跟着零个或多个实参的列表。前序表示法代表操作符可以有任意数量的实参。
  3. Common Lisp 函数调用的求值规则: 依序对实参从左至右求值,接着把它们的值传入由操作符表示的函数。 quote 操作符有自己的求值规则,它完封不动地返回实参。
  4. 除了一般的数据类型, Lisp 还有符号跟列表。由于 Lisp 程序是用列表来表示的,很轻松就能写出能编程的程序。
  5. 三个基本的列表函数是 cons ,它创建一个列表; car ,它返回列表的第一个元素;以及 cdr ,它返回第一个元素之后的所有东西。
  6. 在 Common Lisp 里, t 表示逻辑 ,而 nil 表示逻辑 。在逻辑的上下文里,任何非 nil 的东西都视为 。基本的条件式是 ifandor 是相似的条件式。
  7. Lisp 主要由函数所组成。可以用 defun 来定义新的函数。
  8. 自己调用自己的函数是递归的。一个递归函数应该要被想成是过程,而不是机器。
  9. 括号不是问题,因为程序员通过缩排来阅读与编写 Lisp 程序。
  10. 基本的 I/O 函数是 read ,它包含了一个完整的 Lisp 语法分析器,以及 format ,它通过字符串模板来产生输出。
  11. 你可以用 let 来创造新的局部变量,用 defparameter 来创造全局变量。
  12. 赋值操作符是 setf 。它的第一个实参可以是一个表达式。
  13. 函数式编程代表避免产生副作用,也是 Lisp 的主导思维。
  14. 基本的迭代操作符是 do
  15. 函数是 Lisp 的对象。可以被当成实参传入,并且可以用 lambda 表达式来表示。
  16. 在 Lisp 里,是数值才有类型,而不是变量。

Chapter 2 习题 (Exercises)

  1. 描述下列表达式求值之后的结果:
(a) (+ (- 5 1) (+ 3 7))

(b) (list 1 (+ 2 3))

(c) (if (listp 1) (+ 1 2) (+ 3 4))

(d) (list (and (listp 3) t) (+ 1 2))
  1. 给出 3 种不同表示 (a b c)cons 表达式
  2. 使用 carcdr 来定义一个函数,返回一个列表的第四个元素。
  3. 定义一个函数,接受两个实参,返回两者当中较大的那个。
  4. 这些函数做了什么?
(a) (defun enigma (x)
      (and (not (null x))
           (or (null (car x))
               (enigma (cdr x)))))

(b) (defun mystery (x y)
      (if (null y)
          nil
          (if (eql (car y) x)
              0
              (let ((z (mystery x (cdr y))))
                (and z (+ z 1))))))
  1. 下列表达式, x 该是什么,才会得到相同的结果?
(a) > (car (x (cdr '(a (b c) d))))
    B
(b) > (x 13 (/ 1 0))
    13
(c) > (x #'list 1 nil)
    (1)
  1. 只使用本章所介绍的操作符,定义一个函数,它接受一个列表作为实参,如果有一个元素是列表时,就返回真。
  2. 给出函数的迭代与递归版本:
  1. 接受一个正整数,并打印出数字数量的点。
  2. 接受一个列表,并返回 a 在列表里所出现的次数。
  1. 一位朋友想写一个函数,返回列表里所有非 nil 元素的和。他写了此函数的两个版本,但两个都不能工作。请解释每一个的错误在哪里,并给出正确的版本。
(a) (defun summit (lst)
      (remove nil lst)
      (apply #'+ lst))

(b) (defun summit (lst)
      (let ((x (car lst)))
        (if (null x)
            (summit (cdr lst))
            (+ x (summit (cdr lst))))))

脚注

[1]在 vi,你可以用 :set sm 来启用括号匹配。在 Emacs,M-x lisp-mode 是一个启用的好方法。
[2]真正的区别是词法变量(lexical)与特殊变量(special variable),但到第六章才会讨论这个主题。