《Practical Common Lisp 中文版》样章

《Practical Common Lisp 中文版》样章_第1张图片

(注:我的译作《Practical Common Lisp 中文版》将由人民邮电出版社图灵公司出版,不过由于种种原因目前该书还在紧张的编辑之中,最后确定的出版时间为 10 月底之前。由于拖得比较久,个人感到愧对读者,因此经过和出版社的协商,现公开其中的一章供读者预览。内容直接来自 未经编辑的原始 译稿,因此本文和最后出版的内容将在细节上有所出入,还请读者见谅)

第五章 函数 (英文原版)

有了语法和语义规则以后,所有 Lisp 程序的三个最基本组成部分是函数、变量和宏。在第 3 章里构建数据库时,你已经用到了所有这三个组件,但是我跳过了大量关于它们如何工作以及如何更好使用它们的细节。我将把接下来几章献给这三个主题,从函数 开始——就像在其他语言里那样,函数提供了用于抽象和功能化的基本方法。

Lisp 本身是由大量函数组成的。语言标准中超过四分之三的名字用于定义函数。所有内置的数据类型纯粹是用操作它们的函数来定义的。甚至连 Lisp 强大的对象系统也是构建在函数的概念性扩展——广义函数(generic function)——之上的。我将在第 16 章里介绍它们。

尽管宏对于 Lisp 风格有着重要的作用,但最终所有实际的功能还是由函数来提供的。宏运行在编译期,因此它们生成的代码——当所有宏被展开后将实际构成程序的那些代码——将 完全由对函数和特殊操作符的调用所构成。更不用说,宏本身也是函数。尽管这种函数是用来生成代码,而不是用来完成实际的程序操作的。

定义新函数

函数一般使用 DEFUN 宏来定义。DEFUN 的基本结构看起来像这样:

(defun name (parameter*)
  "Optional documentation string."
  body-form*)

任何符号都可以被用作函数名。

通常函数名仅包含字典字符和连字符,但是在特定的命名约定里,其他字符也被允许使用。 例如,一个将字符串转换成物件(widget)的函数可能被叫做 string->widget。最重要的一个命名约定是在第2章里提到的那个,就是说你要用连字符而不是下划线或内部大写来构造复合名称。因 此,frob-widget 是比 frob_widget 或 frobWidget 更符合 Lisp 风格的名字。一个函数的形参列表定义了一些变量,它们用来保存函数调用时所传递的参数。

如果函数不带有参数,则该列表就是空的,写成 ()。不同类型的形参分别处理必要的、可选的、多重的,以及关键字参数。我将在下一节里讨 论相关细节。

如果一个字符串紧跟在形参列表之后,它应该是一个用来描述函数用途的文档字符串。当函 数被定义时,该文档字符串将被关联到函数的名字上,并且以后可以通过 DOCUMENTATION 函数来获取。

最后,一个 DEFUN 的主体由任意数量的 Lisp 表达式所构成。它们将在函数被调用时依次求值,而最后一个表达式的值将被作为整个函数的值返回。另外 RETURN-FROM 特殊操作符可被用于在函数的任何位置上立即返回,我将很快开始讨论它。

在第 2 章里我们写了一个 hello-world 函数,它看起来像这样:

(defun hello-world () (format t "hello, world"))

现在你可以分析该程序的各部分了。它的名字是 hello-world;它的形参列表为空,因此不接受任何参数;它没有文档字符串;并且它的函数体由一个表达式所构成:

(format t "hello, world")

下面是一个稍微更复杂一些的函数:

(defun verbose-sum (x y)
  "Sum any two numbers after printing a message."
  (format t "Summing ~d and ~d.~%" x y)
  (+ x y))

这个函数称为 verbose-sum,其接受两个参数并绑定到形参 x 和 y 上,带有一个 文档字符串,以及一个由两个表达式所组成的主体。由“+”调用所返回的值将成 verbose-sum 的返回值。

函数形参列表

关于函数名称或文档字符串就没有更多的可说了,而本书的其余部分将用来描述所有你可以 在一个函数体里做的事情,因此就只剩下形参列表需要讨论了。

