上一章:缓冲区变换
很多程序是有图形界面的,就是日常所见的那些有菜单和按钮的窗口以及对话框之类。在终端里运行的程序,通常也叫命令行程序,它们也有界面,即一组选项和参数。这两种程序,各有所长,也各有所短。
我之所以学习 Elisp 语言,是因为感觉它的长处适合编写文本处理程序,例如上一章所写的一个简单的文本处理程序,它可以将文本由 Markdown 格式翻译为 HTML 格式。像这样的文本处理程序,它们的运行通常并不需要图形界面,否则我为何不直接为 Emacs 写一个插件呢?
命令行选项和参数
如同函数可以有参数,命令行程序也可以有一些参数。凡是函数或程序无法决断的一些因素,可抽象为一组参数,交由函数或程序的使用者决断。命令行选项本质上也是命令行参数,只不过它相当于程序的一些功能开关,可用于开启或关闭程序的一些功能,也可用于修饰其他参数。
选项倾向于定性,而参数倾向于定量。当二者统一为程序的参数时,便可使得程序能够明确我们要用它解决什么问题。有些问题只需要定性的角度去解决。有些问题只需要从量化的角度去解决,因此对二者作区分,也是有意义的。
在 Linux 系统里,命令行程序占据了半壁甚至更多的江山。这些命令行程序的选项,通常以 -
或 --
作为前缀,参数则没有前缀,于是在形式上对于程序的使用者而言,二者有着明显的区别。
为一个命令行程序设计界面
在上一章里,我写了个可将文本由 Markdown 格式变换为 HTML 格式的程序。这个程序虽然在功能上远不健全,但是已经到了要为它设计选项和参数的时候了。
假设这个程序名为 mdc.el,执行这个程序时,它支持 -i
和 -o
两个选项。-i
选项用于指定输入文件名,-o
选项用于指定输出文件名,其中输入文件名和输入文件名都是与选项对应的参数。例如
$ emacs -Q --script mdc.el -i foo.md -o foo.html
倘若不向 mdc.el 提供任何选项和参数,或者提供了它不认识的选项和参数,它也不表示任何不满意,仅仅是在终端输出:
用法:emacs -Q --script mdc.el -i 输入文件 -o 输出文件
命令行界面的实现
嵌入在 Emacs 内部的 Elisp 解释器,它能够从终端里获得所有的选项和参数,将结果保存为一个列表变量 argv
,这是个全局变量。于是,在 mdc.el 程序里,只需访问这个列表,便可以获得所需的选项和参数。当然,这需要对 argv
进行遍历,然后做一些文本匹配方面的工作。这些工作不再有任何难度,所需要的知识,在前面的章节里已经运用得很熟练了吧。
假设 mdc.el 的实现如下:
(defun princ\' (x)
(princ x)
(princ "\n"))
(while (not (null argv))
(princ\' (car argv))
(setq argv (cdr argv)))
注意,在判断列表是否为空,我一直是使用 (not (null 列表对象))
的方式,因为我一直不想承认 Elisp 语言里非 nil
即为真的规矩。但是,现在觉得,入乡还是随俗吧,承认 (not (null 列表对象))
等价于 列表对象
。
执行 mdc.el,
$ emacs -Q --script mdc.el a b foo bar blab blab
结果在终端输出以下信息:
a
b
foo
bar
blab
blab
这意味着遍历 argv
的程序是正确的。倘若在遍历过程中增加文本匹配和参数获取功能,便可以得到输入文件名和输出文件名了。例如,
(let (x input output)
(while argv
(setq x (car argv))
(setq argv (cdr argv))
(cond
((string= x "-i")
(setq input (car argv)))
((string= x "-o")
(setq output (car argv)))))
(if (or (not input) (not output))
(princ\' "emacs -Q --script mdc.el -i 输入文件 -o 输出文件")))
在上述代码中,遍历 argv
过程结束后,基于逻辑「或」运算 or
,对 input
和 output
变量进行了基本的有效性检测。该检测仅能保证它们已经得到了赋值,但是所赋之值是否正确,例如在命令行里输入了错误的文件名,这种情况,程序无法判断。
功能与界面的结合
上一节最后的那段代码里,检测变量 input
和 output
的有效性的条件表达式只含有逻辑表达式为真时对应的程序分支,另一个分支不存在,现在可以为将 mdc.el 的功能部分作为该分支。
mdc.el 的功能部分,即上一章所实现的缓冲区变换程序,其中可与界面代码进行结合的部分是
(let ((html-buffer (generate-new-buffer "html")))
(translate-buffer (find-file "foo.md") html-buffer)
(with-current-buffer html-buffer
(write-region nil nil "foo.html")))
现在可以将这段代码中的 foo.md
和 foo.html
替换为字符串变量 input
和 output
,然后将这段代码嵌入到界面代码里,结果为
(let (x input output)
(while argv
(setq x (car argv))
(setq argv (cdr argv))
(cond
((string= x "-i")
(setq input (car argv)))
((string= x "-o")
(setq output (car argv)))))
(if (or (not input) (not output))
(princ\' "emacs -Q --script mdc.el -i 输入文件 -o 输出文件")
(let ((html-buffer (generate-new-buffer "html")))
(translate-buffer (find-file input) html-buffer)
(with-current-buffer html-buffer
(write-region nil nil output)))))
Hash 表
上一节最后给出的代码有些繁冗,不妨将命令行选项解析部分以及程序功能部分处理出来,封装为函数。
若将命令行解析部分封装为函数,那么该函数的求值结果应该包含着 input
和 output
的值。能够包含多个值的求值结果,在 Elisp 语言里,只有表。可以是列表,也可以是 Hash 表,后者更适合存储命令行程序的选项和参数,因为它可以将选项以及它修饰的参数组成键值对结构。
使用 Elisp 函数 make-hash-table
可创建 Hash 表实例,例如
(make-hash-table :test 'equal)
其中,:test 'equal
用于指定使用 equal
函数判断用于从 Hash 表检索数据的键与 Hash 表的键是否相等。我不知道为什么 string=
不可以。equal
可以比较两个对象是否相同,应用范围要比 =
和 string=
更为广泛,例如它也可以判断两个列表是否相等。除 equal
外,Elisp 的 Hash 表还有两个可选的键相等测试函数,eq
和 eql
,倘若不指定测试函数,make-hash-table
默认使用 eql
,仅适用于创建以数字作为键的 Hash 表。
将一个符号与 Hash 表绑定,便有了一个 Hash 表变量:
(setq mdc-args (make-hash-table :test 'equal))
Elisp 函数 puthash
可向 Hash 表添加键值对,例如:
(puthash "-i" "foo.md" mdc-args)
Elisp 函数 gethash
可使用键,从 Hash 表里获得与键对应的值,例如
(gethash "-i" mdc-args)
掌握了上述函数的用法,便可实现一个解析命令行,并将解析结果存储到 Hash 表的函数了,例如:
(defun mdc-get-args (mdc-args)
(let (x)
(while argv
(setq x (car argv))
(setq argv (cdr argv))
(if (string-match "-i\\|-o" x)
(progn
(puthash x (car argv) mdc-args)
(setq argv (cdr argv)))))))
mdc-get-args
函数的用法如下:
(let ((mdc-args (make-hash-table :test 'equal)))
(mdc-get-args mdc-args)
(let ((input (gethash "-i" mdc-args))
(output (gethash "-o" mdc-args)))
(if (or (not input) (not output))
(princ\' "emacs -Q --script mdc.el -i 输入文件 -o 输出文件")
(let ((html-buffer (generate-new-buffer "html")))
(translate-buffer (find-file input) html-buffer)
(with-current-buffer html-buffer
(write-region nil nil output))))))
关联列表
Elisp 的关联列表也可用于存储选项和参数,用法与 Hash 表类似,只是数据访问效率远低于后者。不过,对于存储命令行选项和参数这样的工作,关联列表足以胜任。
关联列表的每个元素是序对。cons
可构造序对,例如:
(cons "-i" "foo.md")
也可以用 .
语法构造序对,例如:
("-i" . "foo.md")
事实上,Elisp 的列表的本质就是一组级联的序对结构,例如 '(1 2 3 4)
,在 Elisp 解释器看来,它的真正结构是
(1 . (2 . (3 . (4 . ()))))
car
可以取序对的第一个元素。cdr
则用于取序对的第二个元素。
构造关联列表,可以像普通列表那样使用 cons
函数。例如:
(setq mdc-args '())
(setq mdc-args (cons ("-i" . "foo.md") mdc-args)))
Elisp 函数 assoc
可根据给定的键,可从关联列表里获取第一个同键的序对,例如:
(assoc "-i" mdc-args)
求值结果为 ("-i" . "foo.md")
。为什么要强调「第一个」呢?因为关联列表里,允许多个序对有相同的键。
要获得键对应的值,需要使用 cdr
,例如
(cdr (assoc "-i" mdc-args))
至于如何使用关联列表保存命令行选项和参数,这个任务可以作为本章的练习题。
结语
这个教程,更确切地说,是我学习 Elisp 所作的笔记,第一部分可就此落幕了。至于这个教程,会不会有第二部分,这取解决于我是否遇到了新的文本处理问题。
附录
完整的 mdc.el 程序代码如下:
(defun princ\' (x)
(princ x)
(princ "\n"))
(defun section-n (level name)
(let ((n (length level)))
(format "%s " n name n)))
(defun empty-line (text) "")
(defun paragraph (text)
(format "%s
" text))
(defun translate (text)
(cond
((string-match "^\\(#+\\)[[:blank:]]+\\(.+\\)$" text)
(section-n (match-string 1 text) (match-string 2 text)))
((string-match "^$" text)
(empty-line text))
(t (paragraph text))))
(defun current-line ()
(buffer-substring (line-beginning-position) (line-end-position)))
(defun translate-buffer (source target)
(with-current-buffer source
(let (text)
(while (< (point) (point-max))
(setq text (translate (current-line)))
(with-current-buffer target
(insert text)
(insert "\n"))
(forward-line 1)))))
(defun mdc-get-args (mdc-args)
(let (x)
(while argv
(setq x (car argv))
(setq argv (cdr argv))
(if (string-match "-i\\|-o" x)
(progn
(puthash x (car argv) mdc-args)
(setq argv (cdr argv)))))))
(let ((mdc-args (make-hash-table :test 'equal)))
(mdc-get-args mdc-args)
(let ((input (gethash "-i" mdc-args))
(output (gethash "-o" mdc-args)))
(if (or (not input) (not output))
(princ\' "emacs -Q --script mdc.el -i 输入文件 -o 输出文件")
(let ((html-buffer (generate-new-buffer "html")))
(translate-buffer (find-file input) html-buffer)
(with-current-buffer html-buffer
(write-region nil nil output))))))