2

前言:不知多久能学会 Elisp

上一章:Hello world!

本章介绍 Elisp 的变量、列表、符号、函数的递归以及一些更便捷的插入点移动函数。这些知识将围绕一个实际问题的解决过程逐步展开。

问题

假设有一份文档 foo.md,内容如下:

# Hello world!

下面是 C 语言的 Hello world 程序源文件 hello.c 的内容:

```
#include <stdio.h>

int main(void) {
    printf("Hello world!\n")
    return 0;
}
```

... ... ...

其中有一部分内容被包含在以 \`\`\` 为开头的两个文本行之间,如何使用 Elisp 编写一个程序,从 foo.md 中识别它们?

注:这个网站的 Markdown 解析器不够健全,无法理解字符转义,导致无法正确显示字符转义形式出现的三个连续的反引号。

解析器

foo.md 文件中每一行文本无非为以下三种情况之一。这三种情况是

  1. \`\`\` 开头的文本行;
  2. 位于两个 \`\`\` 开头的文本行之间的文本行;
  3. 非上述两种情况的文本行。

假设我要编写的程序是 simple-md-parser.el,只要它能够判定每一行文本的情况,并将判定结果记录下来,那么问题便得以解决。这个程序虽然简单,但着实称得上是解析器(Parser)。

变量和列表

simple-md-parser.el 对 foo.md 文件每一行文本的判定结果可存储于 Elisp 的列表类型的变量里。

在 Elisp 语言里,变量是绑定到某种类型的数据对象的符号,因而定义一个变量,就是将一个符号与一个数据对象绑定起来。例如,

(setq x "Hello world!")

将一个符号 x 与一个字符串类型的数据绑定起来,于是便定义了变量 x

列表变量,就是一个符号绑定到了列表类型的实例,后者可由 list 函数创建,例如

(setq x (list 1 2 3 4 5))

将符号 x 绑定到列表对象 (1 2 3 4 5),于是便定义了一个列表变量 x

也可以定义空列表变量,例如

(setq x '())

单引号 ' 在 Elisp 表示引用。Elisp 解释器遇到它领起的符号或列表时,将后者本身作为求值结果。这是 Lisp 语言特性之一。通过下面的例子,也许有助于理解这一特性:

(setq x (list 1 2 3 4 5))
(princ\' x)

(setq x '(list 1 2 3 4 5))
(princ\' x)

(setq x '(1 2 3 4 5))
(princ\' x)

上述程序的输出为

(1 2 3 4 5)
(list 1 2 3 4 5)
(1 2 3 4 5)

基于上述程序的输出,可发现

(setq x '(list 1 2 3 4 5))

是将符号 x 绑定到了 (list 1 2 3 4 5) 这个列表,因为代码中的 '(list 1 2 3 4 5) 阻止了 Elisp 解释器对 (list 1 2 3 4 5) 进行求值,而是直接将改语句本身作为求值结果。

还可以看出以下两行代码等价:

(setq x (list 1 2 3 4 5))
(setq x '(1 2 3 4 5))

倘若理解了上述内容,就不难理解为何 '() 表示空列表了。

列表是单向的

Elisp 的列表是单向的,访问列表首部元素,要比访问其尾部元素容易得多。使用 car 函数可以获得列表首部元素。例如

(setq x '(1 2 3 4 5))
(princ\' (car x))

输出 1

cdr 函数可以去掉列表首部元素,将剩余部分作为求值结果。例如

(princ\' (cdr '(1 2 3 4 5)))

输出 (2 3 4 5)

如果要获得列表的尾部元素,就需要使用 cdr 不断砍掉列表首部,直至列表剩下最后一个元素。好在解决本章开始所提出的问题,并不需要获取列表尾部元素。

同访问列表首部和尾部元素类似,向列表的尾部追加元素,要比在列表的首部追加元素困难得多。Elisp 提供了 cons 函数,可将一个元素添加到列表的首部,然后返回新的列表。例如

(setq x '(1 2 3 4 5))
(setq x (cons 0 x))
(princ\' x)

输出 (0 1 2 3 4 5)

求值

从现在开始,我就不再说函数的返回结果了,而是说求值结果,虽然在大多数情况下可以将它们理解为一回事,但是应当尊重 Lisp 语言的一些术语。

上一章含糊地提及,Elisp 程序由 Elisp 解释器解释执行。这个过程具体是怎样进行的呢?这个过程本质上是由 Elisp 解释器对程序里的每个表达式进行依序求值的过程构成。

表达式,也叫块(Form)。在 Elisp 语言里,变量的定义和使用,函数的定义和使用,皆为表达式。即使一个数字,一个字符串或其他某种类型的一个实例,也是表达式。

以下语句,每一行皆为一个表达式:

42
"Hello world!"
(setq x 42)
(princ\' (buffer-string))

表达式可以嵌套,嵌套结构通常是用成对的括号表达的,例如函数的定义便是典型的嵌套结构:

(defun princ\' (x)
  (princ x)
  (princ "\n"))

没错,Elisp 解释器也会对函数的定义求值,求值结果是函数的名字。

在 Elisp 解释器看来,任何表达式皆有其值,所以它对 Elisp 程序的解释和执行,本质上就是对程序里的所有表达式逐一进行求值。

需要注意的是,表达式 (princ\' "Hello world!) 的求值结果并非是在终端里输出的 Hello world!。一个程序向终端里写入信息,本质上是这个程序向一个文件写入信息。该工作是 Elisp 解释器在求值过程中的副业,它的主业是对表达式进行求值,求值结果在 Elisp 解释器之外不可见。

将一个符号绑定到一个数据对象或一组表达式,亦即定义一个变量或函数,在某种意义上也可以视为 Elisp 解释器的副业。

符号

现在已经明白了,变量就是一个符号绑定到了某种类型的数据对象。事实上,函数也是类似的东西。在定义一个函数时,例如

(defun princ\' (x)
  (princ x)
  (princ "\n"))

不过是将一个符号 princ\' 绑定到了一组表达式罢了。定义一个函数,本质上是将一个符号绑定到一个匿名的函数上。这种匿名的函数,叫作 Lambda 表达式。倘若不打算深究这些知识,也无妨,但是多少应该知道,Lambda 表达式是 Lisp 的精髓之一。

符号可以用作变量和函数的名字,但是符号还有一个用途,就是用其本身。由于单引号 ' 能够阻止 Elisp 对一个名字做任何解读,只是将这个名字本身作为求值结果,因此通过这种办法,在程序里可以直接使用符号本身。

现在回到本章要解决的问题,还记得 foo.md 文件内的每一行文本只可能是三种情况之一吗?我可以用符号来表示这三种情况:

'开头是三个连续的反引号的文本行
'被包含在开头是三个连续的反引号的两个文本行之间的文本行
'开头不是三个连续的反引号而且也没有被开头是三个连续的反引号的两个文本行包含的文本行

不是开玩笑,因为 Elisp 真的支持这么长的符号。但是,符号太长了,写代码也挺累的。简化一下,上述三种情况简化且进一步细分为以下四种情况:

'代码块开始
'代码块
'代码块结束
'未知

为什么要将开头是 \`\`\` 的两个文本行之间所包含的文本区域称为「代码块」呢?因为 foo.md 文件里的内容其实 Markdown 标记文本。

逐行遍历缓冲区

似乎一切都走在正确的道路上,到了考虑如何读取 foo.md 文件的每一行文本的时候了。

上一章已指出,使用 find-file 函数可将指定文件读取至缓冲区,然后使用 goto-char 函数将缓冲区内的插入点移动到指定位置。Elisp 提供了更大步幅的插入点移动函数 forward-line,该函数可将光标移动到当前所在的文本行的后面或前面的文本行的开头。在缓冲区内,插入点所在的文本行,其首尾的坐标可分别通过 line-beginning-positionline-end-position 获得,将它们作为参数值传递于 buffer-substring,便可由后者获取插入点所在的文本行的内容存入一个字符串对象并将其作为求值结果。简而言之,基于这几个函数,能够以字符串对象的形式抓取缓冲区内任一行文本。例如,以下程序可抓取 foo.md 文件的第三行内容:

(find-file "foo.md")
(forward-line 2)
(princ\' (buffer-substring (line-beginning-position) (line-end-position)))

为什么将插入点移动到当前缓冲区的第三行是 (forward-line 2) 呢?这是因为,(find-file "foo.md") 打开文件后,插入点默认是在当前缓冲区第一行的行首。forward-line 函数的参数值是相对于插入点当前所在的文本行的相对偏移行数,从第一行向后移动 2 行,就是第三行了。forward-line 的参数值也可以为负数,可以让插入点移动到当前文本行之前的某行。

注意,为了方便获取插入点所在的文本行内容,我定义了 current-line 函数:

(defun current-line ()
  (buffer-substring (line-beginning-position) (line-end-position)))

倘若定义一个函数,在该函数内部使用 (forward-line 1) 将插入点移动到下一行,然后再调用该函数自身,便可逐行读取缓冲区内容。例如

(defun every-line ()
  (princ\' (current-line))
  (forward-line 1)
  (every-line))

(find-file "foo.md")
(every-line)

every-line 是递归函数。在一个函数的定义里调用该函数自身,即递归函数。任何一种编程语言的解释器在遇到递归函数时,会陷入对函数的定义反复进行求值的过程里。递归函数犹如汽车的发动机,它周而复始的运转。至于汽车可以将人从一个地方载到另一个地方,不过是发动机的副作用罢了。

上述程序的确能逐行将当前缓冲区内容逐行显示出来,但是程序最终会崩溃,临终遗言是

Lisp nesting exceeds ‘max-lisp-eval-depth’

因为在 every-line 函数的定义中,未检测插入点是否移动到缓冲区内容的尽头,递归过程无法终止,导致 Elisp 解释器一直无法得到求值结果。但是,Elisp 解释器对递归深度有限制,默认是 800 次,递归深度超过这个限度,解释器便报错而退出。

条件表达式

如何判断插入点移动到了当前缓冲区的尽头呢?还记得上一章用过的函数 point 吗?它可以给出插入点的当前坐标。还记得 point-minpoint-max 吗?它们可以分别给出当前缓冲区的起止坐标。因此,当 point 的结果与 point-max 的结果相等时,便意味着插入点到了当前缓冲区的尽头。此刻,欠缺的知识是 Elisp 的条件表达式。

在 Elisp 语言里,= 是一个函数,可以用它判断两个数值是否相等。例如

(= (point) (point-max))

便可判断当前插入点是否到了当前缓冲区的尽头。上述逻辑表达式若成立,求值结果就是 t,否则求值结果是 nil。在 Elisp 语言里,符号 t 表示真,nil 表示假。另外,nil 也等价于 '(),但是我觉得最好还是不要混用。

现在差不多明白,为什么 Elisp 定义变量时,不像那些非 Lisp 语言那样,用 =,而是用 setq。那些非 Lisp 语言的变量定义语法虽然简洁一些,但是它们牺牲了 = 的意义,因为在判断两个数值是否相等时,往往使用 == 或其他符号。不要在意我说的,这只是我的幻想。

基于逻辑表达式的求值结果执行相应的程序分支,在 Elisp 语言里可通过 if 表达式。if 表达式的形式如下:

(if 逻辑表达式
    程序分支 1
  程序分支 2)

Elisp 解释器对逻辑表达式的求值结果倘若为真,便转而解释执行程序分支 1,否则解释执行程序分支 2。基于 if 表达式,便可重新定义 every-line 函数了。

(defun every-line ()
  (if (= (point) (point-max))
      (princ "")
  (princ\' (current-line))
  (forward-line 1)
  (every-line)))

这个函数能够如我所愿,在插入点抵达当前缓冲区尽头时,终止递归过程,求值结果是输出空字符串对象。但是,这个函数的语义却有些混乱,在其定义里,以下四行代码,

      (princ "")
    (princ\' (current-line))
    (forward-line 1)
    (every-line)))

其中哪些些应该算是「程序分支 1」,哪些算是「程序分支 2」呢?Elisp 的语法并不是缩进型语法,因此上述第一行代码虽然比后面三行代码的缩进更深无助于它有别于后者。为了让语义明确,需要使用 progn 语法。progn 可将一组语句整合到一起,将最后一条语句的求值结果作为求值结果。例如,

(defun every-line ()
  (if (= (point) (point-max))
      (princ "")
    (progn 
      (princ\' (current-line))
      (forward-line 1)
      (every-line))))

现在,every-line 函数中的条件表达式的语义便很清晰了,无论逻辑表达式的结果是真还是假,对应的程序分支是一个表达式,而不是多个。

字符串匹配

现在我有能力获得当前缓冲区里任意一行文本了,但是为了解决本章开始时提出的问题,还需要判断一行文本是否以 \`\`\` 开头。从每行文本的开头截取 3 个字符,判断它是不是 \`\`\`,这个小问题便可得以解决。事实上,Elisp 提供了完善的正则表达式,可用于匹配具有特定模式的文本,但是我现在不打算用它。因为正则表达式有些复杂,甚至需要为它单独开辟一章。

substring 函数可从一个字符串对象里截取落入指定范围内的子集并将其作为求值结果。例如

(princ\' (substring "天地一指也,万物一马也" 0 4))

输出

天地一指

判断两个字符串对象的内容是否相同,不能使用 =,应该使用 string=,切记。例如,

(string= "Hello" "Hello")

求值结果为 t,而

(string= "Hello" "World")

求值结果为 nil

以下代码可判断插入点所在的文本行的开头是否为 \`\`\`

(string= (substring (current-line) 0 3) "```")

