当前位置: 首页 > 文档资料 > On Lisp >

第 3 章 函数式编程

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

前一章解释了 Lisp 和 Lisp 程序两者是如何由单一的原材料函数,建造起来的。和任何建筑材料一样,它的特质既影响了我们所建造事物的种类,也影响着我们建造它们的方式。

本章描述 Lisp 世界里较常用的一类编程方法。这些方法十分精妙,让我们能够尝试编写更有挑战的程序。

下一章将介绍一种尤其重要的编程方法,是 Lisp 让我们得以运用这种方法:即通过进化的方式开发程序,而非遵循先计划再实现的老办法。

3.1 函数式设计

事物的特征会受其原材料的影响。例如,一座木结构建筑和石结构建筑看起来就会感觉不一样。甚至当离得很远,看不清原材料究竟是木头还是石头,你也可以大体说出它是用什么造的。与之相似,Lisp 函数的特征也影响着 Lisp 程序的结构。

函数式编程意味着利用返回值而不是副作用来写程序。副作用包括破坏性修改对象(例如通过rplaca) 以及变量赋值(例如通过 setq)。如果副作用很少并且局部化,程序就会容易阅读,测试和调试。Lisp 并非从一开始就是这种风格的,但随着时间的推移,Lisp 和函数式编程之间的关系变得越来越密不可分。

这里有个例子,它可以说明函数式编程和你在用其他语言编程时的做法到底有什么不一样。假设由于某种原因我们想把列表里的元素顺序颠倒一下。这次的函数不再颠倒参数列表中的元素顺序,而是接受一个列表作为参数,返回列表中的元素与之相同但是排列次序相反。

