水木社区的 emacs 教程

一个 Hello World 例子

自从 K&R 以来,hello world 程序历来都是程序语言教程的第一个例子。我也用一个 hello world 的例子来演示 emacs 里执行 elisp 的环境。下面就是这个语句:

(message "hello world")

前面我没有说这个一个程序,这是因为,elisp 不好作为可执行方式来运行(当然也不是不可能),所有 的 elisp 都是运行在 emacs 这个环境下。首先切换到 scratch 缓冲区里,如果当前模式不是 lisp-interaction-mode ,用 M-x lisp-interaction-mode 先转换到 lisp-interaction-mode。 然后输入前面这一行语句。在行尾右括号后,按 C-j 键。如果 Minibuffer 里显示 hello world,光 标前一行也显示 "hello world",那说明你的操作没有问题。我们就可以开始 elisp 学习之旅了。

:elisp 里的一个完整表达式,除了简单数据类型(如数字,向量),都是用括号括起来,称为一个 S-表达式。让 elisp 解释器执行一个 S-表达式除了前一种方法之外,还可以用 C-x C-e。它们的区别是, C-x C-e 是一个全局按键绑定,几乎可以在所有地方都能用。它会将运行返回值显示在 Minibuffer 里。 这里需要强调一个概念是返回值和作用是不同的。比如前面 message 函数它的作用是在 Minibuffer 里 显示一个字符串,但是它的返回值是 "hello world" 字符串。

基础知识

这一节介绍一下 elisp 编程中一些最基本的概念,比如如何定义函数,程序的控制结构,变量的使用和作用域等等。

函数和变量


elisp 中定义一个函数是用这样的形式:

(defun function-name (arguments-list)
"document string"
body)

比如:

(defun hello-world (name)
"Say hello to user whose name is NAME."
(message "Hello, %s" name))

其中函数的文档字符串是可以省略的。但是建议为你的函数(除了最简单,不作为接口的)都加上文档字符串。这样将来别人使用你的扩展或者别人阅读你的代码或者自己进行维护都提供很大的方便。 在 emacs 里,当光标处于一个函数名上时,可以用 C-h f 查看这个函数的文档。比如前面这个函数,在 Help 缓冲区里的文档是:

hello-world is a Lisp function.
(hello-world name)

Say hello to user whose name is name.

如果你的函数是在文件中定义的。这个文档里还会给出一个链接能跳到定义的地方。 要运行一个函数,最一般的方式是:

(function-name arguments-list)

比如前面这个函数:

(hello-world "Emacser")                 ; => "Hello, Emacser"

每个函数都有一个返回值。这个返回值一般是函数定义里的最后一个表达式的值。 elisp 里的变量使用无需象 C 语言那样需要声明,你可以用 setq 直接对一个变量赋值。

(setq foo "I'm foo")                    ; => "I'm foo"
(message foo) ; => "I'm foo"

和函数一样,你可以用 C-h v 查看一个变量的文档。比如当光标在 foo 上时用 C-h v 时,文档是这样的:

foo's value is "I'm foo"

Documentation:
Not documented as a variable.

有一个特殊表达式(special form)defvar,它可以声明一个变量,一般的形式是:

(defvar variable-name value
"document string")

它与 setq 所不同的是,如果变量在声明之前,这个变量已经有一个值的话,用 defvar 声明的变量值不会 改变成声明的那个值。另一个区别是 defvar 可以为变量提供文档字符串,当变量是在文件中定义的话, C-h v 后能给出变量定义的位置。比如:

(defvar foo "Did I have a value?"
"A demo variable") ; => foo

foo ; => "I'm foo"

(defvar bar "I'm bar"
"A demo variable named \"bar\"") ; => bar

bar ; => "I'm bar"

用 C-h v 查看 foo 的文档,可以看到它已经变成:

foo's value is "I'm foo"

Documentation:
A demo variable

由于 elisp 中函数是全局的,变量也很容易成为全局变量(因为全局变量和局部变量的赋值都是使用 setq 函数), 名字不互相冲突是很关键的。所以除了为你的函数和变量选择一个合适的前缀之外,用 C-h f 和 C-h v 查看一下函数名和变量名有没有已经被使用过是很关键的。

局部作用域的变量

如果没有局部作用域的变量,都使用全局变量,函数会相当难写。elisp 里可以用 let 和 let* 进行局部 变量的绑定。let 使用的形式是:

(let (bindings)
body)

bingdings 可以是 (var value) 这样对 var 赋初始值的形式,或者用 var 声明一个初始值为 nil 的变量。比如:

(defun circle-area (radix)
(let ((pi 3.1415926)
area)

(setq area (* pi radix radix))
(message "直径为 %.2f 的圆面积是 %.2f" radix area)))

(circle-area 3)

C-h v 查看 area 和 pi 应该没有这两个变量。 let* 和 let 的使用形式完全相同,唯一的区别是在 let* 声明中就能使用前面声明的变量,比如:

(defun circle-area (radix)
(let* ((pi 3.1415926)
(area (* pi radix radix)))

(message "直径为 %.2f 的圆面积是 %.2f" radix area)))

lambda 表达式

可能你久闻 lambda 表达式的大名了。其实依我的理解,lambda 表达式相当于其它语言中的匿名函数。比如 perl 里的匿名函数。它的形式和 defun 是完全一样的:

(lambda (arguments-list)
"documentation string"
body)

调用 lambda 方法如下:

(funcall (lambda (name)
(message "Hello, %s!" name)) "Emacser")

你也可以把 lambda 表达式赋值给一个变量,然后用 funcall 调用:

(setq foo (lambda (name)
(message "Hello, %s!" name)))

(funcall foo "Emacser") ; => "Hello, Emacser!"

lambda 表达式最常用的是作为参数传递给其它函数,比如 mapc。

控制结构

顺序执行

一般来说程序都是按表达式顺序依次执行的。这在 defun 等特殊环境中是自动进行的。但是一般 情况下都不是这样的。比如你无法用 eval-last-sexp 同时执行两个表达式,在 if 表达式中的 条件为真时执行的部分也只能运行一个表达式。这时就需要用 progn 这个特殊表达式。 它的使用形式如下:

(progn A B C ...)

它的作用就是让表达式 A, B, C 顺序执行。比如:

(progn
(setq foo 3)
(message "Square of %d is %d" foo (* foo foo)))

条件判断

elisp 有两个最基本的条件判断表达式 if 和 cond。使用形式分别如下:

(if condition
then
else)


(cond (case1 do-when-case1)
(case2 do-when-case2)
...
(t do-when-none-meet))

使用的例子如下:

(defun my-max (a b)
(if (> a b)
a b))

(my-max 3 4) ; => 4

(defun fib (n)
(cond ((= n 0) 0)
((= n 1) 1)
(t (+ (fib (- n 1))
(fib (- n 2))))
)
)

(fib 10) ; => 55

还有两个宏 when 和 unless,从它们的名字也就能知道它们是作什么用的。使用 这两个宏的好处是使代码可读性提高,when 能省去 if 里的 progn 结构,unless 省去条件为真子句需要的的 nil 表达式。

循环