便可判断当前文本行是否以 \`\`\` 开头,但是在实际情况里,这个表达式过于乐观了,因为并不是所有的文本行包含的字符个数多于 3 个,例如 foo.md 文件里有很多空行,这些空行只包含一个字符 \n,即换行符。在上例中,若当前文本行包含的字符个数少于 3 个,substring 函数便会报错:

Args out of range: "", 0, 3

然后 Elisp 解释器终止工作,程序也就无法再运行下去。若要解决这一问题,就需要特殊情况特殊处理:

(setq x (current-line))
(setq y "```")
(setq n (length y))
(if (< (length x) n)
    nil
  (string= (substring x 0 n) y))

< 也是一个函数,用于比较两个数值的大小。对于表达式 (< a b),若 a 小于 b,则求值结果为 t,否则为 nillength 函数可获得字符串对象的长度,即字符串对象包含的字符个数。

length 也可用于获取列表的长度——列表包含的元素个数,例如

(length '(1 2 3))

求值结果为 3。

实现解析器

只需综合利用上述的全部知识,便可写出 simple-md-parser.el。下面给出它的全部实现:

(defun princ\' (x)
  (princ x)
  (princ "\n"))

(defun current-line ()
  (buffer-substring (line-beginning-position) (line-end-position)))

(defun text-match (src dest)
  (setq n (length dest))
  (if (< (length src) n)
      nil
    (string= (substring src 0 n) dest)))

(defun every-line (result in-code-block)
  (if (= (point) (point-max))
      result
    (progn
      (if (text-match (current) "```")
          (progn
            (if in-code-block
                (progn
                  (setq result (cons '代码块结束 result))
                  (setq in-code-block nil))
              (progn
                (setq result (cons '代码块开始 result))
                (setq in-code-block t))))
        (progn
          (if in-code-block
              (setq result (cons '代码块 result))
            (setq result (cons '未知 result)))))
      (forward-line 1)
      (every-line result in-code-block))))

(progn
  (find-file "foo.md")
  (princ\' (every-line '() nil)))

every-line 函数的定义乍看有些复杂,但实际上它所表达的逻辑很简单。对于当前缓冲区内每一行文本,该函数首先判断它是否以 \`\`\` 开头,倘若是,就需要进一步判断该行文本的上一行是否在代码块里,然后方能确定当前以 \`\`\` 为开头的文本行是 '代码块开始,还是'代码块结束。该函数的第二个参数便是用于记录当前文本行的上一行文本是否属于 '代码块。此外,该函数也展示了作为求值结果的列表 result 如何从一个空列表对象开始在函数的递归过程中逐步增长。

列表反转

上一节实现的解析器,其中 every-line 函数的求值结果是一个列表对象。这个列表对象实际上是倒着的,即 foo.md 文件的倒数第一行所属的情况对应于列表对象的第一个元素;第二行所属情况,对应于列表对象的第二个元素;依此类推。

倘若想将这个列表反转过来,需要再写一个函数:

(defun reverse-list (x y)
  (if (null x)
      y
    (reverse-list (cdr x) (cons (car x) y))))

Elisp 函数 null 可用于判断一个列表是否为 '()

这个函数的用法如以下示例:

(setq x '(5 4 3 2 1))
(princ\' (reverse-list x '()))

输出 (1 2 3 4 5)

利用 reverse-list 函数,便可以对上一节实现的 simple-md-parser.el 进一步完善了,这应该是本章的习题。

结语

本章所实现的 simple-md-parser.el 程序,仅仅是 Elisp 语言的初学者代码,有些繁琐,甚至也不够安全。在后面三章里,我对这些代码进行了一定程度的简化和完善,并在这些工作里学习更多的 Elisp 语法和函数。

下一章:变量


garfileo
6k 声望1.9k 粉丝

这里可能不会再更新了。


引用和评论

0 条评论