图3.1 中的函数能对列表求逆。它把列表看作数组,按位置取反;其返回值是无意义的:

 > (setq lst '(a b c))
 (A B C)
 > (bad-reverse lst)
 NIL
 > lst
 (C B A)

函数如其名,bad-reverse 与好的 Lisp 风格相去甚远。更糟糕的是,它还有其它丑陋之处:

因为其正常工作有赖于副作用,所以它使调用者离函数式编程的理想渐行渐远。

 (defun bad-reverse (lst)
 (let* ((len (length lst))
        (ilimit (truncate (/ len 2))))
   (do ((i 0 (1+ i))
        (j (1- len) (1- j)))
     ((>= i ilimit))
    (rotatef (nth i lst) (nth j lst)))))

图3.1: 一个对列表求逆的函数

尽管是个反派角色, bad-reverse 仍有其可取之处:

它展示了 Common Lisp 交换两个值的习惯用法。

rotatef 宏可以轮转任何普通变量的值。所谓普通变量是指那些可以作为setf 第一个参数的变量。当它只应用于两个参数时,效果就是交换它们。

与之相对,图 3.2 中的函数能返回顺序相反的列表。通过使用 good-reverse ,我们得到的返回值是颠倒顺序后的列表,而原始列表原封不动:

   ;; 代码 3.2: 一个返回相反顺序列表的函数
   > (setq lst '(a b c)
   (A B C)
   > (good-reverse lst)
   (C B A)
   > lst
   (A B C)

   (defun good-reverse (lst)
     (labels ((rev (lst acc)
      (if (null lst)
          acc
          (rev (cdr lst) (cons (car lst) acc)))))
             (rev lst nil)))

过去常认为可以根据外貌来判断一个人的性格。不管这个说法对于人来说是否灵验,但是对于 Lisp 来说, 这一般是可行的。函数式程序有着和命令式程序不同的外形。函数式程序的结构完全是由表达式里参数的组合表现出来的,并且由于参数是缩进的,函数式代码看起来在缩进方面显得更为灵动。函数式代码看起来如同纸面上的行云流水; 命令式代码则看起来坚固驽钝,Basic 语言就是一例。

即使远远的看上去,从bad- 和good-reverse 两个函数的形状也能分清孰优孰劣。另外,good-reverse 不仅短些,也更加高效: 而不是因为 Common Lisp 已经有了内置的 reverse ,所以我们可以不用自己实现它。不过还是有必要简单了解一下这个函数,因为它经常能暴露出一些函数式编程中的错误观念。和 good-reverse 一样,内置的 reverse 通过返回值工作,而没有修改它的参数。但学习 Lisp 的人们可能会误以为它像 bad-reverse 那样依赖于 副作用。如果这些学习者想在程序里的某个地方颠倒一个列表的顺序,他们可能会写

 (reverse lst)

结果还很奇怪为什么函数调用没有效果。事实上,如果我们希望利用那种函数提供的效果,就必须在调用代码里自己处理。也就是需要把程序改成这样

  (setq lst (reverse lst))

调用 reverse 这类操作符的本意就是取返回值,而非利用其副作用。你自己的程序也应该用这种风格编写, 不仅因为它固有的好处,而是因为,如果你不这样写,就等于在跟语言过不去。

在比较 bad-reverse 和 good-reverse 时我们还忽略了一点,那就是 bad-reverse 里没有 cons 。它对原始列表进行操作,但却不构造新的列表。这样是比较危险的,因为有可能在程序的其他地方还会用到原始列表,但为了效率,有时可能必须这样做。为满足这种需要,Common Lisp 还提供了一个称为 nreverse 的求逆函数的破坏性版本。

所谓破坏性函数,是指那类能改变传给它的参数的函数。即便如此,破坏性函数通常也通过取返回值的方式工作:你必须假定 nreverse 将会回收利用你作为参数传给它的列表,但不能认为它帮你在原地把原来的列表掉了个。和以前一样,逆序后的列表只能通过返回值拿到。你仍然不能把

  (nreverse lst)

写在函数中间,然后假定从那以后lst 的顺序就是相反的了。在大多数实现里可以看到下面的现象:

  > (setq lst '(a b c))
  (A B C)

第 164 页有一个很典型的例子。

  > (nreverse lst)
  (C B A)
  > lst
  (A)

要想真正求逆一个列表,你就不得不把 lst 赋给返回值,这和使用原来的 reverse 是一样的。

如果我们知道某个函数有破坏性,这并不是说:调用它就是为了利用其副作用。危险之处在于,有的破坏性函数给人留下了破坏性的印象。例如:

  (nconc x y)

几乎总是和:

  (setq x (nconc x y))

效果相同。如果你写的代码依赖于前一个用法,有时它可以正常工作。然而当 x 为 nil 时,结果就会出人意料。

只有少数 Lisp 操作符的本意就是为了副作用。一般而言,内置操作符本来是为了调用后取返回值的。不要被sort,remove 或者 substitute 这样的名字所误导。如果你需要副作用,那就对返回值使用setq。

这个规则主张某些副作用其实是难免的。坚持函数式的编程思想并没有提倡杜绝副作用。而是说除非必要最好不要有。

养成这个习惯可能要花些时间。不妨开始时先尽量少用下列的操作符:

  set setq setf psetf psetq incf decf push
  pop pushnew rplaca rplacd rotatef shiftf
  remf remprop remhash

还包括 let* ,命令式程序经常藏匿其中。在这里要求有节制地使用这些操作符的目的只是希望倡导良好的Lisp 风格,而不是想制定清规戒律。然而,仅此一项就可让你受益匪浅了。

在其他语言里,导致副作用的最普遍原因就是让一个函数返回多个值的需求。如果函数只能返回一个值,那它就不得不通过改变参数来 "返回" 其余的值。幸运的是,在 Common Lisp 里不必这样做,因为任何函数都可以返回多值。

举例来说,内置函数 truncate 返回两个值,被截断的整数,以及原来数字的小数部分。在典型的实现中,在最外层调用这个函数时两个值都会返回:

  > (truncate 26.21875)
  26
  0.21875

当调用方只需要一个值时,被使用的就是第一个值:

  > (= (truncate 26.21875) 26)
  T

通过使用 multiple-value-bind ,调用方代码可以捕捉到两个值。该操作符接受一个变量列表、一个调用,以及一段程序体。变量将被绑定到函数调用的对应返回值,而这段程序体会依照绑定后的变量求值:

  > (multiple-value-bind (int frac) (truncate 26.21875)
  (list int frac))
  (26 0.21875)

最后,为了返回多值,我们使用 values 操作符:

  > (defun powers (x)
        (values x (sqrt x) (expt x 2)))
  POWERS
  > (multiple-value-bind (base root square) (powers 4)
         (list base root square))
             (4 2.0 16)

一般来说,函数式编程不失为上策。对于 Lisp 来说尤其如此,因为 Lisp 在演化过程中已经支持了这种编程方式。诸如 reverse 和 nreverse 这样的内置操作符的本意就是以这种方式被使用的。其他操作符,例如 values 和 multiple-value-bind,是为了便于进行函数式编程而专门提供的。

3.2 内外颠倒的命令式

函数式程序代码的用意和那些更常见的方法,即命令式程序相比可能显得更加明确一些。函数式程序告诉你它想要什么;而命令式程序告诉你它要做什么。函数式程序说 "返回一个由 a 和 x 的第一个元素的平方所组成的列表:"

  (defun fun (x)
  (list 'a (expt (car x) 2)))

而命令式程序则会说 "取得 x 的第一个元素,把它平方,然后返回由 a 及其平方组成的列表":

  (defun imp (x)
    (let (y sqr)
    (setq y (car x))
     (setq sqr (expt y 2))
     (list 'a sqr)))

Lisp 程序员有幸可以同时用这两种方式来写程序。某些语言只适合于命令式编程 尤其是 Basic,以及大多数机器语言。事实上,imp 的定义和多数 Lisp 编译器从 fun 生成的机器语言代码在形式上很相似。

既然编译器能为你做,为什么还要自己写这样的代码呢?对于许多程序员来说,他们甚至从没想过这个问题。语言给我们的思想打上烙印:一些习惯于命令式语言编程的人或许已经开始用命令式的术语思考问题,而且会觉得写命令式程序比写函数式程序更容易。如果有一种语言可以助你一臂之力,这种思维定势是值得克服的。

对于其他语言的同行来说,刚开始使用 Lisp 可能像初次踏入溜冰场那样。事实上在冰上比在干地面上更容易行走 如果使用溜冰鞋的话。然后你对这项运动的看法就会彻底改观。

溜冰鞋对于冰的意义,和函数式编程对 Lisp 的意义是一样的。这两样东西在一起让你更优雅地移动,事半功倍。但如果你已经习惯于另一种行走模式,那么开始的时候你就无法体会到这一点。把 Lisp 作为第二语言学习的一个障碍就是学会如何用函数式的风格来编程。

幸运的是,有一种把命令式程序转换成函数式程序的诀窍。开始时你可以把这一诀窍用到写好的代码里。

不久以后你就可以预想到这个过程,一边写代码,一边做转换了。而在这之后一段时间,你就有能力从一开始就用函数式的思想构思你的程序。

这个诀窍就是认识到命令式程序其实是一个从里到外翻过来的函数式程序。要想找出藏在命令式程序中的函数式程序,也只要把它从外到里翻一下。让我们在 imp 上实践一下这个技术。

我们首先注意到的是初始 let 里 y 和 sqr 的创建。这预示着接下来会出问题。就像运行期的 eval ,需要未初始化变量的情况很罕见,它们因而被看作程序染病的症状。这些变量就像插在程序上,用来固定的图钉,它们被用来防止程序自己卷回到原形。

不过我们暂时先不考虑它们,直接看函数的结尾。命令式程序里最后发生的事情,也就是函数式程序在最外层发生的事情。所以第一步是抓住最后对 list 的调用,然后把程序的其余部分塞进去就好像把一件衬衫从里到外翻过来。我们继续重复做相同的转换,就好像我们先翻衬衫的袖子,然后再翻袖口那样。

从结尾处开始,我们将 sqr 替换成 (expt y 2),得到:

  (list 'a (expt y 2))

然后我们将y 替换成 (car x):

  (list 'a (expt (car x) 2))

现在我们可以把其余代码扔掉了,因为之前已经把所有内容都填到了最后一个表达式里。在这个过程中我们摆脱了对变量 y 和 sqr 的依赖,因而也得以把 let 一起扔进垃圾堆。

最终的结果比开始的时候要短小,而且更好懂。在原先的代码里,我们面对最终的表达式 (list 'a sqr), 却无法一眼看出 sqr 的值的出处。现在,返回值的来历则像交通指示图一样一览无余。

本章的这个例子很短,但这里的技术是可以推广的。事实上,它对于大型函数应该更有价值。即使存在一些有副作用的函数,也可以把其中没有副作用的那部分清理得干净一些。

3.3 函数式接口

某些副作用比其他的更糟糕。例如,尽管下面的函数调用了 nconc

  (defun qualify (expr)
  (nconc (copy-list expr) (list 'maybe)))

但它没有破坏引用透明。如果你每次都传给它一个确定的参数,那它的返回值将总是相同(equal) 的。

从调用者的角度来看,qualify 就和纯函数型代码一样。但我们不能对bad-reverse (第19页) 下同样的评语,这个函数事实上修改了它的参数。

如果不把所有副作用的有害程度都划上等号,而是有方法能把这些情况分出个高下,那样将会对我们有很大的帮助。可以非正式地说,如果一个函数修改的是其他函数都不拥有的东西,那么它就是无害的。例如, qualify 里的 nconc 就是无害的,因为作为第一个参数的列表是新生成的。它不属于任何其他函数。

通常,在我们提到拥有者关系时,不能说变量的拥有者是某某函数,而应该说其拥有者是函数的某个调用。

尽管这里并没有其他函数拥有变量 x :

  (let ((x 0))
  (defun total (y)
  (incf x y)))

但一次调用的效果会在接下来的调用中看到。所以规则应当是:一个给定的调用 (invocation) 可以安全地修改它唯一拥有的东西。

究竟谁是参数和返回值的拥有者 依照Lisp 的习惯,是函数的调用拥有那些作为返回值得到的对象,但它并不拥有那些作为参数传给它的对象。凡是修改参数的函数都应该打上"破坏性" 的标签,以示区别,但如果函数修改的只是返回给它们的对象,那我们没有准备什么特别的称号给这些函数。

譬如,下面的函数就听从了这个提议:

  (defun ok (x)
  (nconc (list 'a x) (list 'c)))

但它调用的nconc 却置若罔闻。由于nconc 拼出来的列表总是重新生成的,而没有使用原来传给ok 作为参数的那个列表,所以ok 总的来说是ok 的。

如果稍微改一点儿,例如:

    (defun not-ok (x)
    (nconc (list 'a) x (list 'c)))

那么对nconc 的调用就会修改传给not-ok 的参数了。

许多Lisp 程序没有遵守这个惯例,至少在局部上是这样。尽管如此,正如我们从 ok 那里看到的,局部的违背并不会让主调函数变质。而且那些与上述情况相符的函数仍会保留很多纯函数式代码的优点。

要想写出真正意义上的函数式代码,还要再加个条件。函数不能和不遵守这些规则的代码共享对象。例如,尽管这个函数没有副作用:

    (defun anything (x)
    (+ x *anything*))

但它的返回值依赖于全局变量 anything。因此,如果任何其他函数可以改变这个变量的值,那么anything 就可能返回任意值。

关于引用透明的定义见135页。

要是把代码写成让每次调用都只修改它自己拥有的东西的话,那这样的代码就基本上就可以和纯函数式代码媲美了。从外界看来,一个满足上述所有条件的函数至少会拥有有函数式的接口:如果用同一参数调用它两次,你应当会得到同样的结果。正如下一章所展示的那样,这也是自底向上程序设计最重要的组成部分。

破坏性的操作符还有个问题,就是它和全局变量一样会破坏程序的局部性。当你写函数式代码时,可以集中精力:只要考虑调用正在编写的函数的调用方,或者被调用方就行了。要是你想要破坏性地修改某些数据,这个好处就不复存在了。你修改的数据可能在任何一个地方用到。

上面的条件不能保证你能得到和纯粹的函数式代码一样的局部性,尽管它们确实在某种程度上有所改进。

例如,假设f 调用了g ,如下:

    (defun f (x)
    (let ((val (g x)))
     ; safe to modify val here?
    ))

在f 里把某些东西nconc 到val 上面安全吗 如果g 是identity 的话就不安全:这样我们就修改了某些原本作为参数传给 f 本身的东西。

所以,就算要修改那些按照这个规定写就的程序,还是不得不看看f 之外的东西。虽然要多操心一些,但也用不着看得太多:现在我们不用复查程序的所有代码,只消考虑从f 开始的那棵子树就行了。

推论之一是函数不该返回任何不能安全修改的东西。如此说来,就应当避免写那些返回包含引用对象的函数。如果我们这样定义exclaim ,让它的返回值包含一个引用列表,

    (defun exclaim (expression)
    (append expression '(oh my)))

那么任何后续的对返回值的破坏性修改

    > (exclaim '(lions and tigers and bears))
    (LIONS AND TIGERS AND BEARS OH MY)
    > (nconc * '(goodness))
    (LIONS AND TIGERS AND BEARS OH MY GOODNESS)

将替换函数里的列表:

    > (exclaim '(fixnums and bignums and floats))
    (FIXNUMS AND BIGNUMS AND FLOATS OH MY GOODNESS)

为了避免exclaim 的这个问题,它应该写成:

    (defun exclaim (expression)
    (append expression (list 'oh 'my)))

虽说函数不应返回引用列表,但是这个常理也有例外,即生成宏展开的函数。宏展开器可以安全地在它们的展开式里包含引用列表,只要这些展开式是直接送到编译器那里的。

其他时候,还是应该审慎地对待引用列表。除了上面的例外情况,如果发现用到了引用列表,很多情况,这些代码是完全可以用类似in (103页) 这样的宏来完成的。

3.4 交互式编程

前一章说明了函数式的编程风格是一种组织程序的好办法。但它的好处还不止于此。Lisp 程序员并非完全是从美感出发才采纳函数式风格的。他们采用这种风格是因为它让工作更轻松。在 Lisp 的动态环境里, 函数式程序能以非同寻常的速度写就,与此同时,写出的程序也非同寻常的可靠。

在 Lisp 里调试程序相对简单。很多信息在运行期是可见的,可以帮助追查错误的根源。但更重要的是你

可以轻易地测试程序。你不需要编译一个程序然后一次性测试所有东西。你可以在toplevel 循环里通过逐个地调用每个函数来测试它们。

增量测试非常有用,为了更好地利用它,Lisp 风格也随之改进。用函数式风格写出的程序可以逐个函数地

理解它,从读者的观点来看,这是它的主要优点。此外,函数式风格也极其适合增量测试:以这种风格写出的程序可以逐个函数地进行测试。当一个函数既不检查也不改变外部状态时,任何bug 都会立即现形。这样,函数影响外面世界的唯一渠道是它的返回值。只要返回值是你期望的,你就完全可以信任返回它的代码。

事实上有经验的Lisp 程序员会尽量让他们的程序易于测试:

  1. 他们试图把副作用分离到个别函数里,以便程序中更多的部分可以写成纯函数式风格。

  2. 如果一个函数必须产生副作用,他们至少会想办法给它设计一个函数式的接口。

  3. 他们给每个函数赋予一个单一的,定义良好的功能。

一旦函数按照这种办法写成,程序员们就可以用一组有代表性的情况对它测试,测试好了,就使用另一组情况测试。如果每一块砖都各司其职,那么围墙就会屹立不倒。

在Lisp 里,一样可以更好地设计围墙。先假想一下,如果谈话的时候,和对方距离很远,声音的延迟甚至有一分钟,会有什么样的一番感受。要是换成和隔壁房间的人说话,会有怎样的改观。这样,将进行的对话不仅仅是速度比原来快,而是一个完全不同的对话。在Lisp 中,开发软件就像是面对面的交流。你可以边写代码边做测试。和对话相似,即时的回应对于开发来说一样有戏剧化的效果。你不只是把原先的程序写得更快,而是会写出另一种程序。

这是什么道理 当测试更便捷时,你就可以更频繁地进行测试。对于Lisp,和其他语言一样,开发是由编码和测试构成的循环往复的周期性过程。但在Lisp 的周期更短:单个函数,甚至函数的一部分都可以成为一个开发周期。并且如果一边写代码一边测试的话,当错误发生时你就知道该检查哪里:应该看看最后写的那部分。正如听起来那样简单,这一原则极大地提高了自底向上编程的可行性。它带来了额外的信赖感, 使得Lisp 程序员至少在一定程度上从旧式的计划–实现的软件开发风格中解脱了出来。

第 1.1 节强调了自底向上的设计是一个进化的过程。在这个过程中,你在写程序的同时也就是在构造一门语言。这一方法只有当你信赖底层代码时才可行。如果你真的想把这一层作为语言使用,你就必须假设, 如同使用其他语言时那样,任何遇到的bug 都是你程序里的bug,而不是语言本身的。

难道你的新抽象有能力承担这一重任,同时还能按照新的需求随机应变?没错,在Lisp 里你可以两不误。

当以函数式风格编写程序,并且进行增量测试时,你可以得到随心所欲的灵活性,加上人们认为只有仔细计划才能确保的可靠性。