很明显,一个形参列表的基本用途是为了声明那些即将用来接收传递给函数的参数的变量。 当一个形参列表是个由变量名所组成的简单列表时——如同在 verbose-sum 里那样——这些形参被称为必要形参。当一个函数被调用时,它的每一个必要形参都必须被提供一个参数。每一个形参被绑定到对应的参数上。如果一个函数以过少或过多的参数来调用的话,Lisp 将报错。

尽管如此,Common Lisp 的形参列表也给了你更灵活的将函数调用参数映射到函数形参的方式。除了必要形参以外,一个函数还可以有可选形参。或是可以用单一形参绑定到含有任意多个额外参数的列表上。最后,参数还可以通过关键字而不是位置来映射到形参上。这样,Common Lisp 的形参列表对于几种常见的编码问题提供了一种便利的解决方案。

可选形参

虽然许多像 verbose-sum 这样的函数只有必要形参,但并非所有函数都如此简单。有时,一个函数将带有一个只有特定调用者才会关心的形参,这可能是因为它有一个合理的缺省值。例如一个可以创建按需增长的数据结构的函数。 由于数据结构可以增长,它的初始尺寸就无关紧要了——如果从一个正确的观点来看的话。但那些清楚知道自己打算在数据结构中放置多少个元素的调 用者们,可能会通过设置特定的初始尺寸来改进其程序的性能。尽管如此,多数调用者只需让实现数据结构的代码自行选择一个好的通用值就可以了。 在 Common Lisp 中,你可以使用可选形参,从而使两类调用者都满意;不关心它的调用者们将得到一个合理的缺省值,而其他调用者们有机会提供一个指定的值。

为了定义一个带有可选形参的函数,在必要参数的名字之后放置符号 &optional,后接可选形参的名字。一个简单的例子看起来像这样:

(defun foo (a b &optional c d) (list a b c d))

当该函数被调用时,参数被首先绑定到必要形参上。在所有必要形参都被赋值以后,如果还 有任何参数剩余,它们的值将被赋值给可选形参。如果参数在所有可选形参被赋值之前用完了,那么其余的可选形参将自动绑定到值 NIL 上。这样, 前面定义的函数会给出下面的结果:

(foo 1 2)     -> (1 2 NIL NIL)

(foo 1 2 3)   -> (1 2 3 NIL)

(foo 1 2 3 4) -> (1 2 3 4)

Lisp 仍然可以确保适当数量的参数被传递给函数——在本例中介于 2 到 4 之间——而如果函数用太少或太多的参数来调用的话,将会报错。

当然,你会经常想要一个不同于NIL的缺省值。你可以通过将形参名替换成一个含有名字 跟一个表达式的列表来指定该缺省值。这个表达式将只有在调用者没有传递足够的参数来为可选形参提供值的时候才会被求值。通常情况是简单的提供 一个值作为表达式:

(defun foo (a &optional (b 10)) (list a b))

上述函数要求一个参数来绑定到形参a上。第二个形参b将在有第二个参数时使用其值,否 则使用10。

(foo 1 2) -> (1 2)

(foo 1)   -> (1 10)

尽管如此,有时你可能需要更灵活地选择缺省值。你可能想要基于其他形参来计算缺省值。 而你真的可以——缺省值表达式可以引用早先出现在形参列表中的形参。如果你正在编写一个返回矩形的某种表示的函数并且你想要使它可以特别方便 地产生正方形,你可以使用一个像这样的参数列表:

(defun make-rectangle (width &optional (height width)) ...)

这将导致 height 形参除非明确指定否则将带有和 width 形参相同的值。

有时知道一个可选参数的值究竟被调用者明确指定还是使用了缺省值将是有用的。除了通过 代码来检查形参的值是否为缺省值(这样做有时无效,假如调用者碰巧显式传递了缺省值)以外,你还可以通过在形参标识符的缺省值表达式之后添加 另一个变量名来做到这点。该变量将在调用者实际为该形参提供了一个参数时被绑定到真值,否则为NIL。通常约定,这种变量的名字与对应的真实 形参相同,但是带有一个 “-supplied-p” 后缀。例如:

(defun foo (a b &optional (c 3 c-supplied-p))
  (list a b c c-supplied-p))

这将给出类似下面的结果:

(foo 1 2)    -> (1 2 3 NIL)

