huangyufeng0 2019-06-26
按语:我从悬崖上跳了下去,在除了坠落没别的事可干的过程中为「不懂编程的人」写了这一系列文章的第八篇,整理于此。它的前一篇是《周游抑或毁灭世界》,讲述了递归函数的基本用法。
警告,这一篇很长。若不能坚持看到最后,最好还是别看了。更何况,不看它,也没啥损失。
下面这个函数,可以对自然数求和:
(defun sum (n) (if (= n 0) 0 (+ n (sum (- n 1)))))
传说数学界的大宗师高斯同学上小学的时候,曾因飞快地算出从 1 到 100 的自然数之和而震惊四座。
在计算机里不需要精巧,只需要笨拙,所以上述的 sum
函数就是从大到小,将数字逐一累加。例如:
(sum 100)
在 sum
这台发动机周而复始的运转中,每次都将自己的参数减去 1,然后与自己下一次的运行结果相加,最终发动机的运转轨迹形成了 100 + 99 + ... + 1 + 0 这样的表达式,当然在 Emacs Lisp 里是这样的:
(+ 100 (+ 99 (+ ... (+ 1 0) ...)))
当 sum
发现自己的参数为 0 时,它就停止转动,此时 Emacs Lisp 就得到了上述的表达式,接下来,Emacs Lisp 解释器就开始对这个很长的表达式求值,不过就是逐一完成数字累加的这种粗笨的工作,结果为 5050,跟高斯算出来的一样,而且可能比高斯算得还要快许多倍。
不过,这篇文章不是再次重复发动机啊发动机啊的,而是开始思考怎么去定义一个不知道名字的发动机。
再看一遍 sum
函数的定义:
(defun sum (n) (if (= n 0) 0 (+ n (sum (- n 1)))))
要让一个发动机周而复始地运转,必须得知道它的名字。有了名字,sum
函数方能在自己的定义中对自己进行求值。
于是,问题就来了。倘若宇宙的一切运动是由一台发动机驱动起来的,那么这台发动机是如何运转的呢?这台发动机没有名字,因为它比我们这些命名者先出现。这台发动机的存在,暗示着,没有名字也能周而复始。
所谓函数,本质上就是值与值的映射,它有没有名字,无关紧要,关键在于能否在不依赖名字的前提下精确描述映射关系。为此,Emacs Lisp 语言提供了让函数匿名的语法——Lambda 表达式。对于函数 f(x, y) = x + y,用 Lambda 表达式可表示为:
(lambda (x y) (+ x y))
倘若 x = 1, y = 2,那么如何通过这个 Lambda 表达式对 f(x, y) 进行求值呢?像下面这样做:
(funcall (lambda (x y) (+ x y)) 1 2)
在 Emacs Lisp 程序中,需要借助 funcall
方能对匿名函数进行求值。在其他的一些 Lisp 语言里,可不必如此。没错,世界上不止一种 Lisp,Emacs Lisp 不过是 Lisp 语言世界里的一种方言。在 Scheme 这种 Lisp 方言里,对上述的匿名函数进行求值,只需:
((lambda (x y) (+ x y)) 1 2)
没有名字,会让世界变得更混乱一些,不过这反而是世界原本的样子。从这个角度去探索世界的本原会更为客观,可能也更为奇妙。
当我们仰望星空,俯瞰大地,感觉冥冥中有一种力量在驱动着或者支配着这个世界的运行,我们没法给这种力量取名,即使取了名,似乎也没有用,因为没法根据所取的名字去描述它的运作原理。智慧如老子,也只能勉强给这种力量取个名字,叫作「大」。大,就是没有边界。没有边界,就是非常遥远。非常遥远,就是自己的后脑勺——你向任何一个方向看去,距离你最远的地方是你的后脑勺。
不能给这种力量取名,就没法描述它的运作原理了吗?倘若世界受一台没有名字的发动机的驱动而运转,那么我们随便从这个世界里找一台发动机,然后利用匿名函数消除这台发动机的名字,结果是不是就能够切实感受到它的存在呢?
试试看:
(lambda (n) (if (= n 0) 0 (+ n (sum (- n 1)))))
很好,已经消除掉外层的 sum
了,但是里面的 sum
怎么消除?这需要借助一个小技巧,即它虽然在那里,但是我们可以装作看不见它。就像房间里有一个人,你不喜欢他,但是又没有能力让他消失,所以只好装作没看见他。每个人应该都具备这种技能。那么,怎样对上面这个匿名函数里面的 sum
视而不见呢?把它当作房间里一种没有意义的物件就可以了,像下面这样:
(lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (thing (- n 1))))))
没有意义,就是意义不确定。意义不确定的东西,就是变量。函数的参数是变量。所以,只需要将 sum
变成一个函数的参数,就相当于对它视而不见处了。
现在已经成功地将所有的 sum
消除了,我们得到了一个没有名字的函数。不过,再仔细看一下,真的得到了一个没有名字的函数吗?在上述函数的定义中,(thing (- n 1))
显然是一个函数求值表达式,因此我们不经意间依然将 thing
当成了一个有名字的函数了。要彻底的消除名字,必须将 thing
当成一个匿名函数,对它进行求值要借助 funcall
,因此上述函数应当改成:
(lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1))))))
注:在一些其他 Lisp 方言里,例如 Scheme,不需要这一步。
现在算是彻底消除了名字。现在,我们将这个函数视为我们所创造的第一个没有名字的函数,但是为了便于描述,姑且将其称为 X。对 X 进行求值,需要向它传递一个匿名函数,求值结果是一个匿名函数,就这么奇怪。
X 能够用于自然数序列的求和吗?试试看:
(funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1)))))) ...)
注:SegmentFault 网站的 Markdown 解析器可能有 Bug。以单个字母 + 1 个空格开头的文本,会被误认为是带编号的列表,从而变成 1. ... ...
。
写着写着就发现,在省略号的地方写不下去了,不知道该向这个函数传递什么样的参数。虽然我们很清楚,thing
应该是一个匿名函数,但是我们现在并没有这个函数。因此,不妨试着随便定义一个,将它作为 thing
传给 X,看看会发生什么:
(funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1)))))) (lambda (m) m))
新定义的匿名函数就是 (lambda (m) m)
,这个函数什么也没做,就是把自己接受的参数作为求值结果。将它作为参数传递给 X 之后,X 的求值结果就是下面这个匿名函数:
(lambda (n) (if (= n 0) 0 (+ n (funcall (lambda (m) m) (- n 1)))))
倘若将 100
传递给这个匿名函数,即:
(funcall (lambda (n) (if (= n 0) 0 (+ n (funcall (lambda (m) m) (- n 1))))) 100)
结果得到 199,而不是 5050。看来这个匿名函数只能算 100 + 99。不要沮丧,因为我们现在又得到了一个可以作为 thing
传给 X 的匿名函数了。把它传给 X:
(funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1)))))) (lambda (m) m))
现在,得到的是能够计算 100 + 99 + 98 的匿名函数。
是不是发现了一点玄机了?我们一开始随便定义了一个匿名函数,把它传给 X,结果得到了一个新的匿名函数,然后再将这个新的匿名函数传递给 X。
再试试用同样的手法, 将能够计算 100 + 99 + 98 的匿名函数传给 X:
(funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1)))))) (funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1)))))) (lambda (m) m)))
结果就得到了一个能计算 100 + 99 + 98 + 97 的匿名函数。
继续将能计算 100 + 99 + 98 + 97 的匿名函数传给 X:
(funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1)))))) (funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1)))))) (funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall thing (- n 1)))))) (lambda (m) m))))
结果就得到了一个能计算 100 + 99 + 98 + 97 + 96 的匿名函数了。
只要你不怕麻烦,可以将上述过程继续下去,最终 X 就能算出 100 + 98 + ... + 1 + 0。最后一次传给 X 的匿名函数必定是体积极为庞大的怪物。
为了更便于理解,我们用 X 这个名字,将能计算 100 + 99 + 98 + 97 + 96 的匿名函数简写成:
(funcall X (funcall X (funcall X (lambda (m) m))))
利用 X 生成匿名函数,再将这个匿名函数传递给 X,这个想法很好,只是在实现上有些愚蠢。这个过程类似于为了实现 1 个发动机运转 100 圈的效果,制造了 100 个发动机,让它们的每一个只运转一圈。即使这样做,也不是太难,但是倘若要实现 1 个发动机转无数圈的效果呢?好办,制造无数个发动机,让每一个只运转一圈。倘若你真的这样想,那就对了。制造无数个发动机,每一个只运转一圈,这不就是相当于将 X 作为参数传给自己,然后让它运转一圈吗?这个过程应该像下面这样描述:
(funcall X X)
当然,在 X 的定义中,需要让作为参数的 X 运转一圈,以便生成像 (lambda (m) m)
这样的匿名函数。因此,将 X 的定义修改为:
(lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall (funcall thing thing) (- n 1))))))
然后按照 (funcall X X)
这样写:
(funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall (funcall thing thing) (- n 1)))))) (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall (funcall thing thing) (- n 1)))))))
上述表达式的求值结果是一个匿名函数。个匿名函数是什么呢,就是 sum
函数。不信的话,就用它来计算 0 到 100 的和:
(funcall (funcall (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall (funcall thing thing) (- n 1)))))) (lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall (funcall thing thing) (- n 1))))))) 100)
这个表达式看上去很复杂,把它理解为 (funcall (funcall X X) 100)
会更清楚。这样,我们就构造了一个比上文用 X 构造的匿名函数再传给 X 的代码简洁了将近 100 倍的可对自然数序列求和的匿名函数。
看,我们没有使用 defun
,单纯借助匿名函数就实现了一个递归函数。这说明了什么?这说明了,即使这个世界没有任何名字,它依然能够运转,亦即世界只能感受或描述,而不能定义。
一个周而复始的发动机,它之所以能够如此,不是因为它有了名字,而是因为它的本质在于将自己作为参数传递给自己。这就是函数递归求值的本质。
虽然我说的天花乱坠,但上面那个表达式实际上是无法求值的,Emacs Lisp 解释器会报错,说 thing
符号作为变量是无效的。这是因为,Emacs Lisp 语言由于历史太过于悠久,而 Lisp 的世界里许多好东西出现的比较晚,为了兼容过去的程序,导致 Emacs Lisp 解释器不得不墨守成规。要让上述表达式能够正确求值,需要在它之前使用下面的语句开启开启词法域模式:
(setq lexical-binding t)
Emacs Lisp 解释器默认在动态作用域模式下工作,不能正确识别作为参数传递的匿名函数,而词法域模式却可以。关于动态作用域与词法域,以后再作介绍。
现在,我们仅实现了一个特定功能的匿名函数的递归求值,还没有真正达成我们的目标,即寻找一个通用的匿名函数递归机制。只有这种发动机能够驱动整个世界。不过,我们似乎已经有了一个正确的方向,现在要做的就是,继续前进。
重新观察 X:
(lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall (funcall thing thing) (- n 1))))))
位于内层的匿名函数是与特定功能相关的部分,我们可以用前文已经使用了多次的手法,将这部分代码提出来,作为一个单独的匿名函数,称之为 F,然后想办法将它作为参数传给 X 即可。
F 的定义如下:
(lambda (n) (if (= n 0) 0 (+ n (funcall (funcall thing thing) (- n 1)))))
这个定义是错的,因为 thing
原本是 X 的参数。之前, F 位于 X 内部,它认识 thing
,现在它脱离了 X,就不认识它了。该怎么办呢?用老办法,凡是自己不喜欢的或者不认识的,统统提升为参数,装作没看见它们……于是,我们将 F 修改为:
(lambda (thing) (lambda (n) (if (= n 0) 0 (+ n (funcall (funcall thing thing) (- n 1))))))
等会!这东西似曾相识,它不就是 X 么?没错……X 的引力太大,F 似乎无法挣脱它而孤立地存在。
再仔细研究一下,F 之所以难以摆脱 X,主要是因为 (funcall (funcall thing thing) (- n 1))
的制约。这是个函数求值表达式,(funcall thing thing)
是一个函数,它接受一个参数。我们想办法把它作为 F 的参数如何?
试试看:
(lambda (thing*) (lambda (n) (if (= n 0) 0 (+ n (funcall thing* (- n 1))))))
为了与 X 的参数 thing
有所区分,我特意将 F 的参数命名为 thing*
,而实际上,继续用 thing
也没事。
倘若我们对 F 像下面这样求值:
(funcall (lambda (thing*) (lambda (n) (if (= n 0) 0 (+ n (funcall thing* (- n 1)))))) (lambda (m) (funcall (funcall thing thing) m)))
看上去有点乱,这样看就比较清楚了:
(funcall F (lambda (m) (funcall (funcall thing thing) m)))
就是向 F 传递了一个具有 1 个参数的匿名函数,函数体中的 thing
是 X 的参数,所以我们可以将上述表达式扔到 X 的定义里:
(lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m))))
看,现在 X 里面不再有任何特定功能的代码了。我们已经成功地将原先的自然数求和的代码从 X 中分离了出来,这部分代码构成了匿名函数 F。
现在,我们再用 (funcall X X)
的办法对 X 进行求值,即:
(funcall (lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m)))) (lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m)))))
这样,就可以得到一个递归的匿名函数,而且这个匿名函数里面没有任何特定功能的代码,它是一个通用的匿名递归函数。不过,现在它还不知道 F 是什么。用老办法,凡是你不喜欢的或者你不知道的,若你不想因为它们而坏掉好心情,就将它们统统提升为函数的参数:
(lambda (F) (funcall (lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m)))) (lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m))))))
我们将这个函数称为 Y。
还记得上面所定义的含有自然数求和代码的那个 F 吗?
(lambda (thing*) (lambda (n) (if (= n 0) 0 (+ n (funcall thing* (- n 1))))))
以它为参数,对 Y 进行求值,即
(funcall (lambda (F) (funcall (lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m)))) (lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m)))))) (lambda (thing*) (lambda (n) (if (= n 0) 0 (+ n (funcall thing* (- n 1)))))))
看着挺复杂,实际上不过是 (funcall Y F)
,求值结果是什么呢?一个匿名的自然数序列求值函数。倘若不信,那么用它算一下 0 到 100 的和:
(funcall (funcall (lambda (F) (funcall (lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m)))) (lambda (thing) (funcall F (lambda (m) (funcall (funcall thing thing) m)))))) (lambda (thing*) (lambda (n) (if (= n 0) 0 (+ n (funcall thing* (- n 1))))))) 100)
结果为 5050。
上述的匿名函数 Y,由于它所接受的参数是一个匿名函数。按照数学家们的说法,不管函数是不是匿名的,只要是参数为函数的函数,就叫算子。所以,函数 Y 应该叫 Y 算子,当然更多的人愿意称它为 Y 组合子,并将这个算子视为匿名函数世界中的神迹。发现这个算子的那个数学家,将这个算子的数学公式 Y = λf. (λx. f(x x)) (λx. f(x x)) 纹在了自己的胳膊上。
这个 Y 组合子,也就是我们一开始想要寻找的那个驱动整个世界的无名发动机。不过,现在它看上去还有点不够理想。因为它的参数 F
只是具有 1 个参数的函数,这意味着 Y 组合子构造的匿名递归函数只能接受 1 个参数。不过,这不是什么大问题,多个参数的函数总是能通过单个参数的函数构造出来,不过,这是另外一个话题了。
这篇文章可能你看不懂。没关系,不懂这个,不影响学会编程。类似于不懂汽车发动机原理,一样可以驾驶汽车。毕竟我们是生活在一个到处都充满了名字的世界里。似乎老子早在两千多年前就看清了这一切,他低吟着:无名,万物之始也;有名,万物之母也。故常无欲,以观其妙;常有欲,以观其徼……虽然数学家发现了 Y 组合子,但他们也不会真的在编程中使用这种东西来写递归函数。
倘若你真的很想看懂这篇文章,唯一的办法是,集中精力,动手动脑,逐步推导。理解 Y 组合子,并非毫无意义,将 Y 倒过来,它是个人。这方面的话题,我不想多说,不然会令人反感。
下一篇:长长的望远镜
在写这篇文章时,参考了以下网络文档: