按语:我在一锅汤里为「不懂编程的人」写了这一系列文章的第十篇,整理于此。它的前一篇是《长长的望远镜》,作为《无名》一篇的补充,介绍了 Emacs Lisp 的动态域与词法域。
警告,这一篇虽然很长,不过,基本上想看到哪就看到哪,随便看点,都算是对 Emacs Lisp 又多了一点了解。
有这么一个列表:
(list 3 1 0 8 10 9 7 5 4 999 30)
由于列表中都是数字原子,没有需要求值的列表,因此也可以将它写成
'(3 1 0 8 10 9 7 5 4 999 30)
我们一眼可以看出,这个列表中的数字原子的排列没有顺序。倘若我们很在意这个列表,它就可能会让我们有点不舒服。我们更愿意看到像
'(0 1 3 4 5 7 8 9 10 30 999)
或者
'(999 30 10 9 8 7 5 4 3 1 0)
这样的列表。我们认为这样的列表是有序的,更易于记忆。不妨试验一下,用 7 秒的时间可能很难记住上面的那个没有顺序的列表,但是用同样的时间很容易记住有序的列表。
用 Emacs Lisp 语言如何编写一个能够将混乱的数字列表转化为有序的列表的程序呢?
向量
任何排序,第一步是给要排序的对象创造一个有序的空间,而且保证能够瞬间访问或修改这个空间的任一位置上的元素。在 Emacs Lisp 里,这样的空间称为向量。
像使用 list
函数构造列表那样,vector
可以构造向量:
(vector 1 2 3 (+ 5 4) (list 1 2 3))
这个表达式的求值结果为:
[1 2 3 9 (1 2 3)]
这是一个由数字原子与列表构成的向量。
要访问向量中的任一位置上的元素,可以使用 aref
函数。例如:
(aref (vector 1 2 3 (+ 5 4) (list 1 2 3)) 4)
可以访问向量的第 5 个位置的元素,结果为列表 (1 2 3)
。
aref
接受的参数是 4,为什么可以访问向量的第 5 个位置呢?这是因为向量的位置编号是从 0 开始的,所以编号为 4 的位置是第 5个位置。
务必记住,向量的位置编号是自然数,而自然数是从 0 开始。
下面这个表达式
(aref (vector 1 2 3 (+ 5 4) (list 1 2 3)) -1)
它的求值结果是什么?Emacs Lisp 解释器会报错,说参数超出向量的范围。因为向量的位置编号是自然数,而自然数里没有负数。
下面这个表达式
(aref (vector 1 2 3 (+ 5 4) (list 1 2 3)) 5)
它的求值结果是什么?Emacs Lisp 解释器会报错,说参数超出向量的范围。因为这个向量里面只有 5 个元素,没有第 6 个。
务必记住,访问向量中的元素,不能超出向量的长度。
要确定一个向量有多长,可以用 length
函数。例如:
(length (vector 1 2 3 (+ 5 4) (list 1 2 3)))
结果为 5。
注:
length
也能用于确定一个列表的长度。
向量中某个位置上的元素不仅能够访问,也能修改,需要使用 aset
函数。例如,将上述向量的第 3 个位置上的元素修改为布尔值 t
:
(setq v (vector 1 2 3 (+ 5 4) (list 1 2 3)))
(aset v 2 t)
v
对向量 v
的求值结果为 [1 2 t 9 (1 2 3)]
。
在不知道向量中具体包含哪些元素,只知道向量的长度以及向量元素的类型时,可以使用 make-vector
函数构造向量。例如:
(make-vector 7 0)
求值结果为 [0 0 0 0 0 0 0]
。上述的参数值 0
是向量元素的初始值,可根据需要自行选择其他类型的值。
从列表到向量
要对一个只包含数字原子的列表进行排序,需要将列表中的元素放入与列表等长度的向量里面。现在终于有了一个有助于我们深刻理解列表的好机会。
首先,创造与列表等长度的向量:
(make-vector (length a-list) 0)
有了这样的向量,就可以将列表中的元素一个一个放进向量里:
(defun list-to-vector (src dest n)
(if (not src)
dest
(progn
(aset dest n (car src))
(list-to-vector (cdr src) dest (+ n 1)))))
这个函数定义里出现了几个之前从未提到的函数,car
、cdr
以及 not
。
对于任何列表访问任务而言,它们是最基本的函数。car
可以访问列表的第一个元素,cdr
可以访问列表的第一个元素之后的部分。倘若将列表比喻为毛毛虫,那么 car
可以访问毛毛虫的头部,而 cdr
则能访问毛毛虫的身体。
not
函数用于对一个布尔值取反,即将真(t
)变成假(nil
),将假变成真。在上述函数的定义中,用 (not src)
来测试 src
是否为空的列表(即 nil
或 '()
)。这样做,虽然没有错误,但是却有些别扭,更地道的办法是 (null src)
。
将 car
、cdr
以及 null
连接到一个发动机上,就可以访问整个链表:
(defun 周游列表 (列表)
(if (null 列表)
<求值结果>
(progn
<(car 列表) 参与的运算>
(周游列表 (cdr 列表)))))
在这个发动机的运转过程中,它接受的参数是一个在 cdr
函数作用下不断缩小的列表,在它的内部则由 car
函数取出的列表首元素可以参与各种运算。上述的 list-to-vector
就是这种形式的发动机。
上述的代码片段
(if (null 列表)
<求值结果>
用于判定对列表的访问过程是否终止。之所以要作这种形式的判断,是因为 周游列表
的求值逻辑是每次取出列表的首元素之后,就让剩余元素参与下一轮的运算,这个过程相当于 周游列表
每次轮回都会「砍掉」列表的首部元素,只要列表的长度有限,终将会出现无首部元素可砍的空表,而对一个空表取反,结果为真。
无首部元素可砍的表,实际上是一个空表,即 '()
,它与 nil
等价,而 (null nil)
的结果必定为真。所以,对于一个有限长度的列表,在 car
、cdr
以及一个周而复始的函数的作用下,总是可以用上述的条件表达式来判定这个周而复始的过程是否应当就此终止。
可以用下面的语句验证 list-to-vector
函数是否能正确运行:
(setq src '(3 1 0 8 10 9 7 5 4 999 30))
(setq dest (make-vector (length src) 0))
(list-to-vector src dest 0)
求值结果应该是 [3 1 0 8 10 9 7 5 4 999 30]
。
变量
看一下上一节最后出现的语句:
(setq src '(3 1 0 8 10 9 7 5 4 999 30))
(setq dest (make-vector (length src) 0))
(list-to-vector src dest 0)
setq
将 src
与一个列表进行了绑定,将 dest
这与一个向量进行了绑定。src
与 dest
都是符号,而 setq
似乎可以将它们与任何一种表达式进行绑定。因此,我们可以用有限的符号去绑定的无限多的表达式。
从效用上来看,这些符号与函数所接受的参数相似。既然我们可以将函数的参数视为变量,就应该将这些符号也视为变量才对。再者,由于函数本身可以作为参数传递给其他函数,这意味着函数其实也是变量。定义一个函数,本质上不过是 defun
将一个符号与一个表达式绑定了起来。
变量与函数,没必要分得太过于清楚。一个符号,与任意一个表达式存在绑定关系,那么这个符号就是变量。setq
的工作就是将更换一个符号的绑定对象。
从现在开始,除了定义函数之外,我会将符号与表达式绑定起来的这种行为称为定义变量。
Emacs Lisp 语言具有更紧凑的变量定义语法——let 表达式。它的用法可以通过改写上述的三个分离的表达式得以充分体现,即:
(let ((src '(3 1 0 8 10 9 7 5 4 999 30))
(dest (make-vector (length src) 0)))
(list-to-vector src dest 0))
let
表达式的一般性结果如下:
(let (<变量 1>
<变量 2>
<.....>
<变量 n>)
<使用上述变量的表达式>)
不过,上面的 let
表达式是无法求值的。因为在定义 dest
变量时,它引用了变量 src
,而 src
的定义必须在 let
语句的第一个表达式求值时才是一个有定义的变量。这就是说,你不能左脚踩着右脚或右脚踩着左脚来提升自己。
将上述代码中的 let
改成 let*
就可以了。务必记住,当变量定义列表中不存在变量之间的引用时,用 let
,否则用 let*
。
为啥不全部使用 let*
呢,因为它做功比 let
多,更耗能。
使用 let
/let*
的意义在于,可以将一组变量聚集到一起,放在一个局部的环境里。这样,在这个局部环境之外,即使有存在同名的变量,它们也与这个局部环境内部的变量无关。
let
/let*
似乎具有一种神秘的力量,但其实它们的原理非常简单。像下面这样的 let
表达式:
(let ((a 1)
(b 2)
(c 3))
(+ a b c))
它实际上是
(funcall (lambda (a b c) (+ a b c)) 1 2 3)
像下面这样的 let*
表达式:
(let* ((a 1)
(b 2)
(c (+ a b)))
(+ a b c))
它实际上是
(funcall (lambda (a)
(funcall (lambda (b)
(funcall (lambda (c)
(+ a b c)) (+ a b))) 2)) 1)
至于 Emacs Lisp 解释器是如何将 let
/let*
变换成上述的匿名函数形式的,这需要了解 Emacs Lisp 宏。关于宏,这需要一篇专门的文章来讲述它。
最简单的排序方法
经过一番跋涉,见识了 Emacs Lisp 世界里的一些景观,现在再回到一开始的问题:将混乱的数字列表转化为有序的列表。
通过 list-to-vector
函数,我们能够将一个列表转化为与它等长度的向量。因此,现在的问题可以转化为让向量变得有序。之所以要做这样的转化,是为了让程序更省功。通过上文所介绍的访问列表中每一个元素的方法,想必你已经看到了,要访问列表中的某个元素,通常需要 周游列表
函数运转多次,才能找到那个元素,然而要访问向量中的任意一个位置上的元素,却可以瞬间完成。由于在排序过程中,免不了频繁访问一些元素,在这方面向量远胜列表。
怎样对向量里面的数字原子进行排序呢?
不要着急。在计算机里编程,对一组数字进行排序,这是个很大的问题。尚健在的计算机科学界的大宗师 Knuth 老先生在他的传世著作《计算机程序设计艺术》的第 3 卷里,专门用了一章全面地讨论了这个问题。大宗师写的东西,一般人是看不懂的。我恰好也可能和你一样,都是一般人。
复杂的看不懂,就自己去琢磨一些简单的方法吧。从顺序是什么开始思考。在我看来,所谓数字的顺序就是将一个数字放到一个恰当的位置上,使得位于它左边的数字不大于它,而位于它右边的数字大于它。
对于向量 [3 1 0 8 10 9 7 5 4 999 30]
,随便从它里面取出一个数字,例如它的首元素 3。向量里面剩余的数字,要么比 3 大,要么不大于 3 小,倘若我们将所有不大于 3 的数字统统放在 3 的左面,而将所有大于 3 的数字放在 3 的右面,那么就可以认为 3 这个数字已经位于它应该在的位置上了。接下来,我们用同样的办法去处理位于 3 的左侧的数字与右侧的数字就可以了。
「同样的办法」,看到这几个字,我们永远应该立刻首先想起的是制造一个周而复始的发动机——递归函数。这个递归函数可以像下面这样实现:
(defun sort (x begin end)
(if (>= begin end)
x
(let* ((pivot (divide x begin end begin)))
(progn
(sort x begin pivot)
(sort x (+ pivot 1) end)))))
它接受三个参数,参数 x
是待排序的向量,begin
是向量的起始位置,end
是向量的终止位置。在函数递归求值过程中,begin
与 end
用于标定向量子域的起始位置与终止位置。
递归求值过程的终止条件是待排序的向量只含有一个元素或向量为空。例如,倘若比 3 小的数字只有一个,则无需对 3 的左侧再进行排序了。同理,倘若比 3 大的数字只有一个,那么 3 的右侧数字也没必要再进行排序。
写一个递归函数,可能许多人是像我上面所说的那样思考的,即现在脑子里构造了一个一层又一层深入下去的函数求值过程,然后思考这个过程总得有个终点,于是他们就开始思考这个终止条件是什么。以前,我也是这样思考递归的。许多人因此也就觉得递归这种东西,似乎只可意会,不可言传。当我将函数的递归求值过程想象为一个周而复始运转的发动机时,我想明白了这个问题。不是递归难以想象,上面所说的那种思考模式,实际上是上帝视角。因为任何递归,在上帝面前(假设真的有这种东西),都是一览无余的。当我们企图开启上帝视角来理解递归,就变成了要在脑子里模拟这个递归过程许多次,甚至还有人需要在纸上一层一层的把递归过程展开个三五层,然后越看越混乱……我们不是上帝,所以就不要勉强自己。
实际上,采用 POV(Point of View) 视角来理解递归,会更为直观。这种视角就是从最简单的情况开始,往前走两步步看看,一旦发现出现了重复,就意味着递归出现了。
对于 sort
函数而言,最简单的情况是什么呢?是它接受的向量 x
不包含任何元素,即空向量,或者只含有 1 个元素。对于这样的向量,就没必要排序了,直接将 x
原封不动地作为排序过程的求值结果即可。很容易为这种情况写代码,即:
(defun sort (x begin end)
(cond ((>= begin end) x)))
由于 begin
与 end
分别是 x
的第一个元素与最后一个元素的位置,因此只要 (>= begin end)
为真,向量就必定是空向量或只有 1 个元素的向量。
现在,来看向量中含有两个元素的情况。按照上面所述的排序方法,只需要将 x
的首元素调整到一个合适的位置,让它左边的元素都比它小或与它相等,而右边的元素都比它大就可以了。设这个向量是 [a b]
。将 divide
函数作用于它,x
无非变成 [a b]
与 [b a]
这两种形式之一。无论是哪一种,反正 a
的位置被固定下来了,接下来的问题是分别对 a
左侧与右侧的元素进行排序,这相当于对 x
的两个子集进行排序,而 a
所在的位置正是这两个子集的分割点。这两个子集,所包含的元素数量必定不大于 1,而不大于 1 的向量的排序问题,我们已经有了解决方案,只需要将那个已有的方案拿出来用就是了,这样,递归就出现了,即:
(defun sort (x begin end)
(cond ((>= begin end) x)
((= (- end begin) 1) ((let ((pivot (divide x begin end begin)))
(progn
(sort x begin pivot)
(sort x (+ pivot 1) end)))))))
其中 pivot
就是上面所说的将 divide
作用于 x
之后,所形成的分割点,基于这个分割点可以将 x
分为两个部分,然后交给 sort
函数对它们进行排序。
现在,我们继续考虑向量中含有 3 个元素的情况,结果发现,处理过程与只有 2 个元素的情况完全一致,这就意味着没必要再费劲了,接下来含有 4 个、5 个……n 个元素的向量,也都是这样处理,因此可将 sort
函数修改为:
(defun sort (x begin end)
(cond ((>= begin end) x)
(t ((let ((pivot (divide x begin end begin)))
(progn
(sort x begin pivot)
(sort x (+ pivot 1) end)))))))
接下来,再将 cond
表达式换成 if
表达式,这就与之前的 sort
完全一样了。
通过这种方式去定义递归函数,一定要在解决了最简单的情况之后,迅速变得足够懒惰,这样很容易发现递归第一次出现的踪迹,发现了就抓住它。这其实正是我们惯常使用的思考方式。还记得守株待兔的故事吧,即:宋人有耕田者。田中有株,兔走触株,折颈而死。因释其耒而守株,冀复得兔。兔不可复得,而身为宋国笑。这个宋国的农民太过于着急了,至少得再等到 2 天看看还能不能在原来的地方捡到死兔子,再考虑将待兔作为职业。这种思考递归的方法其实我们很久以前就学过,数学归纳法。
真正有些麻烦的是 divide
函数的实现。divide
函数应当以向量 x
的首个元素为枢纽,将比这个元素小的元素旋转到向量的左部,而将比这个元素大的元素旋转到向量的右部,并且将枢纽所在的位置作为求值结果。
倘若你曾经玩过扑克牌,不妨将 divide
函数视为洗牌的拟过程。最常见的洗牌方法是双手各执一组牌,然后让它们交错合并为一组。divide
就是将合并后的牌再重新分开,只不过是以第 1 张牌为枢纽,让牌面小的围绕枢纽向左旋转,这样牌面大的就自然出现在枢纽的右边了。
下面是 divide
的实现:
(defun divide (x i end location)
(if (> i end)
location
(let* ((pivot (aref x location))
(xi (aref x i))
(next (+ location 1)))
(if (> pivot xi)
(progn
(if (> i next)
(aset x i (aref x next)))
(aset x location xi)
(aset x next pivot)
(divide x (+ i 1) end next))
(divide x (+ i 1) end location)))))
感觉语言有点儿无力。divide
函数其实很机械,它充分利用了 Emacs Lisp 的向量的跨函数的可修改性。它的主体部分是对 x
中的元素的顺序访问过程,只是在这个过程中对 x
进行了修改,变相地达到了「让牌面小的围绕枢纽向左旋转,这样牌面大的就自然出现在枢纽的右边」的效果。例如将向量 [3 1 0 7 5 2]
传递给 divide
函数,即 (divide '[3 1 0 7 5 2] 0 5 0)
,这个向量会被修改成 [1 0 2 3 5 7]
,并且 divide
会返回元素 3
的所在的位置。
下面是测试 sort
能否工作的代码:
(let ((x (let* ((src '(3 1 0 8 10 9 5 7 4 999 30))
(dest (make-vector (length src) 0)))
(list-to-vector src dest 0))))
(sort x 0 (- (length x) 1)))
结果得到 [0 1 3 4 5 7 8 9 10 30 999]
。
从向量到列表
现在,我们已经解决了向量的排序问题,而我们最初要解决的问题是列表的排序。因此,还需要将有序的向量转换为有序列表才算得上功德圆满。不过,倘若你能够理解上述的全部代码,这种问题对你而言基本上算不上问题。
试试看:
(defun vector-to-list (src i end dest)
(if (= i end)
dest
(
竟然写不下去了。因为我们还不知道怎样基于向量中的元素逐一添加到一个列表里。
Emacs Lisp 为构造列表提供的最基本的函数是 cons
,它可以将一个元素添加到列表的首部。例如:
(cons 0 '(1 2 3))
求值结果为 (0 1 2 3)
。
要构造只含有 1 个元素的列表,也是可以的,例如 (cons 1 '())
,求值结果为 (1)
。
现在,可以继续写出 vector-to-list
了,
(defun vector-to-list (src i end dest)
(if (= i end)
dest
(vector-to-list src (+ i 1) end (cons (aref src i) dest))))
以下代码可验证这个函数的正确性:
(let ((src '[1 2 3])
(dest '()))
(vector-to-list src 0 2 dest))
不过,vector-to-list
目前是将一个升序的向量转化为一个降序的列表。倘若希望所得列表也是升序,需要将这个函数定义为:
(defun vector-to-list (src i dest)
(if (< i 0)
dest
(vector-to-list src (- i 1) (cons (aref src i) dest))))
以下代码可验证其正确性:
(let ((src '[1 2 3])
(dest '()))
(vector-to-list src 2 dest))
将列表转化为向量,再对向量进行排序,最后将向量转化为列表,这个过程现在可以用下面的代码来描述:
(let* ((x (let* ((src '(3 1 0 8 10 9 5 7 4 999 30))
(dest (make-vector (length src) 0)))
(list-to-vector src dest 0)))
(end (- (length x) 1)))
(vector-to-list (sort x 0 end) end '()))
壳化
现在已经彻底的解决了这篇文章开始所提出的那个问题,但是想必你也看到了,最后写出来的那段代码
(let* ((x (let* ((src '(3 1 0 8 10 9 5 7 4 999 30))
(dest (make-vector (length src) 0)))
(list-to-vector src dest 0)))
(end (- (length x) 1)))
(vector-to-list (sort x 0 end) end '()))
看上去像个丑陋的怪物。可能到现在,你还没搞明白 vector-to-list
的第二个与第三个参数的含义吧?还有 sort
函数的第二个与第三个参数……这些参数,就像一部电器裸露在外的一些混乱的电线一样,令人生厌或生畏。
我们可以把它们隐藏起来。隐藏一些东西,最简单的办法为给它制作一个外壳。例如,可以像下面这样,将 sort
函数裸露在外的电线隐藏起来:
(defun vector-sort (x)
(let ((begin 0)
(end (- (length x) 1)))
(sort x begin end)))
再像下面这样,将 vector-to-list
裸露在外的电线隐藏起来:
(defun vector-to-list (x)
(defun to-list (src i dest)
(if (< i 0)
dest
(to-list src (- i 1) (cons (aref src i) dest))))
(let ((x-end (- (length x) 1))
(dest '()))
(to-list x x-end dest)))
没错,在一个函数的定义里,可以定义一个函数。
现在,使用这两个外壳函数,就可以将丑陋的
(vector-to-list (sort x 0 end) end '())
壳化(我发明的专业术语)为
(vector-to-list (vector-sort x))
而且,新的代码理解起来也很容易,就是对一个向量进行排序,然后再将其转化为列表。
同理,可将 list-to-vector
壳化为:
(defun list-to-vector (x)
(defun to-vector (src dest n)
(if (null src)
dest
(progn
(aset dest n (car src))
(to-vector (cdr src) dest (+ n 1)))))
(let ((dest (make-vector (length x) 0)))
(to-vector x dest 0)))
对这三个函数做壳化处理后,那段怪物般的代码片段就变得像下面这样友善可亲了,
(vector-to-list
(vector-sort
(list-to-vector '(3 1 0 8 10 9 5 7 4 999 30))))
写程序,尽量先将程序的功能完整且正确地实现出来,然后再考虑如何让代码更美观。这是我的做法。
现在,有个问题,divide
函数也露出了许多电线,要不要也给它做壳化手术呢?我觉得不需要。因为,在逻辑上,它并没有暴露在 vector-sort
函数的外部。也就是说,对于要使用 vector-sort
函数对一个向量里的元素进行排序的时候,divide
不可见。不可见的东西,就没必要壳化了。这是我的观点。
异编程者言
一组数字就像一组扑克牌的牌面。它之所以混乱,是因为周而复始的洗牌,而它们能够得以恢复顺序,是因为周而复始的逆洗牌。无他,就是让一个周而复始的发动机卷去对两个自己求值,让这两个自己分别处理牌面的一个子集。用这种办法洗牌,就可以得到混乱的牌面。用这种办法排序,就能恢复混乱的牌面。
百川东到海,何时复西归?到海是洗牌,西归是排序。这两个结果应该同时存在,否则河流就会枯竭,海水就会上涨。自然界的水循环系统像是一台精密的机器,精确地让河流混乱,又精确地将其复原。
我们对一组数字进行排序,排序的结果还是原来的那组数字吗?
人不可能两次踏进同一条河流。
附录
下面是本文所述的排序程序的全部代码,也是我有生以来第一次写这么长的 Lisp 代码。
(defun list-to-vector (x)
(defun to-vector (src dest n)
(if (null src)
dest
(progn
(aset dest n (car src))
(to-vector (cdr src) dest (+ n 1)))))
(let ((dest (make-vector (length x) 0)))
(to-vector x dest 0)))
(defun divide (x i end location)
(if (> i end)
location
(let* ((pivot (aref x location))
(xi (aref x i))
(next (+ location 1)))
(if (> pivot xi)
(progn
(if (> i next)
(aset x i (aref x next)))
(aset x location xi)
(aset x next pivot)
(divide x (+ i 1) end next))
(divide x (+ i 1) end location)))))
(defun sort (x begin end)
(if (>= begin end)
x
(let* ((pivot (divide x begin end begin)))
(progn
(sort x begin pivot)
(sort x (+ pivot 1) end)))))
(defun vector-sort (x)
(let ((begin 0)
(end (- (length x) 1)))
(sort x begin end)))
(defun vector-to-list (x)
(defun to-list (src i dest)
(if (< i 0)
dest
(to-list src (- i 1) (cons (aref src i) dest))))
(let ((x-end (- (length x) 1))
(dest '()))
(to-list x x-end dest)))
(vector-to-list
(vector-sort
(list-to-vector '(3 1 0 8 10 9 5 7 4 999 30))))
下一篇:咒语
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。