(foo 1 2 3) -> (1 2 3 T)

(foo 1 2 4) -> (1 2 4 T)

剩余形参

 可选形参仅用于当你有一些离散的调用者可能想也可能不想提供值的形参的场 合。但是某些函数需要接收可变数量的参数,一些你已经见过的内置函数就是以这种方式工作的。FORMAT有两个必要参数,流和控制串。但在这 两个之后,它还需要一组可变数量的参数,取决于控制串需要插入多少个值。“+”函数也接受可变数量的参数——没有特别的理由限制它只能在两个 数之间相加;它将做任意个数的值的加法(它甚至可在零个参数上工作,返回0——加法的底数)。下面这些都是这两个函数的合法调用:

(format t "hello, world")

(format t "hello, ~a" name)

(format t "x: ~d y: ~d" x y)

(+)

(+ 1)

(+ 1 2)

(+ 1 2 3)

很明显,你可以通过简单的给它一些可选形参来写出一个接受可变数量参数的函数。但这样 将会非常麻烦——光是写形参列表就已经足够麻烦了,何况还要在函数体中处理所有这些形参。为了做好这件事,你将不得不使用相当于一个合法的函 数调用可以传递的参数数量那么多的可选形参。这一数量是具体实现相关的但可以保证至少有50个。在当前的所有实现中,它的范围从 4096 到  536870911。

汗,这种歪曲头脑的乏味事情绝对不是Lisp风格。

相反,Lisp 允许你在符号 &rest 之后包括一个一揽子形参。如果一个函数 带有一个 &rest 形参,任何满足了必要和可选形参之后的其余所有参数将被收集到一个列表里成为该 &rest 形参的值。这 样,FORMAT 和 “+” 的形参列表可能看起来会是这样:

(defun format (stream string &rest values) ...)
(defun + (&rest numbers) ...)

关键字形参

尽管可选和剩余形参给了你很多灵活性,但两者都不能帮助你应对下面的情形:假设你有一 个接受四个可选参数的函数。现在假设在函数被调用的多数场合里,调用者只想为四个参数中的一个提供值,并且,更进一步,不同的调用者将分别选择使用其中一个参数。

想为第一个形参提供值的调用者将会很方便——它们只需传递一个可选参数,然后忽略其他 就好了。但是所有其他的调用者将不得不为它们所不关心的一到三个参数传递一些值。难道这真的是可选形参被设计用于解决的问题吗?

当然是。问题在于可选形参仍然是位置相关的——如果调用者想要给第四个可选形参传递一 个显式的值,这将导致前三个可选形参对于该调用者来说变成了必要形参。幸运的是,另一种形参类型,关键字形参,可以允许调用者指定具体哪个形 参使用哪个值。

为了使函数带有关键字形参,在任何必要的、&optional 和&rest形参之后可以加上符号&key以及任意数量的关键字形参标识符,后者的格式类似于可选形参标识符。这里是一个只 有关键字形参的函数:

(defun foo (&key a b c) (list a b c))

当这个函数被调用时,每一个关键字形参将被绑定到紧跟在同名的关键字后面的那个值上。 回顾第4章里提到的,关键字是以冒号开始的名字,并且它们被自动定义为自求值常量。

如果一个给定的关键字没有出现在参数列表中,那么对应的形参将被赋予其默认值,如同 可 选形参那样。因为关键字参数是标签化的,所以它们在必要参数之后可按任意顺序进行传递。例如 foo 可以用下列形式调用:

(foo)
->
(NIL NIL NIL)
(foo :a 1)
-> (1 NIL NIL)
(foo :b 1)
-> (NIL 1 NIL)
(foo :c 1)
-> (NIL NIL 1)
(foo :a 1 :c 3)
-> (1 NIL 3)
(foo :a 1 :b 2 :c 3)
-> (1 2 3)
(foo :a 1 :c 3 :b 2)
-> (1 2 3)

如同可选形参那样,关键字形参也可以提供一个缺省的值形式以及一个supplied- p变量名。在关键字和可选形参中,这个缺省的值形式都可以引用那些更早出现在形参列表中的形参。

