倘若将 Elisp 的应用场景固定为文本处理,学习 Elisp,我认为无需像学习其他任何一门编程语言那样亦步亦趋,所以本章直接从文件读写开始入手,通过一些小程序,建立对 Elisp 语言的初步感受。
Hello world!
虽然我已决定从文件读写开始学习 Elisp,但是我还是希望对初学者友好一点,毕竟我也是初学者。这种友好应该像学习任何一门编程语言那样,从写一个能够输出 Hello world!
的程序。
用 Emacs 新建一份文本文件,名曰 hello-world.el。当然,也可以使用其他文本编辑器完成此事,但是要保证系统已安装了 Emacs 且可用。hello-world.el 的内容只有一行:
(princ "Hello world!")
在终端(或命令行窗口)里,将工作目录(当前目录)切换至 hello-world.el 文件所在的目录,然后执行
$ emacs -Q --script hello-world.el
终端会随即显示
Hello world!
从这个 Hellow world 程序里,能学到哪些 Elisp 知识呢?
首先,princ
是一个函数,确切地说,是 Elisp 的内建函数。什么是函数?在数学里,y=f(x) 是函数,f 可将 x 映射为 y。princ
也是这样的函数,它将 "Hello world!
这个对象映射为显示于终端的对象。
其次,"Hello world!"
是 Elisp 的字符串类型,用于表示一段文本。文本是数据。数据未必是文本。若将 Elisp 作为用于处理文本的语言,字符串就是基本且核心的数据类型。
最后,这个作为示例的 Elisp 程序的最小单位是一个函数调用。我向 princ
函数提供一个字符串类型的值,便可令其工作,且足以构成一个程序。Emacs 里有 Elisp 解释器。Elisp 程序是由 Elisp 解释器解释运行的,类似于计算机程序是由计算机的 CPU 「解释」运行。换言之,Elisp 解释器能够读懂 Elisp 程序,并完成这个程序所描述的那些工作,例如,在终端里输出 Hello world!
。
定义一个新的函数
hello-world.el 程序虽然能在终端里输出 Hello world!
,但是它的输出很容易令终端有所错乱,例如将我的终端弄成了下面这幅样子:
这是因为,princ
函数仅仅是将字符串类型的数据原样输出。若让终端保持有序,输出的文本末尾需附加一个换行符 \n
。虽然修改 princ
的定义完成此事似乎甚为困难,但是站在它的肩膀上定义一个新的函数完成此事,则甚为简单:
(defun princ\' (x)
(princ x)
(princ "\n"))
(princ\' "Hello world!")
princ\'
便是我定义的新函数。我原本想使用 princ'
这个名字,但是符号 '
已被 Elisp 语言作为一个有特殊语意的符号,因此我不得不使用 Elisp 语言的字符转义符 \
对 '
进行转义,表明后者无特殊含义,仅仅是一个符号。
定义一个函数,遵循的格式是
(defun 函数名 (参数)
函数体)
函数体由一个或一组表达式构成。在 princ\'
的定义中,函数体由两个表达式构成。
运行新的 hello-world.el 程序,结果如下图所示:
缓冲区
假设存在文本文件 foo.txt,其内容为
Hello world!
如何写一个 Elisp 程序,从 foo.txt 读取全部内容并输出到终端?
读取文件,这个操作意味着什么?意味着从计算机辅存(硬盘)中获取数据,放入主存(内存)。原因在于,计算机 CPU 访问主存的速度远快于辅存。
为了简化文件的读写,Elisp 提供一种数据类型——缓冲区(Buffer)。缓冲区对象(也可称为缓冲区实例)本质上是计算机主存里的一段空间。文件的内容被读取后,存入缓冲区实例里,在后者中可进行文件内容的编辑工作。编辑完毕后,缓冲区实例包含的信息可以再存回文件。为了便于描述,在不至于引起误解的前提下,我会将缓冲区实例简称为缓冲区。类似的称谓也适用于 Elisp 的其他数据类型上,例如数字、字符串、列表、数组、哈希表等。
使用 Elisp 函数 generate-new-buffer
可以创建一个有名字的缓冲区。例如,创建一个名曰 foo
的缓冲区:
(generate-new-buffer "foo")
能创建一个,就能创建多个,但是无论创建了多少个,其中只可能有一个是激活的,亦即当前缓冲区。在读取文件时,从文件获取的数据总是存放在当前缓冲区内。Elisp 函数 buffer-name
可以获得当前缓冲区的名字。以下程序可查看当前缓冲区的名字:
(princ\' (buffer-name))
Elisp 解释器有一个默认的缓冲区,名字叫 *scratch*
。倘若没有创建新的缓冲区并将其激活为当前缓冲区,那么上述程序的输出就是 *scratch*
。
Elisp 函数 set-buffer
可将指定的缓冲区设为当前缓冲区。例如,下面这个程序可将上文创建的 foo
缓冲区设为当前缓冲区,并通过输出当前缓冲区的名字它是否为当前缓冲区:
(set-buffer "foo")
(princ\' (buffer-name))
set-buffer
的参数除了可以是缓冲区的名字,也可以是缓冲区本身。由于 generate-new-buffer
能够返回它创建的新缓冲区,因此它可以与 set-buffer
函数复合,用于创建一个缓冲区并将其设为当前的缓冲区,例如
(set-buffer (generate-new-buffer "foo"))
将上述代码综合一下,可以放在一个名字叫 foo.el 的文本文件里。foo.el 内容为
(defun princ\' (x)
(princ x)
(princ "\n"))
(princ\' (buffer-name))
(set-buffer (generate-new-buffer "foo"))
(princ\' (buffer-name))
在终端里,若以 foo.el 所在目录为工作目录,执行
$ emacs -Q --script ./foo.el
输出为
*scratch*
foo
这是我写的第二个 Elisp 程序,感觉还不错。别的编程语言里,可能没有缓冲区这种设施。没有对比,就没有伤害。没有伤害,就没有自豪。
文件读取
对于上一节一开始所提出的问题,事实上并不需要我去为待读取的文件创建一个缓冲区,并将其设为当前缓冲区。Elisp 提供的 find-file
可以替我完成这项工作。例如,
(find-file "foo.txt")
(princ\' (buffer-name))
所产生的输出为
foo.txt
这个名曰 foo.txt
的缓冲区,便是 find-file
函数为 foo.txt 文件而创建的。
如何确认 foo.txt 文件里的内容真的被读取后存放到 foo.txt
缓冲区呢?可通过 buffer-string
函数以字符串的形式获得当前缓冲区存储的数据,然后将所得结果显示于终端,例如
(princ\' (buffer-string))
因此,读取 foo.txt 文件里的内容,并将其显示于终端的程序至此便完成了。完整的程序如下:
(defun princ\' (x)
(princ x)
(princ "\n"))
(find-file "foo.txt")
(princ\' (buffer-string))
代码风格
Elisp 代码,只要不破坏名字,它的风格是很随意的。例如 princ\'
函数的定义,写成
(defun princ\' (x) (princ x) (princ "\n"))
也是可以的。
写成
(defun
princ\'
(x)
(princ
x)(princ
"\n"))
也不是不行。但是,最好不要写怪异的代码。毕竟,那层层括号的嵌套,人生已经很不容易了。
括号无论是内层的,还是外层的,它们总是成对出现。Lisp 语言最大特点就是,无论是函数的定义,还是函数的调用,还是其他的一些表达式,在形式上是由括号构成的嵌套结构。这种结构,Lisp 语言称为列表。
如果使用 Emacs 编写 Elisp 代码或其他 Lisp 方言的代码,要记得安装 paredit 包。我不想浪费时间去讲如何安装和使用这个包。不完全是因为没人给我发稿费,主要是每个人都应该会用网络搜索引擎。
在缓冲区内插入文本
无论是用 find-file
函数自动创建的缓冲区,还是基于 generate-new-buffer
创建的缓冲区,一旦它们被设定为当前缓冲区,便可以使用 Elisp 提供的一些函数,将数据写入其中。
insert
函数可将字符串类型的数据写入当前缓冲区,例如:
(defun princ\' (s)
(princ (concat s "\n")))
(find-file "foo.txt")
(insert "|||")
(princ\' (buffer-string))
输出结果为
|||Hello world!
可见 insert
函数将 |||
插入到了当前缓冲区存储的文本数据的首部。这是因为,当前缓冲区内存在这一个不可见的光标,我将其称为插入点,它对应于 Emacs 图形窗口里不断闪动的那个光标,表示文本的插入点。在使用 find-file
打开一份文件时,插入点会自动定位在文件的开头,坐标为 1。为了理解插入点,就需要将缓冲区想像成一维数组,存放的元素为字符,这个一维数组就像一根很长的纸带那样,插入点的坐标就是插入点位于第几个字符之前。
point
函数可以获得插入点的坐标。例如
(find-file "foo.txt")
(princ (point))
输出 1
。
goto-char
函数可将插入点移动到缓冲区内的任何位置。例如,倘若将 |||
插入 Hello world!
的两个单词的中间,只需
(find-file "foo.txt")
(goto-char 6)
(insert "|||")
由于函数 point-min
和 point-max
可以获得缓冲区的起止位置,因此可基于它们将插入点移动到缓冲区的开头或结尾。例如,将 |||
插入到 Hello world!
的尾部:
(find-file "foo.txt")
(goto-char (point-max))
(insert "|||")
在此,也许应该提出一个疑问,为何需要用 point-min
获得缓冲区起始位置?难道这个位置不是 1 吗?因为在缓冲区内部可以创建更小的局部区域,而它也是 Elisp 的一种数据类型,它的名字叫 Narrowing。对于位于 Narrowing 区域的文本,也可以用 point-min
和 point-max
获取起止位置,故而 point-min
获得的结果未必是 1。关于 Narrowing,它在 Emacs 图形界面里较为有用,在使用 Elisp 编写文本处理程序方面,我暂时还没思考出它的应用场景。
在缓冲区内删除文本
Elisp 函数 delete-char
可以删除插入点之后的字符。例如,以下程序将 foo.txt 读入缓冲区后,插入点尚在缓冲区起始位置时,删除它后面 5 个字符,
(find-file "foo.txt")
(delete-char 5)
Elisp 也提供了一些与插入点位置无关的缓冲区文本删除函数,其中 delete-region
可以删除落入指定区间的文本。例如,删除缓冲区内第 6 个字符到第 12 个字符之间的字符,被删除的字符包括前者,但不包括后者,
(find-file "foo.txt")
(delete-region 6 12)
可以使用 (princ\' (buffer-string))
查看缓冲区内容的变化。
将缓冲区内容写入文件
现在,已经基本掌握了从文件读取内容到缓冲区,在缓冲区内写入一些内容,接下来,需要考虑的一个问题是,缓冲区的内容该如何保存到文件里。保存方式自然有两种,一种是保存到与当前缓冲区关联的文件,另一种是保存到其他文件。
save-buffer
可将当前缓冲区保存到与之关联的文件里。例如
(find-file "foo.txt")
(goto-char (point-max))
(insert "|||")
(save-buffer)
运行上述程序后,可打开 foo.txt 文件查看其内容,是否在 Hello world!
之后多出了 |||
。
write-file
可将当前缓冲区保存到其他文件。例如
(find-file "foo.txt")
(goto-char (point-max))
(insert "|||")
(write-file "bar.txt")
结语
本章的内容虽然较为简单,但是已经隐约触及了 Emacs 的一些本质。倘若理解并熟悉了本文出现的所有 Elisp 已经提供的函数的用法,相当于掌握了 Emacs 最朴素的功能,即打开一份文件,添加一些内容,删除一些内容,然后保存,并不需要一个图形界面帮助我们完成这些事。
文中所出现的函数,除 princ\'
之外,我将其他所有函数说成 Elisp 提供的,甚至一度想将它们称为 Elisp 标准库里的函数。但事实上,Elisp 只是一门语言,而且也不存在这个标准库。这些函数皆来自于 Emacs 的核心功能——数量庞大的函数集,分散于众多 Elisp 源程序文件。我将这些函数统称为 Elisp 函数。
在 Emacs 里默认的键绑定 C-h f
,然后输入其中的某个函数名,回车,Emacs 便会打开该函数的文档。在文档里,函数的用途、参数以及返回结果皆有详细的说明。一开始,看不懂,也不大要紧,关键是要去看。