循环使用的是 while 表达式。它的形式是:

(while condition
body)

比如:

(defun factorial (n)
(let ((res 1))
(while (> n 1)
(setq res (* res n)
n (- n 1)))

res))

(factorial 10) ; => 3628800

逻辑运算

条件的逻辑运算和其它语言都是很类似的,使用 and、or、not。and 和 or 也同样具 有短路性质。很多人喜欢在表达式短时,用 and 代替 when,or 代替 unless。当然 这时一般不关心它们的返回值,而是在于表达式其它子句的副作用。比如 or 经常用于 设置函数的缺省值,而 and 常用于参数检查:

(defun hello-world (&optional name)
(or name (setq name "Emacser"))
(message "Hello, %s" name)) ; => hello-world

(hello-world) ; => "Hello, Emacser"
(hello-world "Ye") ; => "Hello, Ye"

(defun square-number-p (n)
(and (>= n 0)
(= (/ n (sqrt n)) (sqrt n))))

(square-number-p -1) ; => nil
(square-number-p 25) ; => t

函数列表

(defun NAME ARGLIST [DOCSTRING] BODY...)
(defvar SYMBOL &optional INITVALUE DOCSTRING)
(setq SYM VAL SYM VAL ...)
(let VARLIST BODY...)
(let* VARLIST BODY...)
(lambda ARGS [DOCSTRING] [INTERACTIVE] BODY)
(progn BODY ...)
(if COND THEN ELSE...)
(cond CLAUSES...)
(when COND BODY ...)
(unless COND BODY ...)
(when COND BODY ...)
(or CONDITIONS ...)
(and CONDITIONS ...)
(not OBJECT)

基本数据类型之一 数字

elisp 里的对象都是有类型的,而且每一个对象它们知道自己是什么类型。你得到一个变量名之后可以用一系列检测 方法来测试这个变量是什么类型(好像没有什么方法来让它说出自己是什么类型的)。内建的 emacs 数据类型称为 primitive types,包括整数、浮点数、cons、符号(symbol)、字符串、向量(vector)、散列表(hash-table)、 subr(内建函数,比如 cons, if, and 之类)、byte-code function,和其它特殊类型,例如缓冲区(buffer)。 在开始前有必要先了解一下读入语法和输出形式。所谓读入语法是让 elisp 解释器明白输入字符所代表的对象,你不 可能让 elisp 读入 .#@!? 这样奇怪的东西还能好好工作吧(perl好像经常要受这样的折磨:))。简单的来说,一 种数据类型有(也可能没有,比如散列表)对应的规则来让解释器产生这种数据类型,比如 123 产生整数 123, (a . b) 产生一个 cons。所谓输出形式是解释器用产生一个字符串来表示一个数据对象。比如整数 123 的输出形式 就是 123,cons cell (a . b) 的输出形式是 (a . b)。与读入语法不同的是,数据对象都有输出形式。比如散列 表的输出可能是这样的:

#<hash-table 'eql nil 0/65 0xa7344c8>

通常一个对象的数据对象的输出形式和它的读入形式都是相同的。现在就先从简单的数据类型──数字开始吧。 emacs 的数字分为整数和浮点数(和 C 比没有双精度数 double)。1, 1.,+1, -1, 536870913, 0, -0 这些 都是整数。整数的范围是和机器是有关的,一般来最小范围是在 -268435456 to 268435455(29位,-2**28 ~ 2**28-1)。 可以从 most-positive-fixnum 和 most-negative-fixnum 两个变量得到整数的范围。 你可以用多种进制来输入一个整数。比如:

#b101100 => 44      ; 二进制
#o54 => 44 ; 八进制
#x2c => 44 ; 十进制

最神奇的是你可以用 2 到 36 之间任意一个数作为基数,比如:

#24r1k => 44        ; 二十四进制

之所以最大是 36,是因为只有 0-9 和 a-z 36 个字符来表示数字。但是我想基本上不会有人会用到 emacs 的这个特性。 1500.0, 15e2, 15.0e2, 1.5e3, 和 .15e4 都可以用来表示一个浮点数 1500.。遵循 IEEE 标准,elisp 也有一个 特殊类型的值称为 NaN (not-a-number)。你可以用 (/ 0.0 0.0) 产生这个数。

测试函数

整数类型测试函数是 integerp,浮点数类型测试函数是 floatp。数字类型测试用 numberp。你可以分别运行这几个例子来试验一下:

(integerp 1.)                           ; => t
(integerp 1.0) ; => nil
(floatp 1.) ; => nil
(floatp -0.0e+NaN) ; => t
(numberp 1) ; => t

还提供一些特殊测试,比如测试是否是零的 zerop,还有非负整数测试的 wholenump。 :elisp 测试函数一般都是用 p 来结尾,p 是 predicate 的第一个字母。如果函数名是一个单词,通常只是在这个 单词后加一个 p,如果是多个单词,一般是加 -p。

数的比较

常用的比较操作符号是我们在其它言中都很熟悉的,比如 <, >, >=, <=,不一样的是,由于赋值是使用 set 函数,所以 = 不再是一个赋值运算符了,而是测试数字相等符号。和其它语言类似,对于浮点数的相等测试都是不可靠的。比如:

(setq foo (- (+ 1.0 1.0e-3) 1.0))       ; => 0.0009999999999998899
(setq bar 1.0e-3) ; => 0.001
(= foo bar) ; => nil

所以一定要确定两个浮点数是否相同,是要在一定误差内进行比较。这里给出一个函数:

(defvar fuzz-factor 1.0e-6)
(defun approx-equal (x y)
(or (and (= x 0) (= y 0))
(< (/ (abs (- x y))
(max (abs x) (abs y)))

fuzz-factor))
)

(approx-equal foo bar) ; => t

还有一个测试数字是否相等的函数 eql,这是函数不仅测试数字的值是否相等,还测试数字类型是否一致,比如:

(= 1.0 1)                               ; => t
(eql 1.0 1) ; => nil

elisp 没有 +=, -=, /=, *= 这样的命令式语言里常见符号,如果你想实现类似功能的语句,只能用赋值 函数 setq 来实现了。 /= 符号被用来作为不等于的测试了。

数的转换

整数向浮点数转换是通过 float 函数进行的。而浮点数转换成整数有这样几个函数:

  • truncate 转换成靠近 0 的整数
  • floor 转换成最接近的不比本身大的整数
  • ceiling 转换成最接近的不比本身小的整数
  • round 四舍五入后的整数,换句话说和它的差绝对值最小的整数

很晕是吧。自己用 1.2, 1.7, -1.2, -1.7 对这四个函数操作一遍就知道区别了(可以直接看 info。按键 顺序是 C-h i m elisp RET m Numeric Conversions RET。以后简写成 info elisp - Numeric Conversions)。 这里提一个问题,浮点数的范围是无穷大的,而整数是有范围的,如果用前面的函数转换 1e20 成一个整数会出现 什么情况呢?试试就知道了。

数的运算

四则运算没有什么好说的,就是 + - * 。值得注意的是,和 C 语言类似,如果参数都是整数,作除法时要记住 ( 5 6) 是会等于 0 的。如果参数中有浮点数,整数会自动转换成浮点数进行运算,所以 (/ 5 6.0) 的值才会是 5/6。