同样,如果出于某种原因你想要关键字的调用者指定一个与实际形参名不同的形参,你可以 将形参名替换成一个列表,其含有当调用函数时所使用的关键字以及用作形参的名字。下面这个 foo 的定义:

(defun foo (&key ((:apple a)) ((:box b) 0) ((:charlie c) 0 c-supplied-p))
  (list a b c c-supplied-p))

可以让调用者这样调用它:

(foo :apple 10 :box 20 :charlie 30) -> (10 20 30 T)

这种风格在你想要完全将函数的公共API与其内部细节相隔离时特别有用,通常是因为你 想要在内部使用短变量名,而不是API中的描述性关键字。不过该特性不常被用到。

混合不同的形参类型

虽然罕见,但在单一函数里使用所有四种类型的形参也是可能的。无论何时,当超过一种类 型的形参被用到时,它们必须以这样的顺序被声明:首先是必要形参,其次是可选形参,再次是剩余形参,最后是关键字形参。尽管如此,在使用多种类型形参的函数中,典型情况是你将必要形参和另外一种类型的形参组合使用,或者可能是组合 &optional 和 &rest 形参。其他两种组合方式,无论&optional 还是 &rest 形参,当与 &key 形参组合使用时都可能导致某种奇怪的行为。

将 &optional 和 &key 形参组合使用时将产生非常奇怪的结 果,因此你也许应该避免将其一起使用。问题出在如果一个调用者没有为所有可选形参提供值时,那么没有得到值的形参将吃掉原本用于关键字形参的 关键字和值。例如,下面这个函数很不明智地混合了 &optional 和 &key 形参:

(defun foo (x &optional y &key z) (list x y z))

如果像这样被调用的话,它将工作得很好:

(foo 1 2 :z 3) -> (1 2 3)

这样也可以:

(foo 1) -> (1 nil nil)

但是这样的话将报错:

(foo 1 :z 3) -> ERROR

这是因为关键字 :z 被作为一个值填入到可选的 y 形参中了,只留下了参数 3 被处理。在那 一点上,Lisp 将期待要么一个成对的关键字/值,要么什么也没有,否则就会报错。也许更坏的是,如果该函数带有两个 &optional 形参,上面最后一个调用将导致值 :z 和3分别被绑定到两个 &optional 形参上。 而 &key 形参 z 将在毫无指示的情况下得到缺省值 NIL。

一般而言,如果你发现自己正在编写一个同时使用 &optional 和 &key 形参的函数,你可能应该将它变成全部使用 &key 形参的形式——它们更灵活,并且你总是可以在不破坏该函数的已有 调用的情况下添加新的关键字形参。你也可以移除关键字形参,只要没人正在使用它们。

一般而言,使用关键字形参将会使代码相对易于维护和发展——如果你需要为函数添加一些 需要用到新参数的新行为,你可以直接添加关键字形参而无需修改或甚至重新编译任何调用该函数的已有代码。

你可以安全地组合使用 &rest 和 &key 形参,但其行为初看起来可 能会有一点奇怪。正常来讲,无论 &rest 还是 &key 出现在形参列表中都将导致所有出现在必要和 &optional 形参之后的那些值被特别处理——要么被作为 &rest 形参收集到一个形参列表中,要么基于关键字被分配到适当的 &key 形参中。如果 &rest 和 &key 同时出现在形参列表中,那么两件事都会发生——所有剩余的值,包 括关键字本身,都将被收集到一个列表里,然后被绑定到 &rest 形参上;而适当的值也会同时被绑定到 &key 形参上。因此,给定下列函数:

(defun foo (&rest rest &key a b c) (list rest a b c))

你将得到如下结果:

(foo :a 1 :b 2 :c 3) -> ((:A 1 :B 2 :C 3) 1 2 3)

函数返回值

目前你写出的所有函数都使用了默认的返回值行为,即最后一个被求值的表达式作为整个函 数的返回值。这是从函数中返回值的最常见方式。

尽管如此,有办法从函数中间返回,尤其是当你想要从嵌套的控制结构中脱身时,某些时候将是非常便利的。在这种情况下,你可以使用 RETURN-FROM 特殊操作符立即以任何值从函数的中间位置返回。

你将在第 20 章里看到 RETURN-FROM 事实上不只用于函数;它被用来从一个由 BLOCK 特别操作符所定义的代码块中返回。不过 DEFUN 会自动将其整个函数体包装在一个与其函数同名的代码块中。因此,求值一个带有当前函数名和你想要返回的值的RETUN-FROM 将导致函数立即以该值退出。RETURN-FROM 是一个特殊操作符,其第一个 “参数” 是其想要返回的代码块名。该名字不被求值,因此无需引用。

下面这个函数使用了嵌套的循环来发现第一个数对——每个都小于10,并且其成绩大于函 数的参数,它使用 RETURN-FROM 在发现之后立即返回该数对:

(defun foo (n)
  (dotimes (i 10)
    (dotimes (j 10)
      (when (> (* i j) n)
        (return-from foo (list i j))))))

必须承认的是,不得不指定你正在返回的函数名多少有些不便——其中之一就是如果你改变 了函数的名字,你将需要同时改变RETURN-FROM 中所使用的名字。

但在事实上,显式的 RETURN-FROM 调用在 Lisp 中出现的频率远小于 return 语句在源自 C 的语言里所出现的频率,因为所有的 Lisp 表达式,包括诸如循环和条件语句这样的控制结构,都会求值到一个值。因此在实践中这不是什么问题。

作为数据的函数——高阶函数

尽管你使用函数的主要方式是通过名字来调用它们,但有时将函数作为数据看待是很有用 的。例如,如果你可以将一个函数作为参数传给另一个函数的话,你将可以写出一个通用的排序函数,其允许调用者提供一个用来比较任意两元素的函 数。这样同样的底层算法就可以跟许多不同的比较函数配合使用了。类似地,回调函数(callback)和钩子(hook)也需要依赖保存代码 引用从而以后运行的能力。由于函数已经是一种对代码比特进行抽象的标准方式,因此允许函数被视为数据也是合理的。

在 Lisp 中,函数只是另一种类型的对象。当你用 DEFUN 定义一个函数时,你实际上 做了两件事:创建一个新的函数对象以及赋予其一个名字。如同你在第3章里看到的,也可以使用 LAMBDA 表达式来创建一个函数而无需为其指定一个名字。一个函数对象的实际表示,无论是有名的还是匿名的,都只是一些二进制数据——以原生编译的Lisp形式存在,可能大部分由机器码所 构成的。你唯一需要知道的就是如何保持它们以及当你需要时如何调用它们。

特殊操作符 FUNCTION 提供了用来获取一个函数对象的方法。它接受单一参数并返回 带有该名字的函数。这个名字是不被引用的。因此如果你像这样来定义一个函数 foo:

CL-USER> (defun foo (x) (* 2 x))

FOO

你可以像这样得到对应的函数对象:

CL-USER> (function foo)

#<Interpreted Function FOO>

事实上,你已经用过 FUNCTION 了,但它是以伪装的形式出现的。第 3 章里用到的 #' 语法就是 FUNCTION 的语法糖,正如 “'”(单引号)是 QUOTE 的语法糖一样。

因此你也可以像这样得到 foo 的函数对象:

CL-USER> #'foo

#<Interpreted Function FOO>

一旦你得到了函数对象,你就只剩下一件事可做了——调用它。Common Lisp 提供了两个函数用来通过函数对象调用函数:FUNCALL 和 APPLY。

它们的区别仅在于如何获取传递给函数的参数。

FUNCALL 用于在你编写代码时确切知道你打算传递给函数的参数数量的场合。 FUNCALL 的第一个参数是被调用的函数对象,其余的参数被传递到该函数中。因此,下面两个表达式是等价的:

(foo 1 2 3) === (funcall #'foo 1 2 3)

不过,用 FUNCALL 来调用一个当你写代码时名字已知的函数毫无意义。事实上,前面的两个表达式将很可能被编译成相同的机器指令。

下面这个函数演示了 FUNCALL 的一个更有建设性的用法。它接受一个函数对象作为参数,并使用参数函数在 min 和 max 之间以 step 为步长的返回值来绘制一个简单的 ASCII 艺术条形图:

(defun plot (fn min max step)
  (loop for i from min to max by step do
    (loop repeat (funcall fn i) do
      (format t "*"))
    (format t "~%")))

FUNCALL 表达式在每个 i 值上计算函数的值。内层 LOOP 循环使用计算得到的值来 决定向标准输出打印多少个星号。

注意到你不需要使用 FUNCTION 或 #' 来获得 fn 的函数值;你需要它被解释成一个 变量,因为它是作为函数对象的变量的值。你可以用任何接受单一数值参数的函数来调用plot,例如内置的函数EXP,其返回以e为底的其参数 的指数值。

CL-USER> (plot #'exp 0 4 1/2)
*
*
**
****
*******
************
********************
*********************************
******************************************************
NIL

然而,当参数列表只在运行期已知时 FUNCALL 的表现不佳。例如,为了再次调用 plot 函数,假设你已有一个列表,其含包括一个函数对象,一个最小和最大值以及一个步长。换句话说,这个列表包含了你想要作为参数传给 plot 的所有的值。假设这个列表保存在变量 plot-data 中。你可以像这样用列表中的值来调用 plot:

(plot (first plot-data) (second plot-data) (third plot-data) (fourth plot-data))

这样可以用,但仅仅为了将参数传给plot而显式将其解开,看起来相当讨厌。

这就是为什么需要 APPLY。和 FUNCALL 一样,APPLY 的第一个参数是一个函 数对象。但在这个函数对象之后,它期待一个列表而非单独的参数。它将函数作用在列表中的值上。这就使你可以写出下面的替代版本:

(apply #'plot plot-data)

更方便的是,APPLY 还接受 “孤立” 的参数跟作为列表的最后一个参数一起使用。因此,假如 plot-data 只含有最小、最大和步长值,那么你仍然可以像这样来使用 APPLY 在该范围上绘制 EXP 函数:

(apply #'plot #'exp plot-data)

APPLY 并不关心其所应用的函数是否接受 &optional、&rest 或是 &key 参数——由任何孤立参数和最后的列表所组合而成的参数列表必须是一个合法的参数列表,其对于该函数来说必须带有足够的参数用于所有必要的形参和适当的关键字形参。

匿名函数

一旦你开始编写或是简单地使用那些可以接受其他函数作为参数的函数,你将必然发现有时不得不去定义和命名一个仅使用一次的函数是相当恼人的,尤其是当你从不用名字来调用它时。

当用 DEFUN 来定义一个新函数看起来不必要时,你可以使用一个 LAMBDA 表达式来创建 “匿名” 的函数。如同第 3 章里讨论的那样,一个 LAMBDA 表达式看起来像这样:

(lambda (parameters) body)

一种考虑 LAMBDA 表达式的方式,是将其视为一种特殊类型的函数名,其名字本身直接 描述函数的用途。这解释了为什么你可以在一个函数名的位置上使用一个带有 #' 的 LAMBDA 表达式。

(funcall #'(lambda (x y) (+ x y)) 2 3) --> 5

你甚至可以在一个函数调用中使用 LAMBDA 表达式作为函数的名字。如果你想要的话, 你可以更简洁地书写前面的 FUNCALL 表达式:

((lambda (x y) (+ x y)) 2 3) --> 5

但几乎没人这样做;它唯一的用途是来强调将 LAMBDA 表达式用在任何一个正常函数名 可以出现的场合是合法的。

匿名函数在你需要传递一个作为参数的函数给另一个函数并且你需要传递的这个函数简单到 可以内联表达时特别有用。例如,假设你想要绘制函数 2x,你可以定义下面的函数:

(defun double (x) (* 2 x))

并随后将其传给 plot:

CL-USER> (plot #'double 0 10 1)
** 
**** 
****** 
********
**********
************
**************
****************
******************
********************
NIL

但如果写成这样将会更简单和清晰:

CL-USER> (plot #'(lambda (x) (* 2 x)) 0 10 1)
** 
**** 
****** 
********
**********
************
**************
****************
******************
********************
NI L

LAMBDA 表达式的另一项重要用途是制作闭包(closure),即捕捉了其创建时 环境信息的函数。你在第 3 章里使用了一点儿闭包,但要深入了解闭包的工作原理及其用途,更多的还是要从变量而非函数的角度去考察,因此我将在下一章里讨论它们。

你可能感兴趣的:(lisp,common,Practical)