没有 + 和 – 操作了,类似的两个函数是 1 和 1-。可以用 setq 赋值来代替 ++ 和 –:

(setq foo 10)                           ; => 10
(setq foo (1+ foo)) ; => 11
(setq foo (1- foo)) ; => 10

注:可能有人看过有 incf 和 decf 两个实现 ++ 和 – 操作。这两个宏是可以用的。这两个宏是 Common Lisp 里的 ,emacs 有模拟的 Common Lisp 的库 cl。但是 RMS 认为最好不要使用这个库。但是你可以在你的 elisp 包中使用 这两个宏,只要在文件头写上:

(eval-when-compile
(require 'cl))

由于 incf 和 decf 是两个宏,所以这样写不会在运行里导入 cl 库。有点离题是,总之一句话,教主说不好的东西,我 们最好不要用它。其它无所谓,只可惜了两个我最常用的函数 remove-if 和 remove-if-not。不过如果你也用 emms 的话,可以在 emms-compat 里找到这两个函数的替代品。

abs 取数的绝对值。

有两个取整的函数,一个是符号 %,一个是函数 mod。这两个函数有什么差别呢?一是 % 的第个参数必须是整数,而 mod 的第一个参数可以是整数也可以是浮点数。二是即使对相同的参数,两个函数也不一定有相同的返回值:

(+ (% DIVIDEND DIVISOR)
(* (/ DIVIDEND DIVISOR) DIVISOR))

和 DIVIDEND 是相同的。而:

(+ (mod DIVIDEND DIVISOR)
(* (floor DIVIDEND DIVISOR) DIVISOR))

和 DIVIDEND 是相同的。 三角运算有函数: sin, cos, tan, asin, acos, atan。开方函数是 sqrt。 exp 是以 e 为底的指数运算,expt 可以指定底数的指数运算。log 默认底数是 e,但是也可以指定底数。log10 就是 (log x 10)。logb 是以 2 为底数运算,但是返回的是一个整数。这个函数是用来计算数的位。

random 可以产生随机数。可以用 (random t) 来产生一个新种子。虽然 emacs 每次启动后调用 random 总是产生相同 的随机数,但是运行过程中,你不知道调用了多少次,所以使用时还是不需要再调用一次 (random t) 来产生新的种子。 位运算这样高级的操作我就不说了,自己看 info elisp - Bitwise Operations on Integers 吧。

函数列表

;; 测试函数
(integerp object)
(floatp object)
(numberp object)
(zerop number)
(wholenump object)
;; 比较函数
(> num1 num2)
(< num1 num2)
(>= num1 num2)
(<= num1 num2)
(= num1 num2)
(eql obj1 obj2)
(/= num1 num2)
;; 转换函数
(float arg)
(truncate arg &optional divisor)
(floor arg &optional divisor)
(ceiling arg &optional divisor)
(round arg &optional divisor)
;; 运算
(+ &rest numbers-or-markers)
(- &optional number-or-marker &rest more-numbers-or-markers)
(* &rest numbers-or-markers)
(/ dividend divisor &rest divisors)
(1+ number)
(1- number)
(abs arg)
(% x y)
(mod x y)
(sin arg)
(cos arg)
(tan arg)
(asin arg)
(acos arg)
(atan y &optional x)
(sqrt arg)
(exp arg)
(expt arg1 arg2)
(log arg &optional base)
(log10 arg)
(logb arg)
;; 随机数
(random &optional n)

变量列表

most-positive-fixnum
most-negative-fixnum

基本数据类型之二 字符和字符串

在 emacs 里字符串是有序的字符数组。和 c 语言的字符串数组不同,emacs 的字符串可以容纳任何字符,包括 \0:

(setq foo "abc\000abc")                 ; => "abc^@abc"

首先构成字符串的字符其实就是一个整数。一个字符 'A' 就是一个整数 65。但是目前字符串中的字符被限制在 0-524287 之间。字符的读入语法是在字符前加上一个问号,比如 ?A 代表字符 'A'。

?A                                      ; => 65
?a ; => 97

对于标点来说,也可以用同样的语法,但是最好在前面加上转义字符 \,因为有些标点会有岐义,比如 ?\(。\ 必须 用 ?\\ 表示。控制字符,退格、制表符,换行符,垂直制表符,换页符,空格,回车,删除和 escape 表示为 ?\a, ?\b, ?\t, ?\n, ?\v, ?\f, ?\s, ?\r, ?\d, 和 ?\e。对于没有特殊意义的字符,加上转义字符 \ 是没有副作 用的,比如 ?\+ 和 ?+ 是完全一样的。所以标点还是都用转义字符来表示吧。

?\a => 7                 ; control-g, `C-g'
?\b => 8 ; backspace, <BS>, `C-h'
?\t => 9 ; tab, <TAB>, `C-i'
?\n => 10 ; newline, `C-j'
?\v => 11 ; vertical tab, `C-k'
?\f => 12 ; formfeed character, `C-l'
?\r => 13 ; carriage return, <RET>, `C-m'
?\e => 27 ; escape character, <ESC>, `C-['
?\s => 32 ; space character, <SPC>
?\\ => 92 ; backslash character, `\'
?\d => 127 ; delete character, <DEL>

控制字符可以有多种表示方式,比如 C-i,这些都是对的:

?\^I  ?\^i  ?\C-I  ?\C-i

它们都对应数字 9。

meta 字符是用 修饰键(通常就是 Alt 键)输入的字符。之所以称为修饰键,是因为这样输入的字符就是在其 修饰字符的第 27 位由 0 变成 1 而成,也就是如下操作:

(logior (lsh 1 27) ?A)                  ; => 134217793
?\M-A ; => 134217793

你可以用 \M- 代表 meta 键,加上修饰的字符就是新生成的字符。比如:?\M-A, ?\M-\C-b. 后面这个也可以写成 ?\C-\M-b。 如果你还记得前面说过字符串里的字符不能超过 524287 的话,这就可以看出字符串是不能放下一个 meta 字符的。所以按 键序列在这时只能用 vector 来储存。其它的修饰键也是类似的。emacs 用 2**25 位来表示 shift 键,2**24 对应 hyper, 2**23 对应 super,2**22 对应 alt。

测试函数

字符串测试使用 stringp,没有 charp,因为字符就是整数。 string-or-null-p 当对象是一个字符或 nil 时返回 t。 char-or-string-p 测试是否是字符串或者字符类型。比较头疼的是 emacs 没有测试字符串是否为空的函数。这是我用的 这个测试函数,使用前要测试字符串是否为 nil:

(defun string-emptyp (str)
(not (string< "" str)))

构造函数

产生一个字符串可以用 make-string。这样生成的字符串包含的字符都是一样的。要生成不同的字符串可以用 string 函数。

(make-string 5 ?x)                      ; => "xxxxx"
(string ?a ?b ?c) ; => "abc"

在已有的字符串生成新的字符串的方法有 substring, concat。substring 的后两个参数是起点和终点的位置。如果终点越 界或者终点比起点小都会产生一个错误。这个在使用 substring 时要特别小心。

(substring "0123456789" 3)              ; => "3456789"
(substring "0123456789" 3 5) ; => "34"
(substring "0123456789" -3 -1) ; => "78"

concat 函数相对简单,就是把几个字符串连接起来。

字符串比较

char-equal 可以比较两个字符是否相等。与整数比较不同,这个函数还考虑了大小写。如果 case-fold-search 变量 是 t时,这个函数的字符比较是忽略大小写的。编程时要小心,因为通常 case-fold-search 都是 t,这样如果要考虑 字符的大小写时就不能用 char-equal 函数了。

字符串比较使用 string=,string-equal 是一个别名。string< 是按字典序比较两个字符串,string-less 是它的 别名。空字符串小于所有字符串,除了空字符串。前面 string-emptyp 就是用这个特性。当然直接用 length 检测字 符串长度应该也可以,还可以省去检测字符串是否为空。没有 string> 函数。

转换函数

字符转换成字符串可以用 char-to-string 函数,字符串转换成字符可以用 string-to-char。当然只是返回字符串的 第一个字符。数字和字符串之间的转换可以用 number-to-string 和 string-to-number。其中 string-to-number 可以设置字符串的进制,可以从 2 到 16。number-to-string 只能转换成 10 进制的数字。如果要输出八进制或者十六 进制,可以用 format 函数:

(string-to-number "256")                ; => 256
(number-to-string 256) ; => "256"
(format "%#o" 256) ; => "0400"
(format "%#x" 256) ; => "0x100"

如果要输出成二进制,好像没有现成的函数了。calculator 库倒是可以,这是我写的函数:

(defun number-to-bin-string (number)
(require 'calculator)
(let ((calculator-output-radix 'bin)
(calculator-radix-grouping-mode nil))

(calculator-number-to-string number)))

(number-to-bin-string 256) ; => "100000000"

其它数据类型现在还没有学到,不过可以先了解一下吧。concat 可以把一个字符构成的列表或者向量转换成字符串, vconcat 可以把一个字符串转换成一个向量,append 可以把一个字符串转换成一个列表。

(concat '(?a ?b ?c ?d ?e))              ; => "abcde"
(concat [?a ?b ?c ?d ?e]) ; => "abcde"
(vconcat "abdef") ; => [97 98 100 101 102]
(append "abcdef" nil) ; => (97 98 99 100 101 102)

大小写转换使用的是 downcase 和 upcase 两个函数。这两个函数的参数既可以字符串,也可以是字符。capitalize 可以使字符串中单词的第一个字符大写,其它字符小写。upcase-initials 只使第一个单词的第一个字符大写,其它字 符小写。这两个函数的参数如果是一个字符,那么只让这个字符大写。比如:

(downcase "The cat in the hat")         ; => "the cat in the hat"
(downcase ?X) ; => 120
(upcase "The cat in the hat") ; => "THE CAT IN THE HAT"
(upcase ?x) ; => 88
(capitalize "The CAT in tHe hat") ; => "The Cat In The Hat"
(upcase-initials "The CAT in the hAt") ; => "The CAT In The HAt"

格式化字符串

format 类似于 C 语言里的 printf 可以实现对象的字符串化。数字的格式化和 printf 的参数差不多,值得一提的是 "%S" 这个格式化形式,它可以把对象的输出形式转换成字符串,这在调试时是很有用的。

查找和替换

字符串查找的核心函数是 string-match。这个函数可以从指定的位置对字符串进行正则表达式匹配,如果匹配成功, 则返回匹配的起点,如:

(string-match "34" "01234567890123456789")    ; => 3
(string-match "34" "01234567890123456789" 10) ; => 13

注意 string-match 的参数是一个 regexp。emacs 好象没有内建的查找子串的函数。如果你想把 string-match 作为 一个查找子串的函数,可以先用 regexp-quote 函数先处理一下子串。比如:

(string-match "2*" "232*3=696")                ; => 0
(string-match (regexp-quote "2*") "232*3=696") ; => 2

事实上,string-match 不只是查找字符串,它更重要的功能是捕捉匹配的字符串。如果你对正则表达式不了解,可能 需要先找一本书,先了解一下什么是正则表达式。string-match 在查找的同时,还会记录下每个要捕捉的字符串的位置。 这个位置可以在匹配后用 match-data、match-beginning 和 match-end 等函数来获得。先看一下例子:

(progn
(string-match "3\\(4\\)" "01234567890123456789")
(match-data)) ; => (3 5 4 5)

最后返回这个数字是什么意思呢?正则表达式捕捉的字符串按括号的顺序对应一个序号,整个模式对应序号 0,第一个括 号对应序号 1,第二个括号对应序号 2,以此类推。所以 "3\(4\)" 这个正则表达式中有序号 0 和 1,最后 match-data 返回的一系列数字对应的分别是要捕捉字符串的起点和终点位置,也就是说子串 "34" 起点从位置 3 开始,到位置 5 结束,而捕捉的字符串 "4" 的起点是从 4 开始,到 5 结束。这些位置可以用 match-beginning 和 match-end 函数 用对应的序号得到。要注意的是,起点位置是捕捉字符串的第一个字符的位置,而终点位置不是捕捉的字符串最后一个 字符的位置,而是下一个字符的位置。这个性质对于循环是很方便的。比如要查找上面这个字符串中所有 34 出现的位置:

(let ((start 0))
(while (string-match "34" "01234567890123456789" start)
(princ (format "find at %d\n" (match-beginning 0)))
(setq start (match-end 0))))

查找会了,就要学习替换了。替换使用的函数是 replace-match。这个函数既可以用于字符串的替换,也可以用于缓 冲区的文本替换。对于字符串的替换,replace-match 只是按给定的序号把字符串中的那一部分用提供的字符串替换了而已:

(let ((str "01234567890123456789"))
(string-match "34" str)
(princ (replace-match "x" nil nil str 0))
(princ "\n")
(princ str))

可以看出 replace-match 返回的字符串是替换后的新字符串,原字符串被没有改变。 如果你想挑战一下,想想怎样把上面这个字符串中所有的 34 都替换掉?如果想就使用同一个字符串来存储,可能对于 固定的字符串,这个还容易一些,如果不是的话,就要花一些脑筋了,因为替换之后,新的字符串下一个搜索起点的位 置就不能用 (match-end 0) 给出来的位置了,而是要扣除替换的字符串和被替换的字符串长度的差值。emacs 对字 符串的替换有一个函数 replace-regexp-in-string。这个函数的实现方法是把每次匹配部分之前的子串收集起来, 最后再把所有字符串连接起来。单字符的替换有 subst-char-in-string 函数。但是 emacs 没有类似 perl函数或 者程序 tr 那样进行字符替换的函数。只能自己建表进行循环操作了。

函数列表

;; 测试函数
(stringp OBJECT)
(string-or-null-p OBJECT)
(char-or-string-p OBJECT)
;; 构建函数
(make-string LENGTH INIT)
(string &rest CHARACTERS)
(substring STRING FROM &optional TO)
(concat &rest SEQUENCES)
;; 比较函数
(char-equal C1 C2)
(string= S1 S2)
(string-equal S1 S2)
(string< S1 S2)
;; 转换函数
(char-to-string CHAR)
(string-to-char STRING)
(number-to-string NUMBER)
(string-to-number STRING &optional BASE)
(downcase OBJ)
(upcase OBJ)
(capitalize OBJ)
(upcase-initials OBJ)
(format STRING &rest OBJECTS)
;; 查找与替换
(string-match REGEXP STRING &optional START)
(replace-match NEWTEXT &optional FIXEDCASE LITERAL STRING SUBEXP)
(replace-regexp-in-string REGEXP REP STRING &optional FIXEDCASE LITERAL SUBEXP START)
(subst-char-in-string FROMCHAR TOCHAR STRING &optional INPLACE)

基本数据类型之三 cons cell 和列表

如果从概念上来说,cons cell 其实非常简单的,就是两个有顺序的元素。第一个叫 CAR,第二个就 CDR。 CAR 和 CDR 名字来自于 Lisp。它最初在IBM 704机器上的实现。在这种机器有一种取址模式,使人可以访 问一个存储地址中的“地址(address)”部分和“减量(decrement)”部分。CAR 指令用于取出地址部分, 表示(Contents of Address part of Register),CDR 指令用于取出地址的减量部分(Contents of the Decrement part of Register)。 cons cell 也就是 construction of cells。car 函数用于取得 cons cell 的 CAR 部分,cdr 取得 cons cell 的 CDR 部分。cons cell 如此简单,但是它却能衍生出许多高级的数据结构,比如链表,树, 关联表等等。

cons cell 的读入语法是用 . 分开两个部分,比如:

'(1 . 2)                                ; => (1 . 2)
'(?a . 1) ; => (97 . 1)
'(1 . "a") ; => (1 . "a")
'(1 . nil) ; => (1)
'(nil . nil) ; => (nil)

注意到前面的表达式中都有一个 ' 号,这是什么意思呢?其实理解了 eval-last-sexp 的作用就能明白了。 eval-last-sexp 其实包含了两个步骤,一是读入前一个 S-表达式,二是对读入的 S-表达式求值。这样如 果读入的 S-表达式是一个 cons cell 的话,求值时会把这个 cons cell 的第一个元素作为一个函数来调用。 而事实上,前面这些例子的第一个元素都不是一个函数,这样就会产生一个错误 invalid-function。之所以前 面没有遇到这个问题,那是因为前面数字和字符串是一类特殊的 S-表达式,它们求值后和求值前是不变,称为自 求值表达式(self-evaluating form)。' 号其实是一个特殊的函数 quote,它的作用是将它的参数返回而不 作求值。'(1 . 2) 等价于 (quote (1 . 2))。为了证明 cons cell 的读入语法确实就是它的输出形式,可 以看下面这个语句:

(read "(1 . 2)")                        ; => (1 . 2)

列表包括了 cons cell。但是列表中有一个特殊的元素──空表 nil。

nil                                     ; => nil
'() ; => nil

空表不是一个 cons cell,因为它没有 CAR 和 CDR 两个部分,事实上空表里没有任何内容。但是为了编程的方便, 可以认为 nil 的 CAR 和 CDR 都是 nil:

 (car nil)                               ; => nil
(cdr nil) ; => nil

按列表最后一个 cons cell 的 CDR 部分的类型分,可以把列表分为三类。如果它是 nil 的话,这个列表也称为“真列表”(true list)。如果既不是 nil 也不是一个 cons cell,则这个列表称为“点列表”(dotted list)。还有一种可能,它指向列表中之前的一个 cons cell,则称为环形列表(circular list)。这里分别给出一个例子:

 '(1 2 3)                                  ; => (1 2 3)
'(1 2 . 3) ; => (1 2 . 3)
'(1 . #1=(2 3 . #1#)) ; => (1 2 3 . #1)

从这个例子可以看出前两种列表的读入语法和输出形式都是相同的,而环形列表的读入语法是很古怪的,输出 形式不能作为环形列表的读入形式。

如果把真列表最后一个 cons cell 的 nil 省略不写,也就是 (1 . nil) 简写成 (1), 把 ( obj1 . ( obj2 . list)) 简写成 (obj1 obj2 . list),那么列表最后可以写成一个用括号括起的元素列表:

'(1 . (2 . (3 . nil)))                  ; => (1 2 3)

尽管这样写是清爽多了,但是,我觉得看一个列表时还是在脑子里反映的前面的形式,这样在和复杂的 cons cell 打交道时就不会搞不清楚这个 cons cell 的 CDR 是一个列表呢,还是一个元素或者是嵌套的列表。

测试函数


测试一个对象是否是 cons cell 用 consp,是否是列表用 listp。

(consp '(1 . 2))                        ; => t
(consp '(1 . (2 . nil))) ; => t
(consp nil) ; => nil
(listp '(1 . 2)) ; => t
(listp '(1 . (2 . nil))) ; => t
(listp nil) ; => t

没有内建的方法测试一个列表是不是一个真列表。通常如果一个函数需要一个真列表作为参数,都是在运行时发出错误, 而不是进行参数检查,因为检查一个列表是真列表的代价比较高。

测试一个对象是否是 nil 用 null 函数。只有当对象是空表时,null 才返回空值。

构造函数


生成一个 cons cell 可以用 cons 函数。比如:

(cons 1 2)                              ; => (1 . 2)
(cons 1 '()) ; => (1)

也是在列表前面增加元素的方法。比如:

(setq foo '(a b))                       ; => (a b)
(cons 'x foo) ; => (x a b)

值得注意的是前面这个例子的 foo 值并没有改变。事实上有一个宏 push 可以加入元素的同时改变列表的值:

(push 'x foo)                           ; => (x a b)
foo ; => (x a b)

生成一个列表的函数是 list。比如:

(list 1 2 3)                            ; => (1 2 3)

可能这时你有一个疑惑,前面产生一个列表,我常用 quote(也就是 ' 符号)这个函数,它和这个 cons 和 list 函数有什么区别呢?其实区别是很明显的,quote 是把参数直接返回不进行求值,而 list 和 cons 是 对参数求值后再生成一个列表或者 cons cell。看下面这个例子:

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

前一个生成的列表的 CAR 部分是 (+ 1 2) 这个列表,而后一个是先对 (+ 1 2) 求值得到 3 后再生成列表。

append 的功能可以认为它是把第一个参数最后一个列表的 nil 换成第二个参数,比如前面这个例子,第一个参 数写成 cons cell 表示方式是(a . (b . nil)),把这个 nil 替换成 (c) 就成了 (a . (b . (c)))。对 于多个参数的情况也是一样的,依次把下一个参数替换新列表最后一个 nil 就是最后的结果了。

(append '(a b) '(c) '(d))               ; => (a b c d)

一般来说 append 的参数都要是列表,但是最后一个参数可以不是一个列表,这也不违背前面说的,因为 cons cell 的 CDR 部分本来就可以是任何对象:

(append '(a b) 'c)                      ; => (a b . c)

这样得到的结果就不再是一个真列表了,如果再进行 append 操作就会产生一个错误。 如果你写过 c 的链表类型,可能就知道如果链表只保留一个指针,那么链表只能在一端增加元素。elisp 的列表 类型也是类似的,用 cons 在列表前增加元素比用 append 要快得多。

append 的参数不限于列表,还可以是字符串或者向量。前面字符串里已经提到可以把一个字符串转换成一个字符列表,同样可能把向量转换成一个列表:

(append [a b] "cd" nil)                 ; => (a b 99 100)

注意前面最后一个参数 nil 是必要的,不然你可以想象得到的结果是什么。

把列表当数组用

要得到列表或者 cons cell 里元素,唯一的方法是用 car 和 cdr 函数。很容易明白,car 就是取得 cons cell 的 CAR 部分,cdr 函数就是取得 cons cell 的 CDR 部分。通过这两个函数,我们就能访问 cons cell 和列表 中的任何元素。

通过使用 elisp 提供的函数,我们事实上是可以把列表当数组来用。依惯例,我们用 car 来访问列表的第一个元素, cadr 来访问第二个元素,再往后就没有这样的函数了,可以用 nth 函数来访问:

(nth 3 '(0 1 2 3 4 5))                  ; => 3

获得列表一个区间的函数有 nthcdr、last 和 butlast。nthcdr 和 last 比较类似,它们都是返回列表后端的列表。 nthcdr 函数返回第 n 个元素后的列表:

(nthcdr 2 '(0 1 2 3 4 5))               ; => (2 3 4 5)

last 函数返回倒数 n 个长度的列表:

(last '(0 1 2 3 4 5) 2)                 ; => (4 5)

butlast 和前两个函数不同,返回的除了倒数 n 个元素的列表。

(butlast '(0 1 2 3 4 5) 2)              ; => (0 1 2 3)

使用前面这几个函数访问列表是没有问题了。但是你也可以想象,链表这种数据结构是不适合随机访问的,代价比较高, 如果你的代码中频繁使用这样的函数或者对一个很长的列表使用这样的函数,就应该考虑是不是应该用数组来实现。

直到现在为止,我们用到的函数都不会修改一个已有的变量。这是函数式编程的一个特点。只用这些函数编写的代码是很 容易调试的,因为你不用去考虑一个变量在执行一个代码后就改变了,不用考虑变量的引用情况等等。下面就要结束这样 轻松的学习了。

首先学习怎样修改一个 cons cell 的内容。首先 setcar 和 setcdr 可以修改一个 cons cell 的 CAR 部分和 CDR 部分。比如:

(setq foo '(a b c))                     ; => (a b c)
(setcar foo 'x) ; => x
foo ; => (x b c)
(setcdr foo '(y z)) ; => (y z)
foo ; => (x y z)

现在来考虑一下,怎样像数组那样直接修改列表。使用 setcar 和 nthcdr 的组合就可以实现了:

(setq foo '(1 2 3))                     ; => (1 2 3)
(setcar foo 'a) ; => a
(setcar (cdr foo) 'b) ; => b
(setcar (nthcdr 2 foo) 'c) ; => c
foo ; => (a b c)

把列表当堆栈用

前面已经提到过可以用 push 向列表头端增加元素,在结合 pop 函数,列表就可以做为一个堆栈了。

(setq foo nil)                          ; => nil
(push 'a foo) ; => (a)
(push 'b foo) ; => (b a)
(pop foo) ; => b
foo ; => (a)

重排列表

如果一直用 push 往列表里添加元素有一个问题是这样得到的列表和加入的顺序是相反的。通常我们 需要得到一个反向的列表。reverse 函数可以做到这一点:

(setq foo '(a b c))                     ; => (a b c)
(reverse foo) ; => (c b a)

需要注意的是使用 reverse 后 foo 值并没有改变。不要怪我太啰唆,如果你看到一个函数 nreverse, 而且确实它能返回逆序的列表,不明所以就到处乱用,迟早会写出一个错误的函数。这个 nreverse 和 前面的 reverse 差别就在于它是一个有破坏性的函数,也就是说它会修改它的参数。

(nreverse foo)                          ; => (c b a)
foo ; => (a)

为什么现在 foo 指向的是列表的末端呢?如果你实现过链表就知道,逆序操作是可以在原链表上进行的, 这样原来头部指针会变成链表的尾端。列表也是(应该是,我也没有看过实现)这个原理。使用 nreverse 的唯一的好处是速度快,省资源。所以如果你只是想得到逆序后的列表就放心用 nreverse,否则还是用 reverse 的好。

elisp 还有一些是具有破坏性的函数。最常用的就是 sort 函数:

(setq foo '(3 2 4 1 5))                 ; => (3 2 4 1 5)
(sort foo '<) ; => (1 2 3 4 5)
foo ; => (3 4 5)

这一点请一定要记住,我就曾经在 sort 函数上犯了好几次错误。那如果我既要保留原列表,又要进行 sort 操作怎么办呢?可以用 copy-sequence 函数。这个函数只对列表进行复制,返回的列表的元素 还是原列表里的元素,不会拷贝列表的元素。nconc 和 append 功能相似,但是它会修改除最后一个 参数以外的所有的参数,nbutlast 和 butlast 功能相似,也会修改参数。这些函数都是在效率优先 时才使用。总而言之,以 n 开头的函数都要慎用。

把列表当集合用

列表可以作为无序的集合。合并集合用 append 函数。去除重复的 equal 元素用 delete-dups。 查找一个元素是否在列表中,如果测试函数是用 eq,就用 memq,如果测试用 equal,可以用 member。 删除列表中的指定的元素,测试函数为 eq 对应 delq 函数,equal 对应 delete。还有两个函数 remq 和 remove 也是删除指定元素。它们的差别是 delq 和 delete 可能会修改参数,而 remq 和 remove 总是返回删除后列表的拷贝。注意前面这是说的是可能会修改参数的值,也就是说可能不会,所 以保险起见,用 delq 和 delete 函数要么只用返回值,要么用 setq 设置参数的值为返回值。

(setq foo '(a b c))                     ; => (a b c)
(remq 'b foo) ; => (a c)
foo ; => (a b c)
(delq 'b foo) ; => (a c)
foo ; => (a c)
(delq 'a foo) ; => (c)
foo ; => (a c)

把列表当关联表

用在 elisp 编程中,列表最常用的形式应该是作为一个关联表了。所谓关联表,就是可以用一个字符串 (通常叫关键字,key)来查找对应值的数据结构。由列表实现的关联表有一个专门的名字叫 association list。尽管 elisp里也有 hash table,但是 hash table 相比于 association list 至少这样几个缺点:

  • hash table 里的关键字(key)是无序的,而 association list 的关键字 可以按想要的顺序排列
  • hash table 没有列表那样丰富的函数,只有一个 maphash 函数可以遍历列 表。而 association list 就是一个列表,所有列表函数都能适用
  • hash table 没有读入语法和输入形式,这对于调试和使用都带来很多不便

所以 elisp的hash table 不是一个首要的数据结构,只要不对效率要求很高,通常直接用association list。 数组可以作为关联表,但是数组不适合作为与人交互使用数据结构(毕竟一个有意义的名字比纯数字的下标更 适合人脑)。所以关联表的地位在 elisp 中就非比寻常了,emacs 为关联表专门用 c 程序实现了查找的相关 函数以提高程序的效率。在 association list 中关键字是放在元素的 CAR 部分,与它对应的数据放在这个 元素的 CDR 部分。根据比较方法的不同,有 assq 和assoc 两个函数,它们分别对应查找使用 eq 和 equal 两种方法。例如:

(assoc "a" '(("a" 97) ("b" 98)))        ; => ("a" 97)
(assq 'a '((a . 97) (b . 98))) ; => (a . 97)

通常我们只需要查找对应的数据,所以一般来说都要用 cdr 来得到对应的数据:

(cdr (assoc "a" '(("a" 97) ("b" 98))))  ; => (97)
(cdr (assq 'a '((a . 97) (b . 98)))) ; => 97

assoc-default 可以一步完成这样的操作:

(assoc-default "a" '(("a" 97) ("b" 98)))          ; => (97)

如果查找用的键值(key)对应的数据也可以作为一个键值的话,还可以用 rassoc 和 rassq 来根据数据查找键值:

(rassoc '(97) '(("a" 97) ("b" 98)))     ; => ("a" 97)
(rassq '97 '((a . 97) (b . 98))) ; => (a . 97)

如果要修改关键字对应的值,最省事的作法就是用 cons 把新的键值对加到列表的头端。但是这会让列表越来越长, 浪费空间。如果要替换已经存在的值,一个想法就是用 setcdr 来更改键值对应的数据。但是在更改之前要先确定 这个键值在对应的列表里,否则会产生一个错误。另一个想法是用 assoc 查找到对应的元素,再用 delq 删除这 个数据,然后用 cons 加到列表里:

(setq foo '(("a" . 97) ("b" . 98)))     ; => (("a" . 97) ("b" . 98))

;; update value by setcdr
(if (setq bar (assoc "a" foo))
(setcdr bar "this is a")
(setq foo (cons '("a" . "this is a") foo))) ; => "this is a"

foo ; => (("a" . "this is a") ("b" . 98))
;; update value by delq and cons
(setq foo (cons '("a" . 97)
(delq (assoc "a" foo) foo)))
; => (("a" . 97) ("b" . 98))

如果不对顺序有要求的话,推荐用后一种方法吧。这样代码简洁,而且让最近更新的元素放到列表前端,查找更快。

把列表当树用

列表的第一个元素如果作为结点的数据,其它元素看作是子节点,就是一个树了。由于树的操作都涉及递归, 现在还没有说到函数,我就不介绍了。(其实是我不太熟,就不班门弄斧了)。

遍历列表

遍历列表最常用的函数就是 mapc 和 mapcar 了。它们的第一个参数都是一个函数,这个函数只接受一个参数, 每次处理一个列表里的元素。这两个函数唯一的差别是前者返回的还是输入的列表,而 mapcar 返回的函数返回 值构成的列表:

(mapc '1+ '(1 2 3))                     ; => (1 2 3)
(mapcar '1+ '(1 2 3)) ; => (2 3 4)

另一个比较常用的遍历列表的方法是用 dolist。它的形式是:

(dolist (var list [result]) body...)

其中 var 是一个临时变量,在 body 里可以用来得到列表中元素的值。使用 dolist 的好处是不用写lambda 函数。 一般情况下它的返回值是 nil,但是你也可以指定一个值作为返回值(我觉得这个特性没有什么用,只省了一步而已):

(dolist (foo '(1 2 3))
(incf foo)) ; => nil

(setq bar nil)
(dolist (foo '(1 2 3) bar)
(push (incf foo) bar)) ; => (4 3 2)

其它常用函数

如果看过一些函数式语言教程的话,一定对 fold(或叫 accumulate、reduce)和 filter 这些函数记忆深刻。 不过 elisp 里好像没有提供这样的函数。remove-if 和 remove-if-not 可以作 filter 函数,但是它们是 cl 里的,自己用用没有关系,不能强迫别人也跟着用,所以不能写到 elisp 里。如果不用这两个函数,也不用别 人的函数的话,自己实现不妨用这样的方法:

(defun my-remove-if (predicate list)
(delq nil (mapcar (lambda (n)
(and (not (funcall predicate n)) n))

list))
)

(defun evenp (n)
(= (% n 2) 0))

(my-remove-if 'evenp '(0 1 2 3 4 5)) ; => (1 3 5)

fold 的操作只能用变量加循环或 mapc 操作来代替了:

(defun my-fold-left (op initial list)
(dolist (var list initial)
(setq initial (funcall op initial var))))

(my-fold-left '+ 0 '(1 2 3 4)) ; => 10

这里只是举个例子,事实上你不必写这样的函数,直接用函数里的遍历操作更好一些。

产生数列常用的方法是用 number-sequence(这里不禁用说一次,不要再用 loop 产生 tab-stop-list 了,你们 too old 了)。不过这个函数好像 在emacs21 时好像还没有。

解析文本时一个很常用的操作是把字符串按分隔符分解,可以用 split-string 函数:

(split-string "key = val" "\\s-*=\\s-*")  ; => ("key" "val")

与 split-string 对应是把几个字符串用一个分隔符连接起来,这可以用 mapconcat 完成。比如:

(mapconcat 'identity '("a" "b" "c") "\t") ; => "a   b   c"

identity 是一个特殊的函数,它会直接返回参数。mapconcat 第一个参数是一个函数,可以很灵活的使用。

函数列表

;; 列表测试
(consp OBJECT)
(listp OBJECT)
(null OBJECT)
;; 列表构造
(cons CAR CDR)
(list &rest OBJECTS)
(append &rest SEQUENCES)
;; 访问列表元素
(car LIST)
(cdr LIST)
(cadr X)
(caar X)
(cddr X)
(cdar X)
(nth N LIST)
(nthcdr N LIST)
(last LIST &optional N)
(butlast LIST &optional N)
;; 修改 cons cell
(setcar CELL NEWCAR)
(setcdr CELL NEWCDR)
;; 列表操作
(push NEWELT LISTNAME)
(pop LISTNAME)
(reverse LIST)
(nreverse LIST)
(sort LIST PREDICATE)
(copy-sequence ARG)
(nconc &rest LISTS)
(nbutlast LIST &optional N)
;; 集合函数
(delete-dups LIST)
(memq ELT LIST)
(member ELT LIST)
(delq ELT LIST)
(delete ELT SEQ)
(remq ELT LIST)
(remove ELT SEQ)
;; 关联列表
(assoc KEY LIST)
(assq KEY LIST)
(assoc-default KEY ALIST &optional TEST DEFAULT)
(rassoc KEY LIST)
(rassq KEY LIST)
;; 遍历函数
(mapc FUNCTION SEQUENCE)
(mapcar FUNCTION SEQUENCE)
(dolist (VAR LIST [RESULT]) BODY...)
;; 其它
(number-sequence FROM &optional TO INC)
(split-string STRING &optional SEPARATORS OMIT-NULLS)
(mapconcat FUNCTION SEQUENCE SEPARATOR)
(identity ARG)

问题解答

用 list 生成 (a b c)

答案是 (list 'a 'b 'c)。很简单的一个问题。从这个例子可以看出为什么要想出 用 ' 来输入列表。这就是程序员“懒”的美德呀!

nthcdr 的一个实现

(defun my-nthcdr (n list)
(if (or (null list) (= n 0))
(car list)
(my-nthcdr (1- n) (cdr list))))

这样的实现看上去很简洁,但是一个最大的问题的 elisp 的递归是有限的,所以如果想这个函数没有问题,还是用循环还实现比较好。

my-subseq 函数的定义

(defun my-subseq (list from &optional to)
(if (null to) (nthcdr from list)
(butlast (nthcdr from list) (- (length list) to))))

(setcdr foo foo) 是什么怪东西?

可能你已经想到了,这就是传说中的环呀。这在 info elisp - Circular Objects 里有介绍。elisp 里用到这样的 环状列表并不多见,但是也不是没有,org 和 session 那个 bug 就是由于一个环状列表造成的。

基本数据类型之四 ─ 数组和序列

序列是列表和数组的统称,也就是说列表和数组都是序列。它们的共性是内部的元素都是有序的。elisp 里的 数组包括字符串、向量、char-table 和布尔向量。它们的关系可以用下面图表示:

 _____________________________________________
| |
| Sequence |
| ______ ________________________________ |
| | | | | |
| | List | | Array | |
| | | | ________ ________ | |
| |______| | | | | | | |
| | | Vector | | String | | |
| | |________| |________| | |
| | ____________ _____________ | |
| | | | | | | |
| | | Char-table | | Bool-vector | | |
| | |____________| |_____________| | |
| |________________________________| |
|_____________________________________________|

组有这样一些特性:

  • 数组内的元素都对应一个下标,第一个元素下标为 0,接下来是 1。数组内 的元素可以在常数时间内访问。
  • 数组在创建之后就无法改变它的长度。
  • 数组是自求值的。
  • 数组里的元素都可以用 aref 来访问,用 aset 来设置。

向量可以看成是一种通用的数组,它的元素可以是任意的对象。而字符串是一种特殊的数组,它的元素只能是字符。 如果元素是字符时,使用字符串相比向量更好,因为字符串需要的空间更少(只需要向量的1/4),输出更直观, 能用文本属性(text property),能使用 emacs 的 IO 操作。但是有时必须使用向量,比如存储按键序列。

由于 char-table 和 bool-vector 使用较少,而且较难理解,这里就不介绍了。

测试函数

sequencep 用来测试一个对象是否是一个序列。arrayp 测试对象是否是数组。vectorp、char-table-p 和 bool-vector-p 分别测试对象是否是向量、char-table、bool-vector。

序列的通用函数

一直没有提到一个重要的函数 length,它可以得到序列的长度。但是这个函数只对真列表有效。对于一个 点列表和环形列表这个函数就不适用了。点列表会出参数类型不对的错误,而环形列表就更危险,会陷入死 循环。如果不确定参数类型,不妨用 safe-length。比如:

(safe-length '(a . b))                  ; => 1
(safe-length '#1=(1 2 . #1#)) ; => 3

写一个函数来检测列表是否是一个环形列表。由于现在还没有介绍 let 绑定和循环,
不过如果会函数定义,还是可以用递归来实现的。

取得序列里第 n 个元素可以用 elt 函数。但是我建议,对于已知类型的序列,还是用对应的函数比较好。 也就是说,如果是列表就用 nth,如果是数组就用 aref。这样一方面是省去 elt 内部的判断, 另一方面读代码时能很清楚知道序列的类型。

copy-sequence 在前面已经提到了。不过同样 copy-sequence 不能用于点列表和环形列表。对于点列表可以 用 copy-tree 函数。环形列表就没有办法复制了。 好在这样的数据结构很少用到。

数组操作

创建字符串已经说过了。创建向量可以用 vector 函数:

(vector 'foo 23 [bar baz] "rats")

当然也可以直接用向量的读入语法创建向量,但是由于数组是自求值的,所以这样得到的向量和原来是一样的,也就是说参数不进行求值,看下面的例子就明白了:

foo                                     ; => (a b)
[foo] ; => [foo]
(vector foo) ; => [(a b)]

用 make-vector 可以生成元素相同的向量。

(make-vector 9 'Z)                      ; => [Z Z Z Z Z Z Z Z Z]

fillarray 可以把整个数组用某个元素填充。

(fillarray (make-vector 3 'Z) 5)        ; => [5 5 5]

aref 和 aset 可以用于访问和修改数组的元素。如果使用下标超出数组长度的话,会产生一个错误。所以要先确定数组的长度才能用这两个函数。

vconcat 可以把多个序列用 vconcat 连接成一个向量。但是这个序列必须是真列表。这也是把列表转换成向量的方法。

(vconcat [A B C] "aa" '(foo (6 7)))     ; => [A B C 97 97 foo (6 7)]

把向量转换成列表可以用 append 函数,这在前一节中已经提到。

函数列表

;; 测试函数
(sequencep OBJECT)
(arrayp OBJECT)
(vectorp OBJECT)
(char-table-p OBJECT)
(bool-vector-p OBJECT)
;; 序列函数
(length SEQUENCE)
(safe-length LIST)
(elt SEQUENCE N)
(copy-sequence ARG)
(copy-tree TREE &optional VECP)
;; 数组函数
(vector &rest OBJECTS)
(make-vector LENGTH INIT)
(aref ARRAY IDX)
(aset ARRAY IDX NEWELT)
(vconcat &rest SEQUENCES)
(append &rest SEQUENCES)

问题解答

测试列表是否是环形列表

这个算法是从 safe-length 定义中得到的。你可以直接看它的源码。下面是我写的函数。

(defun circular-list-p (list)
(and (consp list)
(circular-list-p-1 (cdr list) list 0)))


(defun circular-list-p-1 (tail halftail len)
(if (eq tail halftail)
t
(if (consp tail)
(circular-list-p-1 (cdr tail)
(if (= (% len 2) 0)
(cdr halftail)
halftail)

(1+ len))

nil))
)

转换字符的 tr 函数

(defun my-tr (str from to)
(if (= (length to) 0) ; 空字符串
(progn
(setq from (append from nil))
(concat
(delq nil
(mapcar (lambda (c)
(if (member c from)
nil c))

(append str nil)))
)
)

(let (table newstr pair)
;; 构建转换表
(dotimes (i (length from))
(push (cons (aref from i) (aref to i)) table))

(dotimes (i (length str))
(push
(if (setq pair (assoc (aref str i) table))
(cdr pair)
(aref str i))

newstr))

(concat (nreverse newstr) nil)))
)

这里用到的 dotimes 函数相当于一个 C 里的 for 循环。如果改写成 while 循环,相当于:

(let (var)
(while (< var count)
body
(setq var (1+ var)))

result)

从这个例子也可以看出,由于列表具有丰富的函数和可变长度,使列表比数组使用更方便,而且效率往往更高。


原连接