本书是 Emacs Lisp 编程入门,面向非程序员读者。
随 Emacs 版本 30.2 一同发布。
版权所有 © 1990–1995, 1997, 2001–2025 自由软件基金会。
可从 https://shop.fsf.org/ 购买纸质版。出版方:
GNU Press, https://www.fsf.org/licensing/gnu-press/ 自由软件基金会下属部门 邮箱:[email protected] Free Software Foundation, Inc. 电话:+1 (617) 542-5942 31 Milk Street, # 960789 传真:+1 (617) 542-2652 Boston, MA 02196 USA
ISBN 1-882114-43-4
允许依据自由软件基金会发布的《GNU 自由文档许可证》第1.3版或其更新版本,对本文档进行复制、分发或修改。 本文档无固定章节,前封面文本为 “A GNU Manual”,后封面文本见下文 (a) 条款。 许可证副本收录在名为 “GNU Free Documentation License” 的章节中。
(a) 自由软件基金会后封面文本为: “您拥有复制与修改本 GNU 手册的自由。 从自由软件基金会购买本手册,将助力其开发 GNU 系统并推广软件自由。”
本主菜单首先列出各章节与索引,随后列出各章节中的每个节点。
car、cdr、cons:基础函数defun 中的单词数量the-the 函数car、cdr、cons:基础函数
defun 中的单词数量
the-the 函数GNU Emacs 集成环境的大部分内容,都是由一种名为 Emacs Lisp 的编程语言编写而成的。用这种语言编写的代码就是软件 — 也就是一系列指令,用于告诉计算机在你发出命令时该执行什么操作。Emacs 的设计允许你使用 Emacs Lisp 编写新代码,并可以轻松地将其安装为编辑器的扩展功能。
(GNU Emacs 有时被称作 “可扩展编辑器”,但它所能做的远不止文本编辑。更准确地说,Emacs 是一个“可扩展计算环境”。不过这个说法过于冗长,简单地把 Emacs 称作编辑器会更方便。此外,你在 Emacs 中做的所有事情 — 查询玛雅日期与月相、化简多项式、调试代码、管理文件、阅读信件、撰写书籍 — 从最广义的角度来讲,都属于编辑活动。)
尽管 Emacs Lisp 通常只被认为与 Emacs 相关,但它实际上是一门完整的计算机编程语言。你可以像使用其他任何编程语言一样使用 Emacs Lisp。
也许你想理解编程,也许你想扩展 Emacs 功能,又或者你希望成为一名程序员。这份 Emacs Lisp 入门指南旨在帮你起步:引导你学习编程基础,更重要的是,向你展示如何自主学习更深入的内容。
在整篇文档中,你会看到许多可以在 Emacs 内部直接运行的小型示例程序。如果你在 GNU Emacs 的 Info 模式中阅读本文,可以直接运行文中出现的程序。(操作十分简单,相关方法会在示例出现时说明。)你也可以一边运行着 Emacs,一边把这份文档当作纸质书来阅读。(这是我个人喜欢的方式,我偏爱纸质书籍。)如果身边没有正在运行的 Emacs,你依然可以阅读本书,但这种情况下最好把它当作小说或未曾到访过的国家的旅行指南:内容有趣,却终究不如亲身体验。
本文大部分内容会逐步讲解、引导阅读 GNU Emacs 中实际使用的代码。这些讲解有两个目的:第一,让你熟悉真实可用的代码(也就是你日常使用的代码);第二,让你熟悉 Emacs 的工作方式。了解一个实际可用的环境是如何实现的,本身就很有意义。
同时,我也希望你能养成浏览源代码的习惯。你可以从中学习,也可以从中汲取思路。拥有 GNU Emacs,就像拥有了一座藏满宝藏的龙穴。
除了把 Emacs 当作编辑器、把 Emacs Lisp 当作编程语言学习之外,文中的示例与逐步讲解还能帮你熟悉 Emacs 作为 Lisp 编程环境的使用方式。GNU Emacs 为编程提供了丰富支持与实用工具,你会希望熟练使用它们,比如 M-.(用于调用 xref-find-definitions 命令的快捷键)。你还会学习缓冲区以及该环境中的其他对象。熟悉 Emacs 的这些功能,就像熟悉家乡周边的新路线一样。
最后,我希望向你传递一些使用 Emacs 学习未知编程知识的技巧。你常常可以借助 Emacs 理解令你困惑的内容,或是找到实现新功能的方法。这种自主学习的能力不仅带来乐趣,更是一种优势。
本文是为非程序员人士编写的入门介绍。如果你是程序员,这份入门指南可能无法满足你。原因在于,你可能已经习惯了阅读参考手册,并且会因为本文的组织结构而感到不适。
一位审阅过本文的专家程序员曾对我说:
我更喜欢从参考手册中学习。我会“深入”每一个段落,然后在段落之间“喘口气”。
当我读完一个段落时,我会认为该主题已经讲完了,我已经掌握了所需的全部信息(除非下一个段落开始更详细地讨论它)。我期望一本写得好的参考手册不会有太多冗余,并且能精准地指向(唯一的)我想要的信息所在之处。
这份入门指南不是为这类人写的!
首先,我会把每个内容至少讲三遍:第一,进行介绍;第二,在具体语境中展示;第三,在另一种语境中展示或进行回顾。
其次,我几乎从不把某个主题的所有信息集中在一个地方,更不用说一个段落里。在我看来,这样会给读者带来过重的负担。相反,我会尽量只讲解你当时需要知道的内容。(有时我会加入一点额外信息,以免你在后续正式介绍到这些内容时感到意外。)
阅读本文时,不要求你一次性掌握所有内容。很多时候,你只需要对提到的一些概念有个初步了解即可。我的希望是,本文的结构和提供的足够提示能让你敏锐地抓住重点,并集中注意力学习。
有些段落你确实需要深入阅读;除此之外,没有其他阅读方式。但我已经尽量减少了这类段落的数量。本书旨在成为一座亲切的小山丘,而非令人望而生畏的巍峨高山。
本书《Emacs Lisp 编程入门》(An Introduction to Programming in Emacs Lisp)有一份配套文档, GNU Emacs Lisp 参考手册 in GNU Emacs Lisp 参考手册。 这份参考手册包含比本文更详细的内容。在参考手册中,某个主题的所有信息都会集中在一个地方。如果你和上面提到的那位程序员喜好相同,就应该去查阅它。当然,在你读完这本 入门指南 之后,在编写自己的程序时,你会发现 参考手册 非常有用。
Lisp 语言于 20 世纪 50 年代末在麻省理工学院(Massachusetts Institute of Technology)首次开发,用于人工智能方面的研究。Lisp 语言的强大功能使其在其他领域也极具优势,例如编写编辑器命令和集成环境。
GNU Emacs Lisp 在很大程度上受到 Maclisp 的启发,后者是 20 世纪 60 年代由麻省理工学院编写。它也受到了 20 世纪 80 年代成为标准的 Common Lisp 的一定影响。然而,Emacs Lisp 比 Common Lisp 要简单得多。(标准的 Emacs 发行版包含一个可选的扩展文件 cl-lib.el,它为 Emacs Lisp 添加了许多 Common Lisp 的特性。)
即使你不了解 GNU Emacs,阅读本文依然是有益的。不过,我建议你学习一下 Emacs,哪怕只是为了学会如何在电脑屏幕上移动。你可以通过内置的教程自学如何使用 Emacs。使用方法是输入 C-h t。(这意味着你同时按下并松开 CTRL 键和 h 键,然后再按下并松开 t 键。)
此外,我经常会通过列出调用命令所需的按键,然后在括号中给出命令名称的方式来提及 Emacs 的某个标准命令,例如:C-M-\ (indent-region)。这意味着 indent-region 命令通常通过输入 C-M-\ 来调用。(当然,如果你愿意,可以修改调用该命令的按键;这被称为 重绑定(rebinding)。See 键盘映射。)缩写 C-M-\ 表示你需要同时按下 CTRL 键、META 键和 \ 键。(在许多现代键盘上,META 键标注为 ALT。)这种组合有时被称为键盘组合键(keychord),因为它类似于在钢琴上弹奏一个和弦。如果你的键盘没有 META 键,则可以使用 ESC 键作为前缀来代替。在这种情况下,C-M-\ 表示你先按下并松开 ESC 键,然后同时按下 CTRL 键和 \ 键。但通常情况下,C-M-\ 指的是同时按下 CTRL 键和标有 ALT 的键,以及 \ 键。
除了单独输入一个键盘组合键之外,你还可以在输入内容前加上 C-u,这被称为 通用参数(universal argument)。C-u 键盘组合键会向后续的命令传递一个参数。因此,要将一个纯文本区域缩进 6 个空格,请标记该区域,然后输入 C-u 6 C-M-\。(如果你不指定数字,Emacs 通常会向命令传递数字 4,或以其他不同的方式运行该命令。)See 数值参数 in GNU Emacs 手册。
如果你正在 GNU Emacs 的 Info 模式中阅读本文,只需按空格键 SPC,就可以通读整篇文档。(要了解 Info 的使用方法,请输入 C-h i 然后选择 Info。)
关于术语的说明:当我单独使用 “Lisp” 一词时,通常指的是各种 Lisp 方言;而当我提到 “Emacs Lisp” 时,则特指 GNU Emacs Lisp。
感谢所有为本书提供帮助的人。特别感谢 Jim Blandy、Noah Friedman、Jim Kingdon、Roland McGrath、 Frank Ritter、Randy Smith、Richard M. Stallman 以及 Melissa Weisshaus。同时感谢 Philip Johnson 与 David Stampe 给予的耐心鼓励。书中出现的错误均由我本人负责。
Robert J. Chassell [email protected]
在未接触过的人看来,Lisp 是一门奇特的编程语言。Lisp 代码里到处都是括号。 甚至有人戏称这个名字代表 “大量孤立的愚蠢括号(Lots of Isolated Silly Parentheses)”。 但这种说法毫无根据。Lisp 是“列表处理”(LISt Processing)的缩写, 这门编程语言通过将列表(以及列表的列表)放在括号中来处理它们。 括号标识了列表的边界。有时列表前面会带有一个撇号 ‘'’, 在 Lisp 中称为单引号(single-quote)。1 列表是 Lisp 的基础。
在 Lisp 中,列表的形式如下:'(rose violet daisy buttercup)。
该列表以一个单引号开头。它也可以写成下面这种更符合你日常认知的形式:
'(rose violet daisy buttercup)
这个列表的元素是四种不同花卉的名称,它们由空白符分隔,并用括号包裹, 就像田野里的花朵被一圈石墙环绕。
列表中也可以包含数字,例如这个列表:(+ 2 2)。
该列表包含一个加号 ‘+’,后跟两个 ‘2’,彼此由空白符分隔。
在 Lisp 中,数据和程序的表示方式完全相同: 它们都是由单词、数字或其他列表组成,用空白符分隔并被括号包裹。 (正因程序看起来像数据,一个程序可以轻易作为另一个程序的数据; 这是 Lisp 非常强大的特性。) (顺便一提,这两句括号内的说明不是 Lisp 列表,因为其中包含了 ‘;’ 和 ‘.’ 这类标点。)
下面是另一个列表,这次内部还嵌套了一个子列表:
'(this list has (a list inside of it))
该列表的组成部分是单词 ‘this’、‘list’、‘has’, 以及子列表 ‘(a list inside of it)’。 内部子列表由单词 ‘a’、‘list’、‘inside’、‘of’、‘it’ 构成。
在 Lisp 中,我们一直称作单词的东西,正式名称是原子(atom)。
该术语源自 atom 一词的本义:“不可分割(indivisible)”。
在 Lisp 看来,列表中使用的这些单词无法再拆分成更小的部分并保持原有程序含义;
数字以及 ‘+’ 这类单字符符号同样如此。
与之相对,和古代原子观念不同,列表是可以拆分的。
(See car cdr & cons 基础函数。)
在列表中,原子之间由空白符分隔,它们可以紧邻括号。
严格来说,Lisp 中的列表由括号包裹,内部可以是:
由空白符分隔的原子、其他列表,或二者兼有。
列表可以只包含一个原子,也可以完全为空。
不包含任何内容的列表写作:(),称为空列表(empty list)。
与其他对象不同,空列表同时被视为原子和列表。
原子和列表的文本表示形式统称为符号表达式(symbolic expression), 更简洁的说法是S-表达式。 单独使用的表达式(expression)一词, 既可以指其文本形式,也可以指计算机内存中的原子或列表本身。 人们通常并不严格区分这两种含义。 (此外,许多文档中也用形式(form)作为表达式的同义词。)
顺带一提,构成我们宇宙的原子当初被命名时,人们以为它们不可分割; 但后来发现物理原子并非不可拆分, 既可以从原子中剥离出部分,也可以将其裂变为大小大致相等的两部分。 物理原子的命名在人们认识其真实本质之前就过早确定了。 在 Lisp 中,某些类型的原子(例如数组)也可以拆分成部分, 但拆分机制与列表不同。 就列表操作而言,列表中的原子是不可分割的。
和英语类似,构成 Lisp 原子的单个字母的含义, 与字母组合成单词后的含义完全不同。 例如,表示南美树懒的单词 ‘ai’, 含义与两个独立单词 ‘a’ 和 ‘i’ 完全无关。
自然界中的原子种类繁多,但 Lisp 中的原子只有少数几类: 例如数字(numbers),如 37、511、1729; 以及符号(symbols),如 ‘+’、‘foo’、‘forward-line’。 前面示例中列出的单词都是符号。 在日常 Lisp 交流中,“原子(atom)”一词并不常用, 因为程序员通常会更精确地说明原子类型。 Lisp 编程主要处理列表中的符号(有时是数字)。 (顺便一提,前面这句括号内的说明是一个合法的 Lisp 列表, 因为它由原子(此处为符号)构成,以空白符分隔并被括号包裹,不含非 Lisp 标点。)
双引号之间的文本 — 即使是句子或段落 — 同样属于原子。示例如下:
'(this list includes "text between quotation marks.")
在 Lisp 中,这段带引号的文本连同标点和空格整体构成一个原子。 这类原子称为字符串(string),即 “字符序列(string of characters)”, 常用于计算机向人类输出可读信息。 字符串是与数字、符号不同的原子类型,用途也不一样。
列表中的空白符数量并不重要。从 Lisp 语言角度来看,
'(this list looks like this)
与下面的写法完全等价:
'(this list looks like this)
两个示例在 Lisp 看来是同一个列表, 均由符号 ‘this’、‘list’、‘looks’、‘like’、‘this’ 按顺序组成。
额外的空白符和换行只是为了方便人类阅读。 当 Lisp 读取表达式时,会忽略所有多余空白(但原子之间至少需要一个空格以区分彼此)。
看似简单,但我们见过的这些示例几乎涵盖了 Lisp 列表的全部外观! Lisp 中的其他列表都与这些示例大同小异,只是更长、更复杂。 简单来说:列表在括号内,字符串在引号内, 符号像单词,数字就是数字。 (某些场景下会用到方括号、点号等少量特殊字符, 但我们在很长一段学习过程中都不会用到它们。)
当你在 GNU Emacs 的 Lisp Interaction 模式或 Emacs Lisp 模式下输入 Lisp 表达式时, 可以使用多种命令格式化代码,使其易于阅读。 例如,按下 TAB 键会自动为光标所在行进行正确缩进。 对区域内代码执行规范缩进的命令通常绑定在 C-M-\ 上。 缩进设计可以让你清晰看出列表元素的从属关系 — 子列表元素的缩进层级会比外层列表更深。
此外,当你输入右括号时,Emacs 会瞬间将光标跳回匹配的左括号, 方便你确认配对关系。这非常实用, 因为 Lisp 中输入的每个列表都必须左右括号一一对应。 (有关 Emacs 模式的更多信息,参见 See 主模式 in GNU Emacs 手册。)
Lisp 中的列表 — 任何列表 — 都是一个可直接运行的程序。 如果你运行它(Lisp 术语称为求值(evaluate)), 计算机会做三件事之一:直接返回列表本身;给出错误信息; 或将列表中的第一个符号当作执行命令。 (当然,通常你真正想要的是第三种结果。)
前面示例中某些列表前的单引号 ' 称为引用(quote);
当它出现在列表前时,告诉 Lisp 直接原样保留该列表,不做任何处理。
但如果列表前没有引用标记,列表的第一个元素就具有特殊含义:
它是计算机要执行的命令。
(在 Lisp 中,这类命令称为函数。)
前面出现的列表 (+ 2 2) 没有前置引用,
因此 Lisp 将 + 理解为对后续数字执行加法操作的指令。
如果你正在 GNU Emacs 的 Info 中阅读本文,可以按如下方式对列表求值: 将光标放在下面列表的右括号紧右侧,然后输入 C-x C-e:
(+ 2 2)
你会在回显区看到数字 4 出现2。
(你刚刚执行的操作就是对列表求值。回显区是屏幕底部用于显示或回显文本的行。)
现在对带引用的列表做同样操作:
将光标放在下面列表末尾,输入 C-x C-e:
'(this is a quoted list)
你会在回显区看到 (this is a quoted list)。
两种操作中,你实际都是在向 GNU Emacs 内部名为Lisp 解释器的程序发送命令 — 让解释器对表达式求值。 Lisp 解释器的名称源自人类对表达式含义进行理解与阐释的行为。
你也可以对不属于任何列表、没有被括号包裹的原子求值; Lisp 解释器同样会将人类可读的表达式转换为计算机可执行的指令。 但在讨论这一点之前(see 变量), 我们先介绍当你出现错误时 Lisp 解释器会如何处理。
为了让你在不小心操作时不必担心,我们现在向 Lisp 解释器输入一个会生成错误信息的命令。 这一操作是无害的;事实上,我们经常会有意触发错误信息。 一旦你理解了相关术语,错误信息其实很有参考价值。 与其称其为 “错误(error)” 信息,不如称之为 “帮助(help)” 信息。 它们就像陌生国度里给旅行者指引方向的路标;读懂它们可能有些困难,但一旦理解,就能指明前进的方向。
错误信息由内置的 GNU Emacs 调试器生成。我们会进入调试器。
输入 q 即可退出调试器。
我们要做的是对一个未加引号、且首元素不是有效命令的列表进行求值。 下面这个列表与我们刚才使用的几乎完全相同,只是前面没有单引号。 将光标移到它的正后方,然后输入 C-x C-e:
(this is an unquoted list)
一个 *Backtrace* 窗口会打开,你应该能在其中看到如下内容:
---------- Buffer: *Backtrace* ---------- Debugger entered--Lisp error: (void-function this) (this is an unquoted list) eval((this is an unquoted list) nil) elisp--eval-last-sexp(nil) eval-last-sexp(nil) funcall-interactively(eval-last-sexp nil) call-interactively(eval-last-sexp nil nil) command-execute(eval-last-sexp) ---------- Buffer: *Backtrace* ----------
光标会出现在这个窗口中(可能需要等待几秒才会显示)。 要退出调试器并关闭调试窗口,请输入:
q
请现在就输入 q,让自己确信可以顺利退出调试器。 然后再次输入 C-x C-e 重新进入。
根据我们已经掌握的知识,基本可以读懂这条错误信息。
*Backtrace* 缓冲区需要从下往上阅读;它会告诉你 Emacs 执行了哪些操作。
当你输入 C-x C-e 时,你是在交互式调用 eval-last-sexp 命令。
eval 是“evaluate(求值)”的缩写,sexp 是 “symbolic expression(符号表达式)” 的缩写。
该命令的含义是 “对光标前的最后一个符号表达式求值”。
上面的每一行都会告诉你 Lisp 解释器接下来求值的内容。 最近执行的操作位于最上方。 该缓冲区被称为 *Backtrace* 缓冲区,是因为它可以让你回溯 Emacs 的执行过程。
在 *Backtrace* 缓冲区的顶部,你会看到这一行:
Debugger entered--Lisp error: (void-function this)
Lisp 解释器尝试对列表的第一个原子 ‘this’ 进行求值。 正是这一操作触发了错误信息 ‘void-function this’。
该信息包含 ‘void-function’ 和 ‘this’ 两个部分。
‘function’ 一词我们之前已经提到过。这是一个非常重要的概念。 就本节而言,我们可以这样定义:函数 是一组告诉计算机执行特定操作的指令。
现在我们可以理解这条错误信息了:‘void-function this’。 该函数(即 ‘this’ 这个词)没有任何可供计算机执行的指令定义。
略显特殊的 ‘void-function’ 这一说法,是为了贴合 Emacs Lisp 的实现方式: 当一个符号没有附加函数定义时,本该存放指令的位置就是空的。
另一方面,我们之前通过求值 (+ 2 2) 成功计算了 2 加 2,
由此可以推断,符号 + 一定包含一组计算机可执行的指令,
这些指令的作用就是对 + 后面的数字进行加法运算。
在这类情况下,其实可以阻止 Emacs 进入调试器。 我们这里不讲解具体方法,但会说明出现的结果, 因为你在使用存在问题的 Emacs 代码时可能会遇到类似情况。 这种情况下,你只会在回显区看到一行错误信息,样式如下:
Symbol's function definition is void: this
只要按下任意按键,哪怕只是移动光标,这条信息就会消失。
我们知道 ‘Symbol’ 的含义。它指的是列表的第一个原子,也就是 ‘this’。 ‘function’ 指的是告诉计算机执行操作的指令。 (严格来说,符号是告诉计算机去哪里查找指令,不过这一细节我们暂时可以忽略。)
这条错误信息的含义可以理解为:‘Symbol's function definition is void: this’。 该符号(即 ‘this’ 一词)缺少可供计算机执行的指令。
根据目前的讨论,我们可以总结出 Lisp 的另一个重要特性:
像 + 这样的符号,其本身并不是计算机要执行的指令集。
相反,该符号(可能只是临时)被用来定位对应的定义或指令集。
我们看到的只是一个可以用来查找指令的名称。
人名的作用也是如此。我可以被称作 ‘Bob’,但我并不是字母 ‘B’、‘o’、‘b’ 的组合,
而是与某个生命体持续关联的意识。名字并不是我本人,只是可以用来指代我。
在 Lisp 中,同一组指令可以附加到多个名称上。
例如,实现加法运算的计算机指令既可以关联到符号 +,也可以关联到符号 plus
(在某些 Lisp 方言中确实如此)。
在人类社会中,我既可以被称作 ‘Robert’,也可以被称作 ‘Bob’,还可以有其他称呼。
另一方面,一个符号同一时间只能附加一个函数定义。 否则计算机就会困惑该使用哪个定义。 如果人类社会也是如此,那全世界就只能有一个人叫 ‘Bob’。 不过,名称所指向的函数定义可以很方便地修改。(See Install a Function Definition.)
由于 Emacs Lisp 规模庞大,通常会按照功能模块为符号命名。 因此,所有处理 Texinfo 的函数名都以 ‘texinfo-’ 开头, 处理邮件阅读的函数名都以 ‘rmail-’ 开头。
根据我们目前的观察,可以开始梳理 Lisp 解释器对列表求值时的工作流程。 首先,它会检查列表前面是否有引号;如果有,解释器会直接返回该列表。 如果没有引号,解释器会查看列表的第一个元素,判断其是否拥有函数定义。 如果有,解释器就执行该函数定义中的指令。 如果没有,解释器就会打印错误信息。
这就是 Lisp 的基本工作方式,非常简单。 后续会有一些更复杂的规则,但这些是核心原理。 当然,要编写 Lisp 程序,你需要学会如何编写函数定义并将其附加到名称上, 同时避免让自己或计算机产生困惑。
首先介绍第一种复杂情况。 除了列表,Lisp 解释器还可以对未加引号、也没有括号包裹的符号进行求值。 解释器会尝试获取该符号作为 变量(variable) 的值。 这一场景会在变量相关章节详细说明。(See 变量.)
第二种复杂情况源于部分函数的特殊性,它们并不遵循常规的执行方式。 这类函数被称为 特殊形式(special forms)。它们用于完成定义函数等特殊任务,数量并不多。 在接下来的几章中,我们会介绍几种较为重要的特殊形式。
除了特殊形式,还有 宏(macros)。宏是在 Lisp 中定义的一种结构, 它与函数的区别在于:宏会将一个 Lisp 表达式转换为另一个表达式, 并使用转换后的表达式替代原表达式进行求值。(See Lisp 宏.)
就本入门教程而言,你不必过于纠结某个结构是特殊形式、宏还是普通函数。
例如,if 是特殊形式 (see if 特殊形式),而 when 是宏 (see Lisp 宏)。
在早期版本的 Emacs 中,defun 是特殊形式,现在则是宏 (see defun 宏),但其行为保持不变。
最后一种复杂情况:如果 Lisp 解释器处理的函数不是特殊形式,且属于某个列表, 解释器会检查该列表中是否嵌套了其他列表。 如果存在内层列表,解释器会先处理内层列表,再处理外层列表。 如果内层列表中还嵌套了其他列表,则优先处理最内层的列表,以此类推。 解释器总是优先对最内层列表求值,其结果会被外层表达式使用。
除此之外,解释器会按照从左到右的顺序依次处理每个表达式。
解释器的另一项能力是可以处理两类内容: 一是人类可读的代码(也是我们重点学习的内容), 二是经过特殊处理的代码,称为 字节编译(byte compiled)代码,这类代码无法直接阅读。 字节编译代码的运行速度比人类可读代码更快。
你可以通过 byte-compile-file 等编译命令,将人类可读代码转换为字节编译代码。
字节编译代码通常保存在后缀为 .elc 的文件中,而非 .el 文件。
在 emacs/lisp 目录中你会看到这两类文件;需要阅读源码时请选择后缀为 .el 的文件。
实际使用中,大多数自定义或扩展 Emacs 的操作都不需要进行字节编译,因此本节不再深入讨论。 完整的字节编译说明请参考 see Byte Compilation in The GNU Emacs Lisp Reference Manual。
Lisp 解释器对表达式进行处理的这一行为,被称为 求值(evaluation)。 我们会说解释器 “对表达式求值”。这个词我之前已经用过多次。 根据《韦氏新大学词典》的释义,该词源自日常用语,意为 “确定价值或数量;评估”。
对表达式求值后,Lisp 解释器通常会 返回(return) 执行函数定义中指令后得到的结果, 或者放弃执行该函数并输出错误信息。 (解释器也可能跳转到其他函数,或陷入无限循环重复执行。这类情况相对少见,我们可以暂时忽略。) 最常见的情况是解释器返回一个值。
在返回值的同时,解释器可能还会执行其他操作,例如移动光标或复制文件。 这类额外操作被称为 副作用(side effect)。 在我们看来很重要的操作(比如打印结果),对 Lisp 解释器而言往往只是副作用。 学习使用副作用相对简单。
总而言之,对符号表达式求值通常会让 Lisp 解释器返回一个值, 并可能执行一个副作用;否则就会触发错误。
如果求值操作作用于某个列表内部的子列表,那么在对外层列表求值时,外层列表可以将内层列表首次求值返回的值作为信息使用。这也解释了为什么要先对内部表达式求值:它们返回的值会被外层表达式使用。
我们可以通过另一个加法示例来观察这一过程。将光标放在下面表达式的后面,然后输入 C-x C-e:
(+ 2 (+ 3 3))
数字 8 会出现在回显区。
执行过程是:Lisp 解释器首先对内部表达式 (+ 3 3) 求值,返回结果 6;随后对外层表达式求值,就像表达式是 (+ 2 6) 一样,返回结果 8。由于没有更外层的表达式需要求值,解释器会将该值打印在回显区。
现在就很容易理解快捷键 C-x C-e 所调用命令的名称了:eval-last-sexp。其中 sexp 是 “符号表达式(symbolic expression)” 的缩写,eval 是 “求值(evaluate)” 的缩写。该命令的作用是对最后一个符号表达式求值。
作为练习,你可以试着把光标放在表达式下一行的开头,或者放在表达式内部,再执行求值操作。
下面是同一个表达式:
(+ 2 (+ 3 3))
如果你把光标放在表达式后面空行的开头并输入 C-x C-e,回显区仍然会显示结果 8。现在试着把光标放在表达式内部。如果将光标放在倒数第二个右括号后面(看起来像是在最后一个括号上方),回显区会显示 6!这是因为命令此时对表达式 (+ 3 3) 进行了求值。
再把光标放在某个数字后面,输入 C-x C-e,你会得到数字本身。在 Lisp 中,对数字求值会直接得到该数字本身 — 这也是数字与符号的区别。如果对以 + 这类符号开头的列表求值,会得到计算机执行该名称所绑定函数定义中的指令后返回的值。如果单独对一个符号求值,则会发生不同的情况,我们将在下一节介绍。
在 Emacs Lisp 中,一个符号既可以绑定函数定义,也可以绑定一个值,二者是相互独立的。函数定义是一组计算机可以执行的指令;而值则是数字、名称这类可以变化的内容(这也是这类符号被称为变量(variable)的原因)。符号的值可以是 Lisp 中的任意表达式,比如符号、数字、列表或字符串。拥有值的符号通常被称为变量(variable)。
一个符号可以同时绑定函数定义和值,也可以只拥有其中一项。二者相互独立。这有点类似于 “Cambridge” 这个名称既可以指代美国马萨诸塞州的一座城市,也可以附加 “优秀编程中心” 这类描述信息。
另一种理解方式是把符号想象成一个多格抽屉。函数定义放在一个抽屉里,值放在另一个抽屉里,以此类推。存放值的抽屉里的内容可以被修改,而不会影响存放函数定义的抽屉,反之亦然。
fill-column ¶变量 fill-column 就是一个绑定了值的符号:在每个 GNU Emacs 缓冲区中,该符号都会被设为某个值,通常是 72 或 70,有时也会是其他值。要查看该符号的值,可以直接对其单独求值。如果你正在 GNU Emacs 的 Info 中阅读本文,可以将光标放在该符号后面,输入 C-x C-e:
fill-column
我输入 C-x C-e 后,Emacs 在回显区打印了数字 72。这是我编写本文时 fill-column 的设定值。你在自己的 Info 缓冲区中看到的值可能不同。注意,变量返回的值与函数执行指令返回的值,打印方式完全一致。在 Lisp 解释器看来,返回的值就是值,一旦确定,其来源表达式的类型就不再重要。
一个符号可以绑定任意类型的值,或者用专业术语来说,我们可以将变量绑定(bind)到一个值上:可以是数字(如 72)、字符串(如 "such as this")、列表(如 (spruce pine oak));甚至可以将变量绑定到一个函数定义上。
为符号绑定值有多种方式。相关方法可参考 see Setting the Value of a Variable。
当我们对 fill-column 单独求值以查看其变量值时,并没有给它加上括号。这是因为我们并不打算将它作为函数名使用。
如果 fill-column 是某个列表的第一个(或唯一)元素,Lisp 解释器就会尝试查找它绑定的函数定义。但 fill-column 并没有函数定义。试着对下面表达式求值:
(fill-column)
你会打开一个 *Backtrace* 缓冲区,内容如下:
---------- Buffer: *Backtrace* ---------- Debugger entered--Lisp error: (void-function fill-column) (fill-column) eval((fill-column) nil) elisp--eval-last-sexp(nil) eval-last-sexp(nil) funcall-interactively(eval-last-sexp nil) call-interactively(eval-last-sexp nil nil) command-execute(eval-last-sexp) ---------- Buffer: *Backtrace* ----------
(记住,要退出调试器并关闭调试窗口,请在 *Backtrace* 缓冲区中输入 q。)
如果你尝试对一个没有绑定任何值的符号求值,会收到错误信息。我们可以用之前 2 加 2 的例子来测试。在下面的表达式中,将光标放在 + 后面、第一个数字 2 前面,输入 C-x C-e:
(+ 2 2)
在 GNU Emacs 22 中,你会打开一个 *Backtrace* 缓冲区,内容如下:
---------- Buffer: *Backtrace* ---------- Debugger entered--Lisp error: (void-variable +) eval(+) elisp--eval-last-sexp(nil) eval-last-sexp(nil) funcall-interactively(eval-last-sexp nil) call-interactively(eval-last-sexp nil nil) command-execute(eval-last-sexp) ---------- Buffer: *Backtrace* ----------
(同样,在 *Backtrace* 缓冲区输入 q 即可退出调试器。)
这次的回溯信息与我们最开始看到的错误不同,之前的错误是 ‘Debugger entered--Lisp error: (void-function this)’。本次是该符号没有变量值,而之前的错误是符号(单词 ‘this’)没有函数定义。
在对 + 的测试中,我们让 Lisp 解释器对 + 求值,并查找其变量值而非函数定义。实现方式是把光标放在符号后面,而不是像之前那样放在外层列表的括号后面。结果就是,Lisp 解释器对前面的符号表达式求值,在本例中就是单独的 +。
由于 + 只绑定了函数定义,没有绑定变量值,因此错误信息提示该符号的变量值为空。
为了理解信息是如何传递给函数的,我们再来看熟悉的 2 加 2 示例。在 Lisp 中写法如下:
(+ 2 2)
如果对该表达式求值,回显区会显示数字 4。Lisp 解释器所做的就是对 + 后面的数字执行加法。
由 + 相加的这些数字被称为函数 + 的参数(arguments)。这些数字是提供给函数、或者说传递(passed)给函数的信息。
“参数(argument)”一词源自数学用法,并非指人与人之间的争论;而是指传递给函数(本例中是 +)的信息。在 Lisp 中,函数的参数就是跟在函数后面的原子或列表。这些原子或列表求值后返回的值会被传递给函数。不同函数需要的参数数量不同,有些函数甚至完全不需要参数。3
应该传递给函数的数据类型,取决于函数要处理的信息类型。像 + 这样的函数,其参数必须是数字类型的值,因为 + 的作用是做加法。其他函数则会使用不同类型的数据作为参数。
例如,concat 函数会将两个或多个文本字符串连接成一个新字符串,它的参数是字符串。将两个字符串 abc 和 def 连接,会得到单个字符串 abcdef。对下面表达式求值即可看到效果:
(concat "abc" "def")
该表达式求值后返回的值为 "abcdef"。
像 substring 这样的函数,同时使用字符串和数字作为参数。该函数会返回字符串的一部分,即第一个参数的子串(substring)。该函数接收三个参数:第一个参数是字符组成的字符串,第二个和第三个参数是数字,分别表示子串的起始位置(包含)和结束位置(不包含)。数字是从字符串开头计算的字符个数(包括空格和标点)。注意,字符串中的字符从 0 开始编号,而非 1。
例如,对下面表达式求值:
(substring "The quick brown fox jumped." 16 19)
你会在回显区看到 "fox"。参数分别是字符串和两个数字。
注意,传递给 substring 的字符串虽然由多个带空格的单词组成,但仍然是一个单独的原子。Lisp 会将两个双引号之间的所有内容(包括空格)都算作字符串的一部分。你可以把 substring 函数想象成原子拆分器,它能从一个原本不可分割的原子中提取一部分内容。不过,substring 只能从字符串类型的参数中提取子串,无法处理数字或符号等其他类型的原子。
参数可以是一个求值后会返回值的符号。例如,符号 fill-column 单独求值会返回一个数字,这个数字可以用在加法运算中。
将光标放在下面表达式后面,输入 C-x C-e:
(+ 2 fill-column)
得到的值会比单独求值 fill-column 大 2。在我的环境中结果是 74,因为我的 fill-column 值为 72。
正如刚才所见,参数可以是求值后返回值的符号。除此之外,参数也可以是求值后返回值的列表。例如,在下面的表达式中,函数 concat 的参数包括字符串 "The "、" red foxes.",以及列表 (number-to-string (+ 2 fill-column))。
(concat "The " (number-to-string (+ 2 fill-column)) " red foxes.")
如果对该表达式求值 — 并且和我的 Emacs 一样,fill-column 求值结果为 72 — 回显区会显示 "The 74 red foxes."。(注意,你需要在单词 ‘The’ 后面和 ‘red’ 前面加上空格,这样才会出现在最终字符串中。函数 number-to-string 会将加法函数返回的整数转换为字符串。number-to-string 也叫作 int-to-string。)
有些函数(如 concat、+ 或 *)可以接受任意数量的参数。(* 是表示乘法的符号。)你可以按常规方式对下面每个表达式求值,回显区中出现的结果会在本文中以 ‘⇒’ 标注,可理解为“求值结果为”。
第一组中,函数不带任何参数:
(+) ⇒ 0 (*) ⇒ 1
这一组中,每个函数各带一个参数:
(+ 3) ⇒ 3 (* 3) ⇒ 3
这一组中,每个函数各带三个参数:
(+ 3 4 5) ⇒ 12 (* 3 4 5) ⇒ 60
当向函数传递类型错误的参数时,Lisp 解释器会生成错误信息。例如,+ 函数要求其参数值为数字。我们可以做个实验,用带引号的符号 hello 代替数字传给它。将光标放在下面表达式后面并输入 C-x C-e:
(+ 2 'hello)
执行后会触发错误信息。原因是 + 尝试将 2 与 'hello 返回的值相加,但 'hello 返回的是符号 hello,并非数字。只有数字可以相加,因此 + 无法完成加法操作。
你会打开并进入 *Backtrace* 缓冲区,内容如下:
---------- Buffer: *Backtrace* ----------
Debugger entered--Lisp error:
(wrong-type-argument number-or-marker-p hello)
+(2 hello)
eval((+ 2 'hello) nil)
elisp--eval-last-sexp(t)
eval-last-sexp(nil)
funcall-interactively(eval-print-last-sexp nil)
call-interactively(eval-print-last-sexp nil nil)
command-execute(eval-print-last-sexp)
---------- Buffer: *Backtrace* ----------
和往常一样,这条错误信息是有帮助的,学会解读后就能理解其含义。4
错误信息的第一部分很直观:‘wrong type argument’(参数类型错误)。接下来是专业术语 ‘number-or-marker-p’,它用于说明 + 期望接收何种类型的参数。
符号 number-or-marker-p 表示 Lisp 解释器正在判断传入的信息(参数值)是否为数字或标记(一种代表缓冲区位置的特殊对象)。它用于检测 + 是否接收到可相加的数字,同时也检测参数是否为标记——这是 Emacs Lisp 的特有类型。(在 Emacs 中,缓冲区中的位置会以标记形式记录。使用 C-@ 或 C-SPC 命令设置标记时,其位置会以标记保存。标记可视为一个数字,即该位置距缓冲区开头的字符数。)在 Emacs Lisp 中,+ 可将标记位置的数值当作普通数字相加。
number-or-marker-p 末尾的 ‘p’ 源自早期 Lisp 编程惯例。‘p’ 代表 谓词(predicate)。在早期 Lisp 研究者的术语中,谓词指用于判断某一属性是否成立的函数。因此 ‘p’ 表明 number-or-marker-p 是一个判断参数是否为数字或标记的函数。其他以 ‘p’ 结尾的 Lisp 符号包括 zerop(判断参数是否为 0)和 listp(判断参数是否为列表)。
最后,错误信息的末尾是符号 hello,即传给 + 的参数值。如果加法运算传入了正确类型的对象,该值应为数字(如 37)而非 hello 这样的符号,自然也不会出现错误。
message 函数 ¶与 + 类似,message 函数也支持可变数量参数。它用于向用户输出信息,实用性很强,因此在这里进行介绍。
信息会打印在回显区。例如,对下面列表求值即可在回显区打印信息:
(message "This message appears in the echo area!")
双引号之间的完整字符串是单个参数,会全部打印出来。(注意本例中,消息本身会带双引号出现在回显区,因为这是 message 函数的返回值。在你编写的程序中,大多数 message 用法会以副作用形式在回显区打印纯文本,不带引号。相关示例可参考 see multiply-by-seven in detail。)
如果带引号的字符串中包含 ‘%s’,message 函数不会直接打印 ‘%s’,而是读取字符串后的下一个参数,对其求值并将结果插入到 ‘%s’ 的位置。
将光标放在下面表达式后面并输入 C-x C-e 即可观察效果:
(message "The name of this buffer is: %s." (buffer-name))
在 Info 中,回显区会显示 "The name of this buffer is: *info*."。buffer-name 函数会以字符串形式返回当前缓冲区名称,message 函数将其插入到 %s 的位置。
若要以整数形式打印值,可使用 ‘%d’,用法与 ‘%s’ 相同。例如,对下面表达式求值可在回显区显示 fill-column 的值:
(message "The value of fill-column is %d." fill-column)
在我的系统中执行后,回显区会显示 "The value of fill-column is 72."5。
如果带引号的字符串中有多个 ‘%s’,第一个后置参数的值会替换第一个 ‘%s’,第二个参数替换第二个 ‘%s’,依此类推。
例如对下面表达式求值:
(message "There are %d %s in the office!"
(- fill-column 14) "pink elephants")
回显区会出现一条趣味消息。在我的系统中显示为:"There are 58 pink elephants in the office!"。
表达式 (- fill-column 14) 被求值,结果数字替换 ‘%d’;双引号字符串 "pink elephants" 作为单个参数替换 ‘%s’。(也就是说,双引号字符串与数字一样,求值结果为自身。)
最后看一个稍复杂的示例,既包含数值计算,也展示了如何在表达式中嵌套表达式以生成替换 ‘%s’ 的文本:
(message "He saw %d %s"
(- fill-column 32)
(concat "red "
(substring
"The quick brown foxes jumped." 16 21)
" leaping."))
本例中 message 有三个参数:字符串 "He saw %d %s"、表达式 (- fill-column 32),以及以 concat 开头的表达式。(- fill-column 32) 的求值结果替换 ‘%d’;以 concat 开头的表达式的返回值替换 ‘%s’。
当填充列设为 70 时执行该表达式,回显区会显示:"He saw 38 red foxes leaping."。
为变量赋值有多种方式,其中一种是使用特殊形式 setq,另一种是使用 let(see let)。该过程的专业术语是将变量绑定(bind)到一个值。
接下来的小节不仅会介绍 setq 的用法,还会演示参数的传递方式。
setq ¶要将符号 flowers 的值设为列表 (rose violet daisy buttercup),将光标放在下面表达式后面并输入 C-x C-e 求值即可。
(setq flowers '(rose violet daisy buttercup))
回显区会显示列表 (rose violet daisy buttercup),这是特殊形式 setq 的返回值。其副作用是将符号 flowers 绑定到该列表;也就是说,作为变量的 flowers 被赋予该列表作为值。(顺便一提,这个例子体现了:对 Lisp 解释器而言的副作用(赋值),往往是我们人类关心的主要效果。这是因为所有 Lisp 函数若无报错都必须返回值,而是否产生副作用则由设计决定。)
对 setq 表达式求值后,再对符号 flowers 求值,就会返回刚才设置的值。下面是该符号,将光标放在后面输入 C-x C-e:
flowers
对 flowers 求值后,回显区会显示列表 (rose violet daisy buttercup)。
顺便一提,如果对前面带引号的 'flowers 求值,回显区会直接显示符号本身 flowers。下面是带引号的符号,你可以试一下:
'flowers
此外,setq 还支持在单个表达式中为多个不同变量分别赋值,使用十分方便。
要使用 setq 将变量 carnivores 的值设为列表 '(lion tiger leopard),可使用下面的表达式:
(setq carnivores '(lion tiger leopard))
setq 也可同时为不同变量赋不同值。第一个参数绑定第二个参数的值,第三个参数绑定第四个参数的值,依此类推。例如,下面的表达式可将树木列表赋给符号 trees,将食草动物列表赋给符号 herbivores:
(setq trees '(pine fir oak maple)
herbivores '(gazelle antelope zebra))
(该表达式写在一行也可以,但可能超出页面宽度;格式化分行后更便于人类阅读。)
虽然我一直使用“赋值(assign)”这个说法,但也可以这样理解 setq 的工作方式:它让符号指向该列表。这种理解方式非常常见,后续章节中我们会遇到名称包含“指针(pointer)”的符号,其命名正是因为该符号绑定了一个值(通常是列表),或者说该符号被设置为指向这个列表。
下面这个例子展示了如何用 setq 实现计数器。你可以用它统计程序某部分的重复执行次数。首先将变量设为 0,之后每次程序重复时都将该数字加 1。实现需要一个作为计数器的变量和两个表达式:初始化用的 setq 表达式(将计数器设为 0),以及每次求值时递增计数的 setq 表达式。
(setq counter 0) ; 我们称之为初始化语句 (setq counter (+ counter 1)) ; 这是递增语句 counter ; 这是计数器本身
(‘;’ 后面的内容是注释。See Change a Function Definition。)
先对第一个表达式(初始化语句 (setq counter 0))求值,再对第三个表达式 counter 求值,回显区会显示数字 0。接着对第二个表达式(递增语句 (setq counter (+ counter 1)))求值,计数器的值会变为 1。再次对 counter 求值,回显区会显示数字 1。每执行一次递增语句,计数器的值就会加 1。
对递增语句 (setq counter (+ counter 1)) 求值时,Lisp 解释器首先对最内层列表(加法表达式)求值。为计算该列表,它需要先对变量 counter 和数字 1 求值。对 counter 求值会得到其当前值,该值与数字 1 一起传给 + 完成相加。求和结果作为内层列表的返回值,再传给 setq,将变量 counter 设为这个新值。这样,变量 counter 的值就被更新了。
学习 Lisp 就像爬山,最初的一段最陡峭。你已经攻克了最难的部分,后续内容会越来越轻松。
总结如下:
forward-paragraph)、单字符符号(如 +)、双引号字符串或数字。
几个简单练习:
在学习编写 Emacs Lisp 函数定义之前,先花一点时间对已有的各种表达式进行求值会很有帮助。这些表达式大多是以函数作为第一个(通常也是唯一)元素的列表。缓冲区相关的函数既简单又有趣,我们就从这里开始。本节会对其中几个函数求值,后续章节还会分析其他缓冲区相关函数的实现代码。
每当你向 Emacs Lisp 输入编辑命令(如移动光标或滚动屏幕),你就在对一个表达式求值,该表达式的第一个元素是一个函数。这就是 Emacs 的工作方式。
按键操作会让 Lisp 解释器对表达式求值,从而得到对应结果。即使输入普通文本也会执行 Emacs Lisp 函数,本例中是使用 self-insert-command 直接插入输入的字符。通过按键触发求值的函数被称为交互式函数(interactive)或命令(commands)。如何将函数设为交互式将在“编写函数定义”章节介绍,详见 See Making a Function Interactive。
除了按键命令,我们还见过第二种求值方式:将光标放在列表后面并输入 C-x C-e。本节后续内容都会使用这种方式。还有其他求值方式,遇到时再做介绍。
接下来几节介绍的函数不仅可用于练习求值,其本身也非常重要。学习这些函数能清晰理解缓冲区与文件的区别、如何切换缓冲区以及如何定位缓冲区内部位置。
buffer-name 和 buffer-file-name 这两个函数展示了文件与缓冲区之间的区别。对以下表达式求值,(buffer-name) 会在回显区显示缓冲区的名称;而对 (buffer-file-name) 求值,会显示该缓冲区所引用文件的名称。通常,(buffer-name) 返回的名称与其引用的文件名称相同,而 (buffer-file-name) 返回的是文件的完整路径名。
文件和缓冲区是两种不同的实体。文件是永久记录在计算机中的信息(除非你删除它);而缓冲区是 Emacs 内部的信息,会在编辑会话结束时(或你关闭缓冲区时)消失。通常,缓冲区包含你从文件中复制的信息,我们说这个缓冲区正在访问(visiting)该文件。你所编辑和修改的正是这份副本。对缓冲区的修改不会改变文件,直到你保存该缓冲区。保存时,缓冲区的内容会被复制到文件中,从而永久保存。
如果你正在 GNU Emacs 的 Info 中阅读本文,可以将光标放在以下每个表达式后面,输入 C-x C-e 来求值:
(buffer-name) (buffer-file-name)
我在 Info 中执行后,(buffer-name) 的返回值是 "*info*",而 (buffer-file-name) 的返回值是 nil。
另一方面,在我编写本文档时,(buffer-name) 的返回值是 "introduction.texinfo",(buffer-file-name) 的返回值是 "/gnu/work/intro/introduction.texinfo"。
前者是缓冲区名称,后者是文件名。在 Info 中,缓冲区名为 "*info*"。Info 不关联任何文件,因此 (buffer-file-name) 的求值结果为 nil。符号 nil 源自拉丁语,意为“无(nothing)”;在这里表示缓冲区与任何文件都无关联。(在 Lisp 中,nil 也表示“假(false)”,是空列表 () 的同义词。)
在编写时,我的缓冲区名为 "introduction.texinfo",它所指向的文件名为 "/gnu/work/intro/introduction.texinfo"。
(表达式中的括号告诉 Lisp 解释器将 buffer-name 和 buffer-file-name 视为函数;如果不带括号,解释器会尝试将这些符号作为变量求值。See 变量。)
尽管文件和缓冲区有区别,但你常会发现人们用“文件”指代“缓冲区”,反之亦然。事实上,大多数人会说“我正在编辑一个文件”,而不是“我正在编辑一个很快会保存到文件中的缓冲区”。在上下文清晰的情况下,这种说法通常不会造成歧义。但在处理计算机程序时,必须牢记这种区别,因为计算机不像人类那样聪明。
顺便一提,“缓冲区(buffer)”一词源自其“缓冲碰撞冲击力的垫子”这一含义。在早期计算机中,缓冲区起到了缓冲文件与中央处理器之间交互的作用。存储文件的鼓形存储器或磁带与中央处理器是两种差异很大的设备,各自以不同的速度间歇工作。缓冲区的存在使它们能够高效地协同工作。最终,缓冲区从一个中介、临时存放地,演变为了实际进行工作的场所。这种转变就像一个小型海港发展成大城市:起初,它只是货物在装船前临时仓储的地方;后来,它自身成为了商业和文化中心。
并非所有缓冲区都与文件关联。例如,*scratch* 缓冲区不访问任何文件。同样,*Help* 缓冲区也不与任何文件关联。
过去,当你没有 ~/.emacs 文件,仅输入命令 emacs 而不指定任何文件启动 Emacs 时,Emacs 会以可见的 *scratch* 缓冲区启动。如今,你会看到一个启动画面。你可以按照启动画面上的建议操作、访问一个文件,或按 q 退出启动画面,进入 *scratch* 缓冲区。
如果你切换到 *scratch* 缓冲区,输入 (buffer-name),将光标放在后面并输入 C-x C-e 求值,会返回名称 "*scratch*",显示在回显区。"*scratch*" 是缓冲区的名称。在 *scratch* 缓冲区中输入 (buffer-file-name) 并求值,回显区会显示 nil,与在 Info 中求值 (buffer-file-name) 的结果相同。
顺便一提,如果你在 *scratch* 缓冲区中,希望表达式的返回值显示在 *scratch* 缓冲区本身而非回显区,可以输入 C-u C-x C-e 代替 C-x C-e。这样返回值会显示在表达式后面。缓冲区会如下所示:
(buffer-name)"*scratch*"
你无法在 Info 中这样做,因为 Info 是只读的,不允许修改缓冲区内容。但你可以在任何可编辑的缓冲区中使用此功能;在编写代码或文档(如本书)时,这个特性非常实用。
buffer-name 函数返回缓冲区的名称;要获取缓冲区本身,则需要另一个函数:current-buffer。在代码中使用此函数时,得到的是缓冲区本身。
名称与名称所指代的对象/实体是不同的。你不是你的名字,而是别人用名字称呼的一个人。如果你要求和 George 说话,有人递给你一张写着字母 ‘G’、‘e’、‘o’、‘r’、‘g’、‘e’ 的卡片,你可能会觉得有趣,但不会满意。你想交谈的不是这个名字,而是这个名字所指的人。缓冲区也是如此:临时缓冲区的名称是 *scratch*,但名称本身不是缓冲区。要获取缓冲区本身,需要使用 current-buffer 这样的函数。
不过,这里有个细微的复杂之处:如果像我们在这里所做的那样,在表达式中单独对 current-buffer 求值,你看到的是缓冲区名称的打印形式,而非缓冲区的内容。Emacs 这样做有两个原因:缓冲区可能有数千行 — 太长而不便显示;并且,另一个缓冲区可能内容相同但名称不同,区分它们很重要。
这是一个包含该函数的表达式:
(current-buffer)
在 Emacs 的 Info 中按常规方式对此表达式求值,回显区会显示 #<buffer *info*>。这种特殊格式表明返回的是缓冲区本身,而不仅仅是其名称。
顺便一提,虽然你可以在程序中输入数字或符号,但不能输入缓冲区的打印形式——获取缓冲区本身的唯一方法是使用 current-buffer 等函数。
相关的函数是 other-buffer。它返回除当前缓冲区之外最近选择的缓冲区,而不是其名称的打印形式。如果你最近在 *scratch* 缓冲区和其他缓冲区之间来回切换,other-buffer 会返回该缓冲区。
对以下表达式求值即可看到效果:
You can see this by evaluating the expression:
(other-buffer)
你应该会在回显区看到 #<buffer *scratch*>,或者你最近从其切换回来的其他缓冲区的名称。6
other-buffer 函数在作为需要缓冲区参数的函数的参数使用时,实际上会提供一个缓冲区。我们可以通过将 other-buffer 和 switch-to-buffer 配合使用来切换到不同缓冲区,以此观察其作用。
不过,先简要介绍一下 switch-to-buffer 函数。你之前在 Info 和 *scratch* 缓冲区之间来回切换以求值 (buffer-name) 时,很可能是在迷你缓冲区提示输入要切换的缓冲区名称时,输入了 C-x b,然后输入 *scratch*。7 按键 C-x b 会让 Lisp 解释器对交互式函数 switch-to-buffer 求值。如前所述,Emacs 就是这样工作的:不同的按键会调用或运行不同的函数。例如,C-f 调用 forward-char,M-e 调用 forward-sentence,等等。
通过在表达式中编写 switch-to-buffer 并为其提供要切换到的缓冲区,我们可以像使用 C-x b 那样切换缓冲区:
(switch-to-buffer (other-buffer))
符号 switch-to-buffer 是列表的第一个元素,因此 Lisp 解释器会将其视为函数并执行它所绑定的指令。但在执行之前,解释器会注意到 other-buffer 位于括号内,因此先处理该符号。other-buffer 是这个列表的第一个(在本例中也是唯一的)元素,所以 Lisp 解释器会调用或运行该函数,返回另一个缓冲区。接着,解释器运行 switch-to-buffer,将另一个缓冲区作为参数传递给它,这就是 Emacs 将要切换到的目标。如果你正在 Info 中阅读本文,现在可以试试这个。求值该表达式。(要返回,输入 C-x b RET。)8
在本文档后续章节的编程示例中,你会看到函数 set-buffer 比 switch-to-buffer 更常用。这是因为计算机程序和人类之间存在一个差异:人类有眼睛,希望在计算机终端上看到正在处理的缓冲区。这是显而易见的。然而,程序没有眼睛。当计算机程序处理一个缓冲区时,该缓冲区不需要在屏幕上可见。
switch-to-buffer 是为人类设计的,它做两件不同的事:切换 Emacs 关注的缓冲区;并将窗口中显示的缓冲区切换为新缓冲区。另一方面,set-buffer 只做一件事:切换计算机程序关注的缓冲区。屏幕上的缓冲区保持不变(当然,通常在命令运行完成之前,屏幕上不会有任何变化)。
此外,我们刚刚介绍了另一个专业术语:调用(call)。当你对一个列表求值,且该列表的第一个符号是函数时,你就是在调用这个函数。该术语源自“函数是一个可被调用的实体,能为你完成某些任务”的概念 — 就像水管工是一个可被你呼叫来修理漏水的实体。
最后,我们来看几个相当简单的函数:buffer-size、point、point-min 和 point-max。它们提供有关缓冲区大小和光标(点)在缓冲区中位置的信息。
函数 buffer-size 会告诉你当前缓冲区的大小;也就是说,该函数返回缓冲区中字符的总数。
(buffer-size)
你可以按常规方式对此求值:将光标放在表达式后面,输入 C-x C-e。
在 Emacs 中,光标的当前位置被称为 点(point)。表达式 (point) 返回一个数字,该数字表示光标相对于缓冲区开头的位置,即从缓冲区开头到点的字符计数。
按常规方式对以下表达式求值,即可查看当前缓冲区中点的字符计数值:
(point)
在我编写本文时,点的值是 65724。point 函数在本书后面的一些示例中经常会用到。
当然,点的值取决于其在缓冲区中的位置。如果你在这个位置求值点,数字会更大:
(point)
对我来说,这个位置的点值是 66043,这意味着两个表达式之间有 319 个字符(包括空格)。(毫无疑问,你看到的数字会不同,因为我在首次求值点之后肯定已经编辑过本文。)
函数 point-min 与 point 有些相似,但它返回当前缓冲区中允许的点的最小值。除非启用了限制(narrowing),否则该值为 1。(限制是一种机制,允许你或程序将操作限制在缓冲区的某一部分。See 限制与扩展。)同样,函数 point-max 返回当前缓冲区中允许的点的最大值。
当 Lisp 解释器对一个列表求值时,它会检查列表的第一个符号是否绑定了函数定义;或者说,该符号是否指向一个函数定义。如果有,计算机就会执行定义中的指令。绑定了函数定义的符号,简单来说,就被称为函数(尽管严格来说,定义才是函数,符号只是引用它)。
defun 宏interactive 的不同选项letif 特殊形式save-excursion所有函数都是基于其他函数定义的,除了少数用 C 编程语言编写的原生(primitive)函数。编写函数定义时,你会使用 Emacs Lisp,并以其他函数作为构建块。你将使用的一些函数本身可能是用 Emacs Lisp 编写的(也许是你编写的),有些则是用 C 编写的原生函数。9
让我再强调一次:用 Emacs Lisp 编写代码时,无需区分使用的是 C 编写的函数还是 Emacs Lisp 编写的函数。这种差异无关紧要。我提及此区别只是因为它很有趣
defun 宏 ¶在 Lisp 中,像 mark-whole-buffer 这样的符号会绑定一段代码,用于告诉计算机在调用该函数时执行什么操作。这段代码被称为函数定义(function definition),通过对以符号 defun(是 define function 的缩写)开头的 Lisp 表达式求值来创建。
在后续小节中,我们会查看 Emacs 源码里的函数定义,例如 mark-whole-buffer。本节先介绍一个简单的函数定义,让你了解其基本结构。这个例子使用算术运算,因为它足够简洁。有些人不喜欢算术示例,不过不必担心,本入门教程后续几乎不会再涉及算术或数学相关代码,示例大多围绕文本操作展开。
在关键字 defun 之后,一个函数定义最多包含五个部分:
()。
可以把函数定义的这五部分看作一个模板,每个部分对应一个位置:
(defun function-name (arguments...)
"optional-documentation..."
(interactive argument-passing-info) ; optional
body...)
下面举一个将参数乘以 7 的函数代码为例。(该示例非交互式,see 让函数支持交互。)
(defun multiply-by-seven (number) "Multiply NUMBER by seven." (* 7 number))
该定义以左括号和符号 defun 开头,后跟函数名。
函数名后面是包含函数参数的列表,称为参数列表(argument list)。本例中列表只有一个元素,即符号 number。调用函数时,该符号会绑定到传入的参数值。
参数名不一定非要用 number,也可以选其他名称,比如 multiplicand(被乘数)。用 “number” 是因为它直观地表明参数类型;用 “multiplicand” 则更能体现其在函数中的作用。当然也可以随便起一个无意义的名字如 foogle,但这会降低可读性。参数名由程序员决定,原则是清晰易懂。
实际上,参数列表中的符号可以任意命名,即使与其他函数中的符号重名也没关系——参数名只在当前函数定义内有效,属于局部名称。这就像你在家里昵称 “矮个子”,家人说这个名字指的是你,但在电影里同名角色指的就是另一个人。由于参数名是函数私有的,在函数体内修改它不会影响函数外部的同名符号,效果与 let 表达式类似。(See let。)
参数列表之后是描述函数的文档字符串。使用 C-h f 加函数名查看帮助时,显示的就是这段内容。顺便提醒,文档字符串的第一行最好写成完整句子,因为 apropos 等命令只会显示多行文档的第一行。此外,如果文档字符串有多行,第二行不要缩进,否则在 C-h f(describe-function)中显示会很奇怪。文档字符串虽然可选,但非常有用,几乎所有函数都应该加上。
示例的第三行是函数体。(当然,大多数函数的定义会比这更长。)该函数的函数体是列表 (* 7 number),含义是将 number 的值乘以 7。(在 Emacs Lisp 中,* 是乘法函数,+ 是加法函数。)
使用 multiply-by-seven 时,参数 number 会被求值为实际传入的数字。下面是调用示例,但暂时不要尝试求值:
(multiply-by-seven 3)
函数定义中声明的符号 number,在实际调用时会绑定到值 3。注意,虽然 number 在函数定义中写在括号里,但调用 multiply-by-seven 时传入的参数并不需要括号。函数定义中的括号只是为了让计算机区分参数列表的结束位置和函数体的开始位置。
如果现在直接求值这个调用表达式,大概率会报错。(不妨试试!)原因是我们只写了函数定义,还没有把定义“告知”计算机——也就是还没有在 Emacs 中加载这个函数。安装函数的过程就是让 Lisp 解释器获知函数定义,下一节会详细说明。
如果你正在 Emacs 的 Info 中阅读本文,可以先对函数定义求值,再对 (multiply-by-seven 3) 求值,以此测试 multiply-by-seven 函数。下面是函数定义,将光标放在最后一个括号后并输入 C-x C-e。执行后,回显区会出现 multiply-by-seven。(这表示:对函数定义求值时,返回值就是被定义函数的名称。)同时,该操作会完成函数定义的安装。
(defun multiply-by-seven (number) "Multiply NUMBER by seven." (* 7 number))
通过对这个 defun 求值,你已经在 Emacs 中安装了 multiply-by-seven。现在它和 forward-word 或其他编辑函数一样,成为 Emacs 的一部分。(multiply-by-seven 会一直有效直到退出 Emacs。如果希望每次启动 Emacs 都自动加载代码,见 永久安装代码。)
对下面的示例求值,即可看到安装 multiply-by-seven 后的效果。将光标放在表达式后输入 C-x C-e,回显区会显示数字 21。
(multiply-by-seven 3)
如果需要,还可以输入 C-h f(describe-function)再输入函数名 multiply-by-seven 查看文档。执行后屏幕会出现 *Help* 窗口,内容如下:
multiply-by-seven 是一个 Lisp 函数。 (multiply-by-seven NUMBER) 将 NUMBER 乘以 7。
(回到单窗口布局可输入 C-x 1。)
如果想修改 multiply-by-seven 的代码,直接重写即可。要让新版本替换旧版本,只需再次对函数定义求值。这就是在 Emacs 中修改代码的方式,非常简单。
例如,可以把 multiply-by-seven 改成将数字自身相加 7 次,而不是直接乘以 7。结果相同,但实现方式不同。同时我们给代码加一条注释:注释是 Lisp 解释器会忽略、但对人类阅读有帮助的文本,这里注明这是第二版。
(defun multiply-by-seven (number) ; Second version.
"Multiply NUMBER by seven."
(+ number number number number number number number))
注释以分号 ‘;’ 开头。在 Lisp 中,一行内分号之后的所有内容都是注释,行末即为注释结束。如果注释需要跨越多行,每行都以分号开头。
更多注释相关内容见 See 开始编写 .emacs 文件,以及 注释 in GNU Emacs Lisp 参考手册。
可以用和第一版同样的方式安装这个版本:光标放在最后一个括号后,输入 C-x C-e 求值即可。
总结一下,在 Emacs Lisp 中编写代码的流程是:编写函数 → 安装 → 测试 → 修复或改进 → 再次安装。
你可以用和安装第一个函数相同的方式来安装这个版本的 multiply-by-seven 函数:将光标放在最后一个右括号后面,然后按下 C-x C-e。
总而言之,这就是在 Emacs Lisp 中编写代码的流程:先编写函数,安装它,进行测试,然后修复问题或增强功能,并再次安装。
要让函数支持交互,只需在文档字符串后紧跟一个以特殊形式 interactive 开头的列表。用户可以通过 M-x 加函数名调用交互式函数,也可以用绑定的快捷键,例如 C-n 对应 next-line,C-x h 对应 mark-whole-buffer。
有趣的是,以交互方式调用交互式函数时,返回值不会自动显示在回显区。因为我们通常使用交互式函数是为了其副作用,比如向前移动一个词或一行,而不是为了返回值。如果每次按键都在回显区显示返回值,会非常干扰操作。
multiply-by-seven 概述 ¶通过创建 multiply-by-seven 的交互式版本,我们可以同时演示 interactive 特殊形式的用法,以及在回显区显示值的一种方式。
代码如下:
(defun multiply-by-seven (number) ; Interactive version.
"Multiply NUMBER by seven."
(interactive "p")
(message "The result is %d" (* 7 number)))
将光标放在代码后输入 C-x C-e 即可安装,回显区会出现函数名。之后可以输入 C-u 加数字,再输入 M-x multiply-by-seven 并按 RET 调用,回显区会显示 ‘结果是 …‘ 并附上乘积。
更一般地,这类函数有两种调用方式:
上面两个例子效果完全相同,都会让光标向前移动三个句子。(由于 multiply-by-seven 没有绑定按键,因此不能用作按键绑定示例。)
(如何将命令绑定到按键,见 See 常用按键绑定。)
前缀参数(prefix argument)可以通过 META 加数字传入,例如 M-3 M-e;也可以用 C-u 加数字,例如 C-u 3 M-e(只输入 C-u 不加数字时,默认值为 4)。
multiply-by-seven 详解 ¶我们来详细看交互式版本 multiply-by-seven 中 interactive 特殊形式和 message 函数的用法。函数定义如下:
(defun multiply-by-seven (number) ; Interactive version.
"Multiply NUMBER by seven."
(interactive "p")
(message "The result is %d" (* 7 number)))
在这个函数中,表达式 (interactive "p") 是一个含两个元素的列表。"p" 告诉 Emacs 把前缀参数传递给函数,并将其值作为函数的参数。
该参数是一个数字,意味着符号 number 会在下面这行中绑定到一个数值:
(message "The result is %d" (* 7 number))
例如,如果前缀参数是 5,Lisp 解释器会把这行等价于:
(message "The result is %d" (* 7 5))
(如果你在 GNU Emacs 中阅读,可以自己对这个表达式求值。)首先,解释器会对内层列表 (* 7 5) 求值,得到 35。接着对外层列表求值,将列表第二个及后续元素的值传递给 message 函数。
我们已经知道,message 是 Emacs Lisp 中专门用于向用户输出单行信息的函数。(See message 函数。简单来说,message 会在回显区原样打印第一个参数,遇到 ‘%d’ 或 ‘%s’(以及其他未介绍的 % 格式符)时,会用后续参数的值替换对应位置。
在交互式版本 multiply-by-seven 中,格式符是 ‘%d’,要求传入数字;而 (* 7 5) 求值结果为 35,因此数字 35 会替换 ‘%d’,最终显示为 ‘结果是 35’。
(注意:直接调用 multiply-by-seven 时,消息不带引号;而直接调用 message 表达式时,文本会带双引号显示。原因是:直接对以 message 开头的表达式求值时,回显区显示的是其返回值;而当 message 嵌在函数内部时,它以副作用形式打印文本,不会带引号。)
interactive 的不同选项 ¶在示例中,multiply-by-seven 使用 "p" 作为
interactive 的参数。该参数告知 Emacs 将你输入的
C-u 后接数字,或是 META 后接数字,解析为将该数字
作为参数传递给函数的指令。Emacs 内置了二十多个可用于
interactive 的字符。几乎在所有场景下,这些选项中的某一个
都能让你以交互方式向函数传递正确的信息。(See Code Characters for
interactive in The GNU Emacs Lisp Reference Manual.)
以函数 zap-to-char 为例。其交互表达式为
(interactive "p\ncZap to char: ")
interactive 参数的第一部分是你已经熟悉的 ‘p’。
该参数告知 Emacs 将前缀参数解析为数字并传递给函数。
你可以通过输入 C-u 后接数字,或是输入 META 后接数字
来指定前缀参数。该前缀参数代表指定字符的个数。因此,若前缀参数为 3,
指定字符为 ‘x’,则会删除从当前位置到下第三个 ‘x’(含)
之间的所有文本。若未设置前缀参数,则删除从当前位置到指定字符(含)
之间的文本,不再多删。
‘c’ 用于告知函数需要删除到哪个字符。
更规范地说,带有两个及以上参数的函数,可以通过在 interactive
后的字符串中追加片段,为每个参数传递信息。这样做时,信息会按照
interactive 列表中的顺序依次传递给各个参数。在字符串中,
每个片段使用 ‘\n’(换行符)分隔。例如,你可以在 ‘p’
后接 ‘\n’ 和 ‘cZap to char: ’。这会让 Emacs 传递前缀参数
(若存在)和指定字符。
在这种情况下,函数定义形如下面的示例,其中 arg 和 char
是 interactive 用于绑定前缀参数和指定字符的符号:
(defun name-of-function (arg char) "documentation..." (interactive "p\ncZap to char: ") body-of-function...)
(提示符中冒号后的空格会让提示显示更美观。
See The Definition of
copy-to-buffer,可查看示例。)
当函数不需要参数时,interactive 也无需参数。
这类函数只需要简单的表达式 (interactive)。
mark-whole-buffer 就是这样的函数。
另外,如果专用字符码不适合你的使用场景,你也可以以列表形式
为 interactive 传递自定义参数。
See The Definition of append-to-buffer,
可查看示例。See Using Interactive in The GNU Emacs Lisp Reference Manual,可获取该用法的更完整说明。
通过求值来安装函数定义后,该函数会一直生效,直到退出 Emacs。 下次启动新的 Emacs 会话时,该函数不会自动加载,除非你再次对其定义求值。
在某些时候,你可能希望每次启动 Emacs 时都自动加载代码。 有几种实现方式:
load 函数让 Emacs 求值并加载文件中的各个函数。
See Loading Files.
最后,如果你的代码可能对所有 Emacs 用户都有用, 可以将其发布到计算机网络,或是发送一份副本给自由软件基金会。 (这样做时,请为代码及其文档选择合适的许可协议,允许他人运行、复制、 学习、修改和重新分发代码,同时保护你的著作权不被侵占。) 如果你将代码发送给自由软件基金会,且做好了自身与他人的权益保护, 该代码可能会被加入下一版 Emacs。很大程度上,Emacs 正是依靠社区贡献 在过去多年中不断发展壮大。
let ¶let 表达式是 Lisp 中的一种特殊形式,
在绝大多数函数定义中都会用到。
let 用于将符号与值关联(绑定),
从而避免 Lisp 解释器将该变量与函数外部同名变量混淆。
要理解 let 特殊形式的必要性,可以设想这样一个场景:
你有一套房子,平时称之为 “家”,比如句子“该给家刷漆了”。
当你拜访朋友时,主人提到“家”,他指的很可能是 他的 房子,
而不是你的,也就是另一处住所。
如果朋友指的是他的房子,而你以为是你的,就会产生混淆。
在 Lisp 中也会出现类似情况:一个函数内部使用的变量与另一个函数
内部的变量同名,而二者本意并不指向同一个值。
let 特殊形式可以避免这类混淆。
let 避免命名冲突 ¶let 特殊形式可以避免命名冲突。let 会创建一个
局部变量(local variable) 名称,该名称会覆盖 let 表达式外部对同名变量的使用
(计算机术语中,我们称之为对变量进行绑定(binding))。
这就好比在主人家中,他提到“家”时,指的是他的房子,而不是你的。
(用于命名函数参数的符号,也以完全相同的方式绑定为局部变量。
See The defun Macro。)
另一种理解 let 的方式是:它在代码中划定了一个专属区域:
在 let 表达式体内,你声明的变量拥有自身的局部含义;
在 let 体外,它们拥有其他含义(或根本未定义)。
这意味着在 let 体内,对 let 声明的变量使用 setq
会修改该名称的**局部**变量。而在 let 体外
(例如调用别处定义的函数时),对 let 声明的变量使用 setq
不会 影响该局部变量。10
let 可以同时创建多个变量。此外,let
会为创建的每个变量赋予初始值,要么是你指定的值,要么是 nil。
(术语中,这就是将变量绑定到值。)let 创建并绑定变量后,
会执行 let 体内的代码,并返回体内最后一个表达式的值,
作为整个 let 表达式的值。(“执行(Execute)”是用于描述对列表求值的术语,
源自“付诸实践”的含义(引自《牛津英语词典》)。
由于对表达式求值是为了执行某个动作,“执行(execute)”逐渐成为“求值(evaluate)”的同义词。)
let 表达式的组成部分 ¶let 表达式是一个包含三部分的列表。第一部分是符号 let。
第二部分是一个列表,称为变量列表(varlist),其中每个元素
要么是单独的符号,要么是一个二元列表,其首个元素为符号。
let 表达式的第三部分是 let 的函数体,
通常由一个或多个列表组成。
(let varlist body...)
变量列表中的符号会由 let 特殊形式赋予初始值。
单独的符号初始值为 nil;
作为二元列表首个元素的符号,则会绑定到 Lisp 解释器对第二个元素求值后返回的值。
例如,一个变量列表可以写作:(thread (needles 3))。
在该 let 表达式中,Emacs 将符号 thread
绑定到初始值 nil,将符号 needles 绑定到初始值 3。
编写 let 表达式时,你只需要将合适的表达式填入 let
模板的对应位置即可。
如果变量列表由二元列表组成(这是常见写法),
let 表达式的模板如下:
(let ((variable value)
(variable value)
...)
body...)
let 表达式示例 ¶下面的表达式创建两个变量 zebra 和 tiger 并赋予初始值。
let 的函数体是一个调用 message 函数的列表。
(let ((zebra "stripes")
(tiger "fierce"))
(message "One kind of animal has %s and another is %s."
zebra tiger))
这里的变量列表是 ((zebra "stripes") (tiger "fierce"))。
两个变量分别是 zebra 和 tiger。
每个变量都是一个二元列表的第一个元素,对应的值是列表的第二个元素。
在变量列表中,Emacs 将变量 zebra 绑定到值 "stripes"11,
将变量 tiger 绑定到值 "fierce"。
本例中两个值均为字符串,它们也可以是列表或符号。
存放变量的列表之后是 let 的函数体。
本例中,函数体是一个在回显区打印字符串的列表,使用了 message 函数。
你可以按照常规方式对该示例求值:将光标移至最后一个括号后, 输入 C-x C-e。执行后,回显区会显示如下内容:
"One kind of animal has stripes and another is fierce."
如前所述,message 函数会打印其第一个参数,
并替换其中的 ‘%s’。本例中,变量 zebra 的值
会打印在第一个 ‘%s’ 位置,变量 tiger 的值
会打印在第二个 ‘%s’ 位置。
let 语句中的未初始化变量 ¶如果在 let 语句中不为变量绑定指定初始值,
它们会自动绑定到初始值 nil,如下例所示:
(let ((birch 3)
pine
fir
(oak 'some))
(message
"Here are %d variables with %s, %s, and %s value."
birch pine fir oak))
这里的变量列表是 ((birch 3) pine fir (oak 'some))。
按常规方式对该表达式求值后,回显区会显示:
"Here are 3 variables with nil, nil, and some value."
本例中,Emacs 将符号 birch 绑定到数字 3,
将符号 pine 和 fir 绑定到 nil,
将符号 oak 绑定到值 some。
注意在 let 的第一部分中,变量 pine 和 fir
以独立原子形式出现,没有被括号包裹;这是因为它们被绑定到空列表 nil。
而 oak 被绑定到 some,因此出现在列表 (oak 'some) 中。
同理,birch 被绑定到数字 3,因此与该数字同属一个列表。
(由于数字求值为自身,无需加引号。同时,在消息中数字使用 ‘%d’
而非 ‘%s’ 打印。)这四个变量被统一放入一个列表,
与 let 的函数体分隔开。
let 绑定变量的方式 ¶Emacs Lisp 支持两种将变量名与其值绑定的方式。 这些方式会影响程序中特定绑定生效的代码范围。 由于历史原因,Emacs Lisp 默认使用名为动态绑定(dynamic binding)的变量绑定方式。 但在本文档中,除非另有说明,我们主要讲解更推荐的绑定方式—— 词法绑定(lexical binding)(未来,Emacs 维护者计划将默认绑定方式改为词法绑定)。 如果你之前使用过其他编程语言,很可能已经熟悉词法绑定的行为。
要在程序中使用词法绑定,应在 Emacs Lisp 文件的第一行添加:
;;; -*- lexical-binding: t -*-
相关更多信息,see Variable Scoping in The Emacs Lisp Reference Manual。
如前所述(see let 避免命名冲突),在词法绑定模式下,
使用 let 创建的局部变量仅在 let 表达式体内有效。
在代码的其他部分,它们拥有其他含义,因此如果在 let 体内
调用别处定义的函数,该函数无法“看见(see)”你创建的局部变量。
(反之,如果调用在 let 体内定义的函数,
该函数则可以看见并修改该 let 表达式的局部变量。)
在动态绑定模式下,规则有所不同:使用 let 时,
创建的局部变量在 let 表达式执行期间全程有效。
这意味着,如果 let 表达式调用了某个函数,
无论该函数定义在何处(甚至在另一个文件中),都能访问这些局部变量。
另一种理解动态绑定下 let 的方式是:
每个变量名都拥有一个全局的绑定“栈”,
每当使用该变量名时,都指向栈顶的绑定。
(你可以将其想象成桌上一叠写有值的纸张。)
使用 let 进行动态绑定时,会将你指定的新绑定压入栈顶,
然后执行 let 函数体。let 函数体执行完毕后,
该绑定会从栈中弹出,恢复 let 表达式之前的绑定(若存在)。
在某些情况下,词法绑定和动态绑定的行为完全一致。 但在另一些场景下,它们会改变程序的语义。 例如,观察下面代码在词法绑定下的运行结果:
;;; -*- lexical-binding: t -*-
(setq x 0)
(defun getx ()
x)
(setq x 1)
(let ((x 2))
(getx))
⇒ 1
此处 (getx) 的结果为 1。
在词法绑定下,getx 无法访问 let 表达式中的值。
这是因为 getx 的函数体位于当前 let 表达式体之外。
由于 getx 定义在代码顶层(全局作用域,即不在任何 let 体内),
它会查找并使用全局作用域中的 x。执行 getx 时,
x 的当前全局值为 1,因此 getx 返回 1。
如果改用动态绑定,行为则不同:
;;; -*- lexical-binding: nil -*-
(setq x 0)
(defun getx ()
x)
(setq x 1)
(let ((x 2))
(getx))
⇒ 2
此时 (getx) 的结果为 2。
这是因为在动态绑定下,执行 getx 时,
x 的栈顶绑定来自 let 表达式。
此时 getx 不会访问 x 的全局值,
因为全局绑定在绑定栈中位于 let 绑定之下。
(部分变量属于“特殊变量(special)”,即使开启 lexical-binding,
它们也始终使用动态绑定。See Initializing a Variable with defvar。)
if 特殊形式 ¶另一种特殊形式是条件表达式 if。
该形式用于指示计算机进行逻辑判断。
编写函数定义时可以不使用 if,
但它使用频率高、重要性强,因此在此讲解。
例如,beginning-of-buffer 函数的代码中就用到了它。
if 的核心逻辑是:如果(if) 条件测试为真,
则(then) 对表达式求值。若测试不成立,则不执行该表达式。
比如你可以做出这样的判断:“如果天气温暖晴朗,就去海边!”
if 详解 ¶Lisp 中的 if 表达式不使用单词“then”;
条件测试与执行语句分别是首个元素为 if 的列表的第二个和第三个元素。
尽管如此,if 表达式的测试部分通常被称为条件部分(if-part),
第二个参数通常被称为执行部分(then-part)。
此外,编写 if 表达式时,真假判断条件通常与符号 if 写在同一行,
而条件为真时执行的语句(执行部分)则写在第二行及后续行。
这样能让 if 表达式更易阅读。
(if true-or-false-test
action-to-carry-out-if-test-is-true)
真假判断条件是一个会被 Lisp 解释器求值的表达式。
下面是一个可以按常规方式求值的示例。测试条件为数字 5 是否大于数字 4。 由于结果为真,会打印消息 ‘5 is greater than 4!’。
(if (> 5 4) ; if-part (message "5 is greater than 4!")) ; then-part
(函数 > 用于判断第一个参数是否大于第二个参数,
若是则返回真。)
当然,在实际使用中,if 表达式中的测试条件
不会像 (> 5 4) 那样固定不变。
相反,测试中使用的至少一个变量会绑定到一个预先未知的值。
(如果值是预先确定的,就无需进行测试了!)
例如,该值可能绑定到函数定义的某个参数。
在下面的函数定义中,动物特征是传递给函数的值。
若绑定到 characteristic 的值为 "fierce",
则打印消息 ‘It is a tiger!’;否则返回 nil。
(defun type-of-animal (characteristic)
"Print message in echo area depending on CHARACTERISTIC.
If the CHARACTERISTIC is the string \"fierce\",
then warn of a tiger."
(if (equal characteristic "fierce")
(message "It is a tiger!")))
如果你正在 GNU Emacs 中阅读本文档, 可以按常规方式对该函数定义求值,将其加载到 Emacs 中, 然后对下面两个表达式求值,查看运行结果:
(type-of-animal "fierce") (type-of-animal "striped")
对 (type-of-animal "fierce") 求值时,
回显区会打印消息:"It is a tiger!";
对 (type-of-animal "striped") 求值时,
回显区会打印 nil。
type-of-animal 函数详解 ¶我们来详细分析 type-of-animal 函数。
type-of-animal 的函数定义是通过填充两个模板完成的:
一个是整体函数定义模板,另一个是 if 表达式模板。
所有非交互式函数的模板为:
(defun name-of-function (argument-list) "documentation..." body...)
该函数中匹配此模板的部分如下:
(defun type-of-animal (characteristic)
"Print message in echo area depending on CHARACTERISTIC.
If the CHARACTERISTIC is the string \"fierce\",
then warn of a tiger."
body: the if expression)
函数名为 type-of-animal,接收一个参数值。
参数列表后是多行文档字符串。示例中包含文档字符串,
是因为为每个函数定义编写文档是一个良好习惯。
函数体由 if 表达式构成。
if 表达式的模板如下:
(if true-or-false-test
action-to-carry-out-if-the-test-returns-true)
在 type-of-animal 函数中,if 代码如下:
(if (equal characteristic "fierce")
(message "It is a tiger!"))
此处的真假判断条件表达式为:
(equal characteristic "fierce")
在 Lisp 中,equal 函数用于判断第一个参数
是否与第二个参数相等。第二个参数是字符串 "fierce",
第一个参数是符号 characteristic 的值,
也就是传递给该函数的参数。
第一次调用 type-of-animal 时,
参数 "fierce" 被传入。由于 "fierce"
与 "fierce" 相等,表达式 (equal characteristic
"fierce") 返回真值。此时 if 会对第二个参数
(即执行部分)(message "It is a tiger!") 求值。
而第二次调用 type-of-animal 时,
参数 "striped" 被传入。"striped"
与 "fierce" 不相等,因此执行部分不会被求值,
if 表达式返回 nil。
if 表达式可以带有一个可选的第三个参数,称为
否则部分(else-part),用于真假判断返回假的情况。
当判断为假时,整个 if 表达式的第二个参数(即那么部分)
不会 被求值,而第三个参数(否则部分) 会 被求值。
你可以把它理解为阴天版的决策:“如果天气温暖晴朗,就去海边,
否则就在家看书!”。
Lisp 代码中并不会写出单词“else”;if 表达式的否则部分
直接写在那么部分之后。在 Lisp 代码书写中,否则部分通常
单独起一行,缩进比那么部分更少:
(if true-or-false-test
action-to-carry-out-if-the-test-returns-true
action-to-carry-out-if-the-test-returns-false)
例如,下面的 if 表达式按常规方式求值时,
会打印消息 ‘4 is not greater than 5!’:
(if (> 4 5) ; if-part (message "4 falsely greater than 5!") ; then-part (message "4 is not greater than 5!")) ; else-part
可以看到,不同层级的缩进让那么部分和否则部分很容易区分。
(GNU Emacs 提供了多个命令,可以自动为 if 表达式正确缩进。
See GNU Emacs Helps You Type Lists。)
我们可以扩展 type-of-animal 函数,
只需在 if 表达式中增加一个部分,即可加入否则分支。
你可以对下面这个版本的 type-of-animal 函数定义求值以安装它,
然后对后续两个表达式求值,传入不同参数,就能看到效果。
(defun type-of-animal (characteristic) ; Second version.
"Print message in echo area depending on CHARACTERISTIC.
If the CHARACTERISTIC is the string \"fierce\",
then warn of a tiger; else say it is not fierce."
(if (equal characteristic "fierce")
(message "It is a tiger!")
(message "It is not fierce!")))
(type-of-animal "fierce") (type-of-animal "striped")
对 (type-of-animal "fierce") 求值时,回显区会打印:
"It is a tiger!";而对 (type-of-animal "striped")
求值时,会打印 "It is not fierce!"。
(当然,如果 characteristic 是 "ferocious",
同样会打印 "It is not fierce!",这就会产生误导!
编写代码时,需要考虑到 if 可能会测试到这类参数,
并相应地设计程序。)
if 表达式中的真假判断有一个重要特点。
到目前为止,我们把谓词返回的 “真(true)” 和 “假(false)” 当作一种新的 Emacs Lisp 对象。
实际上, “假(false)”就是我们熟悉的 nil。除此之外的任何值——
任何东西 — 都是 “真(true)”。
一个用于判断真假的表达式,只要求值结果不是 nil,
就会被解释为真(true)。换句话说,只要测试返回的值是数字(如 47)、
字符串(如 "hello")、符号(nil 除外,如 flowers)、
列表(只要非空),甚至是一个缓冲区,都会被视为真。
nil 说明 ¶在举例说明真假判断之前,我们需要先解释 nil。
在 Emacs Lisp 中,符号 nil 有两层含义。
第一,它表示空列表。第二,它表示假,是真假判断返回假时的值。
nil 可以写为空列表 (),也可以直接写 nil。
在 Lisp 解释器看来,() 和 nil 完全等价。
不过人们习惯用 nil 表示假,用 () 表示空列表。
在 Emacs Lisp 中,任何不是 nil(即不是空列表)的值都被视为真。
这意味着,如果一个表达式求值后返回的不是空列表,
if 就会判定为真。例如,如果把一个数字放在判断位置,
它会求值并返回自身,因为数字求值时就是返回自己。
在这个条件判断中,if 会判定为真。
只有当表达式求值返回 nil(空列表)时,判断才为假。
你可以对下面示例中的两个表达式求值,直观感受这一点。
第一个例子中,数字 4 作为 if 的判断条件被求值并返回自身,
因此表达式的那么部分被求值并返回:回显区显示 ‘true’。
第二个例子中,nil 表示假,因此表达式的否则部分被求值并返回:
回显区显示 ‘false’。
(if 4
'true
'false)
(if nil
'true
'false)
顺便一提,如果一个判断返回真但没有其他可用值,
Lisp 解释器会返回符号 t 表示真。
例如,表达式 (> 5 4) 求值后会返回 t,
你可以按常规方式求值验证:
(> 5 4)
反之,如果判断为假,该函数会返回 nil。
(> 4 5)
save-excursion ¶save-excursion 是本章要讲解的最后一个特殊形式。
在用于编辑的 Emacs Lisp 程序中,save-excursion 非常常用。
它会保存光标位置,执行函数体,然后在位置发生改变时将光标恢复到原先位置。
它的主要作用是避免用户因光标意外移动而感到困惑和不适。
不过在讲解 save-excursion 之前,先回顾一下 GNU Emacs 中的
光标(point)和标记(mark)会很有帮助。光标(point)
就是当前指针所在位置。光标在哪里,point 就在哪里。
更精确地说,在光标显示在某个字符上方的终端上,point 位于该字符紧前方。
在 Emacs Lisp 中,point 是一个整数。缓冲区的第一个字符编号为 1,
第二个为 2,依此类推。函数 point 会以数字形式返回光标当前位置。
每个缓冲区都有自己的 point 值。
标记(mark) 是缓冲区中的另一个位置,可以通过
C-SPC(set-mark-command)等命令设置。
如果已经设置了标记,可以使用命令 C-x C-x
(exchange-point-and-mark)让光标跳转到标记处,
并将标记设为光标原先的位置。此外,如果你设置了新标记,
旧标记的位置会保存在标记环中。可以用这种方式保存多个标记位置。
输入一次或多次 C-u C-SPC 可以让光标跳转到已保存的标记。
缓冲区中光标与标记之间的区域称为选区(region)。
许多命令都作用于选区,包括 center-region、
count-words-region、kill-region 和 print-region。
save-excursion 这个特殊形式会保存光标位置,
并在 Lisp 解释器对其内部代码求值后恢复该位置。
因此,如果光标原本在一段文本开头,而某段代码将光标移到了缓冲区末尾,
在函数体内的表达式求值完成后,save-excursion
会把光标放回原来的位置。
在 Emacs 中,很多函数在内部工作时会移动光标,
即便用户并不期望如此。例如 count-words-region 就会移动光标。
为了避免用户被这些意外且(从用户角度看)不必要的跳转打扰,
经常使用 save-excursion 让光标停留在用户预期的位置。
使用 save-excursion 是一种良好的编程习惯。
为了保证现场整洁,即使内部代码出现问题(更准确地说,
使用专业术语:“异常退出”时),save-excursion
也会恢复光标位置。这个特性非常实用。
除了记录光标位置,save-excursion 还会记录并恢复当前缓冲区。
这意味着你可以编写切换缓冲区的代码,
并由 save-excursion 切回原始缓冲区。
append-to-buffer 中就是这样使用 save-excursion 的。
(See The Definition of append-to-buffer。)
save-excursion 表达式模板 ¶使用 save-excursion 的代码模板很简单:
(save-excursion body...)
函数体由一个或多个表达式组成,会被 Lisp 解释器依次求值。
如果体内有多个表达式,最后一个表达式的值会作为
save-excursion 函数的返回值。
体内其他表达式仅因副作用被求值;
而 save-excursion 本身也只依靠其副作用(即恢复光标位置)发挥作用。
更详细地看,save-excursion 表达式的模板如下:
(save-excursion first-expression-in-body second-expression-in-body third-expression-in-body ... last-expression-in-body)
一个表达式既可以是单独的符号,也可以是列表。
在 Emacs Lisp 代码中,save-excursion 表达式
经常出现在 let 表达式的内部,写法如下:
(let varlist
(save-excursion
body...))
在前面几章中,我们介绍了一个宏、相当多的函数和特殊形式。 这里对它们做简要说明,并补充几个尚未提到的同类函数。
eval-last-sexp对光标当前位置前的最后一个符号表达式求值。 除非该函数带参数调用,否则值会打印在回显区; 带参数时输出会打印在当前缓冲区。该命令通常绑定到 C-x C-e。
defun定义函数。该宏最多包含五部分:函数名、 传递给函数的参数模板、文档字符串、可选的交互式声明,以及定义体。
例如,Emacs 中 dired-unmark-all-marks 的函数定义如下。
(defun dired-unmark-all-marks () "Remove all marks from all files in the Dired buffer." (interactive) (dired-unmark-all-files ?\r))
interactive向解释器声明该函数可交互式使用。 该特殊形式后可接一个包含一个或多个片段的字符串, 按顺序向函数参数传递信息。这些片段也可以告知解释器提示用户输入信息。 字符串中的片段使用换行符 ‘\n’ 分隔。
常用代码字符如下:
b现有缓冲区的名称。
f现有文件的名称。
p数字前缀参数。(注意这里的 p 是小写。)
r光标和标记,以两个数字参数形式返回,较小值在前。 这是唯一一个指定连续两个参数而非一个参数的代码字母。
See Code Characters for ‘interactive’ in The GNU Emacs Lisp Reference Manual,可查看完整代码字符列表。
let声明一组变量在 let 体内使用,
并为其赋予初始值(nil 或指定值);
然后对 let 体内其余表达式求值,并返回最后一个表达式的值。
在 let 内部,Lisp 解释器不会访问 let 外部
同名变量的绑定值。
例如:
(let ((foo (buffer-name))
(bar (buffer-size)))
(message
"This buffer is %s and has %d characters."
foo bar))
save-excursion在对该特殊形式的体求值前,记录光标和当前缓冲区的值, 之后恢复光标与缓冲区。
例如:
(message "We are %d characters into this buffer."
(- (point)
(save-excursion
(goto-char (point-min)) (point))))
if对函数的第一个参数求值;若为真则对第二个参数求值; 否则对第三个参数求值(如果存在)。
if 特殊形式被称为条件表达式(conditional)。
Emacs Lisp 中还有其他条件表达式,但 if 可能是最常用的。
例如:
(if (= 22 emacs-major-version)
(message "This is version 22 Emacs")
(message "This is not version 22 Emacs"))
<><=>=< 函数判断第一个参数是否小于第二个参数。
对应的 > 判断第一个参数是否大于第二个。
同理,<= 判断是否小于等于,>= 判断是否大于等于。
在所有情况下,两个参数都必须是数字或标记(marker,标记表示缓冲区中的位置)。
== 函数判断两个同为数字或标记的参数是否相等。
equaleq判断两个对象是否相同。equal 和 eq
对“相同(same)”的定义不同:equal 在两个对象结构与内容相似时返回真,
如同同一本书的两个副本。而 eq 只有在两个参数
确实是同一个对象时才返回真。
string<string-lesspstring=string-equalstring-lessp 函数判断第一个参数字符串是否小于第二个。
该函数的简短别名(通过 defalias 定义)是 string<。
string-lessp 的参数必须是字符串或符号;
比较按字典序进行,区分大小写。使用符号的打印名而非符号本身进行比较。
空字符串 ‘""’(不含任何字符)小于任何非空字符串。
string-equal 用于对应的相等判断,其简短别名为 string=。
没有对应于 >、>= 或 <= 的字符串测试函数。
message在回显区打印消息。第一个参数是字符串, 可包含 ‘%s’、‘%d’ 或 ‘%c’, 用于打印字符串后续参数的值。‘%s’ 对应的参数必须是字符串或符号; ‘%d’ 对应的参数必须是数字。‘%c’ 对应的参数必须是 ASCII 码数字, 会被打印为对应 ASCII 码的字符。(其他若干 % 格式序列未在此提及。)
setqsetsetq 特殊形式将第一个参数的值设为第二个参数的值。
第一个参数由 setq 自动引用。
它也可以对后续多对参数执行同样操作。
buffer-name无参数时,以字符串形式返回当前缓冲区名称。
buffer-file-name无参数时,返回当前缓冲区访问的文件名称。
current-buffer返回 Emacs 当前活动的缓冲区;它不一定是屏幕上可见的缓冲区。
other-buffer返回最近一次选中的缓冲区(排除作为参数传入的缓冲区和当前缓冲区)。
switch-to-buffer选中一个缓冲区作为 Emacs 活动缓冲区,并在当前窗口显示,供用户查看。 通常绑定到 C-x b。
set-buffer将 Emacs 的操作目标切换到指定缓冲区,但不改变窗口显示内容。
buffer-size返回当前缓冲区中的字符总数。
point以整数形式返回光标当前位置,从缓冲区开头算起计数字符。
point-min返回当前缓冲区中光标的最小合法值。 若未启用缩进限制(narrowing),该值为 1。
point-max返回当前缓冲区中光标的最大合法值。 若未启用缩进限制,该值为缓冲区末尾。
fill-column 的当前值是否大于传递给该函数的参数,如果是,则输出一条合适的提示信息。
本章我们将详细学习 GNU Emacs 中用到的若干函数。这一过程称为“逐步解析(walk-through)”。这些函数作为 Lisp 代码示例使用,但并非虚构示例;除第一个简化的函数定义外,其余均为 GNU Emacs 中实际使用的代码。你可以从这些定义中学到很多内容。本节介绍的函数均与缓冲区相关。后续我们还会学习其他函数。
在本次逐步解析中,遇到新函数时我会逐一讲解,有时详细,有时简略。如果你感兴趣,可以随时按下 C-h f 并输入函数名(再按 RET),查看任意 Emacs Lisp 函数的完整文档。同理,按下 C-h v 并输入变量名(再按 RET),即可查看变量的完整文档。
此外,describe-function 还会告知你函数定义所在的位置。
将光标移至包含该函数的文件名上并按下 RET。此处 RET 表示 push-button,而非 “return” 或 “enter”。Emacs 会直接跳转到该函数的定义处。
更一般地,如果你想在原始源文件中查看某个函数,可以使用 xref-find-definitions 函数进行跳转。xref-find-definitions 支持多种语言,不仅限于 Lisp 和 C,同时也适用于非程序文本。例如,xref-find-definitions 可以跳转到本文档 Texinfo 源文件中的各个节点(前提是你已运行 etags 工具,记录 Emacs 附带手册中的所有节点;see Create Tags Table in The GNU Emacs Manual)。
使用 xref-find-definitions 命令时,按下 M-.(即按住 META 键同时按句点键,或先按 ESC 再按句点键),然后在提示符下输入想要查看源代码的函数名,例如 mark-whole-buffer,再按 RET。(若该命令未出现提示符,可带参数调用:C-u M-.;see interactive 的不同选项。)Emacs 会切换缓冲区并在屏幕上显示该函数的源代码12。若要切回当前缓冲区,按下 M-, 或 C-x b RET。(部分键盘上 META 键标注为 ALT。)
顺便一提,包含 Lisp 代码的文件通常称为 库(libraries)。这一比喻源自专业类图书馆(如法律图书馆、工程图书馆),而非综合图书馆。每个库(即文件)都包含与特定主题或功能相关的函数,例如 abbrev.el 用于处理缩写及其他输入快捷方式,help.el 用于提供帮助功能。(有时多个库会为同一功能提供代码,例如多个 rmail… 文件共同实现电子邮件阅读功能。)在 The GNU Emacs Manual 中,你会看到类似“C-h p 命令允许按主题关键词搜索标准 Emacs Lisp 库”的语句。
beginning-of-buffer 定义 ¶beginning-of-buffer 命令是一个很好的入门函数,因为你很可能已经熟悉它,且易于理解。作为交互式命令,beginning-of-buffer 会将光标移至缓冲区开头,并在原位置留下标记。该命令通常绑定到 M-<。
本节我们会讨论该函数的简化版本,展示其最常用的工作方式。该简化版本代码可正常运行,但不包含复杂选项的相关逻辑。后续章节我们会介绍完整的函数定义。(See Complete Definition of beginning-of-buffer.)
在查看代码之前,先思考函数定义需要包含哪些内容:必须包含使函数可交互的表达式,以便通过 M-x beginning-of-buffer 或 M-< 这类组合键调用;必须包含在缓冲区原位置留下标记的代码;还必须包含将光标移至缓冲区开头的代码。
以下是该简化版函数的完整代码:
(defun simplified-beginning-of-buffer () "Move point to the beginning of the buffer; leave mark at previous position." (interactive) (push-mark) (goto-char (point-min)))
与所有函数定义一样,该定义在宏 defun 之后包含五个部分:
simplified-beginning-of-buffer。
()。
该函数定义的参数列表为空,意味着此函数不需要任何参数。(后续查看完整函数定义时,你会发现它可接收一个可选参数。)
交互式表达式告知 Emacs 该函数可交互式使用。本例中 interactive 没有参数,因为 simplified-beginning-of-buffer 不需要参数。
函数体由两行代码组成:
(push-mark) (goto-char (point-min))
第一行是表达式 (push-mark)。当 Lisp 解释器执行该表达式时,会在光标当前位置设置一个标记。该标记的位置会保存在标记环中。
下一行是 (goto-char (point-min))。该表达式将光标跳转到缓冲区的最小光标位置,即缓冲区开头(若缓冲区已缩窄,则跳转到可访问区域的开头。See Narrowing and Widening。)
push-mark 命令会在 (goto-char (point-min)) 将光标移至缓冲区开头之前,在光标原位置设置标记。因此,你可以按需按下 C-x C-x 回到原先位置。
这就是该函数定义的全部内容!
阅读此类代码时,若遇到不熟悉的函数(如 goto-char),可使用 describe-function 命令查看其功能。使用该命令时,按下 C-h f 并输入函数名,再按 RET。describe-function 会在 *Help* 窗口中显示函数的文档字符串。例如,goto-char 的文档如下:
Set point to POSITION, a number or marker. Beginning of buffer is position (point-min), end is (point-max).
该函数的唯一参数为目标位置。
(describe-function 的提示符会自动补全光标下方或前方的符号,你可以将光标移至函数上方或后方,直接按下 C-h f RET 以减少输入。)
end-of-buffer 函数定义与 beginning-of-buffer 写法基本一致,区别仅在于函数体中使用 (goto-char (point-max)) 替代了 (goto-char (point-min))。
mark-whole-buffer 的定义 ¶mark-whole-buffer 函数的理解难度并不高于 simplified-beginning-of-buffer。不过本节我们会直接查看完整函数,而非简化版本。
mark-whole-buffer 函数的使用频率不如 beginning-of-buffer,但依然很实用:它会将光标置于缓冲区开头、标记置于结尾,从而将整个缓冲区选为区域。该命令通常绑定到 C-x h。
mark-whole-buffer 概述 ¶在 GNU Emacs 22 中,该完整函数的代码如下:
(defun mark-whole-buffer () "将光标置于缓冲区开头,标记置于结尾。 你通常不应在 Lisp 程序中使用此函数; Lisp 函数调用任何使用或设置标记的子程序, 一般都是不合适的。" (interactive) (push-mark (point)) (push-mark (point-max) nil t) (goto-char (point-min)))
与其他所有函数一样,mark-whole-buffer 符合函数定义的通用模板。该模板如下:
(defun name-of-function (argument-list) "documentation..." (interactive-expression...) body...)
该函数的工作方式如下:函数名为 mark-whole-buffer;其后为空参数列表 ‘()’,表示该函数不需要参数。接下来是文档字符串。
下一行是 (interactive) 表达式,告知 Emacs 该函数可交互式使用。这些细节与上一节介绍的 simplified-beginning-of-buffer 类似。
mark-whole-buffer 的函数体 ¶mark-whole-buffer 的函数体由三行代码组成:
(push-mark (point)) (push-mark (point-max) nil t) (goto-char (point-min))
第一行是表达式 (push-mark (point))。
该行的作用与 simplified-beginning-of-buffer 函数体第一行 (push-mark) 完全相同。两种写法下,Lisp 解释器都会在光标当前位置设置标记。
我不清楚为何 mark-whole-buffer 中写作 (push-mark (point)),而 beginning-of-buffer 中写作 (push-mark)。或许编写代码的人不知道 push-mark 的参数是可选的,若不传入参数,函数会默认在光标位置设置标记。也可能是为了与下一行代码结构保持一致。无论如何,该行都会让 Emacs 获取光标位置并在此设置标记。
在早期版本的 GNU Emacs 中,mark-whole-buffer 的下一行是 (push-mark (point-max))。该表达式会在缓冲区编号最大的位置设置标记,即缓冲区结尾(若缓冲区已缩窄,则为可访问区域的结尾。有关缩窄的更多内容,参见 See Narrowing and Widening。)设置该标记后,原先在光标位置设置的标记会失效,但 Emacs 会像保存其他最近标记一样记住其位置。这意味着你可以按需按下两次 C-u C-SPC 回到该位置。
在 GNU Emacs 22 中,(point-max) 相关写法略微复杂。该行代码为:
(push-mark (point-max) nil t)
该表达式的功能与之前基本相同,会在缓冲区可访问的最大编号位置设置标记。不过此版本中 push-mark 多了两个参数。push-mark 的第二个参数为 nil,表示该函数 需要在设置标记时显示 “Mark set” 提示信息。第三个参数为 t,表示在临时标记模式开启时激活标记。临时标记模式会高亮当前活动区域,该模式通常处于关闭状态。
最后一行函数代码为 (goto-char (point-min)),写法与 beginning-of-buffer 中完全一致。该表达式将光标移至缓冲区最小位置,即缓冲区开头(或可访问区域的开头)。执行后,光标位于缓冲区开头,标记位于结尾,整个缓冲区即为选中区域。
append-to-buffer 的定义 ¶append-to-buffer 命令比 mark-whole-buffer 更为复杂。它的功能是将当前缓冲区中的区域(即光标与标记之间的文本)复制到指定缓冲区。
append-to-buffer 概述append-to-buffer 的交互式表达式append-to-buffer 的函数体append-to-buffer 中的 save-excursionappend-to-buffer 概述 ¶append-to-buffer 命令使用 insert-buffer-substring 函数复制区域内容。insert-buffer-substring 的功能可从名称看出:从一个缓冲区取出子串并插入到另一个缓冲区。
append-to-buffer 的大部分逻辑都在为 insert-buffer-substring 创造运行条件:代码需要指定文本要写入的目标缓冲区、文本来源与目标窗口,以及要复制的区域范围。
以下是该函数的一种实现方式:
(defun append-to-buffer (buffer start end) "将区域中的文本追加到指定缓冲区。 文本会插入到该缓冲区的光标之前。
从程序中调用时需传入三个参数:
BUFFER(或缓冲区名)、START 与 END。
START 与 END 指定当前缓冲区中要复制的部分。"
(interactive
(list (read-buffer "Append to buffer: " (other-buffer
(current-buffer) t))
(region-beginning) (region-end)))
(let ((oldbuf (current-buffer)))
(save-excursion
(let* ((append-to (get-buffer-create buffer))
(windows (get-buffer-window-list append-to t t))
point)
(set-buffer append-to)
(setq point (point))
(barf-if-buffer-read-only)
(insert-buffer-substring oldbuf start end)
(dolist (window windows)
(when (= (window-point window) point)
(set-window-point window (point))))))))
可以将该函数拆分为一系列填充好的模板来理解。
最外层是函数定义模板。在该函数中,模板(已填充部分内容)如下:
(defun append-to-buffer (buffer start end) "documentation..." (interactive ...) body...)
函数第一行包含函数名与三个参数:要复制文本的目标 buffer,以及当前缓冲区中待复制区域的起始 start 与结束 end 位置。
接下来是清晰完整的文档字符串。按照惯例,三个参数使用大写字母以便识别。更完善的是,参数说明顺序与参数列表顺序一致。
注意文档中区分了缓冲区与其名称。(该函数两种形式均可处理。)
append-to-buffer 的交互式表达式 ¶由于 append-to-buffer 可交互式使用,函数必须包含 interactive 表达式。(如需回顾 interactive,参见 Making a Function Interactive。)
该表达式如下:
(interactive
(list (read-buffer
"Append to buffer: "
(other-buffer (current-buffer) t))
(region-beginning)
(region-end)))
该表达式并非之前介绍的使用字母表示各部分的形式,而是以一个列表开头,包含以下内容:
列表第一部分是读取缓冲区名并返回字符串的表达式,即 read-buffer。该函数的第一个参数为提示符 ‘"Append to buffer: "’。第二个参数指定用户未输入时的默认值。
本例中第二个参数是一个表达式,包含函数 other-buffer、一个例外项与代表真的 ‘t’。
other-buffer 的第一个参数(例外项)是另一个函数 current-buffer,该缓冲区不会被返回。第二个参数为真符号 t,告知 other-buffer 可以显示可见缓冲区(本例中不会显示当前缓冲区,这一设计合理)。
该表达式如下:
(other-buffer (current-buffer) t)
列表表达式的第二个与第三个参数为 (region-beginning) 与 (region-end)。这两个函数指定要追加文本的起始与结束位置。
最初该命令使用字母 ‘B’ 与 ‘r’。完整的 interactive 表达式如下:
(interactive "BAppend to buffer: \nr")
但这样写会导致切换目标缓冲区的默认值不可见,不符合预期。
(提示符与第二个参数之间使用换行符 ‘\n’ 分隔,后跟 ‘r’,告知 Emacs 将函数参数列表中 buffer 之后的两个参数(即 start 与 end)绑定到光标与标记的位置。该参数本身运行正常。)
append-to-buffer 的函数体 ¶append-to-buffer 的函数体以 let 开头。
如前所述(see let),let 表达式的作用是创建一个或多个变量并赋予初始值,这些变量仅在 let 函数体内有效。这意味着该变量不会与 let 表达式外部同名变量冲突。
通过展示包含 let 表达式的 append-to-buffer 概要模板,可以清晰看出 let 在整个函数中的位置:
(defun append-to-buffer (buffer start end)
"documentation..."
(interactive ...)
(let ((variable value))
body...))
let 表达式包含三个部分:
let;
(variable value);
let 表达式的函数体。
在 append-to-buffer 函数中,变量列表如下:
(oldbuf (current-buffer))
在 let 表达式的这一部分中,变量 oldbuf 被绑定到 (current-buffer) 表达式的返回值。变量 oldbuf 用于记录当前工作的源缓冲区。
变量列表的元素被一对括号包裹,以便 Lisp 解释器区分变量列表与 let 函数体。因此,变量列表内的双元素列表又被外层括号包裹。该行代码如下:
(let ((oldbuf (current-buffer))) ... )
如果你没有意识到 oldbuf 前的第一个括号标记变量列表边界、第二个括号标记双元素列表 (oldbuf (current-buffer)) 开头,可能会对这两个连续括号感到意外。
append-to-buffer 中的 save-excursion ¶append-to-buffer 中 let 表达式的函数体由一个 save-excursion 表达式构成。
save-excursion 函数会保存光标位置,并在其函数体内的所有表达式执行完毕后,将光标恢复到该位置。此外,save-excursion 还会记录原始缓冲区并在最后恢复。这就是 save-excursion 在 append-to-buffer 中的使用方式。
顺便一提,这里值得注意:Lisp 函数通常会按照一定格式排版,多行结构内的所有内容都会比所在块的首个符号向右缩进更多。在本函数定义中,let 的缩进比 defun 更深,save-excursion 的缩进又比 let 更深,如下所示:
(defun ...
...
...
(let...
(save-excursion
...
这种排版约定让我们可以清晰地看出,save-excursion 函数体内的代码被其自身括号包裹,正如 save-excursion 本身被 let 的括号包裹一样:
(let ((oldbuf (current-buffer)))
(save-excursion
...
(set-buffer ...)
(insert-buffer-substring oldbuf start end)
...))
save-excursion 函数的使用可以看作是填充模板的过程:
(save-excursion first-expression-in-body second-expression-in-body ... last-expression-in-body)
在本函数中,save-excursion 的函数体只包含一个表达式,即 let* 表达式。你已经了解 let 函数,而 let* 函数有所不同。它允许 Emacs 按顺序依次设置变量列表中的每个变量,使得变量列表后半部分的变量可以使用前面已设置变量的值。
来看 append-to-buffer 中的 let* 表达式:
(let* ((append-to (get-buffer-create buffer))
(windows (get-buffer-window-list append-to t t))
point)
BODY...)
可以看到 append-to 被绑定到 (get-buffer-create buffer) 的返回值。下一行中,append-to 被用作 get-buffer-window-list 的参数;如果使用普通 let 表达式,这是无法实现的。注意 point 会自动绑定到 nil,与 let 语句中的行为一致。
现在我们重点关注 let* 表达式函数体内的 set-buffer 与 insert-buffer-substring 函数。
在早期版本中,set-buffer 表达式的写法很简单:
(set-buffer (get-buffer-create buffer))
而现在的写法是:
(set-buffer append-to)
原因是 append-to 已经在 let* 表达式前面绑定为 (get-buffer-create buffer)。
append-to-buffer 函数的作用是将当前缓冲区的文本插入到指定缓冲区。而 insert-buffer-substring 的作用恰好相反——它从另一个缓冲区复制文本到当前缓冲区。这就是为什么 append-to-buffer 定义开头使用 let,将局部符号 oldbuf 绑定到 current-buffer 的返回值。
insert-buffer-substring 表达式如下:
(insert-buffer-substring oldbuf start end)
insert-buffer-substring 函数从第一个参数指定的缓冲区复制字符串,并将其插入到当前缓冲区。本例中,该函数的参数是 let 创建并绑定的变量 oldbuf,即执行 append-to-buffer 命令时所在的原始缓冲区。
在 insert-buffer-substring 完成工作后,save-excursion 会将操作恢复到原始缓冲区,append-to-buffer 也就完成了任务。
用框架形式表示,函数体的执行逻辑如下:
(let (bind-oldbuf-to-value-of-current-buffer) (save-excursion ; Keep track of buffer. change-buffer insert-substring-from-oldbuf-into-buffer) change-back-to-original-buffer-when-finished let-the-local-meaning-of-oldbuf-disappear-when-finished
总而言之,append-to-buffer 的工作流程如下:将当前缓冲区保存到变量 oldbuf;获取目标缓冲区(不存在则创建)并切换 Emacs 当前操作缓冲区;使用 oldbuf 将原始缓冲区的区域文本插入到新缓冲区;最后通过 save-excursion 回到原始缓冲区。
通过分析 append-to-buffer,你已经了解了一个相当复杂的函数。它展示了 let 与 save-excursion 的用法,以及如何切换并返回其他缓冲区。许多函数定义都以类似方式使用 let、save-excursion 和 set-buffer。
以下是本章讨论的各类函数的简要总结。
describe-functiondescribe-variable打印函数或变量的文档。常规绑定到 C-h f 与 C-h v。
xref-find-definitions查找包含函数或变量源代码的文件并切换缓冲区,将光标定位到条目开头。常规绑定到 M-.(即 META 键加句点)。
save-excursion保存光标位置,并在其参数表达式执行完毕后恢复;同时记录并返回原始缓冲区。
push-mark在指定位置设置标记,并将上一个标记记录到标记环中。标记是缓冲区中的一个位置,即使缓冲区文本被增删,其相对位置也会保持不变。
goto-char将光标设置为参数指定的位置,参数可以是数字、标记,或返回位置编号的表达式(如 (point-min))。
insert-buffer-substring从作为参数传入的缓冲区复制一段区域文本,并插入到当前缓冲区。
mark-whole-buffer将整个缓冲区选为区域。通常绑定到 C-x h。
let*声明变量列表并赋予初始值,随后执行 let* 函数体内的其余表达式。变量的值可用于设置列表中后续的变量。
set-buffer将 Emacs 的操作目标切换到另一个缓冲区,但不改变显示窗口。用于程序而非用户操作其他缓冲区的场景。
get-buffer-createget-buffer查找指定名称的缓冲区,不存在则创建。get-buffer 在指定缓冲区不存在时返回 nil。
simplified-end-of-buffer 函数定义,并测试其是否正常工作。
if 与 get-buffer 编写一个函数,打印信息提示某个缓冲区是否存在。
xref-find-definitions 查找 copy-to-buffer 函数的源代码。
本章将在前几章所学基础上,分析更复杂的函数。copy-to-buffer 展示了在单个定义中使用两个 save-excursion 表达式的写法;而 insert-buffer 则展示了在 interactive 表达式中使用星号、使用 or,以及名称与名称所指对象之间的重要区别。
copy-to-buffer 的定义 ¶理解 append-to-buffer 之后,理解 copy-to-buffer 就很容易。该函数将文本复制到目标缓冲区,但不会追加内容,而是替换目标缓冲区中原有的全部文本。
copy-to-buffer 的函数体如下:
...
(interactive "BCopy to buffer: \nr")
(let ((oldbuf (current-buffer)))
(with-current-buffer (get-buffer-create buffer)
(barf-if-buffer-read-only)
(erase-buffer)
(save-excursion
(insert-buffer-substring oldbuf start end)))))
copy-to-buffer 的 interactive 表达式比 append-to-buffer 更简单。
定义接下来的语句是:
(with-current-buffer (get-buffer-create buffer) ...
首先看最内层的表达式,它会被优先执行。该表达式以 get-buffer-create buffer 开头,让计算机使用指定名称的缓冲区作为复制目标,不存在则创建。随后,with-current-buffer 函数会临时将该缓冲区设为当前缓冲区并执行其函数体,执行完毕后切回当前缓冲区13。
(这展示了另一种只切换程序操作目标、不改变用户显示窗口的方法。append-to-buffer 使用 save-excursion 与 set-buffer 实现了同样效果,而 with-current-buffer 是更新、更易用的机制。)
barf-if-buffer-read-only 函数会在缓冲区不可修改时,提示缓冲区为只读并报错。
下一行仅包含 erase-buffer 函数,该函数会清空缓冲区。
最后两行是 save-excursion 表达式,其函数体为 insert-buffer-substring。insert-buffer-substring 从你当前所在的缓冲区复制文本(你不会察觉到程序已切换缓冲区,该原始缓冲区已被记为 oldbuf)。
顺便一提,这就是 “替换(replacement)” 的含义:Emacs 先清空原有文本,再插入新文本。
框架结构上,copy-to-buffer 的函数体如下:
(let (bind-oldbuf-to-value-of-current-buffer) (with-the-buffer-you-are-copying-to (but-do-not-erase-or-copy-to-a-read-only-buffer) (erase-buffer) (save-excursion insert-substring-from-oldbuf-into-buffer)))
insert-buffer 的定义 ¶insert-buffer 是另一个与缓冲区相关的函数。该命令将另一个缓冲区的内容插入到当前缓冲区。它与 append-to-buffer 或 copy-to-buffer 作用相反,后两者是从当前缓冲区向其他缓冲区复制区域文本。
以下讨论基于早期原始代码。该代码在 2003 年被简化,反而更难理解。
(如需了解新版函数体,参见 See New Body for insert-buffer。)
此外,这段代码还展示了在可能为 只读(read-only) 的缓冲区中使用 interactive 的方式,以及对象名称与实际所指对象之间的重要区别。
insert-buffer 的代码insert-buffer 中的交互式表达式insert-buffer 的函数体if 替代 or 的 insert-bufferorinsert-buffer 中的 let 表达式insert-buffer 的新版函数体insert-buffer 的代码 ¶以下是早期代码:
(defun insert-buffer (buffer) "在光标后插入 BUFFER 的全部内容。 在插入文本后设置标记。 BUFFER 可以是缓冲区或缓冲区名称。" (interactive "*bInsert buffer: ")
(or (bufferp buffer)
(setq buffer (get-buffer buffer)))
(let (start end newmark)
(save-excursion
(save-excursion
(set-buffer buffer)
(setq start (point-min) end (point-max)))
(insert-buffer-substring buffer start end)
(setq newmark (point)))
(push-mark newmark)))
与其他函数定义一样,你可以用模板查看函数结构:
(defun insert-buffer (buffer) "documentation..." (interactive "*bInsert buffer: ") body...)
insert-buffer 中的交互式表达式 ¶在 insert-buffer 中,interactive 声明的参数分为两部分:星号 ‘*’ 与 ‘bInsert buffer: ’。
星号用于处理当前缓冲区为只读缓冲区(不可修改)的情况。如果在只读缓冲区调用 insert-buffer,回显区会打印相应提示,终端可能发出蜂鸣或闪烁,不允许向当前缓冲区插入任何内容。星号后不需要换行符分隔下一个参数。
交互式表达式的下一个参数以小写 ‘b’ 开头。(这与 append-to-buffer 使用大写 ‘B’ 不同。参见 See The Definition of append-to-buffer。)小写 ‘b’ 告知 Lisp 解释器,insert-buffer 的参数必须是现有缓冲区或其名称。(大写 ‘B’ 则允许缓冲区不存在。)Emacs 会提示输入缓冲区名称,提供默认值并支持名称补全。如果缓冲区不存在,会提示“No match”,终端可能同时发出蜂鸣。
新版简化代码会为 interactive 生成列表,使用我们已熟悉的 barf-if-buffer-read-only 与 read-buffer 函数,以及尚未介绍的 progn 特殊形式(后续会讲解)。
insert-buffer 的函数体 ¶insert-buffer 的函数体主要分为两部分:or 表达式与 let 表达式。or 表达式的作用是确保参数 buffer 绑定到缓冲区对象本身,而非仅缓冲区名称。let 表达式的函数体包含将其他缓冲区内容复制到当前缓冲区的代码。
框架结构上,这两个表达式在 insert-buffer 中的位置如下:
(defun insert-buffer (buffer)
"documentation..."
(interactive "*bInsert buffer: ")
(or ...
...
(let (varlist)
body-of-let... )
要理解 or 表达式如何确保参数 buffer 绑定到缓冲区对象而非名称,首先需要了解 or 函数。
在此之前,我先用 if 重写这部分代码,让你以更熟悉的方式理解其逻辑。
if 替代 or 的 insert-buffer ¶我们要完成的任务是确保 buffer 的值是缓冲区对象,而非缓冲区名称。如果值是名称,则必须获取对应的缓冲区对象。
你可以想象在一场会议中,引导员拿着写有你名字的名单四处找你:引导员一开始绑定的是你的名字,而非你本人;但当他找到你并牵起你的手,就绑定到了你本身。
在 Lisp 中,你可以这样描述这一场景:
(if (not (holding-on-to-guest))
(find-and-take-arm-of-guest))
我们对缓冲区要做的事情类似——如果没有拿到缓冲区对象,就去获取它。
使用判断是否为缓冲区的谓词函数 bufferp(而非名称),可以写出如下代码:
(if (not (bufferp buffer)) ; if-part (setq buffer (get-buffer buffer))) ; then-part
这里 if 表达式的真假判断是 (not (bufferp buffer));then 部分是表达式 (setq buffer (get-buffer buffer))。
在判断条件中,bufferp 函数在参数为缓冲区时返回真,为缓冲区名称时返回假。(函数名 bufferp 的最后一个字符是 ‘p’;如前所述,使用 ‘p’ 是一种惯例,表示该函数是谓词,即用于判断某一属性是否成立。参见 See Using the Wrong Type Object as an Argument。)
函数 not 位于表达式 (bufferp buffer) 之前,因此真假判断如下:
(not (bufferp buffer))
not 函数在参数为假时返回真,参数为真时返回假。因此如果 (bufferp buffer) 返回真,not 表达式返回假,反之亦然。
使用这一判断条件,if 表达式的工作逻辑如下:当变量 buffer 本身就是缓冲区对象而非名称时,真假判断返回假,if 表达式不会执行 then 部分。这符合预期,因为此时 buffer 已经是缓冲区,无需额外处理。
反之,当 buffer 的值不是缓冲区对象而是名称时,真假判断返回真,then 部分会被执行。此时 then 部分 (setq buffer (get-buffer buffer)) 会通过 get-buffer 函数根据名称获取真实缓冲区对象,再通过 setq 将变量 buffer 设置为该缓冲区对象,替换掉原先的名称值。
or ¶insert-buffer 函数中 or 表达式的作用,是确保参数 buffer 绑定到缓冲区对象本身,而不只是缓冲区名称。上一节已经展示了如何用 if 表达式完成这一工作,但 insert-buffer 实际使用的是 or。要理解这一点,必须先了解 or 的工作方式。
or 函数可以接受任意数量的参数。它会依次对每个参数求值,并返回第一个不为 nil 的参数的值。此外,or 还有一个关键特性:在返回第一个非 nil 值之后,不会再对后续任何参数求值。
该 or 表达式如下:
(or (bufferp buffer)
(setq buffer (get-buffer buffer)))
or 的第一个参数是表达式 (bufferp buffer)。如果 buffer 确实是缓冲区对象而非名称,该表达式会返回真(非 nil 值)。在 or 表达式中,如果出现这种情况,它会直接返回该真值,不再执行下一个表达式——这正是我们想要的,因为如果 buffer 已经是缓冲区对象,就无需再做任何处理。
反之,如果 (bufferp buffer) 的值为 nil(即 buffer 是缓冲区名称),Lisp 解释器就会执行 or 表达式的下一个元素,也就是 (setq buffer (get-buffer buffer))。该表达式会返回一个非 nil 值,也就是它为变量 buffer 设置的新值——这个值是缓冲区对象本身,而非名称。
这一切的最终结果是:符号 buffer 始终绑定到缓冲区对象,而非缓冲区名称。之所以必须这样做,是因为下一行的 set-buffer 函数只能操作缓冲区对象,不能直接使用缓冲区名称。
顺便一提,用 or 来描述之前引导员的例子,写法如下:
(or (holding-on-to-guest) (find-and-take-arm-of-guest))
insert-buffer 中的 let 表达式 ¶在确保变量 buffer 指向缓冲区对象而非名称之后,insert-buffer 继续执行一个 let 表达式。它声明了三个局部变量 start、end 和 newmark,并将它们初始值绑定为 nil。这些变量在 let 剩余部分中使用,并会临时屏蔽 Emacs 中其他同名变量,直到 let 结束。
let 的函数体包含两个 save-excursion 表达式。我们先详细看内层的 save-excursion,表达式如下:
(save-excursion (set-buffer buffer) (setq start (point-min) end (point-max)))
表达式 (set-buffer buffer) 将 Emacs 的操作目标从当前缓冲区切换到待复制文本的源缓冲区。在该缓冲区中,通过 point-min 和 point-max 命令,将变量 start 和 end 分别设为缓冲区开头和结尾。这里也展示了 setq 如何在同一个表达式中设置两个变量:第一个参数被设为第二个参数的值,第三个参数被设为第四个参数的值。
内层 save-excursion 的函数体执行完毕后,save-excursion 会恢复原始缓冲区,但 start 和 end 仍然保留着源缓冲区开头和结尾的值。
外层 save-excursion 表达式如下:
(save-excursion (inner-save-excursion-expression (go-to-new-buffer-and-set-start-and-end) (insert-buffer-substring buffer start end) (setq newmark (point)))
insert-buffer-substring 函数从 buffer 中 start 到 end 标记的区域复制文本并插入到当前缓冲区。由于源缓冲区的全部内容都在 start 与 end 之间,因此整个源缓冲区都会被复制到正在编辑的缓冲区。随后,光标位置(此时位于插入文本的末尾)被记录到变量 newmark 中。
外层 save-excursion 的函数体执行完毕后,光标会恢复到原来的位置。
不过,更方便的做法是在新插入文本的末尾设置标记,并将光标留在开头。变量 newmark 记录了插入文本的末尾位置。在 let 表达式的最后一行,(push-mark newmark) 在此位置设置了一个标记。(原标记位置仍然可用,它被保存在标记环中,可以通过 C-u C-SPC 返回。)与此同时,光标位于插入文本的开头,也就是调用插入函数之前的位置,该位置由第一个 save-excursion 保存。
整个 let 表达式如下:
(let (start end newmark)
(save-excursion
(save-excursion
(set-buffer buffer)
(setq start (point-min) end (point-max)))
(insert-buffer-substring buffer start end)
(setq newmark (point)))
(push-mark newmark))
与 append-to-buffer 一样,insert-buffer 也使用了 let、save-excursion 和 set-buffer,此外还展示了 or 的一种用法。这些函数都是常用的基础模块,后续会反复出现和使用。
insert-buffer 的新版函数体 ¶GNU Emacs 22 中的函数体比原版更令人费解。
它只包含两个表达式:
(push-mark
(save-excursion
(insert-buffer-substring (get-buffer buffer))
(point)))
nil
让新手困惑的是,真正核心的工作都在 push-mark 表达式内部完成。
get-buffer 函数根据提供的名称返回对应的缓冲区。注意该函数不是 get-buffer-create;如果缓冲区不存在,它不会创建。get-buffer 返回的现有缓冲区会被传给 insert-buffer-substring,后者会插入整个缓冲区(因为没有指定其他范围)。
缓冲区被插入的位置由 push-mark 记录。随后函数返回 nil,即最后一条命令的值。换句话说,insert-buffer 只用于产生副作用(插入另一个缓冲区的内容),而不返回有意义的值。
beginning-of-buffer 完整定义 ¶beginning-of-buffer 函数的基本结构已经介绍过。(参见 See A Simplified beginning-of-buffer Definition。)本节讲解其定义中较复杂的部分。
如前所述,不带参数调用时,beginning-of-buffer 会将光标移到缓冲区开头(准确说是可访问部分的开头),并将标记留在原位置。但如果调用时传入 1 到 10 之间的数字,函数会将该数字视为缓冲区长度的十分之几,并将光标移动到缓冲区开头对应比例的位置。因此,你可以用快捷键 M-< 将光标移到缓冲区开头,也可以用 C-u 7 M-< 将光标移到缓冲区 70% 的位置。如果参数大于 10,则直接跳到缓冲区末尾。
beginning-of-buffer 可以带参数或不带参数调用,参数是可选的。
默认情况下,Lisp 会认为函数定义中声明的参数,在调用时必须传入对应的值。如果没有传入,就会报错并提示 ‘参数数量错误(Wrong number of arguments)’。
不过 Lisp 支持可选参数:使用特定关键字(keyword)告知 Lisp 解释器某个参数是可选的。该关键字是 &optional。(关键字前面的 ‘&’ 是必需的。)在函数定义中,如果某个参数跟在 &optional 之后,调用函数时可以不传入该参数的值。
因此 beginning-of-buffer 函数定义的第一行如下:
(defun beginning-of-buffer (&optional arg)
整体结构大致如下:
(defun beginning-of-buffer (&optional arg)
"documentation..."
(interactive "P")
(or (is-the-argument-a-cons-cell arg)
(and are-both-transient-mark-mode-and-mark-active-true)
(push-mark))
(let (determine-size-and-set-it)
(goto-char
(if-there-is-an-argument
figure-out-where-to-go
else-go-to
(point-min))))
do-nicety
该函数与简化版 simplified-beginning-of-buffer 类似,区别在于 interactive 表达式使用了 "P" 作为参数,并且 goto-char 后面跟着一个 if-then-else 表达式,用于在传入非序对参数时计算光标位置。
(序对相关内容要在后面章节才会讲解,暂时可以忽略 consp 函数。参见 See How Lists are Implemented,以及 Cons Cell and List Types in The GNU Emacs Lisp Reference Manual。)
interactive 表达式中的 "P" 告诉 Emacs:如果存在前缀参数,以原始形式传给函数。前缀参数通过按 META 加数字,或 C-u 加数字生成。(如果不输入数字,C-u 默认生成一个值为 4 的序对。如果 interactive 中使用小写 "p",函数会自动将前缀参数转为数字。)
这个 if 表达式的判断条件看起来复杂,其实只是检查 arg 是否为非 nil 且不是序对。(consp 的作用就是判断参数是否为序对。)如果 beginning-of-buffer 带数字参数调用,判断条件为真,执行 then 部分;如果不带参数,arg 为 nil,执行 else 部分。else 部分就是 point-min,此时 goto-char 等价于 (goto-char (point-min)),也就是简化版 beginning-of-buffer 的行为。
beginning-of-buffer ¶当 beginning-of-buffer 带参数调用时,会执行一段计算逻辑,确定传给 goto-char 的位置值。这段表达式初看比较复杂,包含内层 if 和大量算术运算,形式如下:
(if (> (buffer-size) 10000)
;; Avoid overflow for large buffer sizes!
(* (prefix-numeric-value arg)
(/ size 10))
(/
(+ 10
(* size
(prefix-numeric-value arg)))
10))
beginning-of-buffer ¶和其他看似复杂的表达式一样,beginning-of-buffer 中的条件表达式可以按 if-then-else 模板拆解。框架如下:
(if (buffer-is-large
divide-buffer-size-by-10-and-multiply-by-arg
else-use-alternate-calculation
内层 if 判断缓冲区大小。原因是早期 Emacs 18 版本使用的数值上限约八百万,程序员担心大缓冲区计算时出现数值过大的问题。注释中提到的 “溢出(overflow)” 就是指数值过大。新版 Emacs 支持更大数值,但这段代码仍被保留,部分原因是现在的缓冲区往往比以前大得多。
逻辑分为两种情况:缓冲区很大,或不大。
在 beginning-of-buffer 中,内层 if 判断缓冲区大小是否超过 10000 字符。它使用 > 函数和 let 表达式中计算出的 size。
早期版本使用 buffer-size,该函数会被多次调用,且返回整个缓冲区大小而非可访问部分。只对可访问部分计算才更合理。(关于聚焦到可访问部分的更多信息,参见 See Narrowing and Widening。)
判断语句如下:
(if (> size 10000)
如果缓冲区很大,就会执行 if 表达式的 then 分支。格式化以便阅读后,代码如下所示:
(* (prefix-numeric-value arg) (/ size 10))
该表达式是一个乘法运算,向函数 * 传入两个参数。
第一个参数是 (prefix-numeric-value arg)。当
"P" 用作 interactive 的参数时,传递给函数的参数是一个 原始前缀参数(raw prefix argument),而非数字。(它是一个包含数字的列表。)要进行算术运算,需要进行类型转换,而
prefix-numeric-value 就承担这一工作。
第二个参数是 (/ size 10)。该表达式将缓冲区可访问部分的大小对应的数值除以十。由此得到的数值,表示缓冲区大小的十分之一对应多少个字符。(在 Lisp 中,/ 用于除法,正如 * 用于乘法。)
在整个乘法表达式中,该数值会与前缀参数的值相乘 — 乘法形式如下:
(* numeric-value-of-prefix-arg number-of-characters-in-one-tenth-of-the-accessible-buffer)
例如,若前缀参数为 ‘7’,十分之一长度的数值会乘以 7,得到缓冲区百分之七十位置的偏移量。
综上,当缓冲区可访问部分较大时,goto-char 表达式如下:
(goto-char (* (prefix-numeric-value arg)
(/ size 10)))
这样就将光标定位到了目标位置。
如果缓冲区字符数少于 10,000,会执行略有不同的计算。你可能认为这没有必要,因为第一种计算方式本可完成任务。但在小缓冲区中,第一种方法可能无法将光标精确定位到目标行;第二种方法效果更好。
代码如下:
(/ (+ 10 (* size (prefix-numeric-value arg))) 10)
这段代码需要通过括号嵌套关系来理解执行逻辑。若按表达式嵌套层级缩进排版,可读性会更高:
(/
(+ 10
(*
size
(prefix-numeric-value arg)))
10)
从括号结构可以看出,最内层操作是 (prefix-numeric-value arg),它将原始前缀参数转换为数字。在下一层表达式中,该数字与缓冲区可访问部分的大小相乘:
(* size (prefix-numeric-value arg))
该乘法得到的数值可能大于缓冲区大小 — 例如参数为 7 时就会是缓冲区大小的七倍。随后给该数值加 10,最后整体除以 10,得到的结果会比缓冲区中按百分比计算的位置大一个字符。
最终得到的数值会传递给 goto-char,光标随之移动到该位置。
beginning-of-buffer 完整实现 ¶以下是 beginning-of-buffer 函数的完整代码:
(defun beginning-of-buffer (&optional arg) "将光标移至缓冲区开头;在原位置保留标记。 带 \\[universal-argument] 前缀时,不在原位置设置标记。 带数字参数 N 时,将光标置于距开头 N/10 处。 若缓冲区被缩窄,该命令会使用缓冲区可访问部分的开头与大小。
不要在 Lisp 程序中使用该命令!
\(goto-char (point-min)) 速度更快,且不会覆盖标记。"
(interactive "P")
(or (consp arg)
(and transient-mark-mode mark-active)
(push-mark))
(let ((size (- (point-max) (point-min))))
(goto-char (if (and arg (not (consp arg)))
(+ (point-min)
(if (> size 10000)
;; 避免大缓冲区数值溢出!
(* (prefix-numeric-value arg)
(/ size 10))
(/ (+ 10 (* size (prefix-numeric-value arg)))
10)))
(point-min))))
(if (and arg (not (consp arg))) (forward-line 1)))
除两处细节外,前文已说明该函数的工作原理。第一处是文档字符串中的细节,第二处是函数最后一行的逻辑。
文档字符串中引用了如下表达式:
\\[universal-argument]
该表达式左方括号前使用了 ‘\\’。这个 ‘\\’ 告知 Lisp 解释器,将 ‘[…]’ 替换为当前绑定的按键。对于 universal-argument,该按键通常为 C-u,但也可能不同。(更多信息参见 See Tips for Documentation Strings in The GNU Emacs Lisp Reference Manual。)
最后,beginning-of-buffer 命令的最后一行表示:若命令带参数调用,则将光标移至下一行开头:
(if (and arg (not (consp arg))) (forward-line 1))
这会将光标置于缓冲区对应十分之几位置的下一行行首。这一细节处理确保光标 至少 位于缓冲区目标比例位置之后,属于锦上添花的设计——虽非必需,但缺少则很可能引发用户抱怨。((not (consp arg)) 部分用于避免:仅使用 C-u 而不带数字时,即原始前缀参数只是一个序对节点时,命令不会将光标跳至第二行开头。)
本章涉及的部分主题简要总结如下。
or依次计算每个参数,返回第一个非 nil 的参数值;若所有参数均为 nil,则返回 nil。简言之,返回参数中第一个真值;只要任一参数为真,就返回真值。
and依次计算每个参数,若任一参数为 nil,则返回 nil;若全部非 nil,返回最后一个参数的值。简言之,仅当所有参数均为真时返回真值。
&optional用于标记函数定义中参数为可选的关键字;表示函数可在不传入该参数的情况下执行。
prefix-numeric-value将 (interactive "P") 生成的原始前缀参数转换为数值。
forward-line将光标移至下一行开头;若参数大于 1,则向后移动对应行数。若无法移动到指定位置,forward-line 会尽可能前移,并返回未能完成移动的剩余行数。
erase-buffer删除当前缓冲区全部内容。
bufferp若参数为缓冲区对象则返回 t,否则返回 nil。
缩窄是 Emacs 的一项功能,可让你专注于缓冲区的特定部分,避免误修改其他区域。缩窄功能默认禁用,因为可能使新手感到困惑。
启用缩窄后,缓冲区其余部分会被隐藏,仿佛不存在一样。这一特性很实用,例如你只想替换缓冲区某一部分中的某个单词,而不影响其他部分:只需缩窄到目标区域,替换操作就只会在该范围内执行。搜索也只会在缩窄区域内生效,因此在修复文档某部分时,可通过缩窄避免误搜到无关内容。
(narrow-to-region 的按键绑定为 C-x n n。)
不过,缩窄会隐藏缓冲区其余内容,若用户误触发缩窄,可能误以为文件部分内容被删除。此外,undo 命令(通常绑定为 C-x u)不会关闭缩窄(也不应关闭),因此不了解 widen 命令可恢复显示全部缓冲区的用户,常会陷入慌乱。
(widen 的按键绑定为 C-x n w。)
缩窄对 Lisp 解释器和人类用户同样有用。很多 Emacs Lisp 函数只需要处理缓冲区的一部分;反之,有些函数则需要处理已缩窄缓冲区的全部内容。例如 what-line 函数会先取消缓冲区缩窄(如果存在),执行完成后再恢复原有缩窄状态。而 count-lines 函数则会利用缩窄限制操作范围,之后再恢复原状。
save-restriction 特殊形式 ¶在 Emacs Lisp 中,可使用 save-restriction 特殊形式记录当前生效的缩窄状态。当 Lisp 解释器遇到 save-restriction 时,会执行其主体内的代码,之后撤销代码对缩窄状态造成的任何修改。例如缓冲区已缩窄,而 save-restriction 后的代码取消了缩窄,save-restriction 会在之后恢复原有缩窄区域。在 what-line 命令中,save-restriction 之后紧跟的 widen 命令会取消缓冲区缩窄,而原始缩窄状态会在函数结束前恢复。
save-restriction 表达式的模板很简单:
(save-restriction body... )
save-restriction 的主体是一个或多个表达式,由 Lisp 解释器依次执行。
最后需要注意:若同时使用 save-excursion 和 save-restriction,应将 save-excursion 放在外层。若顺序相反,可能无法记录调用 save-excursion 后 Emacs 切换到的缓冲区的缩窄状态。因此,两者一起使用时应如下编写:
(save-excursion
(save-restriction
body...))
在其他不一起使用的场景中,save-excursion 与 save-restriction 需根据函数需求确定顺序。
For example,
(save-restriction
(widen)
(save-excursion
body...))
what-line ¶what-line 命令用于显示光标所在行的行号。该函数展示了 save-restriction 与 save-excursion 的用法。函数原始代码如下:
(defun what-line ()
"Print the current line number (in the buffer) of point."
(interactive)
(save-restriction
(widen)
(save-excursion
(beginning-of-line)
(message "Line %d"
(1+ (count-lines 1 (point)))))))
(在新版 GNU Emacs 中,what-line 函数已扩展,可同时显示缩窄后与扩宽后的行号。新版比此处示例更复杂。若你愿意,可在理解该版本后查看新版实现,大概率需要使用 C-h f(describe-function)。新版使用条件判断缓冲区是否缩窄。
另外,新版 what-line 使用了 line-number-at-pos,其中除 (goto-char (point-min)) 等简单表达式外,还使用 (forward-line 0) 而非 beginning-of-line 将光标移至当前行开头。)
此处展示的 what-line 函数包含文档字符串且为交互式,符合预期。接下来两行使用了 save-restriction 与 widen。
save-restriction 特殊形式会记录当前缓冲区的缩窄状态(如果有),并在主体代码执行完毕后恢复该状态。
save-restriction 之后是 widen。该函数会取消调用 what-line 时缓冲区可能存在的缩窄。(原有缩窄状态由 save-restriction 保存。)取消缩窄后,行统计命令可从缓冲区开头计数,否则只会在可访问区域内统计。函数结束前,save-restriction 会恢复原始缩窄状态。
widen 调用之后是 save-excursion,它会保存光标位置,并在主体代码使用 beginning-of-line 移动光标后恢复原位。
(注意 (widen) 表达式位于 save-restriction 与 save-excursion 之间。连续编写两个 save-… 表达式时,应将 save-excursion 放在外层。)
what-line 函数最后两行用于统计缓冲区行数,并在回显区显示行号。
(message "Line %d"
(1+ (count-lines 1 (point)))))))
message 函数在 Emacs 屏幕底部显示单行消息。第一个参数位于引号内,会作为字符串直接打印。其中可包含 ‘%d’ 表达式,用于打印后续参数。‘%d’ 以十进制形式输出参数,因此最终会显示类似 ‘Line 243’ 的内容。
替换 ‘%d’ 打印的数值由函数最后一行计算:
(1+ (count-lines 1 (point)))
该代码从缓冲区起始位置(由 1 表示)统计到 (point) 处的行数,然后加一。(1+ 函数会给参数加一。)加一的原因是:第二行之前只有一行,而 count-lines 只统计当前行 之前 的行数。
count-lines 执行完毕、回显区显示消息后,save-excursion 恢复光标位置,save-restriction 恢复原始缩窄状态(如果有)。
编写一个函数,显示当前缓冲区前 60 个字符,即使你已将缓冲区缩窄到后半部分导致首行不可访问。执行后恢复光标、标记与缩窄状态。本练习需要综合使用多个函数,包括 save-restriction、widen、goto-char、point-min、message 与 buffer-substring。
(buffer-substring 是之前未提及的函数,需要自行查阅;也可使用 buffer-substring-no-properties 或 filter-buffer-substring 等其他函数。文本属性是本章未涉及的特性,参见 See Text Properties in The GNU Emacs Lisp Reference Manual。)
此外,思考:是否真的需要 goto-char 或 point-min?能否不使用它们实现该函数?
car、cdr、cons:基础函数 ¶在 Lisp 中,car、cdr 与 cons 是基础函数。cons 用于构造列表,car 与 cdr 用于拆解列表。
在逐步分析 copy-region-as-kill 函数时,我们会看到 cons 以及两个 cdr 变体:setcdr 与 nthcdr。(参见 See copy-region-as-kill。)
cons 函数的名称其实很合理:它是单词 “construct((构造))”的缩写。而 car 和 cdr 的名称来源则比较晦涩:car 是短语 “Contents of the Address part of the Register(寄存器地址部分内容)”的首字母缩写;cdr(读作 “could-er”)则是 “Contents of the Decrement part of the Register(寄存器减量部分内容)”的首字母缩写。这些说法都源自最早开发 Lisp 时所使用的 IBM 704 计算机。
IBM 704 如今只是计算机史上的一个注脚,但这些名称却成了 Lisp 深受喜爱的传统。
car 与 cdr ¶一个列表的 CAR,简单来说就是列表中的第一个元素。因此列表 (rose violet daisy buttercup) 的 CAR 是 rose。
如果你正在 GNU Emacs 的 Info 中阅读本文,可以通过执行下面的表达式看到结果:
(car '(rose violet daisy buttercup))
执行该表达式后,rose 会出现在回显区。
car 并不会从列表中移除第一个元素,它只是返回该元素的值。对一个列表使用 car 之后,列表本身保持不变。用行话来说,car 是“非破坏性(non-destructive)”的。这一特性非常重要。
一个列表的 CDR 是列表除第一个元素外的剩余部分,也就是说,cdr 函数返回列表中紧跟在第一个元素之后的部分。因此,列表 '(rose violet daisy buttercup) 的 CAR 是 rose,而由 cdr 函数返回的列表剩余部分则是 (violet daisy buttercup)。
你可以照常执行下面的表达式来验证:
(cdr '(rose violet daisy buttercup))
执行后,回显区会显示 (violet daisy buttercup)。
和 car 一样,cdr 不会从列表中移除任何元素——它只返回第二个及后续元素组成的列表。
顺便一提,本例中的花卉列表被加了引用。如果不加引用,Lisp 解释器会尝试把 rose 当作函数调用,去执行整个列表,而这并不是我们想要的效果。
对于列表操作,first(第一个)和 rest(剩余部分)这两个名称比 car 和 cdr 更易懂。事实上,有些程序员会将 first 和 rest 定义为 car 和 cdr 的别名,然后在代码中使用 first 和 rest。
不过,Lisp 中的列表是通过一种更低层的结构 “序对节点(cons cells)”(see 列表的实现方式)构建的,在这种结构里并没有“第一个”或“剩余部分”的概念,CAR 与 CDR 是对称的。Lisp 不会隐藏序对节点的存在,程序也会将它们用于列表之外的用途。因此,这两个名称有助于提醒程序员:car 和 cdr 本质上是对称的,只是在列表用法上表现得不对称。
当把 car 和 cdr 应用到由符号组成的列表(例如 (pine fir oak maple))时,car 返回的列表元素是不带任何括号的符号 pine,它是列表的第一个元素。而该列表的 CDR 本身仍是一个列表 (fir oak maple),你可以照常执行下面的表达式观察结果:
(car '(pine fir oak maple)) (cdr '(pine fir oak maple))
而在由列表组成的列表中,第一个元素本身就是一个列表。car 会以列表形式返回这个第一个元素。例如,下面的列表包含三个子列表,分别是食肉动物、食草动物和海洋哺乳动物:
(car '((lion tiger cheetah)
(gazelle antelope zebra)
(whale dolphin seal)))
在本例中,列表的第一个元素(即 CAR)是食肉动物列表 (lion tiger cheetah),而列表剩余部分是 ((gazelle antelope zebra) (whale dolphin seal))。
(cdr '((lion tiger cheetah)
(gazelle antelope zebra)
(whale dolphin seal)))
值得再次强调:car 和 cdr 都是非破坏性的 — 也就是说,它们不会修改所操作的列表。这对它们的使用方式至关重要。
另外,在第一章关于原子的讨论中提到过:在 Lisp 里,某些类型的原子(例如数组)可以被拆分成部分,但拆分机制与拆分列表不同。对 Lisp 而言,列表中的原子是不可拆分的。(See Lisp 原子。)car 和 cdr 用于拆分列表,被视为 Lisp 的基础操作。由于它们无法拆分或访问数组的内部组成,数组被看作原子。反过来,另一个基础函数 cons 可以拼接或构造列表,但不能构造数组。(数组由专门的数组函数处理。See Arrays in The GNU Emacs Lisp Reference Manual。)
cons ¶cons 函数用于构造列表,它的作用与 car 和 cdr 相反。例如,可以用 cons 从三元素列表 (fir oak maple) 构造出一个四元素列表:
(cons 'pine '(fir oak maple))
执行该表达式后,你会在回显区看到:
(pine fir oak maple)
cons 会创建一个新列表,新列表以指定元素开头,后跟原列表的所有元素。
我们常说 cons 将一个新元素放在列表开头,或者将元素附加、压入列表,但这种说法可能造成误解:cons 并不会修改已有列表,而是创建一个新列表。
和 car、cdr 一样,cons 也是非破坏性的。
cons 必须依附于一个已有的列表才能操作。14 你不能从完全空无一物开始构造。如果要构建列表,至少需要以空列表作为起点。下面是一系列使用 cons 逐步构造花卉列表的表达式。如果你正在 GNU Emacs 的 Info 中阅读本文,可以照常逐个执行;执行结果会在 ‘⇒’ 后显示,可理解为 “求值结果为(evaluates to)”。
(cons 'buttercup ())
⇒ (buttercup)
(cons 'daisy '(buttercup))
⇒ (daisy buttercup)
(cons 'violet '(daisy buttercup))
⇒ (violet daisy buttercup)
(cons 'rose '(violet daisy buttercup))
⇒ (rose violet daisy buttercup)
第一个例子中,空列表写作 (),构造出的列表由 buttercup 后跟空列表组成。可以看到,构造出的列表中并不会显示空列表,只会看到 (buttercup)。空列表不计入列表元素,因为它内部没有任何内容。一般来说,空列表是不可见的。
第二个例子 (cons 'daisy '(buttercup)) 将 daisy 放在 buttercup 前面,构造出一个双元素列表;第三个例子则将 violet 放在前面,构造出三元素列表。
length ¶可以使用 Lisp 函数 length 获取列表中的元素个数,示例如下:
(length '(buttercup))
⇒ 1
(length '(daisy buttercup))
⇒ 2
(length (cons 'violet '(daisy buttercup)))
⇒ 3
第三个例子中,cons 先构造出一个三元素列表,再将其作为参数传给 length 函数。
我们也可以用 length 统计空列表的元素个数:
(length ())
⇒ 0
正如预期,空列表的元素个数为 0。
一个有趣的尝试是:尝试不对任何列表求长度,也就是调用 length 时不提供任何参数,连空列表都不传:
(length )
执行后会看到如下错误信息:
Lisp error: (wrong-number-of-arguments length 0)
这表示函数收到的参数个数错误:本应接收指定个数的参数,实际却收到 0 个。在本例中,length 期望接收一个参数,即需要测量长度的列表。(注意:一个列表就算包含很多元素,也仍然只是一个参数。)
错误信息中的 ‘length’ 是出错函数的名称。
nthcdr ¶nthcdr 函数与 cdr 相关,它的作用是对列表重复执行 CDR 操作。
对列表 (pine fir oak maple) 执行 CDR,会返回列表 (fir oak maple)。对返回结果再次执行 CDR,会返回 (oak maple)。(当然,对原列表重复执行 CDR 只会得到原列表的 CDR,因为函数不会修改列表。需要对 CDR 的结果再执行 CDR,依此类推。)继续执行下去,最终会返回空列表,在本例中不会显示为 (),而是显示为 nil。
作为回顾,下面是一系列重复执行 CDR 的示例,‘⇒’ 后为返回结果:
(cdr '(pine fir oak maple))
⇒ (fir oak maple)
(cdr '(fir oak maple))
⇒ (oak maple)
(cdr '(oak maple))
⇒ (maple)
(cdr '(maple))
⇒ nil
(cdr 'nil)
⇒ nil
(cdr ())
⇒ nil
你也可以连续执行多次 CDR 而不打印中间结果,例如:
(cdr (cdr '(pine fir oak maple)))
⇒ (oak maple)
在本例中,Lisp 解释器先求值最内层列表。该列表被引用,因此直接原样传给内层的 cdr。这个 cdr 将原列表第二个及后续元素组成的列表传给外层 cdr,后者再返回原列表第三个及后续元素组成的列表。本例中 cdr 被重复执行,返回的列表去掉了原列表的前两个元素。
nthcdr 函数的效果等同于重复调用 cdr。下面的例子中,数字 2 和列表一起传给 nthcdr,返回的列表去掉了前两个元素,与对列表执行两次 cdr 的结果完全相同:
(nthcdr 2 '(pine fir oak maple))
⇒ (oak maple)
使用最初的四元素列表,我们可以观察向 nthcdr 传入不同数字参数(包括 0、1 和 5)的效果:
;; 保持列表不变
(nthcdr 0 '(pine fir oak maple))
⇒ (pine fir oak maple)
;; 返回去掉第一个元素的列表副本
(nthcdr 1 '(pine fir oak maple))
⇒ (fir oak maple)
;; 返回去掉前三个元素的列表副本
(nthcdr 3 '(pine fir oak maple))
⇒ (maple)
;; 返回去掉全部四个元素的结果
(nthcdr 4 '(pine fir oak maple))
⇒ nil
;; Return a copy lacking all elements.
(nthcdr 5 '(pine fir oak maple))
⇒ nil
nth ¶nthcdr 函数对列表重复执行 CDR。而 nth 函数会对 nthcdr 返回的结果再执行 CAR,它返回列表的第 N 个元素。
因此,如果不是为了效率用 C 语言重新实现,nth 的定义大致如下:
(defun nth (n list) "返回列表 LIST 的第 N 个元素。 N 从 0 开始计数。若列表长度不足,返回 nil。" (car (nthcdr n list)))
(最初 nth 是在 Emacs Lisp 的 subr.el 中定义的,后来在 1980 年代改用 C 语言重新实现。)
nth 函数返回列表中的单个元素,使用起来非常方便。
注意元素编号从 0 开始,而非从 1 开始。也就是说,列表的第一个元素(CAR)是第 0 个元素。这种从 0 开始的计数方式,常常让习惯从 1 开始计数的人感到不适。
例如:
(nth 0 '("one" "two" "three"))
⇒ "one"
(nth 1 '("one" "two" "three"))
⇒ "two"
值得一提的是,nth 与 nthcdr、cdr 一样,不会修改原列表——该函数是非破坏性的。这与 setcar 和 setcdr 函数形成鲜明对比。
setcar ¶从名称不难猜到,setcar 和 setcdr 函数用于将列表的 CAR 或 CDR 设置为新值。它们会真正修改原列表,这与 car 和 cdr 保留原列表不变的行为不同。想要理解其工作方式,最好的方法是动手实验。我们先从 setcar 开始。
首先,可以创建一个列表,并用 setq 特殊形式将其赋值给变量。因为我们打算用 setcar 修改这个列表,所以这个 setq 不应使用引用形式 '(antelope giraffe lion tiger),否则得到的列表会是程序的一部分,运行时尝试修改程序片段可能导致异常。通常来说,Emacs Lisp 程序的组成部分在运行时应是常量(不可修改)。因此我们改用 list 函数构造动物列表,写法如下:
(setq animals (list 'antelope 'giraffe 'lion 'tiger))
如果你正在 GNU Emacs 的 Info 中阅读本文,可以照常执行该表达式:将光标移到表达式末尾并按 C-x C-e。(我写这段内容时也正在这样操作。这也是将解释器内置在运行环境中的优势之一。顺便一提,如果最后一个括号后面没有注释等内容,光标可以放在下一行。也就是说,即使光标位于下一行的第一列,也无需移动。实际上,Emacs 允许最后一个括号后有任意数量的空白字符。)
当我们对变量 animals 求值时,可以看到它绑定到列表 (antelope giraffe lion tiger):
animals
⇒ (antelope giraffe lion tiger)
换句话说,变量 animals 指向列表 (antelope giraffe lion tiger)。
接下来,执行 setcar 函数并传入两个参数:变量 animals 和被引用的符号 hippopotamus;只需写出三元素列表 (setcar animals 'hippopotamus) 并照常执行即可:
(setcar animals 'hippopotamus)
执行该表达式后,再次对变量 animals 求值,你会发现动物列表已经改变:
animals
⇒ (hippopotamus giraffe lion tiger)
列表的第一个元素 antelope 被替换成了 hippopotamus。
由此可见,setcar 并不会像 cons 那样向列表添加新元素,而是将 antelope 替换为 hippopotamus — 它 修改 了列表。
setcdr ¶setcdr 函数与 setcar 函数类似,区别在于它替换列表的第二个及后续元素,而非第一个元素。
(若想了解如何修改列表的最后一个元素,可提前参阅 The kill-new function,该函数同时使用了 nthcdr 和 setcdr。)
要观察其工作方式,先执行下面的表达式,将变量设为家养动物列表:
(setq domesticated-animals (list 'horse 'cow 'sheep 'goat))
现在对该列表求值,会返回列表 (horse cow sheep goat):
domesticated-animals
⇒ (horse cow sheep goat)
接下来,对 setcdr 传入两个参数:值为列表的变量名,以及要设置为该列表 CDR 的新列表:
(setcdr domesticated-animals '(cat dog))
执行该表达式后,回显区会出现列表 (cat dog),这是函数的返回值。我们真正关心的是它的副作用,再次对变量 domesticated-animals 求值即可看到:
domesticated-animals
⇒ (horse cat dog)
可以看到,列表从 (horse cow sheep goat) 被修改为 (horse cat dog)。列表的 CDR 从 (cow sheep goat) 变为了 (cat dog)。
通过多次使用 cons 求值,构造一个包含四种鸟类的列表。观察将一个列表 cons 到自身上会发生什么。将该鸟类列表的第一个元素替换为一种鱼,再将列表剩余部分替换为其他鱼类组成的列表。
在 GNU Emacs 中,每当你使用 删除(kill) 命令从缓冲区剪切或截取文本时,文本都会被存入一个列表,之后可以通过 回拉(yank) 命令恢复。
(Emacs 中用 “kill”一词表示这类 并不会 真正销毁内容的操作,是一个不太恰当的历史遗留问题。更合适的词应该是 “clip(截取)”,因为删除命令的实际行为就是从缓冲区取出文本并存入可恢复的存储空间。我曾多次想把 Emacs 源码中所有 “kill” 全局替换为“clip”,所有“killed”替换为“clipped”。)
文本从缓冲区被剪切后,会存储在一个列表上。连续剪切的多段文本会依次加入列表,列表大致形如:
("a piece of text" "previous piece")
可以用 cons 函数,将一段文本(用行话说是一个 “原子(atom)”)与已有列表构造成新列表:
(cons "another piece"
'("a piece of text" "previous piece"))
执行该表达式后,回显区会出现一个三元素列表:
("another piece" "a piece of text" "previous piece")
配合 car 和 nthcdr 函数,可以取出任意一段文本。例如下面的代码中,nthcdr 1 … 返回去掉第一个元素后的列表,再用 car 取出该剩余部分的第一个元素,即原列表的第二个元素:
(car (nthcdr 1 '("another piece"
"a piece of text"
"previous piece")))
⇒ "a piece of text"
当然,Emacs 中的实际函数要更复杂。剪切与取回文本的代码需要让 Emacs 识别你想要列表中的第几个元素。此外,当遍历到列表末尾时,Emacs 应该回到列表开头,而不是返回空值。
存储这些文本片段的列表称为 删除环(kill ring)。本章会先逐步分析 zap-to-char 函数的工作原理,再讲解删除环及其用法。该函数会调用另一函数,进而调用操作删除环的函数。可以说,我们先登小山,再攀高峰。
后续章节会介绍如何取回从缓冲区剪切的文本。See Yanking Text Back。
zap-to-char ¶我们来看交互式函数 zap-to-char。
zap-to-char 完整实现 ¶zap-to-char 函数会删除光标(即 point)位置到下一个指定字符(含该字符)之间的文本。被删除的文本会放入删除环,可通过 C-y(yank)恢复。如果命令带参数,会删除到对应次数的字符位置。例如光标在本句开头,指定字符为 ‘s’,则 ‘Thus’ 会被删除;若参数为 2,则会删除到 ‘cursor’ 中的 ‘s’ 为止,即 ‘Thus, if the curs’。
如果未找到指定字符,zap-to-char 会提示“Search failed”并显示你输入的字符,不会删除任何文本。
为确定删除范围,zap-to-char 使用了搜索函数。搜索在文本处理代码中应用广泛,我们会重点讲解搜索与删除命令。
以下是 22 版中该函数的完整代码:
(defun zap-to-char (arg char)
"删除至第 ARG 次出现的 CHAR 字符(含该字符)。
若当前缓冲区中 `case-fold-search' 非空,则忽略大小写。
ARG 为负时反向搜索;未找到 CHAR 则报错。"
(if (char-table-p translation-table-for-input)
(setq char (or (aref translation-table-for-input char) char)))
(kill-region (point) (progn
(search-forward (char-to-string char)
nil nil arg)
(point))))
文档说明十分详尽,你只需要理解“kill”一词在专业语境下的含义即可。
22 版中 zap-to-char 的文档字符串使用 ASCII 反引号和单引号引用符号,显示为 `case-fold-search'。这种引用方式源于 1970 年代的显示设备,当时反引号和单引号常互为镜像,适合作为引号。在现代大多数显示设备上已不再如此,因此当这两个 ASCII 字符出现在文档字符串或诊断信息中时,Emacs 通常会将其转换为 弯引号(curved quotes)(左右单引号),上述被引用符号会显示为 ‘case-fold-search’。源码字符串也可以直接使用弯引号。
interactive 表达式 ¶zap-to-char 中的交互式表达式如下:
(interactive "p\ncZap to char: ")
引号内的部分 "p\ncZap to char: " 指定了两项内容。第一项最简单,是 ‘p’,与下一部分用换行符 ‘\n’ 分隔。‘p’ 表示函数的第一个参数将接收 处理后的前缀参数(processed prefix) 值。前缀参数可通过 C-u 加数字或 M- 加数字传入;若交互式调用时未带前缀,则该参数为 1。
"p\ncZap to char: " 的第二部分是 ‘cZap to char: ’。其中小写字母 ‘c’ 表示 interactive 需要显示提示,且参数为一个字符。提示文本紧跟在 ‘c’ 之后,即字符串 ‘Zap to char: ’(冒号后加空格以美观显示)。
这些设置的作用是为 zap-to-char 准备正确类型的参数,并向用户显示提示信息。
在只读缓冲区中,zap-to-char 会将文本复制到删除环,但不会真正删除。回显区会提示缓冲区为只读,终端可能同时发出蜂鸣或闪烁。
zap-to-char 函数体 ¶zap-to-char 的函数体包含删除光标当前位置到指定字符(含该字符)之间文本的代码。
代码第一部分如下:
(if (char-table-p translation-table-for-input)
(setq char (or (aref translation-table-for-input char) char)))
(kill-region (point) (progn
(search-forward (char-to-string char) nil nil arg)
(point)))
char-table-p 是之前未见过的函数,用于判断参数是否为字符表。如果是,则将传入 zap-to-char 的字符替换为字符表中对应的字符(若存在),否则保持原字符不变。(这对非欧洲语言的某些字符很重要。aref 函数从数组中提取元素,是数组专用函数,本文档不做详细介绍。See Arrays in The GNU Emacs Lisp Reference Manual。)
(point) 表示光标当前位置。
代码下一部分使用了 progn 表达式,其主体包含对 search-forward 和 point 的调用。
先理解 search-forward 更容易掌握 progn 的用法,因此我们先讲解 search-forward,再介绍 progn。
search-forward 函数 ¶search-forward 函数用于在 zap-to-char 中定位待删除的目标字符。如果搜索成功,search-forward 会将光标定位到目标字符串最后一个字符的紧后方。(在 zap-to-char 中,目标字符串仅有一个字符长度。zap-to-char 使用 char-to-string 函数确保计算机将该字符按字符串处理。)如果是反向搜索,search-forward 会将光标置于目标首个字符的紧前方。此外,search-forward 成功时返回 t 表示真。(因此移动光标是该函数的一个副作用。)
在 zap-to-char 中,search-forward 函数的调用形式如下:
(search-forward (char-to-string char) nil nil arg)
search-forward 函数接收四个参数:
实际传入 zap-to-char 的参数是单个字符。由于计算机的底层设计,Lisp 解释器可能会将单个字符与字符字符串视为不同类型。在计算机内部,单个字符与长度为一的字符串具有不同的电子存储格式。(单个字符通常仅需一个字节即可存储;而字符串可能更长,计算机需要为此做好准备。)由于 search-forward 针对字符串进行搜索,因此 zap-to-char 接收的字符参数必须在计算机内部完成格式转换,否则 search-forward 将会执行失败。char-to-string 函数即用于完成这一转换。
nil。
nil。若第三个参数为 nil,则函数会在搜索失败时抛出错误。
search-forward 的第四个参数是重复次数,即需要查找目标字符串的出现次数。该参数为可选参数,若调用时未指定重复次数,则默认传入值为 1。若该参数为负数,则执行反向搜索。
模板形式下,search-forward 表达式结构如下:
(search-forward "target-string"
limit-of-search
what-to-do-if-search-fails
repeat-count)
接下来我们介绍 progn。
progn 特殊形式 ¶progn 是一种特殊形式,它会按顺序依次求值其每一个参数,
并返回最后一个参数的值。前面的表达式仅为执行它们产生的副作用而求值,
这些表达式生成的值会被直接丢弃。
progn 表达式的模板非常简单:
(progn body...)
在 zap-to-char 中,progn 表达式需要完成两件事:
将光标精确定位到正确位置;并返回光标位置,
以便 kill-region 知道需要删除到何处为止。
progn 的第一个参数是 search-forward。
当 search-forward 找到目标字符串时,该函数会将光标置于
目标字符串最后一个字符的紧后方。(本例中目标字符串仅有一个字符长度。)
如果是反向搜索,search-forward 会将光标置于目标字符串
第一个字符的紧前方。光标的移动属于副作用。
progn 的第二个也是最后一个参数是表达式 (point)。
该表达式会返回当前光标位置的值,在本例中即为被 search-forward
移动后的光标位置。(在源码中,有一行用于让函数在向前搜索时回退到前一个字符的代码
已于 1999 年被注释;我已不记得这一功能或缺陷是否曾出现在发布版源码中。)
progn 表达式会返回 point 的值,并将其作为
kill-region 的第二个参数传入。
zap-to-char 总结 ¶现在我们已经了解了 search-forward 与 progn 的工作方式,
就可以完整理解 zap-to-char 函数的整体运行逻辑。
kill-region 的第一个参数是执行 zap-to-char 命令时
的光标初始位置,也就是当时的光标值。在 progn 内部,
搜索函数会将光标移动到目标字符的紧后方,再由 point 返回该位置。
kill-region 函数会使用这两个光标位置,前者作为区域起点,
后者作为区域终点,并将该区域文本删除。
progn 特殊形式是必需的,因为 kill-region 命令只接受两个参数;
如果直接将 search-forward 与 point 表达式依次作为额外参数传入,
函数将无法正常工作。progn 表达式整体作为 kill-region
的单个参数,并返回其第二个参数所需的唯一值。
kill-region ¶zap-to-char 函数内部使用了 kill-region 函数。
该函数会从指定区域剪切文本,并将文本复制到剪切环中,
后续可从中取回文本。
Emacs 22 版本中的该函数使用了 condition-case 和
copy-region-as-kill,这两者我们都会进行讲解。
condition-case 是一种重要的特殊形式。
本质上,kill-region 函数会调用
condition-case,它接收三个参数。在本函数中,
第一个参数不执行任何操作。第二个参数包含
一切正常时执行实际工作的代码。第三个参数包含
发生错误时会被调用的代码。
kill-region 的完整定义 ¶我们稍后会详细分析 condition-case 代码。首先,
让我们来看添加了注释的 kill-region 定义:
(defun kill-region (beg end) "删除(\"剪切cut\")光标与标记之间的文本。 会从缓冲区中删除文本并将其保存到删除环中。 命令 \\[yank] 可从中取回文本。... "
;; • 由于顺序重要,优先传入光标位置。
(interactive (list (point) (mark)))
;; • 无法剪切文本时给出提示。
;; 'unless' 是没有 then 分支的 'if'。
(unless (and beg end)
(error "The mark is not set now, so there is no region"))
;; • 'condition-case' 接收三个参数。 ;; 若第一个参数为 nil(此处即是), ;; 则不会存储错误信号信息供其他函数使用。 (condition-case nil
;; • 'condition-case' 的第二个参数告知
;; Lisp 解释器在一切正常时执行的操作。
;; 以 'let' 函数开头,提取字符串并判断其是否存在。
;; 若存在(即 'when' 所检查的条件),则调用 'if' 函数
;; 判断上一条命令是否同样为 'kill-region';
;; 若是,则将新文本追加到原有文本之后;
;; 若否,则调用另一个函数 'kill-new'。
;; 'kill-append' 函数将新字符串与旧字符串拼接。
;; 'kill-new' 函数将文本插入删除环的新项中。
;; 'when' 是没有 else 分支的 'if'。
;; 第二个 'when' 再次检查当前字符串是否存在;
;; 此外还会检查上一条命令是否同样为 'kill-region'。
;; 若任一条件成立,则将当前命令设为 'kill-region'。
(let ((string (filter-buffer-substring beg end t)))
(when string ;若 BEG = END,则 STRING 为 nil
;; 将该字符串以某种方式添加到删除环。
(if (eq last-command 'kill-region)
;; − 'yank-handler' 是 'kill-region' 的可选参数,
;; 用于告知 'kill-append' 和 'kill-new' 函数
;; 如何处理文本附加的属性,例如粗体或斜体。
;; − 'yank-handler' is an optional argument to
(kill-append string (< end beg) yank-handler)
(kill-new string nil yank-handler)))
(when (or string (eq last-command 'kill-region))
(setq this-command 'kill-region))
nil)
;; • 'condition-case' 的第三个参数告知解释器
;; 出现错误时执行的操作。
;; 第三个参数包含条件部分与主体部分。
;; 若条件满足(此处为
;; 文本或缓冲区为只读)
;; 则执行主体代码。
;; 第三个参数的第一部分如下:
((buffer-read-only text-read-only) ;; 条件部分
;; ... 执行部分
(copy-region-as-kill beg end)
;; 同样作为执行部分,设置 this-command,
;; 确保出错时该变量也会被正确赋值
(setq this-command 'kill-region)
;; 最后,在执行部分中,
;; 若可将文本复制到删除环且不触发错误,则给出提示;
;; 否则不提示。
(if kill-read-only-ok
(progn (message "Read only text copied to kill ring") nil)
(barf-if-buffer-read-only)
;; If the buffer isn't read-only, the text is.
(signal 'text-read-only (list (current-buffer)))))))
condition-case ¶正如我们之前所见(see Generate an Error Message),当 Emacs Lisp 解释器无法求值某一表达式时, 会提供相应提示;专业术语称之为“触发错误(signaling an error)”。 通常计算机会中断程序并显示提示信息。
然而,部分程序会执行复杂操作,不应因错误直接停止运行。
在 kill-region 函数中,最常见的错误
是尝试删除只读且无法移除的文本。
因此 kill-region 函数包含处理该情况的代码。
这段构成 kill-region 主体的代码,
被包裹在 condition-case 特殊形式内部。
condition-case 的模板形式如下:
(condition-case var bodyform error-handler...)
第二个参数 bodyform 较为直观。
condition-case 特殊形式会让 Lisp 解释器
对 bodyform 中的代码进行求值。
若未发生错误,该特殊形式会返回代码的执行结果,
并产生相应的副作用(如有)。
简而言之,condition-case 表达式中的
bodyform 部分定义了一切正常时的执行逻辑。
若发生错误,触发错误的函数除其他操作外, 还会定义一个或多个错误条件名称。
错误处理函数是 condition-case 的第三个参数。
每个错误处理函数包含两部分:condition-name 与
body。若错误处理函数的 condition-name
与错误触发的条件名称匹配,则执行该处理函数的 body 部分。
不难理解,错误处理函数的 condition-name 可以是单个条件名称,也可以是条件名称列表。
此外,完整的 condition-case 表达式
可以包含多个错误处理函数。发生错误时,
会执行第一个匹配的处理函数。
最后,condition-case 表达式的第一个参数
var,有时会绑定为存储错误信息的变量。
但若该参数为 nil(如 kill-region 中),
则会直接丢弃错误信息。
简单来说,kill-region 函数中的
condition-case 逻辑如下:
If no errors, run only this code
but, if errors, run this other code.
condition-case 表达式中预期正常执行的部分
包含一个 when 结构。代码通过 when
判断 string 变量是否指向有效文本。
when 表达式只是为编程提供便利,
等价于不包含 else 分支的 if。
你可以直接将 when 替换为 if 来理解逻辑,
Lisp 解释器也是如此处理的。
严格来说,when 是一个 Lisp 宏。
Lisp 宏允许你定义新的控制结构与其他语言特性,
它告知解释器如何生成另一段 Lisp 表达式,
再由该表达式完成最终求值。此处对应的表达式即为 if。
kill-region 函数定义中还包含 unless 宏,
其逻辑与 when 相反。unless 宏
类似于没有 then 分支的 if,并隐式返回 nil。
如需了解更多 Lisp 宏相关内容,可参考 Macros in The GNU Emacs Lisp Reference Manual。C 语言同样提供宏, 二者实现不同,但都十分实用。
关于 when 宏,在 condition-case 表达式中,
若字符串包含内容,则会执行另一个条件表达式,
这是一个同时包含 then 分支与 else 分支的 if。
(if (eq last-command 'kill-region)
(kill-append string (< end beg) yank-handler)
(kill-new string nil yank-handler))
若上一条命令同样为 kill-region,
则执行 then 分支;否则执行 else 分支。
yank-handler 是 kill-region 的可选参数,
用于告知 kill-append 和 kill-new 函数
如何处理文本附加的属性,例如粗体或斜体。
last-command 是 Emacs 内置变量,我们此前尚未接触。
通常每当函数执行时,Emacs 都会将 last-command
的值设为上一条执行的命令。
在该段定义中,if 表达式判断
上一条命令是否为 kill-region。若是,
(kill-append string (< end beg) yank-handler)
会将新剪切的文本副本追加到删除环中上一次剪切的文本之后。
copy-region-as-kill ¶copy-region-as-kill 函数会从缓冲区中复制一段文本,
并(通过 kill-append 或 kill-new)将其保存到
kill-ring(删除环)中。
如果你在执行 kill-region 命令后立即调用
copy-region-as-kill,Emacs 会将新复制的文本追加到
之前复制的文本之后。这意味着你取回文本时,
本次与上一次操作的内容会一并返回。
反之,如果 copy-region-as-kill 之前是其他命令,
该函数会将文本作为独立条目存入删除环。
copy-region-as-kill 完整函数定义 ¶以下是 Emacs 22 版本中 copy-region-as-kill
的完整代码:
(defun copy-region-as-kill (beg end) "将选区按删除操作的方式保存,但并不真正删除文本。 在临时标记模式下,取消标记激活状态。 若 `interprogram-cut-function' 非 nil,同时为窗口系统的剪切粘贴 保存该文本。" (interactive "r")
(if (eq last-command 'kill-region)
(kill-append (filter-buffer-substring beg end) (< end beg))
(kill-new (filter-buffer-substring beg end)))
(if transient-mark-mode
(setq deactivate-mark t))
nil)
和往常一样,该函数可拆分为几个组成部分:
(defun copy-region-as-kill (argument-list) "documentation..." (interactive "r") body...)
参数为 beg 和 end,且函数通过
"r" 声明为交互式,因此这两个参数必定代表选区的
起始与结束位置。如果你从头阅读本文档,
对函数的这些结构应该已经十分熟悉。
文档字符串乍看有些令人困惑,
除非你记得“kill”在这里的含义与日常不同。
关于临时标记模式与 interprogram-cut-function
的说明解释了相关的副作用。
一旦设置过标记,缓冲区就始终存在一个选区。 你可以开启临时标记模式,让选区临时高亮显示。 (没人希望选区一直高亮,因此临时标记模式只在合适时机高亮。 很多人会关闭临时标记模式,让选区永远不高亮。)
此外,窗口系统允许在不同程序间复制、剪切和粘贴。
例如在 X 窗口系统中,interprogram-cut-function
对应 x-select-text,
它与窗口系统中相当于 Emacs 删除环的机制协同工作。
copy-region-as-kill 的函数体以一个
if 语句开头。该语句用于区分两种情况:
本命令是否紧跟在 kill-region 之后执行。
若是,则新选区追加到之前复制的文本后;
否则,作为独立文本插入删除环开头。
函数最后两行用于在开启临时标记模式时 避免选区持续高亮。
copy-region-as-kill 的函数体值得详细讲解。
copy-region-as-kill 的函数体 ¶copy-region-as-kill 的工作方式与
kill-region 非常相似。两者都设计为:
连续多次删除操作会将文本合并为一个条目。
从删除环取回文本时,会一次性得到全部内容。
此外,从光标位置向前删除的内容会追加到已有文本末尾,
向后复制的内容则添加到已有文本前方,
以此保证文本语序正确。
与 kill-region 一样,
copy-region-as-kill 也使用
last-command 变量来记录上一条 Emacs 命令。
last-command 与 this-command ¶通常,每当函数执行时,Emacs 都会将
this-command 设置为当前执行的函数
(此处即为 copy-region-as-kill)。
同时,Emacs 会将 last-command
设置为 this-command 之前的值。
在 copy-region-as-kill 函数体开头,
一个 if 表达式判断 last-command
是否为 kill-region。若是,
则执行 if 的 then 分支:调用 kill-append
将本次复制的文本与删除环第一个元素(CAR)
中的已有文本拼接。反之,若 last-command
不是 kill-region,则 copy-region-as-kill
通过 kill-new 向删除环添加一个新元素。
该 if 表达式如下,使用了 eq:
(if (eq last-command 'kill-region)
;; then-part
(kill-append (filter-buffer-substring beg end) (< end beg))
;; else-part
(kill-new (filter-buffer-substring beg end)))
(filter-buffer-substring 函数会返回缓冲区中
经过过滤的子串(如有)。该函数可选地删除原文本
或返回不带属性的文本,但此处未使用相关参数,因此均不执行。
该函数用于替代较早的 buffer-substring,
后者出现于文本属性功能之前。)
eq 函数用于判断两个参数是否为同一个 Lisp 对象。
eq 与 equal 类似,都用于判断相等,
但区别在于:eq 判断两个表示是否为计算机内
完全相同的对象(仅名称不同);
而 equal 判断两个表达式的结构与内容是否相同。
若上一条命令是 kill-region,
Emacs Lisp 解释器就会调用 kill-append 函数。
kill-append 函数 ¶kill-append 函数定义如下:
(defun kill-append (string before-p &optional yank-handler)
"将 STRING 追加到删除环最新删除内容的末尾。
若 BEFORE-P 非 nil,则将 STRING 添加到前方。
... "
(let* ((cur (car kill-ring)))
(kill-new (if before-p (concat string cur) (concat cur string))
(or (= (length cur) 0)
(equal yank-handler
(get-text-property 0 'yank-handler cur)))
yank-handler)))
kill-append 的逻辑相当直观。
它使用了 kill-new 函数,我们稍后会详细说明。
(此外,该函数还提供了可选参数 yank-handler;
调用时该参数用于告知函数如何处理
文本附加的属性,如粗体、斜体等。)
它使用 let* 将删除环第一个元素赋值给 cur。
(我不确定此处为何不用 let;
表达式中只设置了一个变量。
这或许是一个不引发问题的小错误?)
看一下作为 kill-new 参数之一的条件表达式:
它使用 concat 将新文本与删除环的 CAR 拼接。
是向前添加还是向后追加,取决于 if 表达式:
(if before-p ; if-part (concat string cur) ; then-part (concat cur string)) ; else-part
如果本次删除的选区位于上一次删除的选区之前,
就应该添加到之前保存的内容前面;
反之,如果本次删除的文本在后方,
就追加到之前文本的后面。
该 if 表达式依靠谓词 before-p
决定新保存的文本放在已有文本之前还是之后。
符号 before-p 是 kill-append
的其中一个参数名。当 kill-append 被求值时,
它会绑定到实际参数的求值结果。
在本例中,该表达式为 (< end beg)。
该表达式并不直接判断本次删除的文本
位于上一次删除文本的前方还是后方,
而是判断变量 end 的值是否小于 beg。
如果成立,通常意味着用户正向缓冲区开头移动。
同时,谓词表达式 (< end beg) 的结果为真,
文本会被添加到之前文本的前面。
反之,如果 end 大于 beg,
文本就会追加到已有文本之后。
当新保存的文本需要向前添加时, 新文本字符串会拼接在旧文本之前:
(concat string cur)
但如果是向后追加,则拼接在旧文本之后:
(concat cur string))
要理解这一机制,我们首先需要回顾
concat 函数。concat
会将两个文本字符串连接为一个字符串。例如:
(concat "abc" "def")
⇒ "abcdef"
(concat "new "
(car '("first element" "second element")))
⇒ "new first element"
(concat (car
'("first element" "second element")) " modified")
⇒ "first element modified"
现在我们可以理解 kill-append:
它修改删除环的内容。删除环是一个列表,
每个元素都是保存的文本。
kill-append 调用 kill-new,
而后者内部又使用了 setcar 函数。
kill-new 函数 ¶在 22 版本中,kill-new 函数的定义如下:
(defun kill-new (string &optional replace yank-handler) "将 STRING 设为删除环中最新的删除内容。 设置 `kill-ring-yank-pointer' 指向该内容。 若 `interprogram-cut-function' 非 nil,将其应用于 STRING。 可选第二个参数 REPLACE 非 nil 表示 STRING 会替换删除环的头部, 而非添加到列表中。 ..."
(if (> (length string) 0)
(if yank-handler
(put-text-property 0 (length string)
'yank-handler yank-handler string))
(if yank-handler
(signal 'args-out-of-range
(list string "yank-handler specified for empty string"))))
(if (fboundp 'menu-bar-update-yank-menu)
(menu-bar-update-yank-menu string (and replace (car kill-ring))))
(if (and replace kill-ring)
(setcar kill-ring string)
(push string kill-ring)
(if (> (length kill-ring) kill-ring-max)
(setcdr (nthcdr (1- kill-ring-max) kill-ring) nil)))
(setq kill-ring-yank-pointer kill-ring)
(if interprogram-cut-function
(funcall interprogram-cut-function string (not replace))))
(注意该函数并非交互式函数。)
和往常一样,我们可以分段查看这个函数。
函数定义中包含可选参数 yank-handler,
调用时该参数用于告知函数如何处理
文本附加的属性,例如粗体或斜体。这部分我们略过不讲。
文档字符串的第一行含义清晰:
将 STRING 设为删除环中最新的删除内容。
我们暂时跳过文档字符串的其余部分。
同时,我们也先跳过开头的 if 表达式
以及涉及 menu-bar-update-yank-menu 的代码行。
我们会在下方进行说明。
关键代码行如下:
(if (and replace kill-ring)
;; then
(setcar kill-ring string)
;; else
(push string kill-ring)
(if (> (length kill-ring) kill-ring-max)
;; avoid overly long kill ring
(setcdr (nthcdr (1- kill-ring-max) kill-ring) nil)))
(setq kill-ring-yank-pointer kill-ring)
(if interprogram-cut-function
(funcall interprogram-cut-function string (not replace))))
条件判断为 (and replace kill-ring)。
当两个条件同时满足时结果为真:删除环不为空,
且 replace 变量为真。
当 kill-append 函数将 replace 设为真,
且删除环中至少有一项内容时,会执行 setcar 表达式:
(setcar kill-ring string)
setcar 函数会将 kill-ring 列表的
第一个元素实际修改为 string 的值,直接替换第一个元素。
反之,如果删除环为空,或者 replace 为假, 则执行条件的 else 分支:
(push string kill-ring)
push 将第一个参数添加到第二个参数的头部。
它与较早的写法类似:
(setq kill-ring (cons string kill-ring))
也与较新的写法类似:
(add-to-list kill-ring string)
条件为假时,表达式首先会将 string
作为新元素添加到现有删除环头部,构造一个新的删除环
(这就是 push 的作用)。
随后执行第二个 if 语句,
用于防止删除环变得过长。
我们按顺序查看这两个表达式。
else 分支中的 push 语句
将待删除字符串添加到旧删除环中,
并将结果设为删除环的新值。
我们可以通过示例理解其工作方式。
首先执行:
(setq example-list '("here is a clause" "another clause"))
使用 C-x C-e 对该表达式求值后,
你可以对 example-list 求值并查看结果:
example-list
⇒ ("here is a clause" "another clause")
(push "a third clause" example-list)
再次对 example-list 求值时,结果为:
example-list
⇒ ("a third clause" "here is a clause" "another clause")
可见 push 将第三个条目添加到了列表头部。
接下来是 if 语句的第二部分。
该表达式用于限制删除环的最大长度,代码如下:
(if (> (length kill-ring) kill-ring-max)
(setcdr (nthcdr (1- kill-ring-max) kill-ring) nil))
代码会检查删除环长度是否超出允许的最大值,
该值由 kill-ring-max 指定(默认为 120)。
如果删除环过长,代码会将其最后一个元素设为 nil。
它通过两个函数实现:nthcdr 和 setcdr。
我们之前已经了解过 setcdr(see setcdr)。
它用于设置列表的 CDR,
就像 setcar 用于设置列表的 CAR。
不过在本例中,setcdr 并非设置整个删除环的 CDR;
nthcdr 函数使其作用于删除环倒数第二个元素的 CDR —
这意味着,由于倒数第二个元素的 CDR 就是删除环的最后一个元素,
该操作会设置删除环的最后一个元素。
nthcdr 函数会反复获取列表的 CDR —
对 CDR 的 CDR 再取 CDR …
重复执行 N 次后返回结果。
(See nthcdr.)
因此,如果我们有一个四元素列表,
但只需要保留三个元素,
就可以将倒数第二个元素的 CDR 设为 nil,
从而缩短列表。
(如果你将最后一个元素设为非 nil 的其他值,
列表并不会被缩短。See setcdr。)
你可以依次对下面三个表达式求值,直观观察列表缩短效果。
首先将 trees 设为 (maple oak pine birch),
然后将其第二个 CDR 的 CDR 设为 nil,
再查看 trees 的值:
(setq trees (list 'maple 'oak 'pine 'birch))
⇒ (maple oak pine birch)
(setcdr (nthcdr 2 trees) nil)
⇒ nil
trees
⇒ (maple oak pine)
(setcdr 表达式返回的值为 nil,
因为这正是被设置的 CDR 内容。)
再重申一次:在 kill-new 中,
nthcdr 会按删除环最大长度减一的次数获取 CDR,
然后 setcdr 将该元素的 CDR
(即删除环中后续的所有元素)设为 nil,
从而避免删除环无限增长。
kill-new 函数中倒数第二个表达式为:
(setq kill-ring-yank-pointer kill-ring)
kill-ring-yank-pointer 是一个全局变量,
被设置为指向 kill-ring。
尽管 kill-ring-yank-pointer 被称为“指针”,
它本质上和删除环一样都是变量。
不过这个命名是为了帮助人类理解其用途。
现在回到函数体开头的表达式:
(if (fboundp 'menu-bar-update-yank-menu)
(menu-bar-update-yank-menu string (and replace (car kill-ring))))
它以一个 if 表达式开头。
本例中,表达式首先检查 menu-bar-update-yank-menu
是否作为函数存在,若存在则调用它。
如果被测试的符号拥有非空的函数定义,
fboundp 函数就会返回真。
如果该符号的函数定义为空,我们就会收到错误提示,
就像之前故意制造错误时一样(see Generate an Error Message)。
then 分支包含一个以 and 函数为首个元素的表达式。
and 特殊形式会依次对每个参数求值,
直到某个参数返回 nil,
此时整个 and 表达式返回 nil;
如果所有参数都不返回 nil,
则返回最后一个参数的求值结果。
(在 Emacs Lisp 中,非 nil 的值均视为真。)
换句话说,and 表达式只有在所有参数都为真时才返回真值。
(See 本章小结.)
该表达式用于确定
传递给 menu-bar-update-yank-menu 的第二个参数是否为真。
menu-bar-update-yank-menu 是支持菜单栏中
“编辑(Edit)”项里 “选择并粘贴(Select and Paste)” 菜单的函数之一;
你可以通过鼠标查看已保存的各段文本,
并选择一段进行粘贴。
kill-new 函数的最后一个表达式
会将新复制的字符串同步到窗口系统中
用于程序间复制粘贴的机制中。
例如在 X 窗口系统中,x-select-text 函数
会接收该字符串并存储到 X 系统管理的内存中,
你可以在 Xterm 等其他程序中粘贴该字符串。
该表达式如下:
(if interprogram-cut-function
(funcall interprogram-cut-function string (not replace))))
如果存在 interprogram-cut-function,
Emacs 就会执行 funcall,
将其第一个参数作为函数调用,
并将剩余参数传递给它。
(顺便一提,在我看来,
这个 if 表达式可以替换为
与函数开头类似的 and 表达式。)
我们不会进一步讨论窗口系统与其他程序, 只需知道这是让 GNU Emacs 能够与其他程序良好协同工作的机制即可。
这段将文本加入删除环的代码(无论是拼接至现有元素还是作为新元素), 将引出用于取回从缓冲区剪切文本的代码 — 即取回命令。 不过在讨论取回命令之前, 最好先了解列表在计算机中的实现方式, 这能澄清 “指针(pointer)” 等术语的内在含义。 但在此之前,我们先穿插介绍一下 C 语言相关内容。
copy-region-as-kill 函数(see copy-region-as-kill)使用了 filter-buffer-substring 函数,
而后者又调用了 delete-and-extract-region 函数。
该函数会移除选区内容,且无法恢复。
与本节讨论的其他代码不同,
delete-and-extract-region 函数并非由 Emacs Lisp 编写,
而是由 C 语言实现,是 GNU Emacs 系统的原语之一。
由于它非常简单,我将暂时脱离 Lisp 主题,在此对其进行说明。
与许多其他 Emacs 原语一样,
delete-and-extract-region 以 C 宏的形式实现,
宏是一段代码模板。完整的宏定义如下:
DEFUN ("delete-and-extract-region", Fdelete_and_extract_region,
Sdelete_and_extract_region, 2, 2, 0,
doc: /* Delete the text between START and END and return it. */)
(Lisp_Object start, Lisp_Object end)
{
validate_region (&start, &end);
if (XFIXNUM (start) == XFIXNUM (end))
return empty_unibyte_string;
return del_range_1 (XFIXNUM (start), XFIXNUM (end), 1, 1);
}
我们不深入宏的编写细节,
只需注意该宏以 DEFUN 开头。
选择 DEFUN 这个名称,
是因为其作用与 Lisp 中的 defun 相同。
(DEFUN 这个 C 宏定义在 emacs/src/lisp.h 中。)
DEFUN 之后的括号内包含七个部分:
delete-and-extract-region。
Fdelete_and_extract_region。
按照惯例,它以 ‘F’ 开头。
由于 C 语言不允许名称中使用连字符,因此改用下划线。
interactive 声明后的参数:
一个字母,可附带提示信息。
与 Lisp 唯一的区别是,当宏不接收参数时,
此处写 0(表示空字符串),本例即是如此。
如果需要指定参数,需将其放在引号内。
例如 goto-char 对应的 C 宏在此处填写
"NGoto char: ",表示该函数接收一个原始前缀参数
(此处为缓冲区中的数值位置),并提供提示信息。
lib-src/make-docfile
会提取这些注释并生成文档。)
在 C 宏中,接下来是形参及其类型声明,
然后是宏的主体。
delete-and-extract-region 的主体包含以下四行:
validate_region (&start, &end); if (XFIXNUM (start) == XFIXNUM (end)) return empty_unibyte_string; return del_range_1 (XFIXNUM (start), XFIXNUM (end), 1, 1);
validate_region 函数会检查
作为选区起始与结束传入的值是否类型正确、范围合法。
如果起始与结束位置相同,则返回空字符串。
del_range_1 函数负责实际删除文本,
它是一个复杂函数,我们不深入探究,
它会更新缓冲区并执行其他操作。
不过值得关注传递给 del_range_1 的两个参数:
XFIXNUM (start) 与 XFIXNUM (end)。
从 C 语言角度来看,start 和 end
是两个标记待删除选区起止位置的不透明值。
更精确地说(需要更专业的知识才能理解),
这两个值的类型为 Lisp_Object,
它可以是 C 指针、C 整数或 C 结构体;
C 代码通常无需关心 Lisp_Object 的具体实现。
Lisp_Object 的宽度取决于机器,
通常为 32 位或 64 位。
其中少数几位用于标识信息类型,
其余位用于存储内容。
‘XFIXNUM’ 是一个 C 宏, 它从较长的位串中提取出对应的整数, 并丢弃类型标识位。
delete-and-extract-region 中的关键语句如下:
del_range_1 (XFIXNUM (start), XFIXNUM (end), 1, 1);
它会删除起始位置 start
与结束位置 end 之间的选区内容。
从编写 Lisp 代码的开发者视角来看,Emacs 非常简单; 但在底层,为了让一切正常运行,隐藏了大量复杂逻辑。
defvar 初始化变量 ¶copy-region-as-kill 函数由 Emacs Lisp 编写。
其中的两个函数 kill-append 和 kill-new
会复制缓冲区中的选区并保存到名为 kill-ring 的变量中。
本节介绍如何使用 defvar 特殊形式
创建并初始化 kill-ring 变量。
(我们再次说明,kill-ring 这个名称其实并不准确。
从缓冲区中剪切的文本可以被取回,
它并非“尸体环”,而是可“复活”的文本环。)
在 Emacs Lisp 中,
像 kill-ring 这样的变量
通过 defvar 特殊形式创建并赋予初始值,
名称取自 “define variable”。
defvar 特殊形式与 setq 类似,
都用于设置变量的值。
它与 setq 有三点不同:
第一,它会将变量标记为 “特殊(special)”,
使其始终采用动态绑定,
即使 lexical-binding 为 t 也是如此
(see let 绑定变量的方式)。
第二,它仅在变量尚无值时才设置其值;
如果变量已有值,defvar 不会覆盖现有值。
第三,defvar 支持文档字符串。
(还有一个相关宏 defcustom,
专为用户需要自定义的变量设计,
功能比 defvar 更丰富。
(See Setting Variables with defcustom.)
你可以使用 describe-variable 函数
查看任意变量的当前值,
该函数通常通过按键 C-h v 调用。
如果你按下 C-h v,
并在提示后输入 kill-ring(再按 RET),
就会看到当前删除环中的内容 — 可能会非常多!
反之,如果你在本次 Emacs 会话中
除了阅读本文档之外没有任何操作,删除环可能为空。
同时,你还会看到 kill-ring 的文档说明:
Documentation: List of killed text sequences. Since the kill ring is supposed to interact nicely with cut-and-paste facilities offered by window systems, use of this variable should
interact nicely with `interprogram-cut-function' and `interprogram-paste-function'. The functions `kill-new', `kill-append', and `current-kill' are supposed to implement this interaction; you may want to use them instead of manipulating the kill ring directly.
删除环通过 defvar 按如下方式定义:
(defvar kill-ring nil "List of killed text sequences. ...")
在该变量定义中,变量的初始值被设为 nil,
这是合理的:如果你未保存任何内容,
执行取回命令时就应该什么都得不到。
文档字符串的写法与 defun 的文档字符串完全相同。
与 defun 的文档字符串一样,
文档的第一行应当是完整句子,
因为 apropos 等命令只会打印第一行文档。
后续行不应缩进,
否则在使用 C-h v(describe-variable)查看时排版会异常。
defvar 与星号 ¶在早期,Emacs 既使用 defvar 特殊形式定义内部变量(用户一般不会修改),
也用它定义用户可配置变量。尽管现在仍然可以用 defvar 定义用户可配置变量,
但建议改用 defcustom,因为它能对接自定义设置命令。
(see Specifying Variables using defcustom.)
在使用 defvar 定义变量时,可以在文档字符串第一列开头写一个星号 ‘*’,
以此区分该变量是用户可能需要修改的配置项。例如:
(defvar shell-command-default-error-buffer nil "*`shell-command' 命令错误输出所用的缓冲区名称。 ... ")
你可以(现在依然可以)使用 set-variable 命令临时修改
shell-command-default-error-buffer 的值。
不过,通过 set-variable 设置的选项只在当前编辑会话生效,
重启 Emacs 后不会保留。每次 Emacs 启动都会读取原始值,
除非你在 .emacs 文件中手动设置或通过 customize 进行修改。
See Your .emacs File.
对我而言,set-variable 命令的主要用途是提示我有哪些变量
可以写进 .emacs 配置。这类变量如今已有 700 多个,
很难全部记住。好在执行 M-x set-variable 后按 TAB,
就能看到变量列表。(See Examining and Setting Variables in The GNU Emacs Manual.)
这里简要总结一下最近介绍的一些函数。
carcdrcar 返回列表的第一个元素;cdr 返回列表中第二个及后续所有元素。
例如:
(car '(1 2 3 4 5 6 7))
⇒ 1
(cdr '(1 2 3 4 5 6 7))
⇒ (2 3 4 5 6 7)
conscons 通过将第一个参数添加到第二个参数头部来构造一个新列表。
例如:
(cons 1 '(2 3 4))
⇒ (1 2 3 4)
funcallfuncall 将第一个参数当作函数执行,并把后续参数传递给该函数。
nthcdr对一个列表连续取 n 次 CDR 后返回结果, 可以理解为 “剩余的剩余(rest of the rest)”。
例如:
(nthcdr 3 '(1 2 3 4 5 6 7))
⇒ (4 5 6 7)
setcarsetcdrsetcar 修改列表的第一个元素;setcdr 修改列表的第二个及后续元素。
例如:
(setq triple (list 1 2 3))
(setcar triple '37)
triple
⇒ (37 2 3)
(setcdr triple '("foo" "bar"))
triple
⇒ (37 "foo" "bar")
progn依次执行所有参数,并返回最后一个表达式的值。
例如:
(progn 1 2 3 4)
⇒ 4
save-restriction记录当前缓冲区中生效的缩进/缩窄范围,执行完参数后恢复该范围。
search-forward搜索指定字符串,找到后移动光标。若使用正则表达式,可使用类似的 re-search-forward。
(See Regular Expression Searches, 了解正则表达式模式与搜索。)
search-forward 和 re-search-forward 接收四个参数:
nil 或抛出错误。
kill-regiondelete-and-extract-regioncopy-region-as-killkill-region 剪切光标与标记之间的文本,存入删除环,
之后可以通过取回命令恢复。
copy-region-as-kill 复制光标与标记之间的文本到删除环,
但不会从缓冲区中删除文本。
delete-and-extract-region 移除光标与标记之间的文本并直接丢弃,
无法恢复。(该函数不是交互式命令。)
search-forward 作为函数名,否则会覆盖 Emacs 自带函数,
可使用 test-search 之类的名称。)
在 Lisp 中,原子的存储方式很直观;即便实际实现不简单,理论上也是直观的。
例如原子 ‘rose’,会以连续的四个字符 ‘r’、‘o’、‘s’、‘e’ 存储。
而列表的存储方式则不同。其机制同样简单,但需要一点时间适应。
列表通过一系列指针对实现:每对中的第一个指针指向一个原子或另一个列表,
第二个指针指向下一对指针,或指向符号 nil(表示列表结束)。
指针本身就是被指向对象的内存地址。 因此,列表在计算机中表现为一连串内存地址。
例如列表 (rose violet buttercup) 包含三个元素:
‘rose’、‘violet’、‘buttercup’。
在计算机中,‘rose’ 的地址被存放在一段名为 表单元(cons cell) 的内存中
(因为它正是函数 cons 创建的结构)。
该表单元同时保存着第二个表单元的地址;
第二个表单元的 CAR 是原子 ‘violet’,
其地址又与第三个表单元的地址一同保存;
第三个表单元则保存原子 ‘buttercup’ 的地址。
这段描述听起来复杂,实际结构很简单,用图更易理解:
___ ___ ___ ___ ___ ___
|___|___|--> |___|___|--> |___|___|--> nil
| | |
| | |
--> rose --> violet --> buttercup
图中每个方框代表一个存储 Lisp 对象的内存单元,通常存放内存地址。
方框成对出现。每个箭头指向地址对应的内容,可以是原子或另一对地址。
第一个方框存放 ‘rose’ 的地址,箭头指向 ‘rose’;
第二个方框指向下一对方框,其前半部分是 ‘violet’ 的地址,
后半部分是再下一对的地址。最后一个方框指向 nil,表示列表结束。
当使用 setq 等操作将变量设为一个列表时,
变量中存储的是第一个方框的地址。
因此执行表达式:
(setq bouquet '(rose violet buttercup))
creates a situation like this:
bouquet
|
| ___ ___ ___ ___ ___ ___
--> |___|___|--> |___|___|--> |___|___|--> nil
| | |
| | |
--> rose --> violet --> buttercup
在这个例子中,符号 bouquet 保存着第一对方框的地址。
同一个列表也可以用另一种方框表示法:
bouquet
|
| -------------- --------------- ----------------
| | car | cdr | | car | cdr | | car | cdr |
-->| rose | o------->| violet | o------->| butter- | nil |
| | | | | | | cup | |
-------------- --------------- ----------------
(符号的结构不止包含地址对,本身也由地址构成。
实际上,符号 bouquet 包含一组地址单元:
一个指向打印名 ‘bouquet’,
一个指向该符号绑定的函数(如果有),
一个指向列表 (rose violet buttercup) 的第一个地址对,依此类推。
这里只展示符号的第三个地址单元指向列表的首地址对。)
如果将一个符号设为某个列表的 CDR,列表本身不会被修改; 该符号只是保存了列表中更靠后的地址。 (用行话说,CAR 和 CDR 都是 “非破坏性(non-destructive)” 操作。) 因此执行下面表达式:
(setq flowers (cdr bouquet))
结果如下:
bouquet flowers
| |
| ___ ___ | ___ ___ ___ ___
--> | | | --> | | | | | |
|___|___|----> |___|___|--> |___|___|--> nil
| | |
| | |
--> rose --> violet --> buttercup
flowers 的值为 (violet buttercup),
也就是说符号 flowers 保存着某对地址单元的地址:
其前半部分指向 violet,后半部分指向 buttercup。
一对地址单元称为一个 表单元(cons cell) 或 点对(dotted pair)。 关于表单元和点对的更多信息, See Cons Cell and List Types in The GNU Emacs Lisp Reference Manual 以及 Dotted Pair Notation in The GNU Emacs Lisp Reference Manual。
函数 cons 会在一串地址前添加一个新的地址对。
例如执行:
(setq bouquet (cons 'lily bouquet))
结果如下:
bouquet flowers
| |
| ___ ___ ___ ___ | ___ ___ ___ ___
--> | | | | | | --> | | | | | |
|___|___|----> |___|___|----> |___|___|---->|___|___|--> nil
| | | |
| | | |
--> lily --> rose --> violet --> buttercup
但这并不会改变符号 flowers 的值,
执行下面表达式可以验证:
(eq (cdr (cdr bouquet)) flowers)
它会返回真值 t。
在被重新赋值前,flowers 仍然是 (violet buttercup),
即它保存着指向 violet 的那个表单元的地址。
同时,原有所有表单元都没有被改动,依然存在。
因此在 Lisp 中:
取列表的 CDR,只是获取下一个表单元的地址;
取 CAR,只是获取列表第一个元素的地址;
用 cons 添加新元素,只是在列表头部新建一个表单元。
原理仅此而已!Lisp 的底层结构简洁得令人惊叹。
一串表单元中的最后一个地址指向哪里?
它指向空列表,即 nil。
总而言之,当一个 Lisp 变量被赋值时, 它保存的是该变量所指向列表的首地址。
在前面的小节中,我曾建议你可以把符号想象成 一个抽屉柜。函数定义放在一个 抽屉里,变量值放在另一个抽屉里,以此类推。存放变量值的抽屉里的内容 可以被修改,而不会影响存放函数定义的抽屉里的内容, 反之亦然。
实际上,每个抽屉里存放的都是值或 函数定义的地址。这就好比你在阁楼里发现一个旧柜子, 在其中一个抽屉里找到一张地图,上面标注着埋藏宝藏的位置。
(符号除了拥有名称、定义和变量值之外,还有一个抽屉用于存放 属性列表(property list),可用于记录其他信息。 属性列表不在此处讨论;详见 Property Lists in The GNU Emacs Lisp Reference Manual。)
下面是一个形象的示意:
Chest of Drawers Contents of Drawers
__ o0O0o __
/ \
---------------------
| directions to | [map to]
| symbol name | bouquet
| |
+---------------------+
| directions to |
| symbol definition | [none]
| |
+---------------------+
| directions to | [map to]
| variable value | (rose violet buttercup)
| |
+---------------------+
| directions to |
| property list | [not described here]
| |
+---------------------+
|/ \|
将 flowers 设为 violet 和 buttercup。
在该列表前再拼接两种花,并将新列表赋值给
more-flowers。将 flowers 的 CAR
设为一种鱼。此时 more-flowers 列表包含哪些内容?
在 GNU Emacs 中,每当你使用删除命令将文本从缓冲区中剪切掉后, 都可以通过回贴命令将其恢复。被剪切出缓冲区的文本 会放入删除环,回贴命令则将删除环中的相应内容 插入到缓冲区中(不一定是原来的缓冲区)。
简单的 C-y(yank)命令会将删除环中的第一项
插入到当前缓冲区。如果 C-y 之后紧接着
按下 M-y,第一项会被替换为第二项。
连续按下 M-y 会依次将第二项替换为第三项、
第四项、第五项,依此类推。到达删除环最后一项后,
会重新回到第一项,循环往复。
(这也是它被称为 “环(ring)” 而非普通 “列表(list)” 的原因。
不过实际存储文本的数据结构仍是列表。
See Handling the Kill Ring,
了解列表被当作环处理的具体细节。)
删除环是一个由文本字符串组成的列表,形式如下:
("some text" "a different piece of text" "yet more text")
如果这是我的删除环内容,按下 C-y 后, 字符串 ‘some text’ 就会插入到光标所在位置。
yank 命令也可用于复制文本。
被复制的文本不会从缓冲区中删除,但其副本会被放入
删除环,再通过回贴命令插入。
有三个函数用于从删除环取回文本:
yank,通常绑定到 C-y;
yank-pop,通常绑定到 M-y;
以及 rotate-yank-pointer,供前两个函数调用。
这些函数通过名为 kill-ring-yank-pointer 的变量
来引用删除环。实际上,yank 和 yank-pop
的插入代码均为:
(insert (car kill-ring-yank-pointer))
(当然,现在已经不是这样了。在 GNU Emacs 22 中,
该函数已被 insert-for-yank 取代,
它会针对每个 yank-handler 片段
重复调用 insert-for-yank-1。
而 insert-for-yank-1 会根据
yank-excluded-properties 去除插入文本的属性。
除此之外,它与 insert 作用相同。
我们仍使用简单的 insert 讲解,便于理解。)
要理解 yank 和 yank-pop 的工作方式,
首先需要了解 kill-ring-yank-pointer 变量。
kill-ring-yank-pointer 变量 ¶kill-ring-yank-pointer 是一个变量,
就像 kill-ring 一样。
它和其他 Lisp 变量一样,通过绑定到目标值来实现指向。
例如,如果删除环的值为:
("some text" "a different piece of text" "yet more text")
而 kill-ring-yank-pointer 指向第二个元素,
那么 kill-ring-yank-pointer 的值为:
("a different piece of text" "yet more text")
正如上一章所述(see 列表的实现方式),
计算机并不会为 kill-ring 和
kill-ring-yank-pointer 分别保存文本副本。
“a different piece of text”和 “yet more text”
并不会被重复存储。相反,两个 Lisp 变量指向同一段文本。
示意图如下:
kill-ring kill-ring-yank-pointer
| |
| ___ ___ | ___ ___ ___ ___
---> | | | --> | | | | | |
|___|___|----> |___|___|--> |___|___|--> nil
| | |
| | |
| | --> "yet more text"
| |
| --> "a different piece of text"
|
--> "some text"
变量 kill-ring 和 kill-ring-yank-pointer
都是指针。但通常描述删除环时,会直接把它当成其构成的列表本身,
即把 kill-ring 当作列表,而非指向列表的指针。
相反,kill-ring-yank-pointer 则明确被描述为指向某个列表。
这两种表述方式初看令人困惑,仔细思考后便会清晰。
删除环一般被视为保存近期被剪切文本的完整数据结构;
而 kill-ring-yank-pointer 的作用是标记 —
也就是指向 — 删除环中即将被插入的起始位置(即 CAR 位置)。
yank 与 nthcdr 练习 ¶describe-variable)查看删除环的值。
向删除环添加若干项,再次查看其值。
使用 M-y(yank-pop)遍历整个删除环。
你的删除环中有多少项?查找 kill-ring-max 的值。
你的删除环是否已满,还是仍可存放更多文本块?
nthcdr 和 car 构造一系列表达式,
分别返回列表的第一个、第二个、第三个和第四个元素。
Emacs Lisp 有两种主要方式可以重复执行一个或一系列表达式:
一种使用 while 循环,
另一种使用 递归(recursion)。
重复执行非常有用。例如,要向前移动四个句子, 只需编写程序实现“向前移动一个句子”并重复四次即可。 计算机不会感到枯燥或疲惫,因此这类重复操作 不会像对人那样产生过度或不当重复带来的负面影响。
人们编写 Emacs Lisp 函数时大多使用 while 循环
及其同类结构;但你也可以使用递归,
递归提供了一种非常强大的思考与解决问题的方式15。
while ¶while 特殊形式会判断其第一个参数求值结果是否为真。
这与 Lisp 解释器处理 if 的方式类似;
但后续行为有所不同。
在 while 表达式中,如果第一个参数的求值结果为假,
Lisp 解释器会跳过表达式剩余部分(即表达式体),不再执行。
反之,如果结果为真,解释器会执行表达式体,
然后再次判断 while 的第一个参数是否为真。
若仍为真,则再次执行表达式体。
while 表达式的结构如下:
(while true-or-false-test body...)
while 循环 ¶只要 while 的真假条件求值为真,
执行体就会被重复执行。这一过程称为循环,
因为解释器会不断重复相同操作,如同飞机盘旋。
当条件求值为假时,
Lisp 解释器不再执行 while 剩余部分,退出循环。
显然,如果 while 第一个参数的求值结果始终为真,
后续执行体就会一遍又一遍… 永远执行下去。
反之,如果结果永远为假,执行体中的表达式则永远不会被执行。
编写 while 循环的技巧在于选择合适的机制,
让真假条件恰好只在需要重复的次数内为真,
之后变为假。
while 的返回值是真假条件的求值结果。
一个有趣的结论是:正常执行的 while 循环
无论执行 1 次、100 次还是 0 次,最终都会返回 nil(假)。
成功执行的 while 永远不会返回真值!
这意味着 while 总是依靠副作用发挥作用,
也就是依靠循环体内表达式重复执行带来的效果。
这合乎逻辑:我们需要的不是循环本身,
而是循环体重复执行产生的结果。
while 循环与列表 ¶控制 while 循环的常用方式是判断列表是否还有元素。
有元素则继续循环,无元素则结束循环。
这是一项重要技术,我们用简短示例说明。
判断列表是否有元素的简单方法是直接对列表求值:
若无元素,即为空列表,返回 (),
等价于 nil(假)。
反之,包含元素的列表在求值时会返回这些元素。
由于 Emacs Lisp 将非 nil 的值均视为真,
因此非空列表在 while 中会被判定为真。
例如,你可以通过下面的 setq 表达式
将变量 empty-list 设为 nil:
(setq empty-list ())
执行该表达式后,你可以像平常一样对变量 empty-list 求值:
将光标放在符号后按下 C-x C-e,
回显区会显示 nil:
empty-list
反之,如果将变量设为包含元素的列表, 求值时就会显示该列表,执行下面两个表达式即可看到:
(setq animals '(gazelle giraffe lion tiger)) animals
因此,要创建一个判断列表 animals 是否有元素的
while 循环,循环开头可以这样写:
(while animals
...
当 while 判断第一个参数时,变量 animals 被求值,
返回一个列表。只要列表非空,while 就认为条件为真;
当列表为空时,则认为条件为假。
为避免 while 无限循环,
需要提供一种机制让列表最终变为空。
常用方法是在 while 表达式中使用某条语句
将列表设为自身的 CDR。
每次执行 cdr,列表都会变短,
直到最后只剩下空列表。
此时 while 的条件返回假,
循环不再继续执行。
例如,绑定到 animals 的动物列表
可以通过下面的表达式设为原列表的 CDR:
(setq animals (cdr animals))
执行前面的表达式后再运行该语句,
回显区会显示 (giraffe lion tiger)。
再次执行,会显示 (lion tiger)。
继续执行,依次出现 (tiger) 和空列表(nil)。
使用 cdr 让条件最终变为假的
while 循环结构如下:
(while test-whether-list-is-empty body... set-list-to-cdr-of-list)
这种判断方式与 cdr 的结合,
可以用来编写一个遍历列表并逐行打印每个元素的函数。
print-elements-of-list ¶print-elements-of-list 函数演示了如何结合列表使用 while 循环。
该函数的输出需要占用多行。如果你正在较新版本的 GNU Emacs 中阅读本文档,可以像往常一样在 Info 中直接执行下面的表达式。
如果你使用的是旧版 Emacs,需要将相关表达式复制到 *scratch* 缓冲区再执行。这是因为旧版本的回显区只有一行。
复制表达式的方法是:先用 C-SPC(set-mark-command)标记区域起点,将光标移到区域终点,再用 M-w(kill-ring-save,它会调用 copy-region-as-kill 并提供视觉反馈)复制区域内容。在 *scratch* 缓冲区中,输入 C-y(yank)即可将表达式回贴回来。
将表达式复制到 *scratch* 缓冲区后,依次执行每个表达式。务必使用 C-u C-x C-e 执行最后一个表达式 (print-elements-of-list animals),也就是为 eval-last-sexp 提供一个参数。这样执行结果会打印在 *scratch* 缓冲区中,而不是回显区。(否则你在回显区会看到类似这样的内容:^Jgazelle^J^Jgiraffe^J^Jlion^J^Jtiger^Jnil,其中每个 ‘^J’ 都代表一个换行符。)
你也可以直接在 Info 缓冲区执行这些表达式,回显区会自动扩展以显示结果。
(setq animals '(gazelle giraffe lion tiger))
(defun print-elements-of-list (list)
"Print each element of LIST on a line of its own."
(while list
(print (car list))
(setq list (cdr list))))
(print-elements-of-list animals)
依次执行这三个表达式后,你会看到如下输出:
gazelle giraffe lion tiger nil
列表中的每个元素都会单独占一行打印(这是 print 函数的行为),随后会打印函数的返回值。由于函数内最后一个表达式是 while 循环,而 while 循环总是返回 nil,因此在列表最后一个元素之后会输出一个 nil。
循环必须在合适的时候停止才有意义。除了用列表控制循环外,另一种常用的停止方式是将第一个参数写为一个判断条件,在完成指定次数重复后返回假。这意味着循环必须包含一个计数器——用于记录循环执行次数的表达式。
带递增计数器的循环条件可以是类似 (< count desired-number) 这样的表达式:当 count 的值小于期望重复次数 desired-number 时返回真 t,当 count 大于或等于 desired-number 时返回假 nil。计数器递增的表达式可以用简单的 setq 实现,例如 (setq count (1+ count)),其中 1+ 是 Emacs Lisp 内置函数,作用是将参数加 1。(表达式 (1+ count) 与 (+ count 1) 结果相同,但更便于人类阅读。)
由递增计数器控制的 while 循环结构如下:
set-count-to-initial-value (while (< count desired-number) ; true-or-false-test body... (setq count (1+ count))) ; incrementer
注意需要为 count 设置初始值,通常初始化为 1。
假设你在沙滩上想用石子摆一个三角形,第一行放 1 颗,第二行放 2 颗,第三行放 3 颗,依此类推,形状如下:
*
* *
* * *
* * * *
(大约 2500 年前,毕达哥拉斯等人正是通过这类问题开创了数论的雏形。)
假设你想知道摆一个 7 行的三角形需要多少颗石子?
显然,你需要把从 1 到 7 的数字相加。有两种实现方式:从最小的数字 1 开始依次累加 1、2、3、4……;或者从最大的数字开始倒序累加 7、6、5、4……。由于这两种方式都能体现 while 循环的常见写法,我们会分别实现两个示例,一个递增计数,一个递减计数。在第一个示例中,我们从 1 开始依次加上 2、3、4 等。
如果只是累加一短串数字,最简单的方法是一次性全部相加。但如果你事先不知道列表有多少个数字,或者需要应对非常长的列表,就需要设计成重复执行简单操作多次,而不是一次性执行复杂操作。
例如,不必一次性算出所有石子总数,你可以先把第一行的 1 颗石子与第二行的 2 颗相加,再把前两行的总数与第三行的 3 颗相加,接着把前两行总数加上第四行的 4 颗,依此类推。
该过程的关键特点是每次重复的动作都很简单。本例中,每一步只需要将两个数字相加:当前行的石子数与已累加的总数。这种两数相加的操作会不断重复,直到最后一行被加入总数。在更复杂的循环中,重复动作可能不会如此简单,但一定比一次性完成所有操作更简洁。
通过上述分析,我们得到了函数定义的基本框架:
首先,需要一个变量 total 用于记录石子总数,它将作为函数的返回值。
其次,函数需要一个参数,表示三角形的总行数,可以命名为 number-of-rows。
最后,需要一个变量作为计数器。可以命名为 counter,但更合适的名称是 row-number。因为该计数器在函数中用于计数行数,程序应尽可能写得易于理解。
当 Lisp 解释器开始执行函数内的表达式时,total 的初始值应设为 0,因为此时尚未累加任何数值。随后函数应依次将第一行、第二行、第三行……的石子数加入总数,直到所有行累加完毕。
total 和 row-number 只在函数内部使用,因此可以用 let 声明为局部变量并赋予初始值。显然 total 初始值应为 0,row-number 初始值应为 1,因为从第一行开始计数。对应的 let 语句如下:
(let ((total 0)
(row-number 1))
body...)
在声明内部变量并绑定初始值后,就可以开始编写 while 循环。作为判断条件的表达式,只要 row-number 小于或等于 number-of-rows 就应返回真 t。(如果条件仅判断行数小于总行数,最后一行将永远不会被计入总数;因此条件必须是小于或等于。)
Lisp 提供了 <= 函数,当第一个参数的值小于或等于第二个参数时返回真,否则返回假。因此 while 的判断条件应写为:
(<= row-number number-of-rows)
石子总数可以通过反复将当前行石子数加到已有总数得到。由于每行石子数等于行数,因此只需将行数累加到总数即可。(显然在更复杂的场景中,每行石子数与行数的关系可能更复杂,此时只需将行数替换为对应表达式即可。)
(setq total (+ total row-number))
该表达式的作用是将 total 的新值设为原有总数与当前行石子数之和。
在更新 total 之后,需要为下一轮循环(如果存在)设置条件。这通过递增作为计数器的 row-number 变量实现。row-number 递增后,while 循环开头的真假条件会再次判断其是否仍小于等于总行数,如果是,则将新的行号累加到上一轮循环得到的总数中。
Emacs Lisp 内置函数 1+ 用于将数字加 1,因此 row-number 可通过如下表达式递增:
(setq row-number (1+ row-number))
我们已经完成了函数定义的各个部分,现在需要将它们整合起来。
首先是 while 表达式的内容:
(while (<= row-number number-of-rows) ; true-or-false-test (setq total (+ total row-number)) (setq row-number (1+ row-number))) ; incrementer
配合 let 表达式的变量列表,这几乎完成了函数体的定义。但还需要最后一个细节,其作用较为微妙。
最后一步是在 while 表达式之后单独一行放置变量 total。否则整个函数的返回值将是 let 体内最后一个被执行表达式的值,也就是 while 的返回值,而它永远是 nil。
这一点初看并不明显。看起来似乎递增表达式是整个函数的最后一个表达式,但该表达式其实是 while 体的一部分,是符号 while 开头的列表中的最后一个元素。而且整个 while 循环又是 let 体内的一个列表。
函数的整体结构大致如下:
(defun name-of-function (argument-list)
"documentation..."
(let (varlist)
(while (true-or-false-test)
body-of-while... )
... )) ; Need final expression here.
由于 let 除了被包含在 defun 内外没有嵌入其他列表,因此 let 的执行结果就是 defun 的返回值。但如果 while 是 let 表达式的最后一个元素,函数将永远返回 nil,这并不是我们想要的结果。我们希望返回的是变量 total 的值。只需将该符号作为 let 开头列表的最后一个元素即可实现:它会在列表前面的元素执行完毕后被求值,此时它已经被赋予了正确的总数。
把 let 开头的列表写在一行上会更容易理解。这种格式可以清楚地看到,变量列表(varlist)和 while 表达式是 let 列表的第二、第三个元素,而 total 是最后一个元素:
(let (varlist) (while (true-or-false-test) body-of-while... ) total)
将所有部分组合后,triangle 函数的定义如下:
(defun triangle (number-of-rows) ; Version with ; incrementing counter. "Add up the number of pebbles in a triangle. The first row has one pebble, the second row two pebbles, the third row three pebbles, and so on. The argument is NUMBER-OF-ROWS."
(let ((total 0)
(row-number 1))
(while (<= row-number number-of-rows)
(setq total (+ total row-number))
(setq row-number (1+ row-number)))
total))
执行该函数定义安装 triangle 后,你可以进行测试。下面是两个示例:
(triangle 4) (triangle 7)
前四个数字之和为 10,前七个数字之和为 28。
编写 while 循环的另一种常用方式是将条件设为判断计数器是否大于 0。只要计数器大于 0,循环就继续执行;当计数器小于或等于 0 时,循环停止。要实现这一点,计数器初始值必须大于 0,并通过重复执行的表达式不断减小。
判断条件可以是类似 (> counter 0) 的表达式:当 counter 大于 0 时返回真 t,等于或小于 0 时返回假 nil。让数值不断减小的表达式可以用简单的 setq 实现,例如 (setq counter (1- counter)),其中 1- 是 Emacs Lisp 内置函数,作用是将参数减 1。
递减 while 循环的结构如下:
(while (> counter 0) ; true-or-false-test body... (setq counter (1- counter))) ; decrementer
为演示带递减计数器的循环,我们重写 triangle 函数,让计数器递减到 0。
这与之前的版本顺序相反。本例中,要计算 3 行三角形所需石子数,先将第三行的 3 颗与前一行的 2 颗相加,再把这两行总数与更前一行的 1 颗相加。
同理,要计算 7 行三角形的石子数,先将第七行的 7 颗与前一行的 6 颗相加,再把这两行总数与更前一行的 5 颗相加,依此类推。与前一个示例一样,每次加法只涉及两个数字:已累加的行数总数与当前要加入总数的行石子数。这种两数相加的过程不断重复,直到没有更多石子需要累加。
我们知道起始石子数:最后一行的石子数等于总行数。如果三角形有 7 行,最后一行就是 7 颗。同样,前一行的石子数也已知,比当前行少 1。
我们从三个变量开始:三角形的总行数、
某一行的石子数,以及我们想要计算的
石子总数。这些变量可以分别命名为
number-of-rows、number-of-pebbles-in-row
和 total。
total 和 number-of-pebbles-in-row
只在函数内部使用,并用 let 声明。
total 的初始值自然应为 0。
而 number-of-pebbles-in-row 的初始值
应等于三角形的行数,因为加法将从最长的一行开始。
这意味着 let 表达式的开头如下:
(let ((total 0)
(number-of-pebbles-in-row number-of-rows))
body...)
石子总数可以通过反复将当前行石子数加到已有总数得到, 即重复执行下面的表达式:
(setq total (+ total number-of-pebbles-in-row))
在将 number-of-pebbles-in-row 加入 total 后,
需要将 number-of-pebbles-in-row 减 1,
因为下一轮循环要将前一行加入总数。
前一行的石子数比当前行少 1,
因此可以使用 Emacs Lisp 内置函数 1-
计算前一行的石子数,表达式如下:
(setq number-of-pebbles-in-row
(1- number-of-pebbles-in-row))
最后,我们知道当某一行没有石子时,
while 循环就应该停止累加。
因此 while 循环的条件很简单:
(while (> number-of-pebbles-in-row 0)
我们可以把这些表达式组合成一个可用的函数定义。 不过仔细观察后会发现,其中一个局部变量其实是多余的!
函数定义如下:
;;; First subtractive version.
(defun triangle (number-of-rows)
"Add up the number of pebbles in a triangle."
(let ((total 0)
(number-of-pebbles-in-row number-of-rows))
(while (> number-of-pebbles-in-row 0)
(setq total (+ total number-of-pebbles-in-row))
(setq number-of-pebbles-in-row
(1- number-of-pebbles-in-row)))
total))
按当前写法,这个函数可以正常工作。
不过,我们其实并不需要 number-of-pebbles-in-row。
当 triangle 函数被执行时,符号 number-of-rows
会绑定到一个数字,获得初始值。
这个数字可以在函数体内被修改,就像局部变量一样,
完全不用担心这种修改会影响函数外部的变量值。
这是 Lisp 非常有用的特性;
这意味着在函数中凡是用到 number-of-pebbles-in-row 的地方,
都可以直接使用 number-of-rows。
下面是写法更简洁的第二个版本:
(defun triangle (number) ; Second version.
"Return sum of numbers 1 through NUMBER inclusive."
(let ((total 0))
(while (> number 0)
(setq total (+ total number))
(setq number (1- number)))
total))
简而言之,一个规范的 while 循环包含三部分:
dolist 与 dotimes ¶除了 while,dolist 和 dotimes
也可用于实现循环。有时它们比等价的 while 循环更易编写。
两者都是 Lisp 宏。(详见
Macros in The GNU Emacs Lisp Reference Manual。)
dolist 的行为类似于不断取 CDR 遍历列表的 while 循环:
每次循环时,dolist 会自动缩短列表(取列表的 CDR),
并将每个更短列表的 CAR 绑定到它的第一个参数。
dotimes 则会循环指定次数:由你指定循环次数。
dolist 宏 ¶例如,假设你想反转一个列表, 让 “第一个” “第二个” “第三个” 变为 “第三个” “第二个” “第一个”。
实际使用中,你会直接用 reverse 函数,如下:
(setq animals '(gazelle giraffe lion tiger)) (reverse animals)
下面是用 while 循环实现列表反转的方式:
(setq animals '(gazelle giraffe lion tiger))
(defun reverse-list-with-while (list)
"Using while, reverse the order of LIST."
(let (value) ; make sure list starts empty
(while list
(setq value (cons (car list) value))
(setq list (cdr list)))
value))
(reverse-list-with-while animals)
下面是使用 dolist 宏的写法:
(setq animals '(gazelle giraffe lion tiger))
(defun reverse-list-with-dolist (list)
"Using dolist, reverse the order of LIST."
(let (value) ; make sure list starts empty
(dolist (element list value)
(setq value (cons element value)))))
(reverse-list-with-dolist animals)
在 Info 中,你可以将光标放在每个表达式的右括号后, 输入 C-x C-e;每种情况都应在回显区看到:
(tiger lion giraffe gazelle)
对于本例,显然已有的 reverse 函数是最佳选择。
while 循环和我们第一个示例类似(see A while Loop and a List)。
while 首先检查列表是否有元素;
如果有,就通过将列表第一个元素添加到现有列表(循环第一次迭代时为 nil)
来构造新列表。由于第二个元素被追加到第一个元素前面,
第三个被追加到第二个前面,列表就被反转了。
在使用 while 循环的表达式中,
(setq list (cdr list)) 会缩短列表,
使 while 循环最终停止。
同时,它在每次循环中创建一个更短的新列表,
为 cons 表达式提供新的第一个元素。
dolist 表达式的作用与 while 非常相似,
区别在于 dolist 宏自动完成了编写 while 时需要手动做的部分工作。
和 while 循环一样,dolist 会进行循环。
不同之处在于,它每次循环都会自动缩短列表 —
自行取列表 CDR — 并自动将每个更短列表的 CAR
绑定到它的第一个参数。
在示例中,每个更短列表的 CAR 用符号 ‘element’ 表示,
列表本身叫作 ‘list’,返回值叫作 ‘value’。
dolist 表达式的其余部分是循环体。
dolist 表达式将每个更短列表的 CAR 绑定到 element,
然后执行表达式体,并重复循环。
结果由 value 返回。
dotimes 宏 ¶dotimes 宏与 dolist 类似,
区别是它会循环指定次数。
dotimes 的第一个参数在每次循环时
会依次被赋值为 0、1、2……
你需要提供第二个参数的值,即宏的循环次数。
例如,下面的代码将从 0 到 3(不含 3)的数字 依次绑定到第一个参数 number, 然后构造包含这三个数字的列表。 (第一个数字是 0,第二个是 1,第三个是 2; 从 0 开始,一共三个数字。)
(let (value) ; otherwise a value is a void variable
(dotimes (number 3)
(setq value (cons number value)))
value)
⇒ (2 1 0)
使用 dotimes 的方式是对某个表达式
重复操作 number 次,然后返回结果,
可以是列表或原子。
下面是一个使用 dotimes 实现的 defun 示例,
用于计算三角形石子总数。
(defun triangle-using-dotimes (number-of-rows)
"Using `dotimes', add up the number of pebbles in a triangle."
(let ((total 0)) ; otherwise a total is a void variable
(dotimes (number number-of-rows)
(setq total (+ total (1+ number))))
total))
(triangle-using-dotimes 4)
递归函数包含的代码会告诉 Lisp 解释器 去调用一个运行方式与自身完全相同、 但参数略有不同的程序。 代码运行方式相同是因为它们同名。 不过,即便程序同名,它们也不是同一个实体, 而是不同的个体。用行话来说,是不同的 “实例(instance)”。
最终,如果程序编写正确, 逐渐变化的参数会与最初参数差异足够大, 使得最后一个实例停止运行。
有时把运行中的程序想象成执行任务的机器人会有助于理解。 在执行任务时,递归函数会请第二个机器人帮忙。 第二个机器人与第一个完全一样, 只是它在协助第一个机器人,并且接收的参数不同。
在递归函数中,第二个机器人可能会调用第三个, 第三个又可能调用第四个,依此类推。 每个都是不同的实体,但全是克隆体。
由于每个机器人的指令略有不同 — 参数各不相同 — 最后一个机器人应当知道何时停止。
我们来扩展 “计算机程序就是机器人” 这个比喻。
函数定义为机器人提供了蓝图。
当你安装函数定义时,也就是执行 defun 宏时,
你就装配好了制造机器人所需的设备。
这就像你在工厂里搭建一条流水线。
同名机器人按照相同蓝图制造,
因此型号相同,但序列号不同。
我们常说递归函数 “调用自身(calls itself)”。 我们的意思是,递归函数中的指令 会让 Lisp 解释器运行另一个同名、任务相同、 但参数不同的函数。
各个实例的参数必须不同,这一点很重要; 否则过程永远不会停止。
递归函数通常包含一个条件表达式,由三部分组成:
递归函数可以比其他任何函数都简洁得多。 事实上,人们刚开始使用时, 常常会觉得它们简洁得近乎神秘而难以理解。 就像骑自行车一样,读懂递归函数定义需要一定技巧, 起初很难,之后就会觉得很简单。
递归有几种常见模式。 一种非常简单的模式如下:
(defun name-of-recursive-function (argument-list)
"documentation..."
(if do-again-test
body...
(name-of-recursive-function
next-step-expression)))
每次递归函数被执行时,都会创建一个新实例并指派任务。 参数告诉实例该做什么。
参数会绑定到步进表达式的值。 每个实例都使用不同的步进表达式值运行。
步进表达式的值会被用于再次执行条件。
步进表达式返回的值会传给新的函数实例, 实例对其求值(或某种转换后的值)以决定继续还是停止。 步进表达式的设计目标是: 当函数不应再重复时,让再次执行条件返回假。
再次执行条件有时也被称为终止条件(stop condition), 因为它在返回假时会停止重复。
之前那个打印数字列表元素的 while 循环示例
也可以用递归实现。下面是代码,
包含将变量 animals 设为列表的表达式。
如果你在 Emacs 的 Info 中阅读本文,
可以直接在 Info 中执行这些表达式。
否则必须把示例复制到 *scratch* 缓冲区再逐一执行。
使用 C-u C-x C-e 执行
(print-elements-recursively animals),
让结果打印在缓冲区中;
否则 Lisp 解释器会试图把结果挤在回显区的一行里。
另外,把光标放在 print-elements-recursively
函数最后一个右括号后面、注释前面的位置。
否则 Lisp 解释器会试图执行注释。
(setq animals '(gazelle giraffe lion tiger)) (defun print-elements-recursively (list) "Print each element of LIST on a line of its own. Uses recursion." (when list ; do-again-test (print (car list)) ; body (print-elements-recursively ; recursive call (cdr list)))) ; next-step-expression (print-elements-recursively animals)
print-elements-recursively 函数首先判断列表是否有内容;
如果有,函数打印列表的第一个元素,即列表的 CAR。
然后函数调用自身,
但参数不是整个列表,而是列表的第二个及后续元素,即列表的 CDR。
换句话说,如果列表非空, 函数会启动另一段与初始代码相似的执行逻辑, 但属于不同的执行线程,参数也与第一个实例不同。
再换一种说法:如果列表非空, 第一个机器人会组装第二个机器人并指派任务; 第二个机器人与第一个个体不同,但型号相同。
第二次执行时,when 表达式被求值,
如果为真,就打印它收到的列表的第一个元素
(也就是原列表的第二个元素)。
然后函数用当前列表的 CDR 调用自身,
第二次时就是原列表 CDR 的 CDR。
注意,虽然我们说函数“调用自身(calls itself)”, 实际意思是 Lisp 解释器创建并指挥一个新的程序实例。 新实例是第一个的克隆体,但属于独立个体。
函数每次调用自身时, 都作用在原列表更短的一个版本上。 它创建一个新实例,处理更短的列表。
最终,函数会在空列表上调用自身。
它创建一个参数为 nil 的新实例。
条件表达式判断 list 的值。
由于 list 的值为 nil,
when 表达式返回假,因此 then 部分不执行。
函数整体返回 nil。
在 *scratch* 缓冲区执行表达式
(print-elements-recursively animals) 后,
你会看到如下结果:
gazelle giraffe lion tiger nil
上一节介绍的 triangle 函数也可以用递归实现,如下:
(defun triangle-recursively (number) "Return the sum of the numbers 1 through NUMBER inclusive. Uses recursion." (if (= number 1) ; do-again-test 1 ; then-part (+ number ; else-part (triangle-recursively ; recursive call (1- number))))) ; next-step-expression (triangle-recursively 7)
你可以执行该函数进行安装,
然后执行 (triangle-recursively 7) 测试。
(记得把光标放在函数定义最后一个括号后面、注释前面。)
函数执行结果为 28。
要理解这个函数的工作方式, 我们分别看参数为 1、2、3、4 时会发生什么。
首先,如果参数值为 1 会发生什么?
函数在文档字符串后有一个 if 表达式。
它判断 number 是否等于 1;
如果是,Emacs 执行 if 的 then 部分,
返回数字 1 作为函数值。
(一行的三角形有 1 颗石子。)
再假设参数值为 2。
这时 Emacs 执行 if 的 else 部分。
else 部分包含一次加法、
对 triangle-recursively 的递归调用
以及一次递减操作,如下:
(+ number (triangle-recursively (1- number)))
Emacs 执行该表达式时, 会先执行最内层表达式,再依次执行其他部分。 详细步骤如下:
最内层表达式是 (1- number),
因此 Emacs 将 number 的值从 2 减为 1。
triangle-recursively 函数Lisp 解释器创建一个独立的 triangle-recursively 实例。
这个函数包含在自身内部并不影响执行。
Emacs 将步骤 1 的结果作为参数传给该实例。
在本例中,Emacs 以参数 1 执行 triangle-recursively。
这意味着本次执行返回 1。
number 的值变量 number 是以 + 开头的列表的第二个元素,
值为 2。
+ 表达式+ 表达式接收两个参数:
第一个来自 number 的求值(步骤 3),
第二个来自 triangle-recursively 的求值(步骤 2)。
加法结果为 2 + 1 = 3,返回数字 3,结果正确。 两行的三角形共有 3 颗石子。
假设调用 triangle-recursively 时参数为 3。
首先执行 if 表达式。
这是再次执行条件,返回假,
因此执行 if 的 else 部分。
(注意在本例中,再次执行条件在为假时才会递归调用,
而不是为真时。)
执行 else 部分最内层表达式,将 3 减为 2。 这是步进表达式。
triangle-recursively 函数数字 2 被传给 triangle-recursively 函数。
我们已经知道参数为 2 时 triangle-recursively 的执行结果,
按照前面的步骤执行后会返回 3。
这里也会得到同样结果。
3 作为参数参与加法, 与函数调用时的参数 3 相加。
函数整体返回值为 6。
既然我们知道参数为 3 时的结果, 那么参数为 4 时的情况也就显而易见:
在递归调用中,执行
(triangle-recursively (1- 4))会得到执行
(triangle-recursively 3)的结果,即 6, 该值会与第三行的加法表达式中的 4 相加。
函数整体返回值为 10。
每次 triangle-recursively 被执行时,
都会以更小的参数执行自身的另一个版本 — 另一个实例 —
直到参数小到不再递归为止。
注意,这种递归设计需要推迟操作执行。
(triangle-recursively 7) 要算出结果,
必须先调用 (triangle-recursively 6);
而 (triangle-recursively 6) 要算出结果,
必须先调用 (triangle-recursively 5),依此类推。
也就是说,(triangle-recursively 7) 的计算
必须推迟到 (triangle-recursively 6) 计算完成;
而 (triangle-recursively 6) 又要推迟到
(triangle-recursively 5) 完成,依此类推。
如果把每个 triangle-recursively 实例
想象成不同的机器人,
第一个机器人必须等待第二个完成任务,
第二个又要等第三个,依此类推。
有一种方法可以避免这种等待, 我们将在 Recursion without Deferments 中讨论。
cond 的递归示例 ¶前面介绍的 triangle-recursively 版本是使用 if 特殊形式编写的。它也可以使用另一种名为 cond 的特殊形式来实现。特殊形式 cond 的名称是单词 ‘conditional’(条件)的缩写。
尽管 cond 特殊形式在 Emacs Lisp 源码中不像 if 那样常用,但其使用频率也足以值得对其进行讲解。
cond 表达式的模板如下所示:
(cond body...)
其中 body 是一系列列表。
更完整地写出来,该模板如下:
(cond (first-true-or-false-test first-consequent) (second-true-or-false-test second-consequent) (third-true-or-false-test third-consequent) ...)
当 Lisp 解释器对 cond 表达式求值时,它会依次对 cond 主体内一系列表达式中的第一个表达式的第一个元素(CAR,即真假测试表达式)进行求值。
如果真假测试返回 nil,则该表达式的其余部分(结果表达式)会被跳过,转而对下一个表达式的真假测试进行求值。当找到某个表达式的真假测试返回非 nil 值时,该表达式的结果部分会被求值。结果部分可以是一个或多个表达式。如果结果部分包含多个表达式,这些表达式会按顺序求值,并返回最后一个表达式的值。如果该表达式没有结果部分,则返回真假测试本身的值。
如果所有真假测试均不成立,则 cond 表达式返回 nil。
使用 cond 编写的 triangle 函数如下所示:
(defun triangle-using-cond (number)
(cond ((<= number 0) 0)
((= number 1) 1)
((> number 1)
(+ number (triangle-using-cond (1- number))))))
在该示例中,若数字小于等于 0,cond 返回 0;若数字等于 1,返回 1;若数字大于 1,则求值 (+ number (triangle-using-cond (1- number)))。
下面介绍三种常见的递归模式,每种都与列表相关。递归并非必须依赖列表,但 Lisp 本身为列表设计,这也能体现其核心能力。
在 every 递归模式中,会对列表的每一个元素执行某项操作。
基本模式为:
nil。
cons 将处理后的元素与剩余部分的处理结果组合。
示例如下:
(defun square-each (numbers-list)
"Square each of a NUMBERS LIST, recursively."
(if (not numbers-list) ; do-again-test
nil
(cons
(* (car numbers-list) (car numbers-list))
(square-each (cdr numbers-list))))) ; next-step-expression
(square-each '(1 2 3))
⇒ (1 4 9)
若 numbers-list 为空,则不执行任何操作。若列表非空,则构造一个新列表,将列表首个数字的平方与递归调用的结果组合在一起。
(该示例严格遵循该模式:数字列表为空时返回 nil。实际使用中,你可以将条件写为在列表非空时执行操作。)
print-elements-recursively 函数(see Recursion with a List)是 every 模式的另一个示例,区别在于本例并非使用 cons 拼接结果,而是逐个打印元素。
print-elements-recursively 函数如下:
(setq animals '(gazelle giraffe lion tiger))
(defun print-elements-recursively (list) "Print each element of LIST on a line of its own. Uses recursion." (when list ; do-again-test (print (car list)) ; body (print-elements-recursively ; recursive call (cdr list)))) ; next-step-expression (print-elements-recursively animals)
print-elements-recursively 的模式为:
另一种递归模式称为 accumulate(累积)模式。在该模式中,会对列表的每个元素执行操作,并将该操作的结果与其他元素的操作结果累积起来。
该模式与使用 cons 的 every 模式非常相似,区别在于不使用 cons,而是使用其他组合方式。
模式如下:
+ 或其他组合函数,将处理后的元素与
示例如下:
(defun add-elements (numbers-list)
"Add the elements of NUMBERS-LIST together."
(if (not numbers-list)
0
(+ (car numbers-list) (add-elements (cdr numbers-list)))))
(add-elements '(1 2 3 4))
⇒ 10
See Making a List of Files,可查看累积模式的更多示例。
第三种递归模式称为 keep(筛选保留)模式。在该模式中,会对列表的每个元素进行测试;仅当元素满足判定条件时,才对其执行操作并保留结果。
该模式同样与 every 模式类似,区别在于不满足条件的元素会被直接跳过。
该模式分为三部分:
nil。
cons 将其与
下面是一个使用 cond 的示例:
The pattern has three parts:
(defun keep-three-letter-words (word-list)
"在 WORD-LIST 中保留由三个字母组成的单词。"
(cond
;; 第一个循环条件:终止条件
((not word-list) nil)
;; 第二个循环条件:执行操作的条件
((eq 3 (length (symbol-name (car word-list))))
;; 将处理后的元素与对更短列表的递归调用结果组合
(cons (car word-list) (keep-three-letter-words (cdr word-list))))
;; 第三个循环条件:跳过元素的条件
;; 对更短列表执行下一步表达式的递归调用
(t (keep-three-letter-words (cdr word-list)))))
(keep-three-letter-words '(one two three four five six))
⇒ (one two six)
不言而喻,终止条件不必使用 nil;你也可以将这些模式进行组合使用。
我们再次分析 triangle-recursively 函数的执行过程,会发现中间计算过程会被延迟,直到所有调用完成后才一并执行。
函数定义如下:
(defun triangle-recursively (number) "Return the sum of the numbers 1 through NUMBER inclusive. Uses recursion." (if (= number 1) ; do-again-test 1 ; then-part (+ number ; else-part (triangle-recursively ; recursive call (1- number))))) ; next-step-expression
当我们以参数 7 调用该函数时会发生什么?
triangle-recursively 的第一个实例会将数字 7 与第二个实例的返回值相加,第二个实例接收的参数为 6。也就是说,第一次计算为:
(+ 7 (triangle-recursively 6))
第一个 triangle-recursively 实例 — 你可以把它想象成一个小机器人 — 无法完成自身任务。它必须将 (triangle-recursively 6) 的计算交给程序的第二个实例,也就是第二个机器人。这个个体与第一个完全不同,用行话说就是“不同实例”。换句话说,它是另一个机器人。型号与第一个相同,都以递归方式计算三角数,但序列号不同。
那么 (triangle-recursively 6) 返回什么?它会将数字 6 与参数为 5 的 triangle-recursively 求值结果相加。用机器人的比喻来说,它会请求另一个机器人协助。
此时总和变为:
(+ 7 6 (triangle-recursively 5))
接下来会发生什么?
(+ 7 6 5 (triangle-recursively 4))
除最后一次外,每次调用 triangle-recursively 都会创建一个新的程序实例 — 另一个机器人 — 并让其执行计算。
最终,完整的加法表达式会被构建并执行:
(+ 7 6 5 4 3 2 1)
该函数的设计会将第一步的计算延迟到第二步完成,第二步又延迟到第三步,依此类推。每次延迟都意味着计算机需要记录等待的内容。在本例这样步骤较少的情况下不会出现问题,但步骤较多时就可能产生问题。
解决操作延迟问题的方案是采用不会延迟操作的编写方式16。这通常需要采用不同的模式,往往需要编写两个函数定义:一个初始化函数和一个辅助函数。
初始化函数负责启动任务,辅助函数负责实际执行工作。
下面是用于数字求和的两个函数定义。它们非常简洁,我初次理解时也颇费功夫。
(defun triangle-initialization (number) "Return the sum of the numbers 1 through NUMBER inclusive. This is the initialization component of a two function duo that uses recursion." (triangle-recursive-helper 0 0 number))
(defun triangle-recursive-helper (sum counter number)
"Return SUM, using COUNTER, through NUMBER inclusive.
This is the helper component of a two function duo
that uses recursion."
(if (> counter number)
sum
(triangle-recursive-helper (+ sum counter) ; sum
(1+ counter) ; counter
number))) ; number
对两个函数定义求值使其生效,然后以 2 行调用 triangle-initialization:
(triangle-initialization 2)
⇒ 3
初始化函数会以三个参数调用辅助函数的第一个实例:0、0 和三角数的行数。
传递给辅助函数的前两个参数为初始化值。这些值会在 triangle-recursive-helper 调用新实例时更新。17
我们来看只有一行的三角数情况。(该三角数只包含一个石子!)
triangle-initialization 会以参数 0 0 1 调用其辅助函数。该函数会运行条件测试 (> counter number):
(> 0 1)
结果为假,因此会执行 if 语句的 else 分支:
(triangle-recursive-helper
(+ sum counter) ; sum plus counter ⇒ sum
(1+ counter) ; increment counter ⇒ counter
number) ; number stays the same
首先计算:
(triangle-recursive-helper (+ 0 0) ; sum (1+ 0) ; counter 1) ; number
which is:
(triangle-recursive-helper 0 1 1)
再次判断,(> counter number) 仍为假,因此 Lisp 解释器会再次对 triangle-recursive-helper 求值,以新参数创建一个新实例。
这个新实例为:
(triangle-recursive-helper
(+ sum counter) ; sum plus counter ⇒ sum
(1+ counter) ; increment counter ⇒ counter
number) ; number stays the same
which is:
(triangle-recursive-helper 1 2 1)
此时,(> counter number) 测试结果为真!因此该实例会返回总和的值,即预期的 1。
现在,我们给 triangle-initialization 传入参数 2,查看两行三角数包含多少个石子。
该函数会调用 (triangle-recursive-helper 0 0 2)。
依次调用的实例为:
sum counter number
(triangle-recursive-helper 0 1 2)
(triangle-recursive-helper 1 2 2)
(triangle-recursive-helper 3 3 2)
当最后一个实例被调用时,(> counter number) 测试为真,因此该实例会返回 sum 的值,即 3。
这种模式在编写可能大量占用计算机资源的函数时非常有用。
triangle 的函数,每行的值为行号的平方。使用 while 循环实现。
triangle 的函数,将求和改为求积。
cond 重写。
你需要用到的许多函数已在前面两章介绍,Cutting and Storing Text 与 Yanking Text Back。若使用 forward-paragraph 在段落开头插入索引条目,你需要使用 C-h f(describe-function)查看如何让该命令反向移动。
更多信息请参见 Indicating in Texinfo Manual,该链接指向当前目录下的 Texinfo 手册。若你处于联网状态,也可访问 https://www.gnu.org/software/texinfo/manual/texinfo/
正则表达式搜索在 GNU Emacs 中被广泛使用。forward-sentence 与 forward-paragraph 这两个函数是很好的示例。它们使用正则表达式定位光标移动位置。“正则表达式(regular expression)” 通常简写为 “regexp”。
正则表达式搜索的相关内容在 Regular Expression Search in The GNU Emacs Manual 以及 Regular Expressions in The GNU Emacs Lisp Reference Manual 中均有介绍。编写本章时,我假定你对其已有初步了解。需要记住的要点是,正则表达式允许你搜索模式而非仅搜索字面字符串。例如,forward-sentence 中的代码会搜索可能表示句子结尾的字符模式,并将光标移动到该位置。
在查看 forward-sentence 函数的代码之前,有必要先分析句子结尾对应的模式。下一节会讨论该模式,随后介绍正则表达式搜索函数 re-search-forward。接下来一节讲解 forward-sentence 函数,本章最后一节介绍 forward-paragraph 函数。forward-paragraph 是一个较为复杂的函数,会引入多个新特性。
sentence-end 对应的正则表达式re-search-forward 函数forward-sentenceforward-paragraph:函数宝库re-search-forward 相关练习sentence-end 对应的正则表达式 ¶符号 sentence-end 绑定到表示句子结尾的模式。该正则表达式应该是什么样的?
显然,句子可以由句号、问号或感叹号结尾。实际上在英语中,只有以这三个字符之一结尾的分句才被视为句子结束。这意味着该模式应包含字符集:
[.?!]
但我们不希望 forward-sentence 只是跳转到句号、问号或感叹号,因为这些字符可能出现在句子中间。例如句号常用于缩写之后。因此还需要其他信息辅助判断。
按照惯例,句子末尾需要输入两个空格,而句子内部的句号、问号或感叹号后只输入一个空格。因此,句号、问号或感叹号后跟两个空格是句子结束的良好标志。但在文件中,这两个空格也可能是制表符或行尾。这意味着正则表达式需要将这三种情况作为备选。
这组备选形式如下:
\\($\\| \\| \\)
^ ^^
TAB SPC
其中 ‘$’ 表示行尾,我已标注出表达式中的制表符与两个空格的位置。两者均通过直接输入实际字符插入表达式。
括号与竖线前需要两个反斜杠 ‘\\’:第一个反斜杠用于在 Emacs 中转义后续反斜杠,第二个反斜杠表示后续字符(括号或竖线)为特殊符号。
此外,句子后可能跟随一个或多个回车符,如下所示:
[ ]*
与制表符和空格类似,回车符通过直接输入字面量插入正则表达式。星号表示 RET 可重复零次或多次。
但句子结尾并非仅由句号、问号或感叹号加合适空格构成:闭合引号或闭合括号等符号可能出现在空格之前。实际上可能有多个此类符号。这部分对应的表达式如下:
[]\"')}]*
该表达式中,第一个 ‘]’ 是表达式的首个字符;第二个字符是 ‘"’,前面加反斜杠 ‘\’ 告知 Emacs 该 ‘"’ 并非特殊字符。最后三个字符为 ‘'’、‘)’ 与 ‘}’。
综合以上内容,即可得到匹配句子结尾的正则表达式模式;实际上,对 sentence-end 求值会返回如下值:
sentence-end
⇒ "[.?!][]\"')}]*\\($\\| \\| \\)[
]*"
(当然,GNU Emacs 22 中并非如此;这是为了简化处理流程并支持更多字形与语言。当 sentence-end 的值为 nil 时,会使用函数 sentence-end 定义的值。(这体现了 Emacs Lisp 中变量值与函数的区别。)该函数返回由变量 sentence-end-base、sentence-end-double-space、sentence-end-without-period 与 sentence-end-without-space 构造的值。关键变量为 sentence-end-base,其全局值与上述描述类似,但额外包含两种引号(弯引号)。变量 sentence-end-without-period 为真时,会告知 Emacs 句子可以不以句号结尾,例如泰语文本。)
re-search-forward 函数 ¶re-search-forward 函数与 search-forward 函数非常相似。(See The search-forward Function。)
re-search-forward 用于搜索正则表达式。若搜索成功,会将光标定位到目标最后一个字符的后方。若为反向搜索,则将光标定位到目标第一个字符的前方。你可以设置 re-search-forward 在成功时返回 t。(移动光标是其副作用。)
与 search-forward 类似,re-search-forward 函数接受四个参数:
nil,搜索失败时函数会抛出错误(并打印信息);其他值则表示失败返回 nil、成功返回 t。
re-search-forward 进行反向搜索。
re-search-forward 的模板如下:
(re-search-forward "regular-expression"
limit-of-search
what-to-do-if-search-fails
repeat-count)
第二、第三、第四个参数为可选。但如果你需要为后两个参数中的一个或两个传值,必须同时为前面所有参数传值。否则 Lisp 解释器会混淆参数对应关系。
在 forward-sentence 函数中,正则表达式为变量 sentence-end 的值。简化形式如下:
"[.?!][]\"')}]*\\($\\| \\| \\)[ ]*"
搜索边界为段落结尾(因为句子不会跨段落)。搜索失败时函数返回 nil;重复次数由 forward-sentence 函数的参数提供。
forward-sentence ¶将光标向前移动一个句子的命令,是在 Emacs Lisp 中使用正则表达式搜索的典型示例。实际上该函数看起来比实际更长更复杂,因为它同时支持向前与向后移动,并且可以可选地跳过多个句子。该函数通常绑定到按键 M-e。
forward-sentence 函数定义 ¶forward-sentence 代码如下:
(defun forward-sentence (&optional arg) "向前移动到下一个句子结尾。带参数时重复执行。 带负参数时,反复向后移动到句子开头。 变量 `sentence-end' 是匹配句子结尾的正则表达式。 同时,所有段落边界也会终止句子。"
(interactive "p")
(or arg (setq arg 1))
(let ((opoint (point))
(sentence-end (sentence-end)))
(while (< arg 0)
(let ((pos (point))
(par-beg (save-excursion (start-of-paragraph-text) (point))))
(if (and (re-search-backward sentence-end par-beg t)
(or (< (match-end 0) pos)
(re-search-backward sentence-end par-beg t)))
(goto-char (match-end 0))
(goto-char par-beg)))
(setq arg (1+ arg)))
(while (> arg 0)
(let ((par-end (save-excursion (end-of-paragraph-text) (point))))
(if (re-search-forward sentence-end par-end t)
(skip-chars-backward " \t\n")
(goto-char par-end)))
(setq arg (1- arg)))
(constrain-to-field nil opoint t)))
该函数初看较长,最好先看整体框架再看细节。观察最左列开始的表达式即可看清框架:
(defun forward-sentence (&optional arg)
"documentation..."
(interactive "p")
(or arg (setq arg 1))
(let ((opoint (point)) (sentence-end (sentence-end)))
(while (< arg 0)
(let ((pos (point))
(par-beg (save-excursion (start-of-paragraph-text) (point))))
rest-of-body-of-while-loop-when-going-backwards
(while (> arg 0)
(let ((par-end (save-excursion (end-of-paragraph-text) (point))))
rest-of-body-of-while-loop-when-going-forwards
handle-forms-and-equivalent
这样看起来清晰很多!函数定义包含文档字符串、interactive 表达式、or 表达式、let 表达式与 while 循环。
下面逐一分析这些部分。
可以看到文档字符串详尽且清晰。
该函数使用 interactive "p" 声明。这意味着处理后的前缀参数(若有)会作为参数传递给函数。(该参数为数字。)若函数未接收参数(可选),则参数 arg 会绑定到 1。
当以非交互方式无参数调用 forward-sentence 时,arg 会绑定到 nil。or 表达式用于处理这种情况:若 arg 已有绑定值则保持不变,若为 nil 则将其设为 1。
接下来是 let 表达式,定义了两个局部变量 opoint 与 sentence-end。搜索前的光标局部值用于 constrain-to-field 函数,该函数用于处理格式与等价内容。sentence-end 变量由 sentence-end 函数设置。
while 循环 ¶接下来是两个 while 循环。第一个 while 的真假测试会在 forward-sentence 的前缀参数为负数时成立,用于实现反向移动。该循环的主体与第二个 while 子句的主体类似,但并不完全相同。我们略过这个 while 循环,重点讲解第二个。
第二个 while 循环用于向前移动光标。其结构如下:
(while (> arg 0) ; true-or-false-test
(let varlist
(if (true-or-false-test)
then-part
else-part
(setq arg (1- arg)))) ; while loop decrementer
该 while 循环属于递减型循环。(See A Loop with a Decrementing Counter。)只要计数器(此处为变量 arg)大于 0,真假测试就成立;并且每次循环重复时,递减器都会将计数器的值减 1。
如果调用 forward-sentence 时未指定前缀参数(这是最常用的方式),该 while 循环会执行一次,因为 arg 的值为 1。
while 循环的主体由一个 let 表达式构成,该表达式创建并绑定一个局部变量,其内部主体为一个 if 表达式。
while 循环的主体如下:
(let ((par-end
(save-excursion (end-of-paragraph-text) (point))))
(if (re-search-forward sentence-end par-end t)
(skip-chars-backward " \t\n")
(goto-char par-end)))
let 表达式创建并绑定局部变量 par-end。稍后我们会看到,该局部变量用于为正则表达式搜索提供边界或限制。如果在段落内未能找到合法的句子结尾,搜索会在到达段落末尾时停止。
首先,我们来看 par-end 如何绑定到段落结尾的值。let 会将 par-end 的值设为 Lisp 解释器对以下表达式求值后返回的结果:
(save-excursion (end-of-paragraph-text) (point))
在该表达式中,(end-of-paragraph-text) 将光标移动到段落结尾,(point) 返回当前光标位置的值,随后 save-excursion 将光标恢复到原始位置。因此,let 将 par-end 绑定到 save-excursion 表达式的返回值,即段落结尾的位置。(end-of-paragraph-text 函数使用了 forward-paragraph,我们稍后会讲解。)
接下来 Emacs 对 let 的主体求值,该主体是一个 if 表达式,如下所示:
(if (re-search-forward sentence-end par-end t) ; if-part (skip-chars-backward " \t\n") ; then-part (goto-char par-end))) ; else-part
if 会测试其第一个参数是否为真,若为真则执行 then 分支;否则 Emacs Lisp 解释器会执行 else 分支。该 if 表达式的真假测试即为正则表达式搜索。
将 forward-sentence 函数的核心逻辑写在这里看起来有些奇怪,但这是 Lisp 中实现此类操作的常用方式。
re-search-forward 函数用于搜索句子结尾,即由 sentence-end 正则表达式定义的模式。如果找到该模式(即找到句子结尾),re-search-forward 函数会完成两件事:
re-search-forward 产生一个副作用:将光标移动到匹配内容的末尾。
re-search-forward 返回真值。该值会被 if 接收,表示搜索成功。
光标移动这一副副作用会在 if 函数获取搜索成功的返回值之前完成。
当 if 函数从 re-search-forward 的成功调用中收到真值时,会执行 then 分支,即表达式 (skip-chars-backward " \t\n")。该表达式会反向跳过所有空格、制表符或回车符,直到找到可打印字符,并将光标停在该字符之后。由于光标已经移动到句子结尾模式的末尾,这一操作会将光标定位在句子最后一个可打印字符之后,通常是句号。
反之,如果 re-search-forward 函数未能找到表示句子结尾的模式,则返回假值。假值会使 if 执行第三个参数,即 (goto-char par-end):将光标移动到段落结尾。
(如果文本位于格式区域或等效位置,光标可能无法完全移动,此时 constrain-to-field 函数会发挥作用。)
正则表达式搜索非常实用,以 re-search-forward 为代表、将搜索作为 if 表达式判断条件的模式也十分便捷。你会经常看到或编写包含这种模式的代码。
forward-paragraph:函数宝库 ¶forward-paragraph 函数将光标向前移动到段落结尾。它通常绑定到按键 M-},并使用了多个本身就很重要的函数,包括 let*、match-beginning 和 looking-at。
forward-paragraph 的函数定义比 forward-sentence 长得多,因为它需要处理段落,而段落的每一行都可能以填充前缀开头。
填充前缀是一段在每行开头重复出现的字符。例如,在 Lisp 代码中,惯例是在段落级注释的每行开头使用 ‘;;; ’。在文本模式中,四个空格是另一种常见的填充前缀,用于创建缩进段落。(关于填充前缀的更多信息,参见 Fill Prefix in The GNU Emacs Manual。)
填充前缀的存在意味着,forward-paragraph 函数不仅要能找到所有行均从最左列开始的段落结尾,还要能处理缓冲区中大部分或所有行以填充前缀开头的段落结尾。
此外,有时忽略已存在的填充前缀更实用,尤其是在段落之间以空行分隔时。这增加了实现的复杂度。
forward-paragraph 函数定义 ¶我们不完整打印 forward-paragraph 函数,只展示部分内容。未经梳理直接阅读,该函数会令人望而生畏!
从结构上看,该函数如下:
(defun forward-paragraph (&optional arg)
"documentation..."
(interactive "p")
(or arg (setq arg 1))
(let*
varlist
(while (and (< arg 0) (not (bobp))) ; backward-moving-code
...
(while (and (> arg 0) (not (eobp))) ; forward-moving-code
...
函数的前几部分是常规结构:参数列表包含一个可选参数,随后是文档字符串。
interactive 声明中的小写 ‘p’ 表示处理后的前缀参数(若有)会传递给函数。该参数为数字,表示光标需要移动的段落重复次数。下一行的 or 表达式处理函数未接收参数的常见情况(即从其他代码而非交互方式调用时)。这种情况前面已经介绍过。(See The forward-sentence function。)至此,函数中熟悉的部分结束。
let* 表达式 ¶forward-paragraph 函数的下一行开始一个 let* 表达式(see let* introduced),Emacs 在其中总共绑定了七个变量:opoint、fill-prefix-regexp、parstart、parsep、sp-parstart、start 和 found-start。let* 表达式的前半部分如下:
(let* ((opoint (point))
(fill-prefix-regexp
(and fill-prefix (not (equal fill-prefix ""))
(not paragraph-ignore-fill-prefix)
(regexp-quote fill-prefix)))
;; Remove ^ from paragraph-start and paragraph-sep if they are there.
;; These regexps shouldn't be anchored, because we look for them
;; starting at the left-margin. This allows paragraph commands to
;; work normally with indented text.
;; This hack will not find problem cases like "whatever\\|^something".
(parstart (if (and (not (equal "" paragraph-start))
(equal ?^ (aref paragraph-start 0)))
(substring paragraph-start 1)
paragraph-start))
(parsep (if (and (not (equal "" paragraph-separate))
(equal ?^ (aref paragraph-separate 0)))
(substring paragraph-separate 1)
paragraph-separate))
(parsep
(if fill-prefix-regexp
(concat parsep "\\|"
fill-prefix-regexp "[ \t]*$")
parsep))
;; This is used for searching.
(sp-parstart (concat "^[ \t]*\\(?:" parstart "\\|" parsep "\\)"))
start found-start)
...)
变量 parsep 出现了两次:第一次用于移除 ‘^’ 实例,第二次用于处理填充前缀。
变量 opoint 就是光标 point 的值。你可以猜到,它和在 forward-sentence 中一样,用于 constrain-to-field 表达式。
变量 fill-prefix-regexp 被设为对以下列表求值后返回的值:
(and fill-prefix
(not (equal fill-prefix ""))
(not paragraph-ignore-fill-prefix)
(regexp-quote fill-prefix))
这是一个以 and 特殊形式为第一个元素的表达式。
如前所述(see The kill-new function),and 特殊形式会依次对每个参数求值,直到某个参数返回 nil,此时整个 and 表达式返回 nil;如果所有参数均未返回 nil,则返回最后一个参数的求值结果。(由于该结果非 nil,在 Lisp 中被视为真。)换句话说,and 表达式仅在所有参数均为真时返回真值。
在本例中,仅当以下四个表达式求值均为真(非 nil)时,变量 fill-prefix-regexp 才会绑定到非 nil 值;否则 fill-prefix-regexp 绑定为 nil。
fill-prefix对该变量求值时,会返回填充前缀的值(若存在)。若无填充前缀,该变量返回 nil。
(not (equal fill-prefix ""))该表达式检查已存在的填充前缀是否为空字符串(即不包含任何字符的字符串)。空字符串不是有效的填充前缀。
(not paragraph-ignore-fill-prefix)如果变量 paragraph-ignore-fill-prefix 被设为真值(如 t)而开启,该表达式返回 nil。
(regexp-quote fill-prefix)这是 and 特殊形式的最后一个参数。如果 and 的所有参数均为真,该表达式的求值结果会由 and 表达式返回,并绑定到变量 fill-prefix-regexp。
该 and 表达式成功求值的结果是:fill-prefix-regexp 会绑定到经 regexp-quote 函数处理后的 fill-prefix 值。regexp-quote 的作用是读取一个字符串,并返回一个能精确匹配该字符串且不匹配其他内容的正则表达式。这意味着,如果填充前缀存在,fill-prefix-regexp 会被设为能精确匹配该填充前缀的值;否则该变量设为 nil。
let* 表达式中的接下来两个局部变量用于从 parstart 和 parsep(分别表示段落起始和段落分隔的局部变量)中移除 ‘^’ 实例。随后的表达式再次设置 parsep,用于处理填充前缀。
这也是需要使用 let* 而非 let 定义的原因。if 的真假测试依赖于变量 fill-prefix-regexp 求值为 nil 还是其他值。
如果 fill-prefix-regexp 没有值,Emacs 会执行 if 表达式的 else 分支,并将 parsep 绑定到其局部值。(parsep 是一个匹配段落分隔内容的正则表达式。)
但如果 fill-prefix-regexp 有值,Emacs 会执行 if 表达式的 then 分支,并将 parsep 绑定到一个包含 fill-prefix-regexp 作为模式一部分的正则表达式。
具体来说,parsep 被设为原段落分隔正则表达式的值,与一个备选表达式拼接而成:该备选表达式由 fill-prefix-regexp 后跟行尾可选空白字符组成。空白字符由 "[ \t]*$" 定义。‘\\|’ 将这部分正则表达式定义为 parsep 的备选。
根据代码中的注释,下一个局部变量 sp-parstart 用于搜索;最后两个变量 start 和 found-start 被设为 nil。
现在我们进入 let* 的主体。主体第一部分处理函数接收负参数并因此反向移动的情况,我们略过这一节。
while 循环 ¶let* 主体的第二部分处理向前移动,是一个只要 arg 值大于 0 就会重复执行的 while 循环。在该函数最常见的使用场景中,参数值为 1,因此 while 循环的主体只会被求值一次,光标向前移动一个段落。
这部分处理三种情况:光标位于段落之间、存在填充前缀、不存在填充前缀。
该 while 循环如下:
;; 向前移动且未到达缓冲区末尾 (while (and (> arg 0) (not (eobp))) ;; 段落之间 ;; 向前跳过分隔行... (while (and (not (eobp)) (progn (move-to-left-margin) (not (eobp))) (looking-at parsep)) (forward-line 1)) ;; 递减循环计数 (unless (eobp) (setq arg (1- arg))) ;; ... 再前进一行。 (forward-line 1)
(if fill-prefix-regexp
;; 存在填充前缀,优先级高于 parstart;
;; 逐行向前移动
(while (and (not (eobp))
(progn (move-to-left-margin) (not (eobp)))
(not (looking-at parsep))
(looking-at fill-prefix-regexp))
(forward-line 1))
;; 不存在填充前缀;
;; 逐字符向前移动
(while (and (re-search-forward sp-parstart nil 1)
(progn (setq start (match-beginning 0))
(goto-char start)
(not (eobp)))
(progn (move-to-left-margin)
(not (looking-at parsep)))
(or (not (looking-at parstart))
(and use-hard-newlines
(not (get-text-property (1- start) 'hard)))))
(forward-char 1))
;; 若无填充前缀且未到缓冲区末尾,
;; 跳转到正则表达式搜索 sp-parstart 找到的位置
(if (< (point) (point-max))
(goto-char start))))
可以看出这是一个递减计数器型 while 循环,使用表达式 (setq arg (1- arg)) 作为递减器。该表达式距离 while 不远,但隐藏在另一个 Lisp 宏 unless 中。除非我们到达缓冲区末尾(由 eobp 函数判断,是 ‘End Of Buffer P’ 的缩写),否则将 arg 的值减 1。
(如果已到缓冲区末尾,就无法再向前移动,由于判断条件包含 (not (eobp)),while 表达式的下一轮循环会判定为假。not 函数的含义正如其名,它是 null 的别名,在参数为假时返回真。)
有趣的是,循环计数直到我们离开段落之间的空白区域才会递减,除非到达缓冲区末尾或不再匹配段落分隔的局部值。
第二个 while 还包含一个 (move-to-left-margin) 表达式。该函数顾名思义,它位于 progn 表达式内部且不是主体最后一个元素,因此仅为执行其副作用:将光标移动到当前行的左页边距。
looking-at 函数也顾名思义:如果光标后的文本匹配其参数指定的正则表达式,则返回真。
循环主体的其余部分初看有些复杂,但理解后就会变得清晰。
首先看存在填充前缀时的情况:
(if fill-prefix-regexp
;; 存在填充前缀,优先级高于 parstart;
;; 逐行向前移动
(while (and (not (eobp))
(progn (move-to-left-margin) (not (eobp)))
(not (looking-at parsep))
(looking-at fill-prefix-regexp))
(forward-line 1))
该表达式会逐行向前移动光标,只要以下四个条件均成立:
最后一个条件初看有些费解,但别忘了在 forward-paragraph 函数开头光标已被移动到行首。这意味着如果文本存在填充前缀,looking-at 函数就能检测到。
再看不存在填充前缀时的情况。
(while (and (re-search-forward sp-parstart nil 1)
(progn (setq start (match-beginning 0))
(goto-char start)
(not (eobp)))
(progn (move-to-left-margin)
(not (looking-at parsep)))
(or (not (looking-at parstart))
(and use-hard-newlines
(not (get-text-property (1- start) 'hard)))))
(forward-char 1))
该 while 循环会向前搜索 sp-parstart,即可能的空白字符加上段落起始或段落分隔的局部值。(后两者位于以 \(?: 开头的表达式内,因此不会被 match-beginning 函数引用。)
以下两个表达式:
(setq start (match-beginning 0)) (goto-char start)
含义是跳转到正则表达式搜索匹配文本的开头。
(match-beginning 0) 表达式是新知识点。它返回一个数字,指定上一次搜索匹配文本的起始位置。
这里使用 match-beginning 函数是因为向前搜索的一个特性:无论普通搜索还是正则表达式搜索,成功的向前搜索都会将光标移动到找到文本的末尾。在本例中,成功的搜索会将光标移动到 sp-parstart 模式的末尾。
但我们希望将光标放在当前段落的末尾,而非其他位置。实际上,由于搜索可能包含段落分隔符,如果不使用包含 match-beginning 的表达式,光标可能会停在下一段落的开头。
当参数为 0 时,match-beginning 返回最近一次搜索匹配文本的起始位置。在本例中,最近一次搜索的目标是 sp-parstart。(match-beginning 0) 表达式返回该模式的起始位置,而非结束位置。
(顺便一提,当传入正数作为参数时,match-beginning 函数返回上一次搜索中对应括号表达式的光标位置,除非该括号表达式以 \(?: 开头。我不清楚此处为何出现 \(?:,因为参数是 0。)
不存在填充前缀时的最后一个表达式是:
(if (< (point) (point-max))
(goto-char start))))
(注意该代码片段直接复制自原始代码,因此末尾两个额外的右括号用于匹配前面的 if 和 while。)
含义是:如果不存在填充前缀且未到缓冲区末尾,光标应跳转到正则表达式搜索 sp-parstart 所找到内容的开头。
forward-paragraph 函数的完整定义不仅包含向前移动的代码,也包含向后移动的代码。
如果你正在 GNU Emacs 内阅读本文并想查看完整函数,可以按 C-h f(describe-function)并输入函数名。这会显示函数文档以及包含函数源码的库名称。将光标放在库名称上并按 RET 键,即可直接跳转到源码。(务必安装源码!没有源码,你就像闭着眼睛开车一样!)
这里简要汇总一些近期介绍的函数。
while只要表达式主体的第一个元素测试为真,就重复执行主体内容。执行完毕后返回 nil。(该表达式仅为产生副作用而执行。)
例如:
(let ((foo 2))
(while (> foo 0)
(insert (format "foo is %d.\n" foo))
(setq foo (1- foo))))
⇒ foo is 2.
foo is 1.
nil
(insert 函数将其参数插入到当前光标位置;format 函数根据参数格式化并返回字符串,方式与 message 类似;\n 表示换行。)
re-search-forward搜索指定模式,若找到则将光标移动到匹配内容的紧后方。
与 search-forward 类似,接受四个参数:
nil 或抛出错误信息。
let*将若干变量局部绑定到指定值,随后执行剩余参数并返回最后一个表达式的值。绑定局部变量时,可使用此前已绑定变量的局部值。
例如:
(let* ((foo 7)
(bar (* 3 foo)))
(message "`bar' is %d." bar))
⇒ ‘bar’ is 21.
match-beginning返回上一次正则表达式搜索所匹配文本的起始位置。
looking-at若光标后的文本与参数(正则表达式)匹配,则返回 t 表示真。
eobp若光标位于缓冲区可访问部分的末尾,则返回 t 表示真。若缓冲区未进行窄化,可访问部分的末尾即为缓冲区末尾;若已窄化,则为窄化范围的末尾。
re-search-forward 相关练习 ¶the-the Duplicated Words Function.
循环与正则表达式搜索是编写 Emacs Lisp 代码时常用的强大工具。本章将通过构建单词统计命令,演示如何结合 while 循环与递归使用正则表达式搜索。
标准 Emacs 发行版中已包含统计区域内行数与单词数的函数。
某些类型的写作需要统计单词数量。例如写短文可能限制在 800 词以内,写小说可能要求每天写 1000 词。奇怪的是,在很长一段时间里 Emacs 并没有自带单词统计命令。也许人们主要用 Emacs 编写代码或无需统计单词的文档,或是直接使用系统自带的单词统计命令 wc。也有人沿用出版行业惯例,将文档字符数除以五来估算单词数。
实现单词统计命令有很多方式。以下示例可供你与 Emacs 标准命令 count-words-region 对比。
count-words-example 函数 ¶单词统计命令可以按行、按段落、按区域或按缓冲区统计。命令的适用范围该如何设计?你可以设计成对整个缓冲区统计单词数。但 Emacs 的传统更强调灵活性——你可能只想统计某一部分而非整个缓冲区。因此更合理的设计是统计指定区域内的单词数。拥有区域统计命令后,如需统计整个缓冲区,只需用 C-x h(mark-whole-buffer)全选即可。
显然,单词统计是重复操作:从区域起始位置开始,依次统计第一个、第二个、第三个单词,直到区域末尾。这意味着单词统计非常适合用递归或 while 循环实现。
count-words-example 的设计 ¶我们先使用 while 循环实现单词统计命令,再改用递归实现。该命令自然需要支持交互调用。
交互式函数定义的模板一如既往:
(defun name-of-function (argument-list) "documentation..." (interactive-expression...) body...)
我们需要填充这些占位部分。
函数名应直观易记。count-words-region 是最直观的选择,但该名称已被 Emacs 标准单词统计命令占用,因此我们将实现命名为 count-words-example。
该函数用于统计区域内单词,因此参数列表需要包含绑定区域起始与结束两个位置的符号,可分别命名为 ‘beginning’ 和 ‘end’。文档字符串首行应为单句,因为类似 apropos 的命令只会显示这部分内容。交互式表达式格式为 ‘(interactive "r")’,使 Emacs 将区域起止位置传入函数参数。这些都是常规写法。
函数主体需要完成三项工作:第一,为 while 循环统计单词设置合适条件;第二,运行 while 循环;第三,向用户输出提示信息。
用户调用 count-words-example 时,光标可能在区域开头或结尾。但统计必须从区域起始位置开始,因此需要用 (goto-char beginning) 确保光标移至起始处。函数执行完毕后,应将光标恢复到原有位置,因此主体需包裹在 save-excursion 表达式中。
函数主体的核心是 while 循环:一个表达式逐词移动光标,另一个表达式统计次数。while 循环的条件判断应在需要继续前进时为真,到达区域末尾时为假。
我们可以用 (forward-word 1) 逐词前移光标,但使用正则表达式搜索能更清晰地看出 Emacs 如何定义 “单词(word)”。
正则表达式搜索在找到匹配模式后,会将光标留在匹配内容的最后一个字符之后。因此连续成功的单词搜索会逐词推进光标。
实际使用中,我们希望正则表达式能跳过单词之间的空白与标点,连同单词一起跳过。若正则表达式无法跳过单词间空白,就永远只能前进一个单词!这意味着正则表达式应包含单词本身以及其后可选的空白与标点。(单词可能位于缓冲区末尾,其后无任何空白或标点,因此这部分必须设为可选。)
因此,我们需要的正则表达式模式为:一个或多个单词构成字符,后接可选的一个或多个非单词构成字符。对应的正则表达式为:
\w+\W*
缓冲区的语法表决定哪些字符属于或不属于单词构成字符。有关语法的更多信息,see Syntax Tables in The GNU Emacs Lisp Reference Manual。
搜索表达式如下:
(re-search-forward "\\w+\\W*")
(注意 ‘w’ 和 ‘W’ 前成对的反斜杠。单个反斜杠对 Emacs Lisp 解释器有特殊含义,表示其后字符按特殊方式解析。例如 ‘\n’ 代表换行而非反斜杠加字母 n。连续两个反斜杠表示普通无特殊含义的反斜杠,因此 Emacs Lisp 最终看到的是单个反斜杠加字母,并识别其特殊含义。)
我们需要一个计数器统计单词数量,该变量初始化为 0,并在每次 while 循环时自增。自增表达式为:
(setq count (1+ count))
最后,我们需要告知用户区域内单词总数。message 函数用于向用户展示此类信息。提示信息的措辞需根据单词数量正确变化,避免出现 “区域内有 1 单词” 这类语法错误。我们可以使用条件表达式,根据区域内单词数量输出不同信息,共三种情况:无单词、一个单词、多个单词。因此使用 cond 特殊形式最为合适。
综合以上内容,得到如下函数定义:
;;; 第一版;存在错误!
(defun count-words-example (beginning end)
"打印区域内单词数量。
单词定义为至少一个单词构成字符,后接至少一个非单词构成字符。
哪些字符属于此类由缓冲区语法表决定。"
(interactive "r")
(message "Counting words in region ... ")
;;; 1. 设置合适条件
(save-excursion
(goto-char beginning)
(let ((count 0))
;;; 2. 运行 while 循环 (while (< (point) end) (re-search-forward "\\w+\\W*") (setq count (1+ count)))
;;; 3. 向用户输出信息
(cond ((zerop count)
(message
"The region does NOT have any words."))
((= 1 count)
(message
"The region has 1 word."))
(t
(message
"The region has %d words." count))))))
当前版本的函数虽可运行,但并非在所有场景下都正常。
count-words-example 中的空白字符错误 ¶上一节介绍的 count-words-example 命令存在两处错误,更准确地说是同一错误的两种表现。第一,若在文本中间选中一段仅含空白的区域,count-words-example 会提示该区域包含一个单词!第二,若在缓冲区末尾或窄化缓冲区可访问部分末尾选中一段仅含空白的区域,命令会抛出如下错误:
Search failed: "\\w+\\W*"
如果你正在 GNU Emacs 的 Info 中阅读本文,可以亲自测试这些错误。
首先,按常规方式求值函数以完成加载。
如需,还可求值以下代码绑定快捷键:
(keymap-global-set "C-c =" 'count-words-example)
进行第一次测试:将标记与光标分别置于下一行开头与结尾,然后键入 C-c =(若未绑定快捷键则使用 M-x count-words-example):
one two three
Emacs 会正确提示该区域有三个单词。
重复测试,但将标记置于行首,光标放在单词 ‘one’ 前方。再次执行命令 C-c =(或 M-x count-words-example)。Emacs 本应提示该区域无单词,因为只有行首空白,但却提示有一个单词!
第三次测试:将示例行复制到 *scratch* 缓冲区末尾,并在行尾添加若干空格。将标记放在单词 ‘three’ 之后,光标置于行尾(即缓冲区末尾)。再次键入 C-c =(或 M-x count-words-example)。Emacs 本应提示无单词,却抛出 ‘Search failed’ 错误。
两处错误源于同一问题。
先看第一种错误表现:命令提示行首空白处有一个单词。过程如下:M-x count-words-example 将光标移至区域起始,while 判断光标位置小于 end,条件成立。随后正则表达式搜索找到第一个单词,将光标移至单词后,count 设为 1。while 循环再次判断时,光标位置已大于 end,循环退出,函数提示区域内有一个单词。简言之,正则表达式搜索超出选中区域找到了单词。
第二种错误表现:区域为缓冲区末尾的空白。Emacs 提示 ‘Search failed’。原因是 while 循环条件成立,执行搜索表达式,但缓冲区已无单词,搜索失败。
两种错误中,搜索均超出或试图超出区域范围。
解决方案是将搜索限制在区域内——这一操作看似简单,但实际并非如想象中直接。
如前所述,re-search-forward 函数第一个参数为搜索模式,此外还接受三个可选参数:第二个可选参数限定搜索边界;第三个可选参数若为 t,搜索失败时返回 nil 而非抛出错误;第四个可选参数为重复次数。(在 Emacs 中,可通过 C-h f 加函数名并回车查看函数文档。)
在 count-words-example 定义中,区域结束位置保存在变量 end 中并作为参数传入函数。因此可将 end 加入正则表达式搜索参数:
(re-search-forward "\\w+\\W*" end)
但若仅对 count-words-example 做此修改并在空白区域测试,仍会收到 ‘Search failed’ 错误。
原因是:搜索被限制在区域内,且区域内无单词构成字符,因此按预期失败并抛出错误。但我们此时不希望报错,而希望显示 “该区域内没有任何单词。”
解决方法是为 re-search-forward 提供第三个参数 t,使其搜索失败时返回 nil 而非抛出错误。
但若仅做此修改,你会看到 “Counting words in region ...”(正在统计区域内单词 ...) 并一直停留,直到键入 C-g(keyboard-quit)。
原因是:搜索仍限制在区域内,且因无单词构成字符失败,re-search-forward 返回 nil,未执行任何其他操作,尤其没有像搜索成功时那样移动光标。re-search-forward 返回 nil 后,while 循环内下一个表达式执行,计数器自增,循环再次判断。由于光标位置仍小于 end,条件持续成立,循环无限执行。
count-words-example 还需进一步修改,使 while 循环条件在搜索失败时为假。换句话说,计数器自增前必须同时满足两个条件:光标仍在区域内,且搜索成功找到单词。
由于两个条件需同时成立,可将区域判断与搜索表达式用 and 特殊形式结合,作为 while 循环条件:
(and (< (point) end) (re-search-forward "\\w+\\W*" end t))
re-search-forward 搜索成功时返回 t 并移动光标,因此找到单词时光标会在区域内推进。当搜索无法找到更多单词或光标到达区域末尾时,循环条件为假,while 循环退出,count-words-example 显示对应提示。
加入最终修改后,count-words-example 可正常运行(至少未发现已知错误)。完整代码如下:
;;; 最终版: while 实现
(defun count-words-example (beginning end)
"打印区域内单词数量。"
(interactive "r")
(message "Counting words in region ... ")
;;; 1. 设置合适条件
(save-excursion
(let ((count 0))
(goto-char beginning)
;;; 2. 运行 while 循环 (while (and (< (point) end) (re-search-forward "\\w+\\W*" end t)) (setq count (1+ count)))
;;; 3. 向用户输出信息
(cond ((zerop count)
(message
"The region does NOT have any words."))
((= 1 count)
(message
"The region has 1 word."))
(t
(message
"The region has %d words." count))))))
除 while 循环外,也可递归编写单词统计函数。下面介绍实现方法。
首先,count-words-example 函数承担三项任务:设置统计所需条件、统计区域内单词、向用户输出统计结果。
若用单个递归函数完成所有工作,每次递归调用都会输出一条信息。若区域有 13 个单词,会连续弹出 13 条信息,这并非我们想要的。因此需拆分为两个函数,其中递归函数嵌套在另一个函数内部:一个负责设置条件并显示信息,另一个返回单词计数。
我们继续将负责显示信息的函数命名为 count-words-example。
该函数由用户直接调用,支持交互,结构与之前版本类似,区别在于调用 recursive-count-words 获取区域单词数。
基于之前版本可快速构建函数模板:
;; 递归版;使用正则表达式搜索
(defun count-words-example (beginning end)
"documentation..."
(interactive-expression...)
;;; 1. 设置合适条件
(explanatory message)
(set-up functions...
;;; 2. 统计单词
recursive call
;;; 3. 向用户输出信息
message providing word count))
定义看似直观,关键在于递归调用返回的计数值需传递给信息展示部分。稍加思考可知,可借助 let 表达式:在 let 变量列表中绑定变量为递归调用返回的单词数,再通过 cond 表达式向用户展示结果。
通常 let 内的绑定被视为函数次要工作,但在此例中,统计单词这一核心工作恰在 let 内部完成。
使用 let 后的函数定义如下:
(defun count-words-example (beginning end) "Print number of words in the region." (interactive "r")
;;; 1. 设置合适条件
(message "Counting words in region ... ")
(save-excursion
(goto-char beginning)
;;; 2. 统计单词
(let ((count (recursive-count-words end)))
;;; 3. 向用户输出信息
(cond ((zerop count)
(message
"The region does NOT have any words."))
((= 1 count)
(message
"The region has 1 word."))
(t
(message
"The region has %d words." count))))))
接下来编写递归统计函数。
递归函数至少包含三部分:继续执行条件、步进表达式、递归调用。
继续执行条件决定函数是否再次调用。由于我们在区域内统计单词并可逐词移动光标,该条件可检查光标是否仍在区域内,获取光标位置并判断其在区域结束位置之前、之上还是之后。可使用 point 函数获取光标位置,显然需将区域结束位置作为参数传入递归统计函数。
此外,继续执行条件还需判断搜索是否找到单词,未找到则不再递归调用。
步进表达式用于修改值,使递归函数在合适时机停止调用。更准确地说,它修改值使继续执行条件在恰当时候终止递归。本例中,步进表达式即为逐词推进光标的表达式。
递归函数第三部分是递归调用本身。
同时还需一部分负责实际计数工作,这是核心功能。
至此,递归统计函数的框架已清晰:
(defun recursive-count-words (region-end) "documentation..." do-again-test next-step-expression recursive call)
现在填充具体内容。先处理最简单情况:若光标已达或超出区域末尾,区域内无单词,函数返回 0;同理,若搜索失败,无单词可统计,也返回 0。
反之,若光标在区域内且搜索成功,函数应再次调用自身。
因此,继续执行条件如下
(and (< (point) region-end)
(re-search-forward "\\w+\\W*" region-end t))
注意搜索表达式是继续执行条件的一部分——搜索成功返回 t,失败返回 nil。(See The Whitespace Bug in count-words-example, 查阅 re-search-forward 工作原理。)
继续执行条件作为 if 语句的真假判断。条件成立时,if 真值分支再次调用函数;失败时,假值分支返回 0,表示光标超出区域或搜索未找到单词。
在讨论递归调用前,先明确步进表达式。有趣的是,它就是继续执行条件中的搜索部分。
re-search-forward 除为继续执行条件返回 t 或 nil 外,搜索成功时还会前移光标,这一副作用改变光标位置,使递归函数在光标遍历完区域后停止调用。因此 re-search-forward 表达式即为步进表达式。
综上,recursive-count-words 主体框架如下:
(if do-again-test-and-next-step-combined
;; then
recursive-call-returning-count
;; else
return-zero)
如何融入计数机制?
若不熟悉递归函数编写,这一问题可能令人困惑,但可按逻辑逐步推导。
我们知道,计数机制在某种程度上应当与递归调用相关联。实际上,由于下一步表达式会将指针向前移动一个词,并且对每个词都会执行一次递归调用,因此计数机制必然是这样一种表达式:它会对 recursive-count-words 调用所返回的值加一。
分情况说明:
由框架可知,if 假值分支在无单词时返回 0,因此真值分支必须返回剩余单词计数加 1。
表达式如下,其中 1+ 为对参数加 1 的函数:
(1+ (recursive-count-words region-end))
完整 recursive-count-words 函数如下:
(defun recursive-count-words (region-end)
"documentation..."
;;; 1. do-again-test
(if (and (< (point) region-end)
(re-search-forward "\\w+\\W*" region-end t))
;;; 2. then-part: the recursive call (1+ (recursive-count-words region-end)) ;;; 3. else-part 0))
我们来分析其工作原理:
如果区域内没有单词,将执行 if 表达式的 else 分支,函数最终返回零。
如果区域内有一个单词,指针 point 的值小于 region-end 的值,且搜索成功。此时,if 表达式的条件判断结果为真,将执行 if 表达式的 then 分支,并对计数表达式进行求值。该表达式返回的值(也将是整个函数的返回值)为递归调用返回值加一。
与此同时,下一步表达式会使指针 point 跳过区域内的第一个(在此情况下也是唯一的)单词。这意味着,当递归调用第二次对 (recursive-count-words region-end) 求值时,指针 point 的值将等于或大于区域结束位置的值。因此本次 recursive-count-words 会返回零。将零与一相加,最初对 recursive-count-words 的求值将返回一加零,结果为一,即正确的计数。
显然,如果区域内有两个单词,第一次调用 recursive-count-words 会返回一,再加上对包含剩余一个单词的区域调用 recursive-count-words 的返回值 — 也就是一加一,结果为二,即正确的计数。
同理,如果区域内有三个单词,第一次调用 recursive-count-words 会返回一,再加上对包含剩余两个单词的区域调用 recursive-count-words 的返回值,以此类推。
完整文档化后的两个函数如下所示:
递归函数:
(defun recursive-count-words (region-end) "Number of words between point and REGION-END."
;;; 1. do-again-test
(if (and (< (point) region-end)
(re-search-forward "\\w+\\W*" region-end t))
;;; 2. then-part: the recursive call (1+ (recursive-count-words region-end)) ;;; 3. else-part 0))
包装函数:
;;; Recursive version
(defun count-words-example (beginning end)
"Print number of words in the region.
Words are defined as at least one word-constituent character followed by at least one character that is not a word-constituent. The buffer's syntax table determines which characters these are."
(interactive "r")
(message "Counting words in region ... ")
(save-excursion
(goto-char beginning)
(let ((count (recursive-count-words end)))
(cond ((zerop count)
(message
"The region does NOT have any words."))
((= 1 count)
(message "The region has 1 word."))
(t
(message
"The region has %d words." count))))))
defun 中的单词数量 ¶我们的下一个项目是统计函数定义中的单词数量。显然,这可以借助 count-words-example
的某种变体实现。See Counting via Repetition and Regexps。如果我们
只需要统计单个定义中的单词,只需使用 C-M-h(mark-defun)命令标记该定义,
然后调用 count-words-example 即可,十分简便。
不过,我的目标更宏大:我想统计 Emacs 源码中每个定义里的单词与符号数量,然后生成一张图表, 展示不同长度的函数各有多少:有多少函数包含 40 到 49 个单词或符号,多少包含 50 到 59 个, 依此类推。我一直很好奇一个典型函数的长度是多少,这个项目可以给出答案。
count-words-in-defun 函数defunlengths-list-file 详解defun 的单词用一句话概括,这个直方图项目看起来很艰巨;但如果拆分成许多小步骤,一步步逐个完成,项目就不再 那么令人畏惧。我们来梳理一下必须的步骤:
count-words-in-defun。
这确实是个不小的项目!但只要一步步慢慢来,并不会很难。
当我们开始思考如何统计函数定义中的单词时,第一个问题(或者说应该问的问题)是:我们要统计什么?
当我们针对 Lisp 函数定义谈论 “单词” 时,实际上大部分指的是符号。例如,下面的 multiply-by-seven
函数包含五个符号:defun、multiply-by-seven、number、* 和 7。
此外,在文档字符串中还包含四个单词:‘Multiply’、‘NUMBER’、‘by’ 和 ‘seven’。
符号 ‘number’ 重复出现,因此该定义总共有十个单词与符号。
(defun multiply-by-seven (number) "Multiply NUMBER by seven." (* 7 number))
但如果我们用 C-M-h(mark-defun)标记 multiply-by-seven 定义,
然后对其调用 count-words-example,会发现 count-words-example 声称该定义
有十一个单词,而不是十个!哪里出了问题!
问题有两方面:count-words-example 不把 ‘*’ 算作单词,并且将单个符号
multiply-by-seven 计为三个单词。连字符被当作单词间的分隔符,而非单词内部连接符:
‘multiply-by-seven’ 被当作 ‘multiply by seven’ 统计。
造成这种混淆的原因是 count-words-example 定义中用于逐个单词移动光标位置的正则表达式搜索。
在标准版本的 count-words-example 中,正则表达式为:
"\\w+\\W*"
该正则表达式定义的模式是:一个或多个单词构成字符,后接可选的一个或多个非单词构成字符。 而“单词构成字符”的含义将我们引向语法问题,这值得单独用一节讲解。
Emacs 将不同字符归入不同的 语法类别(syntax categories)。例如,正则表达式 ‘\\w+’ 是匹配一个或多个 单词构成字符的模式。单词构成字符属于一个语法类别。其他语法类别包括标点符号类(如句号、逗号) 和空白字符类(如空格、制表符)。(更多信息参见 see Syntax Tables in The GNU Emacs Lisp Reference Manual。)
语法表规定了哪些字符属于哪些类别。通常,连字符并不被指定为单词构成字符,而是被归入属于符号名
一部分但不属于单词的字符类别。这意味着 count-words-example 函数将其当作单词间空白符
处理,这也是它把 ‘multiply-by-seven’ 计为三个单词的原因。
有两种方法可以让 Emacs 将 ‘multiply-by-seven’ 计为一个符号:修改语法表,或修改正则表达式。
我们可以通过修改 Emacs 为每种模式维护的语法表,将连字符重新定义为单词构成字符。这能满足需求, 但问题在于,连字符只是符号中最常见的非典型单词构成字符,还有其他类似字符。
另一种方法是重新定义 count-words-example 中使用的正则表达式,使其包含符号。
这种方法更清晰,但实现起来略有些技巧性。
第一部分很简单:模式必须匹配至少一个单词或符号构成字符。如下:
"\\(\\w\\|\\s_\\)+"
‘\\(’ 是分组结构的开头,将 ‘\\w’ 与 ‘\\s_’ 作为由 ‘\\|’ 分隔的候选项。 ‘\\w’ 匹配任意单词构成字符,‘\\s_’ 匹配任意属于符号名但非单词构成的字符。 分组后的 ‘+’ 表示必须至少匹配一次单词或符号构成字符。
不过,正则表达式的第二部分设计起来更困难。我们希望在第一部分后接可选的一个或多个非单词、 非符号构成字符。起初我以为可以这样定义:
"\\(\\W\\|\\S_\\)*"
大写的 ‘W’ 与 ‘S’ 匹配非单词或非符号构成字符。遗憾的是,该表达式会匹配 任意“非单词构成 或 非符号构成”的字符——这等价于匹配任意字符!
后来我发现,测试区域中的每个单词或符号后都跟着空白符(空格、制表符或换行)。于是我尝试在 单词或符号构成模式后添加匹配一个或多个空格的模式,但同样失败。单词与符号通常由空白分隔, 但实际代码中括号可能跟在符号后,标点可能跟在单词后。最终,我设计出一种模式:单词或符号构成 字符后接可选的非空白字符,再后接可选的空白字符。
完整的正则表达式如下:
"\\(\\w\\|\\s_\\)+[^ \t\n]*[ \t\n]*"
count-words-in-defun 函数 ¶我们已经见过多种编写 count-words-region 函数的方式。要编写 count-words-in-defun,
只需对其中一个版本进行适配即可。
使用 while 循环的版本易于理解,因此我选择适配该版本。由于 count-words-in-defun
将作为更复杂程序的一部分,它无需是交互式函数,也不必显示消息,只需返回计数值即可。这些考虑
略微简化了定义。
另一方面,count-words-in-defun 将在包含函数定义的缓冲区中使用。因此,合理的设计是
让函数判断光标是否位于某个函数定义内,如果是,则返回该定义的计数值。这增加了定义的复杂度,
但省去了向函数传递参数的需要。
基于这些考虑,我们可以准备如下模板:
(defun count-words-in-defun ()
"documentation..."
(set up...
(while loop...)
return count)
和往常一样,我们的工作是填充这些占位部分。
首先是初始化部分。
我们假定该函数在包含函数定义的缓冲区中调用。光标可能位于某个函数定义内,也可能不在。
为使 count-words-in-defun 正常工作,光标需要移至定义开头,计数器从零开始,
且计数循环在光标到达定义结尾时停止。
beginning-of-defun 函数向后搜索行首的左分隔符(如 ‘(’),并将光标移至该位置,
或到达搜索边界。实际使用中,这意味着 beginning-of-defun 会将光标移至当前或前一个
函数定义的开头,或缓冲区开头。我们可以用它将光标置于统计起始位置。
while 循环需要一个计数器跟踪被统计的单词或符号。可以使用 let 表达式创建
局部变量,并将其初始值绑定为零。
end-of-defun 函数与 beginning-of-defun 类似,只是将光标移至定义结尾。
它可以用于确定定义结尾位置的表达式中。
count-words-in-defun 的初始化部分很快成型:首先将光标移至定义开头,然后创建
保存计数值的局部变量,最后记录定义结尾位置,使 while 循环知道何时停止。
代码如下:
(beginning-of-defun)
(let ((count 0)
(end (save-excursion (end-of-defun) (point))))
代码十分简洁。唯一略显复杂的地方可能与 end 有关:该变量通过 save-excursion 表达式绑定到定义的结束位置,该表达式会在 end-of-defun 临时将指针移动到定义末尾后,返回指针 point 的值。
count-words-in-defun 初始化之后的第二部分是 while 循环。
循环中需要一个表达式逐个单词、逐个符号地向前跳转光标,另一个表达式统计跳转次数。
while 循环的条件测试在需要继续跳转时为真,到达定义结尾时为假。我们已经重新定义了
对应的正则表达式,因此循环十分直观:
(while (and (< (point) end)
(re-search-forward
"\\(\\w\\|\\s_\\)+[^ \t\n]*[ \t\n]*" end t))
(setq count (1+ count)))
函数定义的第三部分返回单词与符号的计数值。这部分是 let 表达式体内的最后一个表达式,
可以简单地使用局部变量 count,对其求值即返回计数值。
组合起来,count-words-in-defun 定义如下:
(defun count-words-in-defun ()
"Return the number of words and symbols in a defun."
(beginning-of-defun)
(let ((count 0)
(end (save-excursion (end-of-defun) (point))))
(while
(and (< (point) end)
(re-search-forward
"\\(\\w\\|\\s_\\)+[^ \t\n]*[ \t\n]*"
end t))
(setq count (1+ count)))
count))
如何测试?该函数并非交互式,但可以很容易地包装成交互式函数;我们可以使用与
count-words-example 递归版本几乎相同的代码:
;;; Interactive version.
(defun count-words-defun ()
"Number of words and symbols in a function definition."
(interactive)
(message
"Counting words and symbols in function definition ... ")
(let ((count (count-words-in-defun)))
(cond
((zerop count)
(message
"The definition does NOT have any words or symbols."))
((= 1 count)
(message
"The definition has 1 word or symbol."))
(t
(message
"The definition has %d words or symbols." count)))))
我们复用 C-c = 作为方便的快捷键绑定:
(keymap-global-set "C-c =" 'count-words-defun)
现在可以测试 count-words-defun:安装 count-words-in-defun 与
count-words-defun,并设置快捷键。然后将以下代码复制到 Emacs Lisp 缓冲区
(如 *scratch*),将光标置于定义内,使用 C-c = 命令。
(defun multiply-by-seven (number)
"Multiply NUMBER by seven."
(* 7 number))
⇒ 10
成功!该定义包含 10 个单词与符号。
下一个问题是统计单个文件中多个定义的单词与符号数量。
defun ¶像 simple.el 这样的文件可能包含上百个函数定义。我们的长期目标是收集多个文件的统计数据, 但第一步先实现单个文件的统计。
统计信息是一系列数字,每个数字代表一个函数定义的长度。我们可以将数字保存在列表中。
我们知道后续需要将单个文件的信息与多个文件的信息合并;这意味着用于统计单个文件内定义长度 的函数只需返回长度列表即可,不必也不应该显示任何消息。
单词统计命令包含一个逐个单词前移光标的表达式和一个统计跳转次数的表达式。返回定义长度的函数 可以按同样思路设计:一个表达式逐个定义前移光标,另一个表达式构造长度列表。
这样梳理后,函数定义就十分简单了。显然,我们需要从文件开头开始统计,因此第一条命令是
(goto-char (point-min))。接着启动 while 循环;循环的条件测试可以是
正则表达式搜索下一个函数定义 — 只要搜索成功,光标就前移,然后执行循环体。循环体需要
一个构造长度列表的表达式。列表构造函数 cons 可以用来创建列表。几乎就是全部了。
代码片段如下:
(goto-char (point-min))
(while (re-search-forward "^(defun" nil t)
(setq lengths-list
(cons (count-words-in-defun) lengths-list)))
我们尚未实现的是查找包含函数定义的文件的机制。
在之前的示例中,我们要么使用当前 Info 文件,要么在其他缓冲区(如 *scratch*)间切换。
查找文件是我们尚未讨论的新操作。
在 Emacs 中查找文件使用 C-x C-f(find-file)命令。该命令基本适用于长度统计任务,
但并非完全契合。
我们来看 find-file 的源码:
(defun find-file (filename) "Edit file FILENAME. Switch to a buffer visiting file FILENAME, creating one if none already exists." (interactive "FFind file: ") (switch-to-buffer (find-file-noselect filename)))
(最新版本的 find-file 函数定义允许指定可选通配符以打开多个文件;这使其定义更复杂,
此处不做讨论,因为与当前问题无关。你可以通过 M-.(xref-find-definitions)
或 C-h f(describe-function)查看其源码。)
我展示的定义拥有简洁但完整的文档,以及交互式声明,在交互使用时会提示输入文件名。
定义体包含两个函数:find-file-noselect 与 switch-to-buffer。
根据 C-h f(describe-function 命令)显示的文档,find-file-noselect
函数将指定文件读入缓冲区并返回该缓冲区。(其最新版本也包含可选的 wildcards 参数,
以及用于直接读取文件和抑制警告消息的参数,这些可选参数与当前无关。)
但 find-file-noselect 并不会选中它存放文件的缓冲区。Emacs 不会将注意力
(以及使用该函数的用户)切换到选中的缓冲区。这正是 switch-to-buffer 的作用:
它将 Emacs 关注的缓冲区切换到新缓冲区,并将窗口中显示的缓冲区切换为新缓冲区。
我们在其他地方讨论过缓冲区切换。(See 切换缓冲区。)
在本直方图项目中,程序在计算每个定义长度时,无需在屏幕上显示每个文件。我们可以不使用
switch-to-buffer,而是使用 set-buffer,它将程序的注意力重定向到另一个
缓冲区,但不在屏幕上重新显示。因此,我们不能直接调用 find-file,必须编写
自己的表达式。
任务很简单:使用 find-file-noselect 与 set-buffer。
lengths-list-file 详解 ¶lengths-list-file 函数的核心是一个 while 循环,其中包含逐个 defun 前移光标的
函数,以及统计每个 defun 中单词与符号数量的函数。该核心外围需要包裹完成其他任务的函数,
包括查找文件、确保光标从文件开头开始。函数定义如下:
(defun lengths-list-file (filename) "Return list of definitions' lengths within FILE. The returned list is a list of numbers. Each number is the number of words or symbols in one function definition."
(message "Working on `%s' ... " filename)
(save-excursion
(let ((buffer (find-file-noselect filename))
(lengths-list))
(set-buffer buffer)
(setq buffer-read-only t)
(widen)
(goto-char (point-min))
(while (re-search-forward "^(defun" nil t)
(setq lengths-list
(cons (count-words-in-defun) lengths-list)))
(kill-buffer buffer)
lengths-list)))
该函数接收一个参数,即要处理的文件名。它有四行文档,但没有交互式声明。由于人们在看不到 任何进展时会担心程序出错,函数体第一行是一条提示消息。
下一行包含 save-excursion,在函数结束时将 Emacs 注意力切回当前缓冲区。
这在将该函数嵌入其他假定光标恢复到原缓冲区的函数中时十分有用。
在 let 表达式的变量列表中,Emacs 查找文件并将局部变量 buffer 绑定到
存放该文件的缓冲区。同时,Emacs 创建局部变量 lengths-list。
接下来,Emacs 将注意力切换到该缓冲区。
下一行中,Emacs 将缓冲区设为只读。理想情况下这一行并非必需,统计函数定义中单词与符号 的所有函数都不应修改缓冲区。况且,即使缓冲区被修改,也不会被保存。这一行完全是出于 高度(或许过度)的谨慎。谨慎的原因是该函数及其调用的函数操作 Emacs 源码,意外修改 会带来不便。不用说,我也是在一次实验出错并开始修改我的 Emacs 源码文件后,才意识到 需要这一行…
接下来调用 widen 函数,如果缓冲区被 narrowed 则展开。该函数通常并非必需——Emacs 在缓冲区不存在时会创建新缓冲区;但如果访问该文件的缓冲区已存在,Emacs 会直接返回它。 这种情况下缓冲区可能被 narrowed,必须展开。如果我们想做到完全友好,应该保存 narrowing 状态与光标位置,但这里不做处理。
(goto-char (point-min)) 表达式将光标移至缓冲区开头。
然后是 while 循环,函数的主要工作在此完成。循环中,Emacs 计算每个定义的长度,
并构造包含该信息的长度列表。
Emacs 在处理完缓冲区后将其杀死,以节省 Emacs 内部空间。我的 GNU Emacs 19 版本有
300 多个相关源码文件;GNU Emacs 22 则有超过一千个源码文件。另一个函数会对每个文件
调用 lengths-list-file。
最后,let 表达式内的最后一个表达式是变量 lengths-list;其值作为
整个函数的返回值。
你可以按常规方式安装该函数进行测试。然后将光标置于以下表达式后,输入 C-x C-e
(eval-last-sexp)。
(lengths-list-file "/usr/local/share/emacs/22.1/lisp/emacs-lisp/debug.el")
你可能需要修改文件路径;此处路径适用于 GNU Emacs 22.1 版本。要修改表达式, 将其复制到 *scratch* 缓冲区并编辑。
此外,要查看列表完整长度而非截断版本,你可能需要执行以下代码:
(custom-set-variables '(eval-expression-print-length nil))
(See Specifying Variables using defcustom。
然后重新执行 lengths-list-file 表达式。)
debug.el 的长度列表生成耗时不到一秒,在 GNU Emacs 22 中如下所示:
(83 113 105 144 289 22 30 97 48 89 25 52 52 88 28 29 77 49 43 290 232 587)
(在我的旧机器上,19 版本 debug.el 的长度列表生成耗时七秒,结果如下:
(75 41 80 62 20 45 44 68 45 12 34 235)
新版 debug.el 包含更多 defun;并且我的新机器比旧机器快得多。)
注意,文件中最后一个定义的长度出现在列表首位。
defun 的单词 ¶在上一节,我们创建了返回一个文件中每个定义长度列表的函数。现在,我们要定义一个函数, 返回文件列表中所有定义长度的总列表。
处理文件列表中的每个文件是重复操作,因此可以使用 while 循环或递归。
defun 的长度 ¶使用 while 循环的设计很常规。函数接收的参数是文件列表。如前所述(see while 循环与列表),
你可以编写 while 循环,使其在列表包含元素时执行循环体,列表为空时退出循环。
要使该设计生效,循环体必须包含每次执行时缩短列表的表达式,最终使列表为空。
常用方法是每次执行循环体时,将列表值设为其 CDR。
模板如下:
(while test-whether-list-is-empty body... set-list-to-cdr-of-list)
同时,我们记得 while 循环返回 nil(条件测试的求值结果),而非循环体内
任何求值的结果。(循环体内的求值依靠副作用完成。)但设置长度列表的表达式是循环体的一部分 —
而这正是我们希望函数整体返回的值。为此,我们将 while 循环包裹在 let 表达式内,
并使 let 表达式的最后一个元素为长度列表的值。(See Loop Example with an Incrementing Counter。)
基于这些考虑,我们直接得到函数本身:
;;; Use while loop.
(defun lengths-list-many-files (list-of-files)
"Return list of lengths of defuns in LIST-OF-FILES."
(let (lengths-list) ;;; true-or-false-test (while list-of-files (setq lengths-list (append lengths-list ;;; Generate a lengths’ list. (lengths-list-file (expand-file-name (car list-of-files)))))
;;; Make files’ list shorter. (setq list-of-files (cdr list-of-files))) ;;; Return final value of lengths’ list. lengths-list))
expand-file-name 是内置函数,将文件名转换为绝对长路径形式。
该函数使用调用时所在目录的名称。
因此,如果 Emacs 正在访问 /usr/local/share/emacs/22.1.1/lisp/emacs-lisp/ 目录,
对 debug.el 调用 expand-file-name,
debug.el
会变为
/usr/local/share/emacs/22.1.1/lisp/emacs-lisp/debug.el
该函数定义中另一个新元素是尚未学习的 append 函数,值得单独用一小节讲解。
append 函数 ¶append 函数用于将一个列表拼接至另一个列表之后。例如:
(append '(1 2 3 4) '(5 6 7 8))
会生成列表:
(1 2 3 4 5 6 7 8)
这正是我们想要的、将 lengths-list-file 生成的两个长度列表相互拼接的方式。该结果与 cons 形成对比:
(cons '(1 2 3 4) '(5 6 7 8))
cons 会构造一个新列表,其中传给 cons 的第一个参数成为新列表的第一个元素:
((1 2 3 4) 5 6 7 8)
除了使用 while 循环,你也可以通过递归方式处理文件列表中的每一个文件。lengths-list-many-files 的递归版本简洁短小。
递归函数包含常规组成部分:继续执行判断条件、步进表达式,以及递归调用。继续执行判断条件决定函数是否需要再次调用自身——当 list-of-files 中仍有剩余元素时便会继续;步进表达式将 list-of-files 重置为自身的 CDR,最终列表会变为空;递归调用则在更短的列表上执行自身。完整的函数比这段描述还要简短!
(defun recursive-lengths-list-many-files (list-of-files)
"Return list of lengths of each defun in LIST-OF-FILES."
(if list-of-files ; do-again-test
(append
(lengths-list-file
(expand-file-name (car list-of-files)))
(recursive-lengths-list-many-files
(cdr list-of-files)))))
简单来说,该函数先获取 list-of-files 中第一个文件的长度列表,再将其与自身在剩余文件上调用的结果拼接起来。
下面是对 recursive-lengths-list-many-files 的测试,同时分别展示在各个文件上运行 lengths-list-file 的结果。
如有需要,先加载 recursive-lengths-list-many-files 和 lengths-list-file,再对下列表达式求值。你可能需要修改文件路径;此处给出的路径适用于本 Info 文件与 Emacs 源码位于常规位置的情况。若要修改表达式,可将其复制到 *scratch* 缓冲区编辑后再求值。
结果会显示在 ‘⇒’ 之后。(这些结果基于 Emacs 22.1.1 版本的文件,其他版本的 Emacs 文件可能产生不同结果。)
(cd "/usr/local/share/emacs/22.1.1/")
(lengths-list-file "./lisp/macros.el")
⇒ (283 263 480 90)
(lengths-list-file "./lisp/mail/mailalias.el")
⇒ (38 32 29 95 178 180 321 218 324)
(lengths-list-file "./lisp/hex-util.el")
⇒ (82 71)
(recursive-lengths-list-many-files
'("./lisp/macros.el"
"./lisp/mail/mailalias.el"
"./lisp/hex-util.el"))
⇒ (283 263 480 90 38 32 29 95 178 180 321 218 324 82 71)
recursive-lengths-list-many-files 函数输出了我们需要的结果。
下一步是将列表中的数据整理为适合图表展示的形式。
recursive-lengths-list-many-files 函数返回一个数字列表,每个数字记录一个函数定义的长度。我们现在需要将这些数据转换为适合生成图表的数字列表,新列表将分别说明:有多少个函数定义包含少于 10 个单词与符号,多少个在 10 至 19 个之间,多少个在 20 至 29 个之间,依此类推。
简言之,我们需要遍历 recursive-lengths-list-many-files 生成的长度列表,统计每个长度区间内的函数数量,并生成对应的数字列表。
基于之前的工作,我们可以很容易预见:编写一个函数,沿着长度列表依次取 CDR,查看每个元素,判断其所属长度区间,并对对应区间的计数器递增,这并不困难。
不过,在编写该函数之前,我们应当考虑先对长度列表排序的好处,让数字从小到大有序排列。首先,排序会让每个区间的数字统计更简单,因为相邻数字要么在同一区间,要么在相邻区间。其次,通过查看排序后的列表,我们可以找到最大值与最小值,从而确定需要使用的最大与最小长度区间。
Emacs 内置了用于列表排序的函数,顾名思义就是 sort。sort 函数接收两个参数:待排序列表,以及一个判断条件,用于确定两个列表元素中第一个是否小于第二个。
如前所述(see Using the Wrong Type Object as an Argument),判断条件是一个用于判断某属性真假的函数。sort 会根据判断条件指定的规则重排列表,这意味着 sort 可用于按非数值规则对非数值列表排序 — 例如可以对列表按字母序排序。
对数值列表排序时使用 < 函数。例如:
(sort '(4 8 21 17 33 7 21 7) '<)
会得到:
(4 7 7 8 17 21 21 33)
(注意本例中两个参数均加了引号,避免符号在传给 sort 之前被求值。)
对 recursive-lengths-list-many-files 返回的列表排序很简单,直接使用 < 函数即可:
(sort
(recursive-lengths-list-many-files
'("./lisp/macros.el"
"./lisp/mailalias.el"
"./lisp/hex-util.el"))
'<)
结果为:
(29 32 38 71 82 90 95 178 180 218 263 283 321 324 480)
(注意本例中传给 sort 的第一个参数未加引号,因为该表达式需要求值以生成传给 sort 的列表。)
recursive-lengths-list-many-files 函数需要一个文件列表作为参数。在测试示例中,我们手动构造了这样的列表,但 Emacs Lisp 源码目录过大,无法手动处理。因此我们需要编写一个函数来完成这项工作,该函数会同时使用 while 循环与递归调用。
在旧版 GNU Emacs 中无需编写此类函数,因为所有 ‘.el’ 文件都放在同一目录下,我们可以直接使用 directory-files 函数,它会列出单个目录中匹配指定模式的文件名。
但新版 Emacs 将 Lisp 文件放在顶层 lisp 目录的子目录中,这种结构便于浏览。例如所有邮件相关文件都在 lisp 下名为 mail 的子目录中。但同时这也要求我们编写一个能递归进入子目录的文件列表函数。
我们可以创建名为 files-in-below-directory 的函数,结合 car、nthcdr、substring 等常用函数与现有函数 directory-files-and-attributes 实现。后者不仅会列出目录中的所有文件名(包括子目录名),还会列出其属性。
重申目标:创建一个函数,能够生成如下形式的文件列表(实际元素更多),供 recursive-lengths-list-many-files 使用:
("./lisp/macros.el"
"./lisp/mail/rmail.el"
"./lisp/hex-util.el")
directory-files-and-attributes 函数返回一个列表的列表,主列表中的每个子列表包含 13 个元素。第一个元素是文件名字符串 — 在 GNU/Linux 中可能是一个 目录文件(directory file),即具备目录特殊属性的文件。列表第二个元素:目录为 t,符号链接为链接目标字符串,否则为 nil。
例如 lisp/ 目录下第一个 ‘.el’ 文件是 abbrev.el,路径为 /usr/local/share/emacs/22.1.1/lisp/abbrev.el,它既不是目录也不是符号链接。
directory-files-and-attributes 对该文件及其属性的列出形式如下:
("abbrev.el"
nil
1
1000
100
(20615 27034 579989 697000) (17905 55681 0 0) (20615 26327 734791 805000)(18) 13188 "-rw-r--r--"
t 2971624 773)
而 lisp/ 目录下的 mail/ 是一个子目录,其列表开头如下:
("mail"
t
...
)
(如需了解各类属性含义,可查看 file-attributes 的文档。注意 file-attributes 不会列出文件名,因此它的第一个元素对应 directory-files-and-attributes 的第二个元素。)
我们希望新函数 files-in-below-directory 能够列出指定目录及其所有下层子目录中的 ‘.el’ 文件。
这提示了 files-in-below-directory 的实现思路:在目录中,将 ‘.el’ 文件名加入列表;若遇到子目录,则进入该目录重复执行操作。
不过,我们应当注意,每个目录都包含一个指向自身的名称,名为 .(“点”),以及一个指向其父目录的名称,名为 ..(“点点”)。(在根目录 / 中,.. 指向其自身,因为 / 没有父目录。)显然,我们不希望 files-in-below-directory 函数进入这些目录,因为它们总会直接或间接地将路径引回当前目录。
因此 files-in-below-directory 必须完成多项任务:
接下来编写实现这些功能的函数定义。我们使用 while 循环在目录内遍历文件名并判断处理方式,使用递归调用在每个子目录重复操作。递归模式为累积模式(see 递归模式:accumulate(累积)),使用 append 作为合并函数。
函数定义如下:
(defun files-in-below-directory (directory) "List the .el files in DIRECTORY and in its sub-directories." ;; Although the function will be used non-interactively, ;; it will be easier to test if we make it interactive. ;; The directory will have a name such as ;; "/usr/local/share/emacs/22.1.1/lisp/" (interactive "DDirectory name: ")
(let (el-files-list
(current-directory-list
(directory-files-and-attributes directory t)))
;; while we are in the current directory
(while current-directory-list
(cond
;; check to see whether filename ends in '.el'
;; and if so, add its name to a list.
((equal ".el" (substring (car (car current-directory-list)) -3))
(setq el-files-list
(cons (car (car current-directory-list)) el-files-list)))
;; check whether filename is that of a directory
((eq t (car (cdr (car current-directory-list))))
;; decide whether to skip or recurse
(if
(equal "."
(substring (car (car current-directory-list)) -1))
;; then do nothing since filename is that of
;; current directory or parent, "." or ".."
()
;; else descend into the directory and repeat the process
(setq el-files-list
(append
(files-in-below-directory
(car (car current-directory-list)))
el-files-list)))))
;; move to the next filename in the list; this also
;; shortens the list so the while loop eventually comes to an end
(setq current-directory-list (cdr current-directory-list)))
;; return the filenames
el-files-list))
files-in-below-directory 与 directory-files 函数接收一个参数,即目录名。
在我的系统中,执行:
(length (files-in-below-directory "/usr/local/share/emacs/22.1.1/lisp/"))
可以得知 Lisp 源码目录及其子目录中共有 1031 个 ‘.el’ 文件。
files-in-below-directory 返回的列表为逆字母序。按字母序排序的表达式如下:
(sort (files-in-below-directory "/usr/local/share/emacs/22.1.1/lisp/") 'string-lessp)
我们当前的目标是生成一个列表,分别说明:有多少函数定义包含少于 10 个单词与符号,多少个在 10 至 19 个之间,多少个在 20 至 29 个之间,依此类推。
有了排序后的数字列表,统计就很简单:先统计列表中小于 10 的元素个数,跳过已统计数字后再统计小于 20 的个数,接着跳过再统计小于 30 的个数,以此类推。10、20、30、40 等数字均为对应区间上限加一,我们将这类数字组成的列表称为 top-of-ranges 列表。
(defvar top-of-ranges '(10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 190 200 210 220 230 240 250 260 270 280 290 300) "List specifying ranges for `defuns-per-range'.")
如需修改区间,直接编辑该列表即可。
接下来需要编写函数,生成每个区间内的函数定义数量列表。显然该函数需要接收 sorted-lengths 与 top-of-ranges 两个列表作为参数。
defuns-per-range 函数需要重复执行两件事:统计当前区间上限对应的函数定义数量;统计完成后切换到 top-of-ranges 中的下一个更大值。由于这些操作都是重复的,我们可以使用 while 循环实现:一个循环统计当前区间上限对应的函数数量,另一个循环依次选取每个区间上限值。
每个区间会统计 sorted-lengths 中的多项内容,因此 sorted-lengths 的循环应嵌套在 top-of-ranges 的循环内部,如同小齿轮在大齿轮内部。
内层循环统计区间内的函数数量,是我们之前见过的简单计数循环。(See A loop with an incrementing counter。)循环的真假判断用于检查 sorted-lengths 中的值是否小于当前区间上限,若是则计数器递增并检查下一个值。
内层循环大致如下:
(while length-element-smaller-than-top-of-range (setq number-within-range (1+ number-within-range)) (setq sorted-lengths (cdr sorted-lengths)))
外层循环从 top-of-ranges 的最小值开始,依次切换到后续更大值,循环形式如下:
(while top-of-ranges body-of-loop... (setq top-of-ranges (cdr top-of-ranges)))
两层循环组合后如下:
(while top-of-ranges ;; Count the number of elements within the current range. (while length-element-smaller-than-top-of-range (setq number-within-range (1+ number-within-range)) (setq sorted-lengths (cdr sorted-lengths))) ;; Move to next range. (setq top-of-ranges (cdr top-of-ranges)))
此外,在外层循环的每一轮中,Emacs 都需要将当前区间的函数数量(number-within-range 的值)记录到列表中,可使用 cons 实现。(See cons。)
cons 可以正常工作,但生成的列表会将最大区间的数量放在开头,最小区间的数量放在结尾。因为 cons 会将新元素添加到列表开头,而循环从长度较小的区间开始统计,最终 defuns-per-range-list 会是大数在前。但我们希望图表从小到大展示,因此需要反转 defuns-per-range-list 的顺序,可使用 nreverse 函数实现列表反转。
例如:
(nreverse '(1 2 3 4))
结果为:
(4 3 2 1)
注意 nreverse 是破坏性函数——会修改原列表,这与非破坏性的 car 和 cdr 不同。本例中我们不需要原 defuns-per-range-list,因此破坏原列表并无影响。(reverse 函数会生成反转副本,保留原列表不变。)
完整的 defuns-per-range 函数如下:
(defun defuns-per-range (sorted-lengths top-of-ranges)
"SORTED-LENGTHS defuns in each TOP-OF-RANGES range."
(let ((top-of-range (car top-of-ranges))
(number-within-range 0)
defuns-per-range-list)
;; Outer loop.
(while top-of-ranges
;; Inner loop. (while (and ;; Need number for numeric test. (car sorted-lengths) (< (car sorted-lengths) top-of-range))
;; Count number of definitions within current range. (setq number-within-range (1+ number-within-range)) (setq sorted-lengths (cdr sorted-lengths))) ;; Exit inner loop but remain within outer loop.
(setq defuns-per-range-list
(cons number-within-range defuns-per-range-list))
(setq number-within-range 0) ; Reset count to zero.
;; Move to next range. (setq top-of-ranges (cdr top-of-ranges)) ;; Specify next top of range value. (setq top-of-range (car top-of-ranges)))
;; Exit outer loop and count the number of defuns larger than ;; the largest top-of-range value. (setq defuns-per-range-list (cons (length sorted-lengths) defuns-per-range-list))
;; Return a list of the number of definitions within each range, ;; smallest to largest. (nreverse defuns-per-range-list)))
该函数整体清晰,仅有一处细节需要注意。内层循环的真假判断如下:
(and (car sorted-lengths)
(< (car sorted-lengths) top-of-range))
而非简单写成:
(< (car sorted-lengths) top-of-range)
该判断的目的是检查 sorted-lengths 第一个元素是否小于区间上限。
简单形式在 sorted-lengths 不为空时正常,但当列表为空时,(car sorted-lengths) 会返回 nil。< 函数无法比较数字与空列表 nil,因此 Emacs 会报错并终止函数执行。
sorted-lengths 在遍历到列表末尾时必然变为 nil,因此使用简单判断的 defuns-per-range 一定会执行失败。
我们通过将 (car sorted-lengths) 与 and 表达式结合解决该问题。只要列表中至少有一个数字,(car sorted-lengths) 就会返回非 nil 值,列表为空时则返回 nil。and 会先求值 (car sorted-lengths),若为 nil 则直接返回假,不会再执行 < 表达式;若为非 nil 值,则继续执行 < 并将结果作为 and 的返回值。
这样就避免了报错。
下面是对 defuns-per-range 函数的简短测试。先对绑定(简化版)top-of-ranges 列表的表达式求值,再对绑定 sorted-lengths 列表的表达式求值,最后对 defuns-per-range 求值。
;; (Shorter list than we will use later.)
(setq top-of-ranges
'(110 120 130 140 150
160 170 180 190 200))
(setq sorted-lengths
'(85 86 110 116 122 129 154 176 179 200 265 300 300))
(defuns-per-range sorted-lengths top-of-ranges)
返回的列表如下:
(2 2 2 0 0 1 0 2 0 0 4)
确实,sorted-lengths 中有 2 个元素小于 110,2 个在 110 至 119 之间,2 个在 120 至 129 之间,依此类推,有 4 个元素大于等于 200。
本节目标是构建一张图表,用于展示 Emacs Lisp 源码中不同长度的函数定义的数量。
在实际使用中,如果你要创建图表,通常会使用 gnuplot 这类程序来完成。(gnuplot 与 GNU Emacs 有着很好的集成。)不过在本章,我们会从零开始实现一个图表绘制功能,借此重温之前学过的知识,并学习更多新内容。
本章中,我们首先编写一个简单的图表打印函数。这个最初的定义会是一个 原型(prototype),即快速编写的函数,用于探索这片未知的图表制作领域。我们会探明其中是否存在难点,或是发现那些难点只是假想。在探查清楚情况之后,我们会更有信心地完善该函数,使其能够自动为坐标轴添加标注。
由于 Emacs 设计上追求灵活,可适配各类终端(包括纯字符终端),因此图表需要使用打字机符号来构成。星号就很合适;后续完善图表打印函数时,我们可以把符号的选择改为用户可配置项。
我们可以将该函数命名为 graph-body-print,它只接收一个 numbers-list 作为参数。在当前阶段,我们不会为图表添加标注,只打印其主体部分。
graph-body-print 函数会为 numbers-list 中的每个元素插入一列垂直的星号。每列的高度由 numbers-list 中对应元素的值决定。
插入多列是重复操作,这意味着该函数既可以用 while 循环实现,也可以用递归实现。
我们首先要解决的问题是如何打印一列星号。在 Emacs 中,我们通常是逐行横向输入字符到屏幕上。这里有两条思路:自己编写列插入函数,或是查找 Emacs 中是否已有现成的实现。
想要查找 Emacs 中是否存在相关功能,可以使用 M-x apropos 命令。该命令与 C-h a(command-apropos)类似,区别是后者只查找作为命令的函数,而 M-x apropos 会列出所有匹配正则表达式的符号,包括非交互式函数。
我们需要查找用于打印或插入列的相关命令。函数名中很可能包含 “print”、“insert” 或 “column” 这类单词。因此可以直接输入 M-x apropos RET print\|insert\|column RET 并查看结果。在我的系统上,该命令曾经需要较长时间执行,最终列出 79 个函数和变量;如今运行速度很快,会列出 211 个函数和变量。浏览列表后,看起来能满足需求的函数只有 insert-rectangle。
事实上这正是我们需要的函数,其文档说明如下:
insert-rectangle: 以光标位置为左上角插入矩形区域的文本。 矩形区域的第一行插入在光标位置, 第二行插入在光标正下方位置,依此类推。 RECTANGLE 应为字符串列表。 执行该命令后,标记位于左上角, 光标位于右下角。
我们可以快速测试,确认它的行为符合预期。
将光标放在 insert-rectangle 表达式之后并输入 C-u C-x C-e(eval-last-sexp),结果如下。该函数会在光标及下方位置插入字符串 ‘"first"’、‘"second"’、‘"third"’,同时返回 nil。
(insert-rectangle '("first" "second" "third"))first
second
thirdnil
当然,我们不会把 insert-rectangle 表达式本身插入到绘制图表的缓冲区中,而是在程序中调用该函数。不过我们必须确保光标位于缓冲区中合适的位置,以便 insert-rectangle 插入字符串列。
如果你正在 Info 中阅读本文,可以切换到另一个缓冲区(例如 *scratch* 缓冲区),将光标放在缓冲区某处,输入 M-:,在小缓冲的提示符后输入 insert-rectangle 表达式,然后按 RET。这会让 Emacs 在小缓冲中求值表达式,但使用 *scratch* 缓冲区中的光标位置。(M-: 是 eval-expression 的按键绑定。另外 nil 不会出现在 *scratch* 缓冲区中,因为表达式是在小缓冲中执行的。)
测试后可以发现,执行结束时光标会停在最后插入行的末尾 — 也就是说该函数会附带移动光标的副作用。如果在该位置重复执行命令,下一次插入会出现在上一次插入的右下方,这并不是我们想要的效果!绘制柱状图时,各列应当并排排列。
由此可知,执行列插入的 while 循环每次都需要将光标重新定位到目标位置,且该位置应在列的顶部而非底部。此外我们知道,打印图表时各列高度并不一定相同,这意味着每列的顶部位置可能不同。我们不能每次都简单地将光标定位到同一行再向右移动 — 或许也并非完全不行…
我们计划使用星号构成柱状图的每一列。列中的星号数量由 numbers-list 当前元素的值指定。每次调用 insert-rectangle 时,都需要构造一个长度合适的星号列表。如果该列表只包含对应数量的星号,就必须将光标向上移动对应行数,才能让图表正确打印,这会比较麻烦。
另一种思路是,设法让每次传给 insert-rectangle 的列表长度都相同,这样就可以每次都将光标放在同一行,只是每新增一列就向右移动一列。不过这样一来,传给 insert-rectangle 的列表中部分元素需要是空格而非星号。例如,图表最大高度为 5,而当前列高度为 3,则 insert-rectangle 的参数应如下所示:
(" " " " "*" "*" "*")
只要能确定列高度,后一种方案实现起来并不复杂。指定列高度有两种方式:可以人为指定固定高度,这对对应高度的图表有效;也可以遍历数字列表,将列表中的最大值作为图表的最大高度。如果后一种操作很复杂,那么前一种方式更简单,但 Emacs 内置了可获取参数最大值的函数,我们可以直接使用。该函数名为 max,会返回所有数字参数中的最大值。例如:
(max 3 4 6 5 7 3)
会返回 7。(对应的函数 min 会返回所有参数中的最小值。)
不过我们不能直接对 numbers-list 调用 max;max 函数接收的参数是数字,而非数字列表。因此下面的表达式:
(max '(3 4 6 5 7 3))
会产生如下错误信息:
Wrong type of argument: number-or-marker-p, (3 4 6 5 7 3)
我们需要一个能将列表中的元素作为参数传给函数的工具,这个函数就是 apply。它会将第一个参数(函数名)应用到后续参数上,最后一个参数可以是列表。
例如:
(apply 'max 3 4 7 3 '(4 8 5))
会返回 8。
(顺带一提,如果没有这类书籍参考,你可能很难发现这个函数。像 search-forward 或 insert-rectangle 这类函数可以通过猜测部分名称再使用 apropos 查找,但 apply 即便从语义上很明确 — 将第一个参数应用到其余参数上 — 新手在使用 apropos 或其他工具时也不太可能想到这个单词。当然我也可能判断有误,毕竟该函数最初也是由开发者命名的。)
apply 的第二个及后续参数是可选的,因此我们可以用它调用函数并将列表元素作为参数传入,如下例同样返回 8:
(apply 'max '(4 8 5))
这正是我们使用 apply 的方式。recursive-lengths-list-many-files 函数会返回一个数字列表,我们可以对其应用 max(也可以对排序后的数字列表应用 max,列表是否排序不影响结果)。
因此,获取图表最大高度的操作如下:
(setq max-graph-height (apply 'max numbers-list))
现在回到如何为图表列构造字符串列表的问题。已知图表最大高度与当前列应显示的星号数量,函数需要返回一个可供 insert-rectangle 插入的字符串列表。
每一列由星号或空格构成。函数接收列高度与列中星号数量,空格数量可通过列高度减去星号数量得到。已知空格数与星号数后,可以使用两个 while 循环构造该列表:
;;; First version.
(defun column-of-graph (max-graph-height actual-height)
"Return list of strings that is one column of a graph."
(let ((insert-list nil)
(number-of-top-blanks
(- max-graph-height actual-height)))
;; Fill in asterisks.
(while (> actual-height 0)
(setq insert-list (cons "*" insert-list))
(setq actual-height (1- actual-height)))
;; Fill in blanks.
(while (> number-of-top-blanks 0)
(setq insert-list (cons " " insert-list))
(setq number-of-top-blanks
(1- number-of-top-blanks)))
;; Return whole list.
insert-list))
如果你安装该函数并执行下面的表达式,会看到它返回了预期的列表:
(column-of-graph 5 3)
returns
(" " " " "*" "*" "*")
当前版本的 column-of-graph 存在一个明显缺陷:用于表示空格和标记的符号被硬编码为空格和星号。这对原型来说尚可,但你或其他用户可能希望使用其他符号。例如在测试图表函数时,可能想用点号代替空格,以确保每次调用 insert-rectangle 时光标都被正确重定位;也可能想用 ‘+’ 或其他符号代替星号。你甚至可能希望图表列的显示宽度不止一列。程序应当更灵活。解决方法是将空格和星号替换为两个变量,分别命名为 graph-blank 和 graph-symbol,并单独定义这两个变量。
此外文档说明也不够完善。基于这些考虑,我们给出该函数的第二版:
(defvar graph-symbol "*" "图表中使用的符号,通常为星号。")
(defvar graph-blank " " "图表中使用的空白符号,通常为空格。 graph-blank 的列宽度必须与 graph-symbol 一致。")
(关于 defvar 的说明,参见 使用 defvar 初始化变量。)
;;; Second version.
(defun column-of-graph (max-graph-height actual-height)
"返回长度为 MAX-GRAPH-HEIGHT 的字符串;其中 ACTUAL-HEIGHT 个为 graph-symbols。
graph-symbols 为列表末尾连续的元素。 该列表会作为图表的一列插入。 字符串内容为 graph-blank 或 graph-symbol。"
(let ((insert-list nil)
(number-of-top-blanks
(- max-graph-height actual-height)))
;; Fill in graph-symbols.
(while (> actual-height 0)
(setq insert-list (cons graph-symbol insert-list))
(setq actual-height (1- actual-height)))
;; Fill in graph-blanks.
(while (> number-of-top-blanks 0)
(setq insert-list (cons graph-blank insert-list))
(setq number-of-top-blanks
(1- number-of-top-blanks)))
;; Return whole list.
insert-list))
如果需要,我们可以第三次重写 column-of-graph,使其同时支持折线图与柱状图。这并不难实现。折线图可以看作一种特殊的柱状图,每个柱子除顶部外其余部分均为空白。要构造折线图的列,函数可以先生成一个长度比目标值短 1 的空格列表,然后用 cons 将图表符号附加到列表头部,再用 cons 将顶部空格附加到列表最前方。
编写该函数的思路很清晰,但由于当前并不需要,我们暂不实现。不过该功能完全可以实现,且会在 column-of-graph 内部完成。更重要的是,其他代码几乎不需要修改。后续若想添加该功能,实现起来会很简单。
现在,我们终于可以编写第一个实际可用的图表打印函数。该函数只打印图表主体,不打印横竖坐标轴的标注,因此命名为 graph-body-print。
graph-body-print 函数 ¶经过上一节的准备,graph-body-print 函数的实现会很直观。该函数会逐列打印星号与空格,使用数字列表中的元素指定每列的星号数量。这是重复操作,因此可以使用递减 while 循环或递归函数实现。本节我们使用 while 循环编写定义。
column-of-graph 函数需要图表高度作为参数,因此我们应将其计算结果保存为局部变量。
由此我们得到该函数 while 循环版的模板:
(defun graph-body-print (numbers-list)
"documentation..."
(let ((height ...
...))
(while numbers-list
insert-columns-and-reposition-point
(setq numbers-list (cdr numbers-list)))))
我们需要填充模板中的空缺部分。
显然,我们可以使用 (apply 'max numbers-list) 表达式确定图表高度。
while 循环会逐个遍历 numbers-list。随着 (setq numbers-list (cdr numbers-list)) 不断缩短列表,每次的列表首个元素即为 column-of-graph 的参数值。
在 while 循环的每一轮中,insert-rectangle 会插入由 column-of-graph 返回的列表。由于 insert-rectangle 会将光标移动到插入矩形的右下角,我们需要在插入时保存光标位置,插入完成后返回该位置,再水平移动到下一次调用 insert-rectangle 的起始位置。
如果插入的列宽为一个字符(使用单个空格和星号时),重定位命令只需 (forward-char 1);但列宽也可能大于一个字符。因此重定位命令应写为 (forward-char symbol-width)。symbol-width 本身即为 graph-blank 的长度,可通过表达式 (length graph-blank) 获取。将 symbol-width 变量绑定为图表列宽的最佳位置是 let 表达式的变量列表中。
基于以上考虑,得到如下函数定义:
(defun graph-body-print (numbers-list)
"根据 NUMBERS-LIST 打印柱状图。
numbers-list 由纵轴数值构成。"
(let ((height (apply 'max numbers-list))
(symbol-width (length graph-blank))
from-position)
(while numbers-list
(setq from-position (point))
(insert-rectangle
(column-of-graph height (car numbers-list)))
(goto-char from-position)
(forward-char symbol-width)
;; Draw graph column by column.
(sit-for 0)
(setq numbers-list (cdr numbers-list)))
;; Place point for X axis labels.
(forward-line height)
(insert "\n")
))
该函数中有一个不太直观的表达式:while 循环中的 (sit-for 0)。该表达式让图表打印过程的可视效果更好。它会让 Emacs 等待(sit) 零时长并刷新屏幕。放在此处可以让 Emacs 逐列刷新屏幕;如果没有它,Emacs 会等到函数退出后才刷新屏幕。
我们可以用一个简短的数字列表测试 graph-body-print。
graph-symbol、graph-blank、column-of-graph(位于
打印图表的列
)以及 graph-body-print。
(graph-body-print '(1 2 3 4 6 4 3 5 7 6 5 2 3))
eval-expression)。
yank)将 graph-body-print 表达式粘贴到小缓冲中。
graph-body-print 表达式。
Emacs 会打印出类似如下的图表:
*
* **
* ****
*** ****
********* *
************
*************
recursive-graph-body-print 函数 ¶graph-body-print 函数同样可以用递归方式实现。递归方案分为两部分:外层包装函数使用 let 表达式计算只需获取一次的变量值(如图表最大高度),内层函数则通过递归调用完成图表打印。
包装函数并不复杂:
(defun recursive-graph-body-print (numbers-list)
"根据 NUMBERS-LIST 打印柱状图。
numbers-list 由纵轴数值构成。"
(let ((height (apply 'max numbers-list))
(symbol-width (length graph-blank))
from-position)
(recursive-graph-body-print-internal
numbers-list
height
symbol-width)))
递归函数则稍复杂一些。它包含四部分:继续执行判断、打印代码、递归调用和步进表达式。继续执行判断使用 when 表达式,用于确定 numbers-list 是否还有剩余元素;若有,则函数通过打印代码绘制一列图表并再次调用自身。函数会根据步进表达式生成的值递归调用,作用于长度更短的 numbers-list。
(defun recursive-graph-body-print-internal (numbers-list height symbol-width) "打印柱状图。 在 recursive-graph-body-print 内部使用。"
(when numbers-list
(setq from-position (point))
(insert-rectangle
(column-of-graph height (car numbers-list)))
(goto-char from-position)
(forward-char symbol-width)
(sit-for 0) ; Draw graph column by column.
(recursive-graph-body-print-internal
(cdr numbers-list) height symbol-width)))
安装后即可测试该表达式,示例如下:
(recursive-graph-body-print '(3 2 5 6 7 5 3 4 6 4 3 2 1))
recursive-graph-body-print 生成的图表效果如下:
*
** *
**** *
**** ***
* *********
************
*************
graph-body-print 和 recursive-graph-body-print 这两个函数均可生成图表主体。
“‘You don’t have to like Emacs to like it(不必一开始就喜欢 Emacs,最终你会爱上它)” — 这句看似矛盾的话正是 GNU Emacs 的精髓。未经定制的原生 Emacs 只是一个通用工具,绝大多数使用者都会按自己的习惯对其进行个性化配置。
GNU Emacs 大部分由 Emacs Lisp 编写;这意味着你可以通过编写 Emacs Lisp 表达式来修改或扩展 Emacs 功能。
defcustom 定义变量line-to-top-of-window有用户偏爱 Emacs 的默认配置。毕竟,编辑 C 文件时会自动进入 C 模式,编辑 Fortran 文件进入 Fortran 模式,编辑无格式普通文件则进入基本模式。在不确定使用者身份的前提下,这些默认行为是合理的——谁也不知道用户会用普通文件做什么。基本模式是这类文件的合适默认值,就像 C 模式是编辑 C 代码的合适默认值一样。(很多编程语言语法相近,可共用或近似共用相关功能,因此 C 模式现在由 CC 模式(C 集合模式)提供。)
但当你明确使用者就是自己时,对 Emacs 进行自定义就很有必要了。
例如,我编辑普通无格式文件时很少使用基本模式,而是希望直接进入文本模式。这就是我自定义 Emacs 的原因:让它更贴合我的使用习惯。
你可以通过编写或修改 ~/.emacs 文件来自定义和扩展 Emacs。这是你的个人初始化文件,其中用 Emacs Lisp 编写的内容会告诉 Emacs 如何运行。19
~/.emacs 文件包含 Emacs Lisp 代码。你可以手动编写,也可以使用 Emacs 的 customize 功能自动生成代码。你可以在 .emacs 文件中混合使用手写表达式和 Customize 自动生成的表达式。
(我个人更倾向于手写表达式,只有字体等设置会使用 customize 命令更方便地调整。我会混合使用两种方式。)
本章大部分内容介绍如何手动编写表达式,并以一个简单的 .emacs 文件为例;更多信息可参考 初始化文件 in GNU Emacs 手册 以及 初始化文件 in GNU Emacs Lisp 参考手册。
除个人初始化文件外,Emacs 还会自动加载各类全局初始化文件(若存在)。这些文件格式与 .emacs 相同,但对所有用户生效。
site-load.el 和 site-init.el 这两个全局初始化文件会被加载进 Emacs,并且在生成转储版 Emacs 时会被一并转储(这是最常见的方式)。(转储后的 Emacs 加载速度更快。但文件被加载并转储后,对其修改不会反映到 Emacs 中,除非手动重新加载或重新转储 Emacs。See 构建 Emacs in GNU Emacs Lisp 参考手册 以及 INSTALL 文件。)
另外三个全局初始化文件会在每次启动 Emacs 时自动加载(若存在):site-start.el 在 .emacs 文件 之前 加载,default.el 与终端类型文件在 .emacs 文件 之后 加载。
.emacs 中的设置与定义会覆盖 site-start.el 中冲突的内容;但 default.el 或终端类型文件中的设置会覆盖 .emacs 中的内容。(你可以将 term-file-prefix 设置为 nil 以避免终端类型文件的干扰。See 简单扩展。)
发布包中的 INSTALL 文件包含了 site-init.el 和 site-load.el 的说明。
loadup.el、startup.el 和 loaddefs.el 文件控制加载行为。这些文件位于 Emacs 发布包的 lisp 目录,值得翻阅查看。
loaddefs.el 中提供了大量可放入个人 .emacs 文件或全局初始化文件的配置建议。
defcustom 定义变量 ¶你可以使用 defcustom 定义变量,方便自己和他人通过 Emacs 的 customize 功能设置其值。(你不能使用 customize 编写函数定义,但可以在 .emacs 文件中编写 defun;事实上,你可以在 .emacs 中编写任意 Lisp 表达式。)
customize 功能依赖 defcustom 宏。虽然你可以使用 defvar 或 setq 定义用户可设置的变量,但 defcustom 宏是为此专门设计的。
你可以沿用编写 defvar 的经验来编写 defcustom 的前三个参数。defcustom 的第一个参数是变量名,第二个参数是变量初始值(若有),且仅在变量未被设置时生效,第三个参数是文档说明。
defcustom 的第四个及后续参数用于指定类型和选项,这些是 defvar 不具备的。(这些参数为可选。)
每个参数均由关键字和对应值组成,关键字以冒号 ‘:’ 开头。
例如,可自定义的用户选项变量 text-mode-hook 定义如下:
(defcustom text-mode-hook nil "进入文本模式及诸多相关模式时运行的标准钩子。" :type 'hook :options '(turn-on-auto-fill flyspell-mode) :group 'wp)
变量名为 text-mode-hook,无默认值,文档字符串说明了其用途。
:type 关键字告诉 Emacs 该变量应设置为何种类型的数据,以及如何在自定义缓冲区中显示该值。
:options 关键字指定变量的推荐值列表,通常用于钩子变量。该列表仅为建议而非强制限制,用户可将变量设为其他值;:options 后的列表旨在为用户提供便捷选项。
最后,:group 关键字告诉 Emacs 自定义命令该变量所属的组,方便查找定位。
defcustom 宏支持十多个关键字,更多信息可参考 编写自定义定义 in GNU Emacs Lisp 参考手册。
以 text-mode-hook 为例。
自定义该变量有两种方式:使用自定义命令,或手动编写对应表达式。
使用自定义命令时,输入:
M-x customize
找到文本编辑相关的组名为“Text”。进入该组后,Text Mode Hook 是第一个选项。你可以点击各个选项(如 turn-on-auto-fill)设置值。点击按钮:
Save for Future Sessions
后,Emacs 会向你的 .emacs 文件写入表达式,形式如下:
(custom-set-variables ;; custom-set-variables 由 Customize 自动添加。 ;; 手动编辑可能导致出错,请注意。 ;; 初始化文件应只包含一个该实例。 ;; 多个实例会导致功能异常。 '(text-mode-hook '(turn-on-auto-fill text-mode-hook-identify)))
(text-mode-hook-identify 函数用于告知 toggle-text-mode-auto-fill 哪些缓冲区处于文本模式,会自动启用。)
custom-set-variables 函数的工作方式与 setq 略有不同。我虽未深究差异,但会手动修改 .emacs 中的 custom-set-variables 表达式,以自认为合理的方式调整,从未出现问题。其他用户更倾向于使用自定义命令,让 Emacs 自动完成配置。
另一个 custom-set-… 系列函数是 custom-set-faces,用于设置各类文本的视觉样式。我长期以来设置了大量样式,有时会通过 customize 重新设置,有时则直接编辑 .emacs 中的 custom-set-faces 表达式。
第二种自定义 text-mode-hook 的方式是在 .emacs 文件中手动编写与 custom-set-… 函数无关的代码。
这样设置后,后续使用 customize 时会看到提示信息:
CHANGED outside Customize; operating on it here may be unreliable.
该信息仅为警告。点击
Save for Future Sessions
按钮后,Emacs 会在 .emacs 文件末尾附近写入 custom-set-… 表达式,该表达式会在你手写的表达式之后求值,从而覆盖手写配置,不会造成损坏。但这样做时需记住哪个表达式生效,否则可能造成混淆。
只要记住值的设置位置,就不会出现问题。无论如何,所有配置值最终都保存在初始化文件中,通常为 .emacs。
我个人几乎不使用 customize,大部分配置都是手动编写表达式。
顺带一提,更完整的定义类宏包括:defsubst 用于定义内联函数,语法与 defun 一致;defconst 用于将符号定义为常量,设计意图是程序和用户都不应修改其值。(你 technically 可以修改,因为它本质仍是变量,但建议不要这样做。)
启动 Emacs 时,除非在命令行指定 ‘-q’ 参数,否则 Emacs 会自动加载 .emacs 文件。(emacs -q 命令会启动一个原生无定制的 Emacs。)
.emacs 文件包含 Lisp 表达式,通常只是设置变量值,有时也包含函数定义。
关于初始化文件的简要说明,可参考 初始化文件 ~/.emacs in GNU Emacs 手册。
本章会介绍类似内容,但会以我长期使用的完整 .emacs 文件片段为例逐步讲解。
文件开头部分是注释,用于提醒自己。当然现在我已经熟记这些内容,但刚开始使用时并非如此。
;;;; Bob's .emacs file ; Robert J. Chassell ; 26 September 1985
看看这个日期!我很久以前就创建了这个文件,并一直在持续补充内容。
; 文件中的每个节以四个分号开头的行标识; ; 每个条目以三个分号开头的行标识。
这是 Emacs Lisp 中常用的注释规范。一行中分号后的所有内容均为注释,两个、三个、四个分号分别用作子小节、小节和节的标记。(关于注释的更多内容,可参考 Comments in GNU Emacs Lisp 参考手册。)
;;;; 帮助按键 ; Ctrl-h 为帮助键; ; 按下 Ctrl-h 后再按一个字母, ; 即可获取对应主题的帮助信息。 ; 关于帮助功能的说明, ; 连续按两次 Ctrl-h。
记住:连续按两次 C-h 查看帮助。
; 想要了解任意模式的帮助,在该模式下 ; 按 Ctrl-h m。例如,想要了解邮件模式, ; 进入邮件模式后按 Ctrl-h m。
我称之为“模式帮助(mode help)”,非常实用,通常能告诉你所需的全部信息。
当然,你不必在自己的 .emacs 文件中加入这类注释。我加入这些是因为曾经总是忘记模式帮助或注释规范,但能记得在这里查看提醒自己。
现在我们来到开启文本模式与自动填充模式的部分20。
;;; 文本模式与自动填充模式 ;; 下面两行将 Emacs 设为文本模式 ;; 和自动填充模式,适用于需要撰写散文 ;; 而非编写代码的写作者。 (setq-default major-mode 'text-mode) (add-hook 'text-mode-hook 'turn-on-auto-fill)
这是 .emacs 文件中第一部分真正做事情、而不只是提醒健忘用户的配置!
括号内两行配置中的第一行告诉 Emacs:打开文件时启用文本模式,除非该文件本应进入其他模式,例如 C 语言模式。
Emacs 读取文件时,会查看文件名后缀(如果有的话)。(后缀是 ‘.’ 后面的部分。)如果文件以 ‘.c’ 或 ‘.h’ 结尾,Emacs 会启用 C 模式。此外,Emacs 还会查看文件第一行非空内容;如果该行是 ‘-*- C -*-’,Emacs 也会启用 C 模式。Emacs 内置了一份后缀与模式对应表,会自动据此选择模式。除此之外,Emacs 还会在文件末尾附近查找缓冲区局部变量列表(如果存在)。
现在回到 .emacs 文件。
我们再看这一行;它是如何工作的?
(setq-default major-mode 'text-mode)
这一行是简短但完整的 Emacs Lisp 表达式。
我们已经熟悉 setq。这里使用类似的宏 setq-default 来设置变量 major-mode21,将其值设为 text-mode。text-mode 前面的单引号告诉 Emacs 直接处理符号本身,而不是它可能代表的值。
See Setting the Value of a Variable,可回顾 setq 的用法。核心要点是:在 .emacs 中设置变量的方式,与在 Emacs 其他任何地方完全相同。
再看下一行:
(add-hook 'text-mode-hook 'turn-on-auto-fill)
这一行中,add-hook 命令将 turn-on-auto-fill 添加到钩子变量中。
turn-on-auto-fill 就是一个程序的名字,顾名思义,它用于启用自动填充模式。
每次 Emacs 启用文本模式时,都会运行挂载在文本模式钩子上的命令。因此每次启用文本模式时,Emacs 也会同时启用自动填充模式。
简单来说,第一行让 Emacs 在编辑文件时进入文本模式,除非文件名后缀、文件首行或局部变量指定了其他模式。
文本模式还会做一件事:将语法表调整为更适合写作者的形式。在文本模式下,Emacs 会将撇号视为单词的一部分,就像字母一样;但不会把句点或空格当作单词的一部分。因此 M-f 会跳过整个 ‘it's’。而在 C 模式中,M-f 只会停在 ‘it's’ 中的 ‘t’ 后面。
第二行让 Emacs 在启用文本模式时自动开启自动填充模式。在自动填充模式下,Emacs 会自动将过长的行换行,把超出宽度的部分移到下一行。Emacs 会在单词之间换行,而不会从单词中间断开。
关闭自动填充模式时,输入的文本会一直向右延伸。根据 truncate-lines 的设置,超出屏幕的内容要么直接在屏幕右侧消失,要么以难看、难以阅读的折行形式显示在屏幕上。
此外,在我的 .emacs 这一部分,我还让 Emacs 的填充命令在冒号后插入两个空格:
(setq colon-double-space t)
下面这条 setq 用于启用邮件别名功能,附带一些注释说明。
;;; 消息模式 ; 进入消息模式,输入 'C-x m' ; 进入 RMAIL(阅读邮件), ; 输入 'M-x rmail' (setq mail-aliases t)
这条 setq 将变量 mail-aliases 的值设为 t。由于 t 代表真,这一行实际上就是:“启用邮件别名功能”。
邮件别名是为长邮箱地址或地址列表设置的便捷简称。存放别名的文件是 ~/.mailrc。可以像这样定义别名:
alias geo [email protected]
当你给 George 写邮件时,收件人写 ‘geo’ 即可;邮件工具会自动将 ‘geo’ 展开为完整地址。
默认情况下,Emacs 在格式化区域时会用制表符代替多个空格。(例如,使用 indent-region 命令一次性缩进多行文本时。)制表符在终端或普通打印中显示正常,但在 TeX 或 Texinfo 中会导致缩进错乱,因为 TeX 会忽略制表符。
下面配置用于关闭缩进制表符模式:
;;; 禁止多余的制表符 (setq-default indent-tabs-mode nil)
注意这一行使用了 setq-default 而非之前见过的 setq;setq-default 只对没有自身局部值的缓冲区生效。
下面是一些个人常用的按键绑定:
;;; 窗口对比 (keymap-global-set "C-c w" 'compare-windows)
compare-windows 是一个很实用的命令,用于对比当前窗口与下一个窗口中的文本。它从每个窗口的光标位置开始比对,逐字符移动直到内容不匹配。我经常使用这个命令。
这也展示了如何为所有模式全局设置按键。
设置按键的命令是 keymap-global-set,后面跟着按键绑定。在 .emacs 中,按键写法如下:C-c 代表 Control-C,即同时按住 Ctrl 键和 c 键。w 代表按下 w 键。整个按键组合用双引号包围。(如果绑定的是 META 键而非 CTRL 键,在 .emacs 中应写作 M-c。
See Rebinding Keys in Your Init File in The GNU
Emacs Manual,查看详细说明。)
按键触发的命令是 compare-windows。注意 compare-windows 前面有一个单引号;否则 Emacs 会先尝试求值该符号以获取其值。
双引号、‘C’ 前面的写法以及单引号,这三处是按键绑定中我经常忘记的部分。好在我已经养成习惯,直接查看已有的 .emacs 并参照修改。
按键绑定本身是:C-c w。它将前缀键 C-c 与单个字符 w 组合。这类 C-c 加单个字符的按键组合严格保留给用户自定义使用。(我称其为 自定义专属按键,因为它们仅供个人使用。)你可以放心创建这类按键,不会与他人的按键冲突。如果你为 Emacs 编写扩展,请不要占用这类按键供公共使用,改用 C-c C-w 这类组合。否则专属按键很快就会被用完。
下面是另一个按键绑定,附带注释:
;;; 'occur' 命令按键绑定 ; 我经常使用 occur,因此为其绑定按键: (keymap-global-set "C-c o" 'occur)
occur 命令会在当前缓冲区中列出所有匹配正则表达式的行。如果选区处于激活状态,occur 只在选区内匹配;否则搜索整个缓冲区。
匹配行显示在名为 *Occur* 的缓冲区中,
该缓冲区相当于一个菜单,可直接跳转到对应位置。
下面是取消按键绑定的方法,使其失效:
;;; 取消绑定 'C-x f' (keymap-global-unset "C-x f")
取消绑定是有原因的:我经常误按 C-x f,而本意是按 C-x C-f。结果并没有打开文件,而是意外修改了文本填充宽度,而且几乎总是改成我不想要的宽度。因为我几乎从不重置默认宽度,所以直接取消了该按键。
下面重新绑定一个已有按键:
;;; 将 'C-x C-b' 重绑定为 'buffer-menu' (keymap-global-set "C-x C-b" 'buffer-menu)
默认情况下,C-x C-b 运行 list-buffers 命令,
该命令会在另一个窗口列出缓冲区。
由于我几乎总是想在该窗口进行操作,
因此我更喜欢 buffer-menu 命令,
它不仅列出缓冲区,还会将光标移到该窗口。
历史上,全局按键绑定使用更低层的函数 global-set-key,现在已视为旧版用法。虽然推荐使用 keymap-global-set,但你在很多地方仍会见到 global-set-key。本节第一个例子用旧版写法如下:
(global-set-key "\C-cw" 'compare-windows)
它与 keymap-global-set 非常相似,只是按键格式略有不同。Control-C 写作 \C-c 而非 C-c,按键之间没有空格,比如本例中的 \C-c 和 w。尽管格式不同,文档中为了可读性仍写作 C-c w。
历史上,全局取消按键绑定使用低层函数 global-unset-key,现在也视为旧版用法。其按键格式与 global-set-key 一致。本节取消按键的例子可改写为:
;;; Unbind 'C-x f' (global-unset-key "\C-xf")
Emacs 使用 按键映射(keymaps) 记录哪些按键调用哪些命令。当你使用 keymap-global-set 为某个命令设置全局按键绑定时,实际上是在 current-global-map 中指定绑定。
特定模式(如 C 模式或文本模式)有各自的按键映射;模式专属映射会覆盖所有缓冲区共享的全局映射。
keymap-global-set 函数用于绑定或重绑定全局按键映射。例如,下面将 C-x C-b 绑定到函数 buffer-menu:
(keymap-global-set "C-x C-b" 'buffer-menu)
模式专属按键映射使用 keymap-set 函数绑定,它接收具体映射、按键和命令作为参数。例如,下面表达式将 texinfo-insert-@group 命令绑定到 C-c C-c g:
(keymap-set texinfo-mode-map "C-c C-c g" 'texinfo-insert-@group)
历史上,按键映射使用低层函数 define-key,现在视为旧版用法。虽然推荐使用 keymap-set,但你仍会在很多地方见到 define-key。上面的按键绑定用旧版写法如下:
(define-key texinfo-mode-map "\C-c\C-cg" 'texinfo-insert-@group)
texinfo-insert-@group 函数本身是 Texinfo 模式的一个小扩展,用于在 Texinfo 文件中插入 ‘@group’。我经常使用这个命令,更愿意按三次键 C-c C-c g,而不是六次键 @ g r o u p。
(‘@group’ 与对应的 ‘@end group’ 用于将包含的内容保持在同一页;本书中许多多行示例都被 ‘@group … @end group’ 包围。)
下面是 texinfo-insert-@group 函数定义:
(defun texinfo-insert-@group () "在 Texinfo 缓冲区中插入字符串 @group。" (interactive) (beginning-of-line) (insert "@group\n"))
(当然,我也可以用缩写模式减少输入,不必专门写函数插入一个单词;但我希望按键风格与其他 Texinfo 模式绑定保持一致。)
你会在 loaddefs.el 以及各种模式库(如 cc-mode.el 和 lisp-mode.el)中看到大量 keymap-set 和 define-key 表达式。
See Customizing Key Bindings in The GNU Emacs Manual,以及 Keymaps in The GNU Emacs Lisp Reference Manual,获取更多关于按键映射的信息。
GNU Emacs 社区中有很多人为其编写扩展。随着时间推移,这些扩展常常被纳入新版本发布。例如日历、日记工具包以及 Calc 现在都是标准 GNU Emacs 的一部分。
你可以使用 load 命令对整个文件求值,从而将文件中的所有函数和变量安装到 Emacs 中。例如:
(load "~/emacs/slowsplit")
这会加载并求值主目录下 emacs 子目录中的 slowsplit.el 文件,若存在更快的字节编译版本 slowsplit.elc 则优先加载。该文件包含 John Robinson 在 1989 年编写的 split-window-quietly 函数。
split-window-quietly 函数分割窗口时会最小化重绘。我在 1989 年安装它,因为当时使用的 1200 波特慢速终端效果很好。如今很少遇到这么慢的连接,但我仍在使用这个函数,因为它会把缓冲区下半部分留在新窗口下方、上半部分留在上方。
要替换默认 split-window-vertically 的按键绑定,需要将按键绑定到 split-window-quietly,如下:
(keymap-global-set "C-x 2" 'split-window-quietly)
如果你像我一样加载很多扩展,可以不必像上面那样指定文件完整路径,而是将该目录加入 Emacs 的 load-path。之后 Emacs 加载文件时,会同时搜索该目录和默认目录列表。(默认列表在编译 Emacs 时由 paths.h 指定。)
下面命令将 ~/emacs 目录添加到现有加载路径:
;;; Emacs 加载路径 (setq load-path (cons "~/emacs" load-path))
顺便一提,load-library 是 load 函数的交互式接口。完整函数如下:
(defun load-library (library)
"加载名为 LIBRARY 的 Emacs Lisp 库。
本函数是 `load' 的接口。会在 `load-path' 中
搜索 LIBRARY,尝试添加与不添加 `load-suffixes'
(以及 `load-file-rep-suffixes')的文件名。
详见 Info 节点 `(emacs)Lisp Libraries'。
`load-file' 是 `load' 的另一种接口。"
(interactive
(list (completing-read "Load library: "
(apply-partially 'locate-file-completion-table
load-path
(get-load-suffixes)))))
(load library))
函数名 load-library 来自习惯上将 “库(library)” 作为 “文件(file)” 的同义词。load-library 命令的源码位于 files.el 库中。
另一个功能略有不同的交互式命令是 load-file。
See Libraries of Lisp Code for
Emacs in The GNU Emacs Manual,了解 load-library 与该命令的区别。
你不必通过加载文件或直接求值函数定义来安装函数,而是可以先让函数可用,但直到第一次调用时才真正加载安装。这称为 自动加载(autoloading)。
当你执行一个自动加载的函数时,Emacs 会自动求值包含其定义的文件,然后调用该函数。
使用自动加载函数可以让 Emacs 启动更快,因为相关库不会立即加载;但第一次使用时需要稍等片刻,等待对应文件被求值加载。
不常用的函数通常会被设为自动加载。loaddefs.el 库包含数千个自动加载函数,从 5x5 到 zone 应有尽有。当然,你可能会频繁使用某个原本不常用的函数。这时就应该在 .emacs 中用 load 表达式提前加载该文件。
在我的 .emacs 中,我加载了 14 个原本会自动加载的库。(其实把这些文件加入转储 Emacs 会更好,只是我忘了。 See Building Emacs in The GNU Emacs Lisp Reference Manual,以及 INSTALL 文件了解更多转储相关内容。)
你也可以在 .emacs 中添加自动加载表达式。autoload 是内置函数,最多接收五个参数,后三个为可选。第一个参数是要自动加载的函数名;第二个是要加载的文件名;第三个是函数说明;第四个指明该函数是否可交互式调用;第五个指明对象类型 — autoload 同时支持按键映射或宏,默认为函数。
下面是一个典型示例:
(autoload 'html-helper-mode "html-helper-mode" "Edit HTML documents" t)
(html-helper-mode 是 html-mode 的早期替代方案,后者现已成为标准组件。)
该表达式对 html-helper-mode 函数设置自动加载,
从 html-helper-mode.el(或字节编译版本 html-helper-mode.elc,若存在)加载。
文件必须位于 load-path 指定的目录中。
文档说明这是用于编辑超文本标记语言文档的模式。
你可以通过 M-x html-helper-mode 交互式调用该模式。
(需要在自动加载表达式中重复函数原本的文档,因为此时函数尚未加载,文档不可用。)
See Autoload in The GNU Emacs Lisp Reference Manual,获取更多信息。
line-to-top-of-window ¶这里是一个 Emacs 简单扩展,它会将光标所在行移动到 框架 顶部。我经常使用该功能,让文本更易于阅读。
你可以将下面的代码放入独立文件,再从 .emacs 文件加载,也可以直接写在 .emacs 文件内部。
定义如下:
;;; 将行移至 框架 顶部; ;;; 替代三次按键序列 C-u 0 C-l (defun line-to-top-of-window () "将光标所在行移至 框架 顶部。" (interactive) (recenter 0))
接下来设置按键绑定。
功能键、鼠标按键事件以及非ASCII字符都写在方括号内,不加引号。
我将 line-to-top-of-window 绑定到 F6 功能键,写法如下:
(keymap-global-set "<f6>" 'line-to-top-of-window)
更多信息请参考 Rebinding Keys in Your Init File in The GNU Emacs Manual。
如果你同时运行两个版本的 GNU Emacs(例如 27 版和 28 版),并且共用一个 .emacs 文件,可以使用下面的条件语句选择要执行的代码:
(cond ((= 27 emacs-major-version) ;; evaluate version 27 code ( ... )) ((= 28 emacs-major-version) ;; evaluate version 28 code ( ... )))
例如,新版本 Emacs 默认会让光标闪烁。我很讨厌光标闪烁以及其他一些特性,因此在 .emacs 文件中加入了以下配置22:
(when (>= emacs-major-version 21) (blink-cursor-mode 0) ;; 在缓冲区末尾按 'C-n' (next-line) 时自动插入换行 (setq next-line-add-newlines t)
;; Turn on image viewing (auto-image-file-mode t)
;; Turn on menu bar (this bar has text) ;; (Use numeric argument to turn on) (menu-bar-mode 1)
;; Turn off tool bar (this bar has icons) ;; (Use numeric argument to turn on) (tool-bar-mode nil)
;; Turn off tooltip mode for tool bar ;; (This mode causes icon explanations to pop up) ;; (Use numeric argument to turn on) (tooltip-mode nil) ;; 若开启提示,让提示快速出现 (setq tooltip-delay 0.1) ; 默认 0.7 秒 )
在 MIT X 窗口系统下使用 Emacs 时,你可以指定颜色。
我不喜欢默认颜色,会自定义一套配色。
以下是我在 .emacs 中设置的相关表达式:
;; Set cursor color (set-cursor-color "white") ;; Set mouse color (set-mouse-color "white") ;; Set foreground and background (set-foreground-color "white") (set-background-color "darkblue")
;;; 设置增量搜索与拖拽的高亮颜色 (set-face-foreground 'highlight "white") (set-face-background 'highlight "blue")
(set-face-foreground 'region "cyan") (set-face-background 'region "blue")
(set-face-foreground 'secondary-selection "skyblue") (set-face-background 'secondary-selection "darkblue")
;; 设置日历高亮颜色 (with-eval-after-load 'calendar (set-face-foreground 'diary "skyblue") (set-face-background 'holiday "slate blue") (set-face-foreground 'holiday "white"))
各种蓝色调看着更舒适,也能减少屏幕闪烁带来的视觉疲劳。
另外,我也可以在各类 X 初始化文件中进行设置。例如,在 ~/.Xresources 文件中设置前景、背景、光标和指针(鼠标)颜色:
Emacs*foreground: white Emacs*background: darkblue Emacs*cursorColor: white Emacs*pointerColor: white
无论如何,X 窗口的根窗口颜色不属于 Emacs 配置,我在 ~/.xinitrc 文件中这样设置23:
xsetroot -solid Navy -fg white &
以下是一些杂项设置:
; 光标形状定义在 ; '/usr/include/X11/cursorfont.h'; ; 例如 'target' 光标编号为 128; ; 'top_left_arrow' 光标编号为 132
(let ((mpointer (x-get-resource "*mpointer"
"*emacs*mpointer")))
;; 若未设置鼠标指针
;; 则进行设置,否则保持不变
(if (eq mpointer nil)
(setq mpointer "132")) ; top_left_arrow
(setq x-pointer-shape (string-to-number mpointer)) (set-mouse-color "white"))
(setq-default default-frame-alist '((cursor-color . "white") (mouse-color . "white") (foreground-color . "white") (background-color . "DodgerBlue4") ;; (cursor-type . bar) (cursor-type . box)
(tool-bar-lines . 0)
(menu-bar-lines . 1)
(width . 80)
(height . 58)
(font .
"-Misc-Fixed-Medium-R-Normal--20-200-75-75-C-100-ISO8859-1")
))
;; Translate 'C-h' to <DEL>. ; (keyboard-translate ?\C-h ?\C-?) ;; 将 <DEL> 转为 'C-h' (keyboard-translate ?\C-? ?\C-h)
(if (fboundp 'blink-cursor-mode)
(blink-cursor-mode -1))
或者直接使用命令 emacs -nbc 启动 GNU Emacs。
grep 时(setq grep-command "grep -i -nH -e ")
(setq find-file-existing-other-name t)
(set-language-environment "latin-1")
;; 可通过 toggle-input-method' (C-\) 命令
;; 开启或关闭多语言文本输入
(setq default-input-method "latin-1-prefix")
若需要输入中文 GB 字符,可改为:
(set-language-environment "Chinese-GB") (setq default-input-method "chinese-tonepy")
部分系统的按键绑定很不合理。例如,CTRL 键有时会放在别扭的位置,而不是主键盘行最左侧。
通常修复这类按键绑定时,不会修改 ~/.emacs 文件。而是在启动脚本中用 loadkeys 或 install-keymap 命令设置控制台按键,再在 X Window 的 .xinitrc 或 .Xsession 文件中加入 xmodmap 命令。
启动脚本示例:
loadkeys /usr/share/keymaps/i386/qwerty/emacs2.kmap.gz
或
install-keymap emacs2
当 Caps Lock 键在主键盘行最左侧时,在 .xinitrc 或 .Xsession 中设置:
# 将标为 'Caps Lock' 的键绑定为 'Control' # 这种糟糕的设计说明键盘厂商还把电脑当 1885 年的打字机 xmodmap -e "clear Lock" xmodmap -e "add Control = Caps_Lock"
在 .xinitrc 或 .Xsession 中将 ALT 键转为 META 键:
# 部分设计糟糕的键盘只有 ALT 键没有 Meta 键 xmodmap -e "keysym Alt_L = Meta_L Alt_L"
最后介绍一个我非常喜欢的功能:自定义模式行。
在网络上工作时,我经常忘记当前所在主机;也常常搞不清当前位置和光标所在行号。
因此我将模式行重设为如下样式:
-:-- foo.texi rattlesnake:/home/bob/ Line 1 (Texinfo Fill) Top
表示正在编辑主机 rattlesnake 上 /home/bob 目录下的 foo.texi 文件,当前在第 1 行,Texinfo 模式,位于缓冲区顶部。
我的 .emacs 中相关配置如下:
;; 设置模式行,显示主机、目录、行号
;; 以及其他常用信息
(setq-default mode-line-format
(quote
(#("-" 0 1
(help-echo
"mouse-1: select window, mouse-2: delete others ..."))
mode-line-mule-info
mode-line-modified
mode-line-frame-identification
" "
mode-line-buffer-identification
" "
(:eval (substring
(system-name) 0 (string-match "\\..+" (system-name))))
":"
default-directory
#(" " 0 1
(help-echo
"mouse-1: select window, mouse-2: delete others ..."))
(line-number-mode " Line %l ")
global-mode-string
#(" %[(" 0 6
(help-echo
"mouse-1: select window, mouse-2: delete others ..."))
(:eval (format-time-string "%F"))
mode-line-process
minor-mode-alist
#("%n" 0 2 (help-echo "mouse-2: widen" local-map (keymap ...)))
")%] "
(-3 . "%P")
;; "-%-"
)))
这里重定义了默认模式行。大部分组件沿用原版,只做了少量修改。我设置的是 默认 模式行格式,以便 Info 等模式可以覆盖它。
列表中的很多元素都一目了然:mode-line-modified 标记缓冲区是否被修改,mode-name 显示当前模式名称等。格式看起来复杂,是因为用到了两个尚未介绍的特性。
模式行的第一个字符串是连字符 ‘-’。早期只需简单写作 "-"。但现在 Emacs 可以为字符串附加属性,比如高亮或本例中的帮助提示。将鼠标移到连字符上就会显示帮助信息(默认需要等待 0.7 秒,可通过修改 tooltip-delay 调整延迟)。
新的字符串格式语法如下:
#("-" 0 1 (help-echo "mouse-1: select window, ..."))
#( 表示列表开始。第一个元素是字符串本身,即单个 ‘-’。第二个和第三个元素指定属性作用的范围。范围从字符 之后 开始,0 表示从第一个字符前开始,1 表示到第一个字符后结束。第三个元素是该范围的属性,由属性名(本例为 ‘help-echo’)和对应值(字符串)组成。这种新格式的第二、三、四个元素可以重复出现。
更多信息请参考 See Text Properties in The GNU Emacs Lisp Reference Manual 以及 Mode Line Format in The GNU Emacs Lisp Reference Manual。
mode-line-buffer-identification
显示当前缓冲区名称。它是一个以 (#("%12b" 0 4 … 开头的列表。
‘"%12b"’ 使用我们熟悉的 buffer-name 函数显示缓冲区名;‘12’ 指定最多显示的字符数。名称不足时会用空格补齐。(缓冲区名通常可以长于 12 个字符,在标准 80 列 框架 中该长度效果很好。)
:eval 表示对后续表达式求值,并将结果作为字符串显示。本例中该表达式会显示完整主机名的第一部分。第一部分以 ‘.’ 结尾,因此我用 string-match 函数获取其长度,从第 0 个字符到该长度的子串即为主机名。
对应表达式:
(:eval (substring
(system-name) 0 (string-match "\\..+" (system-name))))
‘%[’ 和 ‘%]’ 会为每一层递归编辑生成一对方括号。‘%n’ 在启用缩窄时显示 “Narrow”。‘%P’ 显示缓冲区内容在 框架 底部上方的百分比,或显示 “Top”、“Bottom”、“All”。(小写 ‘p’ 显示在 框架 顶部上方的百分比。)‘%-’ 插入足够的连字符填满整行。
记住:你不必接受默认的 Emacs — 你的 Emacs 可以拥有不同的颜色、命令和按键。
另一方面,如果你想启动一个完全未定制的原生 Emacs,输入:
emacs -q
这样启动的 Emacs 不会加载 ~/.emacs 初始化文件,是纯粹的默认配置,无任何额外设置。
GNU Emacs 有两个调试器:debug 和 edebug。前者内置于 Emacs 核心,随时可用;后者需要先对函数进行插桩才能使用。
这两个调试器在 Debugging Lisp Programs in The GNU Emacs Lisp Reference Manual 中有详细说明。本章将通过简短示例分别介绍两者的用法。
debug ¶假设你写了一个函数,用于计算从 1 到指定数字的累加和。(即之前讲过的 triangle 函数。See Example with Decrementing Counter。)
但函数定义存在错误:你把 ‘1-’ 误写成了 ‘1=’。错误定义如下:
(defun triangle-bugged (number)
"返回数字 1 到 NUMBER 的累加和。"
(let ((total 0))
(while (> number 0)
(setq total (+ total number))
(setq number (1= number))) ; Error here.
total))
如果你在 Info 中阅读本文,可以按常规方式执行该定义,回显区会出现 triangle-bugged。
现在以 4 为参数执行 triangle-bugged 函数:
(triangle-bugged 4)
会创建并进入 *Backtrace* 缓冲区,内容如下:
---------- Buffer: *Backtrace* ----------
Debugger entered--Lisp error: (void-function 1=)
(1= number)
(setq number (1= number))
(while (> number 0) (setq total (+ total number))
(setq number (1= number)))
(let ((total 0)) (while (> number 0) (setq total ...)
(setq number ...)) total)
triangle-bugged(4)
eval((triangle-bugged 4) nil) eval-expression((triangle-bugged 4) nil nil 127) funcall-interactively(eval-expression (triangle-bugged 4) nil nil 127) call-interactively(eval-expression nil nil) command-execute(eval-expression) ---------- Buffer: *Backtrace* ----------
(我对格式做了轻微调整;调试器不会自动折行。和往常一样,在 *Backtrace* 缓冲区按 q 可退出调试器。)
对于如此简单的错误,Lisp 错误行本身就足以告诉你如何修复:函数 1= 未定义。
但如果你不确定具体问题,可以阅读完整回溯信息。
Emacs 会自动启动调试器并进入 *Backtrace* 缓冲区。你也可以按下面说明手动启动调试器。
*Backtrace* 缓冲区应从下往上阅读,它记录了导致错误的 Emacs 执行步骤。Emacs 交互式调用了 C-x C-e(eval-last-sexp),进而执行了 triangle-bugged 表达式。往上每一行都记录了 Lisp 解释器下一步执行的内容。
缓冲区顶部第三行是:
(setq number (1= number))
Emacs 尝试执行该表达式,为此需要先执行顶部第二行的内层表达式:
(1= number)
错误就发生在这里,正如第一行所示:
Debugger entered--Lisp error: (void-function 1=)
你可以修正错误,重新执行函数定义,再进行测试。
debug-on-entry ¶当函数出现错误时,Emacs 会自动启动调试器。
顺便一提,在所有版本的 Emacs 中你都可以手动启动调试器;这样做的好处是,即使代码中没有错误,调试器也照样可以运行。毕竟有时候你的代码是完全没有 Bug 的!
通过调用 debug-on-entry,你可以在调用函数时直接进入调试器。
输入:
M-x debug-on-entry RET triangle-bugged RET
接下来,执行下面的表达式:
(triangle-bugged 5)
所有版本的 Emacs 都会创建一个 *Backtrace* 缓冲区,并提示即将开始执行 triangle-bugged 函数:
---------- Buffer: *Backtrace* ---------- Debugger entered--entering a function: * triangle-bugged(5) eval((triangle-bugged 5) nil)
eval-expression((triangle-bugged 5) nil nil 127) funcall-interactively(eval-expression (triangle-bugged 5) nil nil 127) call-interactively(eval-expression nil nil) command-execute(eval-expression) ---------- Buffer: *Backtrace* ----------
在 *Backtrace* 缓冲区中按 d。Emacs 会执行 triangle-bugged 内的第一个表达式,缓冲区内容会变成这样:
---------- Buffer: *Backtrace* ----------
Debugger entered--beginning evaluation of function call form:
* (let ((total 0)) (while (> number 0) (setq total ...)
(setq number ...)) total)
* triangle-bugged(5)
eval((triangle-bugged 5))
eval((triangle-bugged 5) nil) eval-expression((triangle-bugged 5) nil nil 127) funcall-interactively(eval-expression (triangle-bugged 5) nil nil 127) call-interactively(eval-expression nil nil) command-execute(eval-expression) ---------- Buffer: *Backtrace* ----------
现在,再次慢慢按 d,连续按八次。每按一次 d,Emacs 就会执行函数定义中的下一个表达式。
最终,缓冲区会显示如下内容:
---------- Buffer: *Backtrace* ----------
Debugger entered--beginning evaluation of function call form:
* (setq number (1= number))
* (while (> number 0) (setq total (+ total number))
(setq number (1= number)))
* (let ((total 0)) (while (> number 0) (setq total ...)
(setq number ...)) total)
* triangle-bugged(5)
eval((triangle-bugged 5) nil)
eval-expression((triangle-bugged 5) nil nil 127) funcall-interactively(eval-expression (triangle-bugged 5) nil nil 127) call-interactively(eval-expression nil nil) command-execute(eval-expression) ---------- Buffer: *Backtrace* ----------
最后,再按两次 d 之后,Emacs 就会触发错误,*Backtrace* 缓冲区最上方两行会显示:
---------- Buffer: *Backtrace* ---------- Debugger entered--Lisp error: (void-function 1=) * (1= number) ... ---------- Buffer: *Backtrace* ----------
通过按 d,你可以单步跟踪函数的执行过程。
在 *Backtrace* 缓冲区中按 q 可以退出调试,但并不会取消 debug-on-entry。
要取消 debug-on-entry 的效果,调用 cancel-debug-on-entry 并指定函数名即可,如下:
M-x cancel-debug-on-entry RET triangle-bugged RET
(如果你正在 Info 中阅读本文,请现在取消 debug-on-entry。)
debug-on-quit 与 (debug) ¶除了设置 debug-on-error 或调用 debug-on-entry,还有另外两种方式可以启动 debug。
将变量 debug-on-quit 设置为 t 后,每当你按 C-g(keyboard-quit)时都会启动调试器。这对调试死循环非常有用。
或者,你也可以在代码中希望启动调试器的位置插入一行 (debug),如下所示:
(defun triangle-bugged (number)
"Return sum of numbers 1 through NUMBER inclusive."
(let ((total 0))
(while (> number 0)
(setq total (+ total number))
(debug) ; Start debugger.
(setq number (1= number))) ; Error here.
total))
debug 函数的详细说明见 The Lisp Debugger in The GNU Emacs Lisp Reference Manual。
edebug 源码级调试器 ¶Edebug 是一款源码级调试器。Edebug 通常会显示你正在调试的代码源码,并在左侧用箭头标出当前执行到哪一行。
你可以逐行单步跟踪函数执行,也可以让程序快速运行,直到遇到 断点(breakpoint) 时停下。
Edebug 的详细说明见 Edebug in The GNU Emacs Lisp Reference Manual。
下面是 triangle-recursively 的一个带错误版本的函数定义。回顾内容可参考 See Recursion in place of a counter。
(defun triangle-recursively-bugged (number)
"返回数字 1 到 NUMBER 的累加和。
使用递归实现。"
(if (= number 1)
1
(+ number
(triangle-recursively-bugged
(1= number))))) ; Error here.
通常情况下,你会将光标移到函数结尾的右括号后,按 C-x C-e(eval-last-sexp)加载该定义;或者将光标放在定义内部,按 C-M-x(eval-defun)。(默认情况下,eval-defun 命令只在 Emacs Lisp 模式或 Lisp 交互模式下生效。)
不过,要让这个函数能被 Edebug 使用,必须先用另一个命令对代码进行 插桩(instrument)。你可以将光标放在函数定义内部或紧接其后,然后输入:
M-x edebug-defun RET
如果 Edebug 尚未加载,Emacs 会自动加载它,并对函数完成正确插桩。
函数插桩完成后,将光标放在下面表达式的后面,按 C-x C-e(eval-last-sexp):
(triangle-recursively-bugged 3)
界面会跳转到 triangle-recursively-bugged 的源码处,光标会定位在函数的 if 行开头。同时,你会在该行左侧看到一个箭头符号。这个箭头标记了函数当前正在执行的行。(下面的例子中我们用 ‘=>’ 表示箭头;在图形化 框架 中,你可能会在 框架 边缘看到一个实心三角箭头。)
=>∗(if (= number 1)
本例中,光标位置显示为 ‘∗’(在印刷书籍中会用五角星标出)。
如果现在按 SPC,光标会移动到下一个待执行的表达式,该行会变成:
=>(if ∗(= number 1)
继续按 SPC,光标会在各个表达式之间移动。同时,每当一个表达式返回值时,该值会显示在回显区。例如,当光标移过 number 后,你会看到:
Result: 3 (#o3, #x3, ?\C-c)
意思是 number 的值为 3,对应八进制 3、十六进制 3,以及 ASCII 字符 Control-C(字母表第三个字符,仅供参考)。
你可以继续单步执行代码,直到到达出错的行。执行前,该行看起来是这样的:
=> ∗(1= number))))) ; Error here.
再按一次 SPC,就会出现错误提示:
Symbol's function definition is void: 1=
这就是程序中的 Bug。
按 q 退出 Edebug。
要移除函数的插桩,只需用不进行插桩的命令重新执行一遍定义即可。例如,将光标移到函数结尾右括号后,按 C-x C-e。
Edebug 的功能远不止单步跟踪函数。你可以让它快速自动运行,只在出错或指定断点处停下;可以让它实时显示各个表达式的变化值;可以统计函数被调用的次数,等等。
Edebug 的详细说明见 Edebug in The GNU Emacs Lisp Reference Manual。
count-words-example 函数,并使其在调用时进入内置调试器。在包含两个单词的区域上运行该命令。你需要多次按下 d 键。在你的系统中,命令执行完毕后会调用钩子吗?(有关钩子的信息,参见 Command Loop Overview in The GNU Emacs Lisp Reference Manual。)
count-words-example 复制到 *scratch* 缓冲区,为该函数启用 Edebug 检测,并逐步执行其运行过程。该函数本身不必存在缺陷,不过你也可以主动引入一个。如果函数没有缺陷,逐步执行过程会顺利完成。
global-edebug-prefix 通常为 C-x X,即先按 CTRL-x,再按大写 X;在 Edebug 调试缓冲区之外执行命令时需使用此前缀。)
edebug-bounce-point)命令查看 count-words-example 正在处理区域中的哪个位置。
edebug-goto-here)命令跳转到该位置。
edebug-trace-mode)命令让 Edebug 自动遍历执行函数;使用大写 T 开启 edebug-Trace-fast-mode。
本入门指南至此结束。你已经掌握了足够的 Emacs Lisp 编程知识,可以设置变量值、为自己和他人编写简单的 .emacs 文件,以及为 Emacs 编写简单的自定义配置与扩展功能。
你可以在此处停下。或者,如果你愿意,也可以继续深入,自主学习更多内容。
你已经学到了编程的一些基础构件,但这只是一部分。还有大量易用的语法与机制我们尚未涉及。
你当下可以继续探索的方向,是阅读 GNU Emacs 源码,以及 GNU Emacs Lisp 参考手册。
Emacs Lisp 源码本身就是一场探索。当你阅读源码时遇到不熟悉的函数或表达式,需要自行分析或查阅其作用。
去查阅参考手册。它对 Emacs Lisp 的描述全面、完整且易于阅读,不仅面向专家,也适合像你这样具备当前知识水平的读者。(该参考手册 随标准 GNU Emacs 发行版一同提供。与本入门指南一样,它以 Texinfo 源文件形式发布,你可以在计算机上阅读,也可阅读排版后的印刷版。)
使用 GNU Emacs 内置的其他帮助工具:所有函数与变量的内置文档,以及可直接跳转到源码的 xref-find-definitions。
下面是我探索源码的一个示例。很久以前,我首先查看的是 simple.el,仅从文件名就能大致判断其内容。实际上,simple.el 中的部分函数较为复杂,至少初看时会觉得复杂。例如 open-line 函数看起来就比较繁琐。
你可以像之前分析 forward-sentence 函数那样,慢慢逐行阅读该函数。(See The forward-sentence function。)或者跳过该函数,查看其他函数,比如 split-line。你不必阅读所有函数。根据 count-words-in-defun 的统计,split-line 函数包含 102 个单词与符号。
尽管 split-line 篇幅不长,却包含我们尚未学习的表达式:skip-chars-forward、indent-to、current-column 与 insert-and-inherit。
以 skip-chars-forward 函数为例。
在 GNU Emacs 中,你可以通过输入 C-h f(describe-function)并输入函数名,来查看该函数的详细信息,获取其文档说明。
对于命名清晰的函数,比如 indent-to,你或许能猜出其功能;当然也可以直接查阅。顺便一提,describe-function 函数本身位于 help.el 中,它是一个较长但可读懂的函数。你甚至可以用 C-h f 命令去查询 describe-function 自身!
在这种情况下,由于代码是 Lisp 代码,*Help* 缓冲区会显示包含该函数源码的库文件名。将光标移到库文件名上并按下 RET 键(该按键在此场景下绑定为 help-follow),即可直接跳转到源码,效果与 M-.(xref-find-definitions)一致。
describe-function 的定义展示了如何在不使用标准字符编码的情况下自定义 interactive 表达式,同时也展示了如何创建临时缓冲区。
(indent-to 函数由 C 语言而非 Emacs Lisp 编写,属于内置函数。在配置正确的前提下,help-follow 与 xref-find-definitions 均可跳转到其源码。)
你可以使用绑定在 M-. 上的 xref-find-definitions 查看函数源码。最后,你可以在 Info 中打开参考手册,输入 i(Info-index)并输入函数名,或在印刷版手册的索引中查找该函数,了解手册中的相关说明。
同理,你也可以查阅 insert-and-inherit 的含义。
其他值得阅读的源码文件包括 paragraphs.el、loaddefs.el 与 loadup.el。paragraphs.el 中既有简短易懂的函数,也包含较长的函数。loaddefs.el 包含大量标准自动加载配置与键盘映射,我从未完整阅读过,只查看过部分内容。loadup.el 负责加载 Emacs 的标准组件,能让你深入了解 Emacs 的构建机制。 (更多构建相关内容,参见 See Building Emacs in The GNU Emacs Lisp Reference Manual。)
如前所述,你已经掌握了一些基础编程构件,但非常重要的是,我们几乎没有涉及编程的核心高阶内容:除了使用预定义的 sort 函数外,我没有讲解如何对信息排序;除了使用变量与列表外,没有讲解如何存储信息;也没有讲解如何编写能生成代码的程序。这些内容属于另一类书籍与另一种学习范畴。
你目前学到的知识,已经足以完成大量 GNU Emacs 实际应用工作。你已经成功迈出了第一步。这只是起点的终点。
the-the 函数 ¶有时在编写文本时会不小心重复输入单词——比如本句开头的“you you”。我发现自己最常重复输入的是“the”,因此将这个检测重复单词的函数命名为 the-the。
第一步,你可以使用下面的正则表达式搜索重复单词:
\\(\\w+[ \t\n]+\\)\\1
该正则表达式匹配一个或多个单词构成字符,后跟一个或多个空格、制表符或换行符。但它无法检测位于不同行的重复单词,因为第一个单词以行尾结束,第二个单词以空格结束,二者结尾不同。(有关正则表达式的更多信息,参见 Regular Expression Searches,以及 Syntax of Regular Expressions in The GNU Emacs Manual 和 Regular Expressions in The GNU Emacs Lisp Reference Manual。)
你可以尝试仅搜索重复的单词构成字符,但这并不可行,因为该模式会误判类似“with the”中连续出现的“th”。
另一种可行的正则表达式是搜索单词构成字符后跟非单词构成字符并重复的组合。其中 ‘\\w+’ 匹配一个或多个单词构成字符,‘\\W*’ 匹配零个或多个非单词构成字符。
\\(\\(\\w+\\)\\W*\\)\\1
该方式同样不适用。
下面是我实际使用的模式。它并不完美,但足够实用。 ‘\\b’ 匹配位于单词开头或结尾的空字符串;‘[^@ \n\t]+’ 匹配一个或多个 非 @-符号、空格、换行符或制表符的字符。
\\b\\([^@ \n\t]+\\)[ \n\t]+\\1\\b
可以编写更复杂的表达式,但我发现该表达式已足够好用,因此一直沿用。
下面是我在 .emacs 文件中配置的 the-the 函数,同时附带一个便捷的全局按键绑定:
(defun the-the () "向前搜索重复的单词。" (interactive) (message "Searching for for duplicated words ...") (push-mark)
;; 该正则表达式并不完美
;; 但整体效果尚可:
(if (re-search-forward
"\\b\\([^@ \n\t]+\\)[ \n\t]+\\1\\b" nil 'move)
(message "Found duplicated word.")
(message "End of buffer")))
;; 将 'the-the' 绑定到 C-c \ (keymap-global-set "C-c \\" 'the-the)
下面是测试文本:
one two two three four five five six seven
你可以将上面展示的其他正则表达式替换到函数定义中,并在该文本列表上逐一测试。
剪切环是一个列表,通过 current-kill 函数的逻辑转换为环形结构。yank 与 yank-pop 命令均使用 current-kill 函数。
本附录介绍 current-kill 函数以及 yank 和 yank-pop 命令,首先讲解剪切环的工作机制。
剪切环默认最大长度为 60 项,该数值过大不便于讲解。因此我们将其设为 4。请执行下列表达式:
(setq old-kill-ring-max kill-ring-max) (setq kill-ring-max 4)
然后,将下面缩进示例中的每一行依次复制到剪切环。你可以使用 C-k 剪切每行,或标记区域后用 M-w 复制。
(在只读缓冲区(如 *info* 缓冲区)中,剪切命令 C-k(kill-line)不会删除文本,仅将其复制到剪切环,但系统可能会发出提示音。若想静音操作,可使用 M-w(kill-ring-save)命令复制每行区域。使用该命令需要标记每行,但光标与标记的位置顺序不影响结果。)
请按顺序执行操作,使五个元素尝试填充剪切环:
first some text second piece of text third line fourth line of text fifth bit of text
然后执行下列代码查看 kill-ring 的值:
kill-ring
结果为:
("fifth bit of text" "fourth line of text"
"third line" "second piece of text")
第一个元素 ‘first some text’ 被移出剪切环。
执行下列代码恢复剪切环的原有最大长度:
(setq kill-ring-max old-kill-ring-max)
current-kill 函数 ¶current-kill 函数会修改 kill-ring-yank-pointer 指向的剪切环元素。(同时,kill-new 函数会将 kill-ring-yank-pointer 指向剪切环的最新元素。kill-append、copy-region-as-kill、kill-ring-save、kill-line 与 kill-region 均直接或间接使用 kill-new 函数。)
current-kill 函数代码 ¶yank 与 yank-pop 均使用 current-kill 函数。下面是 current-kill 的代码:
(defun current-kill (n &optional do-not-move) "将粘贴指针旋转 N 位,然后返回对应剪切内容。 若 N 为 0 且 `interprogram-paste-function' 设为可返回字符串或字符串列表的函数, 且该函数返回非空值,则将该字符串(或列表)添加到剪切环头部, 并将该字符串(或列表首个字符串)作为最新剪切内容返回。
若 N 不为 0 且 `yank-pop-change-selection' 非空, 则使用 `interprogram-cut-function' 将新粘贴指针处的剪切内容 传递到窗口系统剪贴板。
若可选参数 DO-NOT-MOVE 非空,则不实际移动粘贴指针,
仅返回向前第 N 个剪切内容。"
(let ((interprogram-paste (and (= n 0)
interprogram-paste-function
(funcall interprogram-paste-function))))
(if interprogram-paste
(progn
;; 将新文本添加到剪切环时禁用程序间剪切函数,
;; 避免 Emacs 以相同文本占用剪贴板。
(let ((interprogram-cut-function nil))
(if (listp interprogram-paste)
(mapc 'kill-new (nreverse interprogram-paste))
(kill-new interprogram-paste)))
(car kill-ring))
(or kill-ring (error "Kill ring is empty"))
(let ((ARGth-kill-element
(nthcdr (mod (- n (length kill-ring-yank-pointer))
(length kill-ring))
kill-ring)))
(unless do-not-move
(setq kill-ring-yank-pointer ARGth-kill-element)
(when (and yank-pop-change-selection
(> n 0)
interprogram-cut-function)
(funcall interprogram-cut-function (car ARGth-kill-element))))
(car ARGth-kill-element)))))
同时记住,kill-new 函数会将 kill-ring-yank-pointer 指向剪切环的最新元素,这意味着所有调用它的函数都会间接设置该值:kill-append、copy-region-as-kill、kill-ring-save、kill-line 与 kill-region。
下面是 kill-new 中的相关代码,其解释参见 The kill-new function。
(setq kill-ring-yank-pointer kill-ring)
current-kill 概览 ¶current-kill 函数看似复杂,但和往常一样,将其拆分成多个部分就能理解。先看它的骨架结构:
(defun current-kill (n &optional do-not-move)
"将粘贴位置轮转 N 位,然后返回该剪切内容。"
(let varlist
body...)
该函数接收两个参数,其中一个是可选参数。它带有文档字符串,不 是交互式函数。
current-kill 的函数体 ¶函数定义的主体是一个 let 表达式,它自身除了 varlist 变量列表外也包含函数体。
该 let 表达式声明了一个仅在此函数内部可用的变量。这个变量名为 interprogram-paste,用于向其他程序复制内容,而非在当前 GNU Emacs 实例内部复制。大多数窗口系统都提供程序间粘贴功能,但遗憾的是,该功能通常只支持最近一条内容。尽管 Emacs 提供环形剪切缓冲区已有数十年,多数窗口系统仍未采用支持多条记录的环形结构。
该 if 表达式分为两部分:存在 interprogram-paste 时的分支,以及不存在时的分支。
我们来看 current-kill 函数的 else 分支。(then 分支使用了 kill-new 函数,该函数我们已经介绍过。See The kill-new function。)
(or kill-ring (error "Kill ring is empty"))
(let ((ARGth-kill-element
(nthcdr (mod (- n (length kill-ring-yank-pointer))
(length kill-ring))
kill-ring)))
(or do-not-move
(setq kill-ring-yank-pointer ARGth-kill-element))
(car ARGth-kill-element))
代码首先检查剪切环是否有内容;若无,则抛出错误。
注意 or 表达式与使用 if 判断长度的写法非常相似:
(if (zerop (length kill-ring)) ; if-part (error "Kill ring is empty")) ; then-part ;; No else-part
如果剪切环中没有任何内容,其长度必然为零,此时会向用户发送错误提示:‘Kill ring is empty’。current-kill 函数使用了更简洁的 or 表达式,但 if 表达式能更直观地体现逻辑。
该 if 表达式使用了 zerop 函数,当检测值为零时返回真。当 zerop 检测为真时,会执行 if 的 then 分支。then 分支是以 error 函数开头的列表,该函数与 message 函数类似(see The message Function),都会在回显区显示单行信息。不过,error 除了显示信息外,还会终止其所在函数的后续执行。这意味着,若剪切环长度为零,函数剩余部分将不会执行。
随后 current-kill 函数会选定要返回的元素。选择结果取决于 current-kill 轮转的位数,以及 kill-ring-yank-pointer 当前指向的位置。
接下来,若可选参数 do-not-move 为真,则不移动指针;否则将 kill-ring-yank-pointer 的当前值设为指向对应列表。最后,即便 do-not-move 为真,仍有表达式返回列表的首个元素。
在我看来,将 error 函数命名为“错误(error)”,至少对人类而言略有误导性,更合适的名称应为“取消(cancel)”。当然,严格来说,你无法指向一个长度为零的列表,更无法轮转其指针,因此从计算机角度看,“错误(error)”一词是准确的。但人类会尝试这类操作,仅仅是为了确认剪切环是否为空,这属于探索行为。
从人类视角出发,探索与发现的行为未必是错误,即便在计算机底层也不应如此标记。目前 Emacs 中的代码暗含一种意味:一个出于探索环境而做出正当操作的人,反而是在犯错,这并不妥当。即便计算机执行的步骤与处理错误时一致,使用“取消(cancel)”这类词汇含义会更清晰。
在其他操作之外,当剪切环非空且 do-not-move 值为 nil 时,if 表达式的 else 分支会将 kill-ring-yank-pointer 的值设为 ARGth-kill-element。
相关代码如下:
(nthcdr (mod (- n (length kill-ring-yank-pointer))
(length kill-ring))
kill-ring)))
这段代码需要仔细分析。除非设定不移动指针,否则 current-kill 函数会修改 kill-ring-yank-pointer 的指向,这正是表达式 (setq kill-ring-yank-pointer ARGth-kill-element)) 的作用。同时很明显,ARGth-kill-element 被赋值为剪切环的某个 CDR 部分,使用了前文介绍过的 nthcdr 函数。(See copy-region-as-kill。)具体是如何实现的呢?
如我们之前所见(see nthcdr),nthcdr 函数通过反复取列表的 CDR 工作 — 持续取 CDR 的 CDR 的 CDR…
下面两个表达式效果完全相同:
(setq kill-ring-yank-pointer (cdr kill-ring)) (setq kill-ring-yank-pointer (nthcdr 1 kill-ring))
不过此处的 nthcdr 表达式更为复杂,它使用 mod 函数确定要选取哪一个 CDR。
(你应当记得先查看内层函数;事实上我们需要深入 mod 内部。)
mod 函数返回第一个参数对第二个参数取模的结果,即第一个参数除以第二个参数后的余数,返回值符号与第二个参数一致。
例如:
(mod 12 4)
⇒ 0 ;; because there is no remainder
(mod 13 4)
⇒ 1
本例中第一个参数通常小于第二个,这完全合法。 That is fine.
(mod 0 4) ⇒ 0 (mod 1 4) ⇒ 1
我们可以猜到 - 函数的作用:它与 + 类似,只是执行减法而非加法,- 函数用第一个参数减去第二个参数。同时我们已经知道 length 函数的作用(see 获取列表长度:length),它返回列表的长度。
而 n 是 current-kill 函数的必选参数名。
因此当 nthcdr 的第一个参数为零时,nthcdr 表达式会返回整个列表,执行下面代码即可验证:
;; kill-ring-yank-pointer and kill-ring have a length of four ;; and (mod (- 0 4) 4) ⇒ 0 (nthcdr (mod (- 0 4) 4) '("fourth line of text" "third line" "second piece of text" "first some text"))
当 current-kill 函数的第一个参数为 1 时,nthcdr 表达式会返回去掉首个元素后的列表。
(nthcdr (mod (- 1 4) 4)
'("fourth line of text"
"third line"
"second piece of text"
"first some text"))
顺带一提,kill-ring 与 kill-ring-yank-pointer 均为 全局变量(global variables)。这意味着 Emacs Lisp 中的任意表达式都可以访问它们,不同于 let 定义的局部变量或参数列表中的符号。局部变量仅能在定义它们的 let 表达式、或在参数列表中声明它们的函数内部(以及其调用的表达式内)访问。
(See let Prevents Confusion, 和
The defun Macro.)
yank ¶学习完 current-kill 之后,yank 函数的代码就几乎很容易理解了。
yank 函数并不直接使用 kill-ring-yank-pointer 变量,它调用 insert-for-yank,而后者再调用 current-kill,由 current-kill 设置 kill-ring-yank-pointer 变量。
代码如下:
(defun yank (&optional arg) "重新插入(\"粘贴(paste)\")最近一次剪切的文本片段。 更准确地说,重新插入最近一次剪切或粘贴的文本片段。 将光标置于末尾,标记置于开头。 若仅使用 \\[universal-argument] 作为参数,效果相同但光标置于开头(标记置于末尾)。 参数为 N 时,重新插入第 N 近的剪切文本片段。 当该命令将剪切文本插入缓冲区时,会遵循 `yank-excluded-properties' 与 `yank-handler',详见 `insert-for-yank-1' 的文档字符串。 另请参见命令 `yank-pop'(\\[yank-pop])。"
(interactive "*P") (setq yank-window-start (window-start)) ;; 若未完整执行完毕,让 last-command 向后续命令指明这一点 (setq this-command t) (push-mark (point))
(insert-for-yank (current-kill (cond
((listp arg) 0)
((eq arg '-) -2)
(t (1- arg)))))
(if (consp arg)
;; 效果类似交换光标与标记,但不激活标记
;; 即便命令循环会因插入文本而取消标记激活,
;; 避免激活依然更简洁
(goto-char (prog1 (mark t)
(set-marker (mark-marker) (point) (current-buffer)))))
;; 若完整执行完毕,让 this-command 指明这一点
(if (eq this-command t)
(setq this-command 'yank))
nil)
核心表达式是 insert-for-yank,它插入 current-kill 返回的字符串,并移除其中部分文本属性。
在执行该表达式之前,函数会先将 yank-window-start 设为 (window-start) 表达式返回的位置,即当前显示起始位置。yank 函数还会设置 this-command 并推入标记。
粘贴对应元素后,若可选参数为 CONS 类型而非数字或空,则将光标置于粘贴文本开头,标记置于末尾。
(prog1 函数与 progn 类似,但返回首个参数的值而非最后一个。它的首个参数会强制以整数形式返回缓冲区标记。你可以将光标置于本缓冲区的这些函数上,然后按 C-h f(describe-function)再按 RET 查看文档,默认即为当前函数。)
函数最后一部分说明执行成功时的处理逻辑。
yank-pop ¶理解 yank 与 current-kill 之后,你就知道该如何分析 yank-pop 函数了。为节省篇幅省略文档字符串,代码如下:
(defun yank-pop (&optional arg)
"..."
(interactive "*p")
(if (not (eq last-command 'yank))
(error "Previous command was not a yank"))
(setq this-command 'yank)
(unless arg (setq arg 1))
(let ((inhibit-read-only t)
(before (< (point) (mark t))))
(if before
(funcall (or yank-undo-function 'delete-region) (point) (mark t))
(funcall (or yank-undo-function 'delete-region) (mark t) (point)))
(setq yank-undo-function nil)
(set-marker (mark-marker) (point) (current-buffer))
(insert-for-yank (current-kill arg))
;; 尽可能将窗口起始位置恢复到粘贴命令执行时的状态
(set-window-start (selected-window) yank-window-start t)
(if before
;; 效果类似交换光标与标记,但不激活标记
;; 即便命令循环会因插入文本而取消标记激活,
;; 避免激活依然更简洁
(goto-char (prog1 (mark t)
(set-marker (mark-marker)
(point)
(current-buffer))))))
nil)
该函数为交互式函数,使用小写 ‘p’ 前缀参数,前缀参数会被处理并传入函数。该命令只能在之前执行过粘贴操作后使用,否则会抛出错误提示。该检查使用了 last-command 变量,其值由 yank 设置,相关内容会在其他地方讨论。(See copy-region-as-kill。)
let 子句会根据光标在标记之前还是之后,将 before 变量设为真或假,随后删除光标与标记之间的区域。该区域正是上一次粘贴插入的内容,即将被替换的文本。
funcall 将首个参数作为函数执行,并将剩余参数传入。首个参数是 or 表达式的返回值,剩余两个参数是上一次 yank 命令设置的光标与标记位置。
还有更多细节,但这已是最难理解的部分。
有趣的是,GNU Emacs 有一个名为 ring.el 的文件,提供了我们刚才讨论的诸多功能。但像 kill-ring-yank-pointer 这类函数并未使用该库,可能是因为它们实现得更早。
打印出的坐标轴能帮助你理解图表,传递刻度信息。在前一章节(see Readying a Graph)中,我们编写了打印图表主体的代码。本节将编写打印并标注纵轴与横轴,同时打印图表主体的代码。
由于插入操作会向光标右侧与下方填充缓冲区,新的图表打印函数应先打印 Y 轴(纵轴),再打印图表主体,最后打印 X 轴(横轴)。这一顺序明确了函数的构成:
下面是完成后图表的示例效果:
10 -
*
* *
* **
* ***
5 - * *******
* *** *******
*************
***************
1 - ****************
| | | |
1 5 10 15
该图表的纵轴与横轴均标注了数字。不过在某些图表中,横轴代表时间,用月份标注会更合适,如下所示:
5 - *
* ** *
*******
********** **
1 - **************
| ^ |
Jan June Jan
事实上稍加思考就能想到多种纵轴与横轴的标注方案,任务可能因此变得复杂,而复杂会带来混乱。为避免这种情况,我们首次实现时最好选用简单的标注方案,后续再修改或替换。
这些考虑为 print-graph 函数提供了如下结构:
(defun print-graph (numbers-list)
"documentation..."
(let ((height ...
...))
(print-Y-axis height ... )
(graph-body-print numbers-list)
(print-X-axis ... )))
我们可以依次实现 print-graph 函数定义的各个部分。
print-graph 的变量列表 ¶在编写 print-graph 函数时,首要任务是写出 let 表达式中的变量列表。(我们暂且不考虑将该函数改为交互式,也不考虑其文档字符串的内容。)
变量列表应当设置若干个值。显然,纵轴标注的顶端数值至少要等于图表高度,这意味着我们必须在此处获取该信息。注意 print-graph-body 函数同样需要该信息。没有必要在两个不同位置计算图表高度,因此我们应当修改之前定义的 print-graph-body,使其复用这次的计算结果。
同样,打印横轴标注的函数与 print-graph-body 函数都需要知道每个符号的宽度。我们可以在此处完成计算,并修改上一章节中定义的 print-graph-body。
横轴标注的长度至少要与图表宽度一致。不过该信息只在打印横轴的函数中使用,因此无需在此处计算。
基于这些考虑,我们可以直接写出 print-graph 中 let 的变量列表形式:
(let ((height (apply 'max numbers-list)) ; First version.
(symbol-width (length graph-blank)))
稍后我们会看到,这个表达式并不完全正确。
print-Y-axis 函数 ¶print-Y-axis 函数的作用是打印纵轴标注,效果如下所示:
10 -
5 -
1 -
该函数应接收图表高度作为参数,然后构造并插入合适的数字与刻度标记。
print-Y-axis 函数详解 ¶从示例图中可以很直观地看到纵轴标注的样式,但要用文字描述并写出实现函数则是另一回事。简单说每五行打印一个数字和刻度并不准确:数字 ‘1’ 和 ‘5’ 之间只有三行(第 2、3、4 行),而 ‘5’ 和 ‘10’ 之间却有四行(第 6、7、8、9 行)。更准确的说法是:我们要在基线(数字 1)打印数字和刻度,然后在从底部数第五行,以及所有 5 的倍数行打印数字和刻度。
下一个问题是标注应该取多高?假设图表最高一列的高度为 7。纵轴最高标注应该是 ‘5 -’,让图表高出标注吗?还是最高标注为 ‘7 -’,恰好对齐图表顶端?又或者最高标注为 10 -,即 5 的倍数,并且高于图表的最大值?
后一种方式更为常用。大多数图表都绘制在边长为整数步长的矩形内——步长为 5 时,就使用 5、10、15 等数值。但一旦决定为纵轴使用步长高度,我们就会发现变量列表中计算高度的简单表达式是错误的。表达式 (apply 'max numbers-list) 只返回精确高度,而不会向上取整到最近的 5 的倍数。我们需要一个更复杂的表达式。
和这类常见情况一样,复杂问题拆分成若干小问题就会变简单。
首先考虑图表最大值恰好是 5 的整数倍的情况——比如 5、10、15 或更大的 5 的倍数。我们可以直接用该值作为纵轴高度。
判断一个数是否为 5 的倍数,一个相当简单的方法是将其除以 5 看是否有余数。若无余数,则该数是 5 的倍数。例如 7 除以 5 余数为 2,因此 7 不是 5 的整数倍。换一种更贴近课堂的说法:5 除 7 商 1 余 2。而 5 除 10 商 2 余 0,因此 10 是 5 的整数倍。
在 Lisp 中,用于计算余数的函数是 %。该函数返回第一个参数除以第二个参数的余数。巧合的是,% 是一个无法通过 apropos 查到的 Emacs Lisp 函数:输入 M-x apropos RET remainder RET 不会得到任何结果。了解 % 存在的唯一途径是阅读本书这类资料,或是 Emacs Lisp 源码。
你可以通过计算下面两个表达式来尝试 % 函数:
(% 7 5) (% 10 5)
第一个表达式返回 2,第二个返回 0。
要判断返回值是否为 0,我们可以使用 zerop 函数。当参数(必须为数字)为 0 时,该函数返回 t。
(zerop (% 7 5))
⇒ nil
(zerop (% 10 5))
⇒ t
因此,若图表高度可以被 5 整除,下面的表达式会返回 t:
(zerop (% height 5))
(当然,height 的值可以通过 (apply 'max numbers-list) 得到。)
另一方面,如果 height 的值不是 5 的倍数,我们希望将其重置为更大的下一个 5 的倍数。这是简单的算术运算,使用我们已经熟悉的函数即可实现。首先用 height 除以 5,得到 5 能被除几次。例如 5 除 12 商 2。将商加 1 再乘以 5,就得到比该高度更大的下一个 5 的倍数。5 除 12 商 2,2 加 1 再乘以 5,结果是 15,也就是比 12 大的下一个 5 的倍数。对应的 Lisp 表达式为:
(* (1+ (/ height 5)) 5)
例如计算下面表达式,结果为 15:
(* (1+ (/ 12 5)) 5)
在整个讨论中,我们一直使用 5 作为纵轴标注的间距;但我们也可能使用其他数值。为了通用性,应该用一个可赋值的变量代替 5。我能想到的最合适的变量名是 Y-axis-label-spacing。
使用该变量并配合 if 表达式,我们得到如下代码:
(if (zerop (% height Y-axis-label-spacing))
height
;; else
(* (1+ (/ height Y-axis-label-spacing))
Y-axis-label-spacing))
如果高度恰好是 Y-axis-label-spacing 值的整数倍,该表达式直接返回 height;否则计算并返回比当前高度更大的下一个 Y-axis-label-spacing 倍数。
现在我们可以将该表达式加入 print-graph 函数的 let 表达式中(需先设置 Y-axis-label-spacing 的值):
(defvar Y-axis-label-spacing 5 "纵轴相邻两个标注之间的行数。")
...
(let* ((height (apply 'max numbers-list))
(height-of-top-line
(if (zerop (% height Y-axis-label-spacing))
height
;; else
(* (1+ (/ height Y-axis-label-spacing))
Y-axis-label-spacing)))
(symbol-width (length graph-blank))))
...
(注意这里使用了 let* 函数:(apply 'max numbers-list) 先计算出 height 初始值,再用该结果计算其最终值。关于 let* 的更多内容,参见 See The let* expression。)
打印纵轴时,我们希望每隔五行插入类似 ‘5 -’ 和 ‘10 - ’ 这样的字符串。 此外,我们希望数字和短横线对齐,因此较短的数字需要在前面补空格。如果部分字符串使用两位数,那么一位数的字符串必须在数字前补一个空格。
要获取数字的长度,需要使用 length 函数。但 length 只能用于字符串,不能直接用于数字。因此必须先将数字转换为字符串,这可以通过 number-to-string 函数实现。例如:
(length (number-to-string 35))
⇒ 2
(length (number-to-string 100))
⇒ 3
(number-to-string 也叫作 int-to-string;你会在不同资料中看到这个别名。)
此外,每个标注中的数字后面都会跟着类似 ‘ - ’ 的字符串,我们称之为 Y-axis-tic 刻度标记。该变量使用 defvar 定义:
(defvar Y-axis-tic " - " "纵轴标注中跟在数字后的字符串。")
纵轴标注的总长度为刻度标记长度与图表顶端数字长度之和。
(length (concat (number-to-string height) Y-axis-tic)))
该值会由 print-graph 函数在其变量列表中计算为 full-Y-label-width 并传递使用。(注意我们最初提出变量列表时并未想到包含它。)
要构造完整的纵轴标注,需要将数字与刻度标记拼接;根据数字长度,前面可能带有一个或多个空格。一个标注由三部分组成:(可选的)前导空格、数字、刻度标记。函数接收当前行对应的数字,以及顶端行宽度(由 print-graph 只计算一次)作为参数。
(defun Y-axis-element (number full-Y-label-width) "构造带 NUMBER 数字的标注元素。 一个带编号的元素形如 ` 5 - ', 并根据需要填充空格,使其与最大数字的标注对齐。"
(let* ((leading-spaces
(- full-Y-label-width
(length
(concat (number-to-string number)
Y-axis-tic)))))
(concat
(make-string leading-spaces ? )
(number-to-string number)
Y-axis-tic)))
Y-axis-element 函数将前导空格(如有)、转为字符串的数字以及刻度标记拼接在一起。
为计算标注需要多少前导空格,函数用目标标注宽度减去实际标注长度——即数字长度加上刻度标记长度。
空格使用 make-string 函数插入。该函数接收两个参数:第一个指定字符串长度,第二个以特殊格式指定要插入的字符符号。格式为问号后跟空格,即 ‘? ’。关于字符语法的说明,参见 See Character Type in The GNU Emacs Lisp Reference Manual。(当然,你也可以把空格换成其他字符……你知道该怎么做。)
拼接表达式中使用了 number-to-string 函数,将数字转为字符串后与前导空格和刻度标记拼接。
前面的函数已经提供了全部工具,我们可以据此构造一个函数,生成带编号与空白字符串的列表,用作纵轴标注:
(defun Y-axis-column (height width-of-label) "构造纵轴标注与空白字符串列表。 HEIGHT 为基线以上的行数,WIDTH-OF-LABEL 为标注宽度。" (let (Y-axis)
(while (> height 1)
(if (zerop (% height Y-axis-label-spacing))
;; Insert label.
(setq Y-axis
(cons
(Y-axis-element height width-of-label)
Y-axis))
;; Else, insert blanks. (setq Y-axis (cons (make-string width-of-label ? ) Y-axis))) (setq height (1- height))) ;; 插入基线行。 (setq Y-axis (cons (Y-axis-element 1 width-of-label) Y-axis)) (nreverse Y-axis)))
在该函数中,我们从 height 的值开始,不断对其减 1。每次减法之后,判断该值是否为 Y-axis-label-spacing 的整数倍。如果是,就用 Y-axis-element 构造带编号的标注;否则用 make-string 构造空白标注。基线行是数字 1 后跟一个刻度标记。
print-Y-axis 的接近最终版 ¶由 Y-axis-column 构造的列表会被传给 print-Y-axis 函数,由它将列表以列的形式插入。
(defun print-Y-axis (height full-Y-label-width) "使用 HEIGHT 和 FULL-Y-LABEL-WIDTH 插入纵轴。 高度应为图表的最大高度。 总宽度为最高标注元素的宽度。" ;; height 和 full-Y-label-width 的值 ;; 由 print-graph 传入。
(let ((start (point)))
(insert-rectangle
(Y-axis-column height full-Y-label-width))
;; 将光标定位到适合插入图表主体的位置。
(goto-char start)
;; 将光标向前移动 full-Y-label-width 宽度
(forward-char full-Y-label-width)))
print-Y-axis 使用 insert-rectangle 函数插入由 Y-axis-column 创建的纵轴标注。此外,它还会将光标放到正确位置,以便打印图表主体。
你可以测试 print-Y-axis:
Y-axis-label-spacing Y-axis-tic Y-axis-element Y-axis-column print-Y-axis
(print-Y-axis 12 5)
eval-expression)。
yank)将 graph-body-print 表达式粘贴到小缓冲。
Emacs 会纵向打印标注,最上方一条是 ‘10 - ’。(print-graph 函数会传入 height-of-top-line,在本例中最终为 15,从而消除看似 bug 的表现。)
print-X-axis 函数 ¶横轴标注与纵轴标注很相似,只是刻度位于数字上方。标注效果如下:
| | | |
1 5 10 15
第一个刻度位于图表第一列下方,前面有若干空格。这些空格为上方的纵轴标注留出空间。第二个、第三个、第四个及后续刻度均匀分布,间距由 X-axis-label-spacing 决定。
横轴第二行是数字,前面同样有若干空格,分隔距离也由变量 X-axis-label-spacing 决定。
X-axis-label-spacing 变量的值本身应以 symbol-width 为单位,因为你可能希望在不改变标注方式的前提下,修改用于打印图表主体的符号宽度。
print-X-axis 函数的构造方式与 print-Y-axis 大致相同,只是它包含两行:刻度标记行和数字行。我们会分别编写函数打印每一行,然后在 print-X-axis 中组合使用。
这一过程分为三步:
print-X-axis-tic-line。
print-X-axis-numbered-line。
print-X-axis,使用
print-X-axis-tic-line 和
print-X-axis-numbered-line。
第一个函数用于打印横轴刻度标记。我们必须指定刻度标记本身及其间距:
(defvar X-axis-label-spacing
(if (boundp 'graph-blank)
(* 5 (length graph-blank)) 5)
"横轴相邻两个标注之间的单位数。")
(注意 graph-blank 的值由另一个 defvar 设置。boundp 谓词用于检测它是否已被定义;若未定义则返回 nil。如果 graph-blank 未绑定且我们未使用这种条件构造,就会进入调试器并看到错误信息:‘Debugger entered--Lisp error: (void-variable graph-blank)’。)
下面是 X-axis-tic-symbol 的 defvar 定义:
(defvar X-axis-tic-symbol "|" "用于指向横轴某一列的插入字符串。")
目标是生成如下所示的一行:
| | | |
第一个刻度缩进,使其位于第一列下方,而第一列本身已缩进以便为纵轴标注留出空间。
一个刻度元素由相邻刻度间的空白加上一个刻度符号组成。空白数量由刻度符号宽度和 X-axis-label-spacing 共同决定。
代码如下:
;;; X-axis-tic-element ... (concat (make-string ;; Make a string of blanks. (- (* symbol-width X-axis-label-spacing) (length X-axis-tic-symbol)) ? ) ;; Concatenate blanks with tic symbol. X-axis-tic-symbol) ...
接下来,我们计算将第一个刻度缩进至图表第一列所需的空白数。这里使用由 print-graph 函数传入的 full-Y-label-width 值。
生成 X-axis-leading-spaces 的代码如下:
;; X-axis-leading-spaces ... (make-string full-Y-label-width ? ) ...
我们还需要确定横轴长度(即数字列表的长度)以及横轴上的刻度数量:
;; X-length ... (length numbers-list)
;; tic-width ... (* symbol-width X-axis-label-spacing)
;; number-of-X-ticks
(if (zerop (% (X-length tic-width)))
(/ (X-length tic-width))
(1+ (/ (X-length tic-width))))
所有这些直接导出了打印横轴刻度行的函数:
(defun print-X-axis-tic-line
(number-of-X-tics X-axis-leading-spaces X-axis-tic-element)
"Print ticks for X axis."
(insert X-axis-leading-spaces)
(insert X-axis-tic-symbol) ; Under first column.
;; Insert second tic in the right spot. (insert (concat (make-string (- (* symbol-width X-axis-label-spacing) ;; Insert white space up to second tic symbol. (* 2 (length X-axis-tic-symbol))) ? ) X-axis-tic-symbol))
;; Insert remaining ticks.
(while (> number-of-X-tics 1)
(insert X-axis-tic-element)
(setq number-of-X-tics (1- number-of-X-tics))))
数字行的实现同样简单:
首先,创建每个数字前带空白的编号元素:
(defun X-axis-element (number)
"构造带编号的横轴元素。"
(let ((leading-spaces
(- (* symbol-width X-axis-label-spacing)
(length (number-to-string number)))))
(concat (make-string leading-spaces ? )
(number-to-string number))))
接下来,创建函数打印数字行,以第一列下方的数字 1 开始:
(defun print-X-axis-numbered-line
(number-of-X-tics X-axis-leading-spaces)
"Print line of X-axis numbers"
(let ((number X-axis-label-spacing))
(insert X-axis-leading-spaces)
(insert "1")
(insert (concat
(make-string
;; Insert white space up to next number.
(- (* symbol-width X-axis-label-spacing) 2)
? )
(number-to-string number)))
;; Insert remaining numbers.
(setq number (+ number X-axis-label-spacing))
(while (> number-of-X-tics 1)
(insert (X-axis-element number))
(setq number (+ number X-axis-label-spacing))
(setq number-of-X-tics (1- number-of-X-tics)))))
最后,我们需要编写使用 print-X-axis-tic-line 和 print-X-axis-numbered-line 的 print-X-axis 函数。
该函数必须确定两个子函数所用变量的局部值,然后调用它们。同时,它还需要打印分隔两行的换行符。
该函数包含一个指定五个局部变量的变量列表,以及对两个行打印函数的调用:
(defun print-X-axis (numbers-list)
"打印与 NUMBERS-LIST 长度匹配的横轴标注。"
(let* ((leading-spaces
(make-string full-Y-label-width ? ))
;; symbol-width is provided by graph-body-print
(tic-width (* symbol-width X-axis-label-spacing))
(X-length (length numbers-list))
(X-tic
(concat
(make-string
;; Make a string of blanks.
(- (* symbol-width X-axis-label-spacing)
(length X-axis-tic-symbol))
? )
;; Concatenate blanks with tic symbol.
X-axis-tic-symbol))
(tic-number
(if (zerop (% X-length tic-width))
(/ X-length tic-width)
(1+ (/ X-length tic-width)))))
(print-X-axis-tic-line tic-number leading-spaces X-tic)
(insert "\n")
(print-X-axis-numbered-line tic-number leading-spaces)))
你可以测试 print-X-axis:
X-axis-tic-symbol、X-axis-label-spacing、
print-X-axis-tic-line,以及 X-axis-element、
print-X-axis-numbered-line 和 print-X-axis。
(progn
(let ((full-Y-label-width 5)
(symbol-width 1))
(print-X-axis
'(1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16))))
eval-expression)。
yank)将测试表达式粘贴到小缓冲。
Emacs 会如下打印横轴:
| | | | |
1 5 10 15 20
现在我们几乎已经准备好打印完整图表了。
打印带正确标注的图表的函数遵循我们之前创建的结构(see A Graph with Labeled Axes),但有所扩充。
结构如下:
(defun print-graph (numbers-list)
"documentation..."
(let ((height ...
...))
(print-Y-axis height ... )
(graph-body-print numbers-list)
(print-X-axis ... )))
最终版与我们之前的规划有两处不同:第一,它在变量列表中增加了一次性计算的若干值;第二,它增加了一个选项,用于指定每行标注的增量。后一功能后来被证明是必不可少的;否则图表可能会有超出显示器或纸张可容纳的行数。
这一新功能要求修改 Y-axis-column 函数,为其增加 vertical-step 参数。函数如下:
;;; Final version.
(defun Y-axis-column
(height width-of-label &optional vertical-step)
"构造纵轴标注列表。
HEIGHT 是图表最大高度。
WIDTH-OF-LABEL 是标注最大宽度。
VERTICAL-STEP 为可选参数,是一个正整数,
指定纵轴标注每行递增多少。
例如,步长 5 表示每一行代表图表的 5 个单位。"
(let (Y-axis
(number-per-line (or vertical-step 1)))
(while (> height 1)
(if (zerop (% height Y-axis-label-spacing))
;; Insert label.
(setq Y-axis
(cons
(Y-axis-element
(* height number-per-line)
width-of-label)
Y-axis))
;; Else, insert blanks.
(setq Y-axis
(cons
(make-string width-of-label ? )
Y-axis)))
(setq height (1- height)))
;; Insert base line.
(setq Y-axis (cons (Y-axis-element
(or vertical-step 1)
width-of-label)
Y-axis))
(nreverse Y-axis)))
图表最大高度和符号宽度的值由 print-graph 在其 let 表达式中计算;因此 graph-body-print 必须修改以接收这些值。
;;; Final version.
(defun graph-body-print (numbers-list height symbol-width)
"打印 NUMBERS-LIST 对应的柱状图。
数字列表由纵轴数值组成。
HEIGHT 为图表最大高度。
SYMBOL-WIDTH 为每列宽度。"
(let (from-position)
(while numbers-list
(setq from-position (point))
(insert-rectangle
(column-of-graph height (car numbers-list)))
(goto-char from-position)
(forward-char symbol-width)
;; Draw graph column by column. (sit-for 0) (setq numbers-list (cdr numbers-list))) ;; Place point for X axis labels. (forward-line height) (insert "\n")))
最后是 print-graph 函数的代码:
;;; Final version.
(defun print-graph
(numbers-list &optional vertical-step)
"打印带标注的 NUMBERS-LIST 柱状图。
数字列表由纵轴数值组成。
可选参数 VERTICAL-STEP 为正整数, 指定纵轴标注每行递增多少。 例如,步长 5 表示每一行代表 5 个单位。
(let* ((symbol-width (length graph-blank))
;; 既是最大数字,也是位数最多的数字。
(height (apply 'max numbers-list))
(height-of-top-line
(if (zerop (% height Y-axis-label-spacing))
height
;; else
(* (1+ (/ height Y-axis-label-spacing))
Y-axis-label-spacing)))
(vertical-step (or vertical-step 1))
(full-Y-label-width
(length
(concat
(number-to-string
(* height-of-top-line vertical-step))
Y-axis-tic))))
(print-Y-axis
height-of-top-line full-Y-label-width vertical-step)
(graph-body-print
numbers-list height-of-top-line symbol-width)
(print-X-axis numbers-list)))
print-graph ¶我们可以用一个简短的数字列表来测试 print-graph 函数:
Y-axis-column、graph-body-print 和 print-graph(除此之外还要安装其余相关代码)。
(print-graph '(3 2 5 6 7 5 3 4 6 4 3 2 1))
eval-expression)。
yank)将测试表达式粘贴到小缓冲。
Emacs 会打印出如下所示的图表:
10 -
*
** *
5 - **** *
**** ***
* *********
************
1 - *************
| | | |
1 5 10 15
另一方面,如果你给 print-graph 传入 vertical-step 参数为 2,执行下面的表达式:
(print-graph '(3 2 5 6 7 5 3 4 6 4 3 2 1) 2)
图表会变成这样:
20 -
*
** *
10 - **** *
**** ***
* *********
************
2 - *************
| | | |
1 5 10 15
(一个小问题:纵轴底部的 ‘2’ 是 bug 还是特意设计的功能?如果你认为这是 bug,应该显示 ‘1’(甚至是 ‘0’),你可以修改源码。)
现在到了编写所有这些代码的最终目的:生成一张图表,展示有多少函数定义包含少于 10 个单词和符号、多少包含 10–19 个、多少包含 20–29 个,依此类推。
这是一个多步骤的过程。首先确保你已经加载了所有必需的代码。
建议重置 top-of-ranges 的值,以防你之前把它设成了别的值。可以执行下面的代码:
(setq top-of-ranges '(10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 160 170 180 190 200 210 220 230 240 250 260 270 280 290 300)
接下来创建一个列表,记录每个区间内的单词与符号数量。
执行下面的代码:
(setq list-for-graph
(defuns-per-range
(sort
(recursive-lengths-list-many-files
(directory-files "/usr/local/emacs/lisp"
t ".+el$"))
'<)
top-of-ranges))
在我老旧的机器上,这花了大约一个小时。它遍历了我这份 Emacs 19.23 里的 303 个 Lisp 文件。全部计算完成后,list-for-graph 的值如下:
(537 1027 955 785 594 483 349 292 224 199 166 120 116 99 90 80 67 48 52 45 41 33 28 26 25 20 12 28 11 13 220)
这表示在我的 Emacs 里,有 537 个函数定义包含少于 10 个单词或符号,1027 个包含 10–19 个,955 个包含 20–29 个,依此类推。
显然,只看这个列表就能发现,大多数函数定义包含 10 到 30 个单词和符号。
接下来开始打印。我们 不希望 打印一张高达 1030 行的图表……相反,应该打印一张高度少于 25 行的图表。这个高度几乎能在任何显示器上显示,也能轻松打印在一张纸上。
这意味着 list-for-graph 中的每个数值都要缩小到原来的五十分之一。
下面是一个实现该功能的简短函数,它用到了两个我们还没见过的函数:mapcar 和 lambda。
(defun one-fiftieth (full-range) "返回一个列表,其中每个数字为原数字的五十分之一。" (mapcar (lambda (arg) (/ arg 50)) full-range))
lambda 表达式:实用的匿名函数 ¶lambda 是匿名函数的符号,也就是没有名字的函数。每次使用匿名函数时,都需要把整个函数体写出来。
例如:
(lambda (arg) (/ arg 50))
这个函数会把传入的参数 arg 除以 50 并返回结果。
之前我们写过一个 multiply-by-seven 函数,用来把参数乘以 7。这个函数与之类似,只是把参数除以 50;而且它没有名字。与 multiply-by-seven 等价的匿名函数是:
(lambda (number) (* 7 number))
(See The defun Macro。)
如果我们想计算 3 乘以 7,可以写成:
(multiply-by-seven 3)
\_______________/ ^
| |
function argument
这个表达式会返回 21。
同样,我们也可以写成:
((lambda (number) (* 7 number)) 3)
\____________________________/ ^
| |
anonymous function argument
如果我们想把 100 除以 50,可以写成:
((lambda (arg) (/ arg 50)) 100)
\______________________/ \_/
| |
anonymous function argument
这个表达式返回 2。100 被传给函数,函数将其除以 50。
关于 lambda 的更多内容,参见 see Lambda Expressions in The GNU Emacs Lisp Reference Manual。Lisp 与 lambda 表达式均源自 λ 演算。
mapcar 函数 ¶mapcar 会依次将第二个参数(序列)中的每个元素作为参数,传给第一个参数(函数)执行。
名称中的“map”来自数学术语 “在定义域上映射”,意思是对一个集合里的每个元素都应用一次函数。这个数学说法源自测绘人员一步步丈量并绘制地图的比喻。而“car”自然来自 Lisp 里表示列表首元素的概念。
例如:
(mapcar '1+ '(2 4 6))
⇒ (3 5 7)
函数 1+ 会对列表中的**每一个**元素执行加一操作,并返回一个新列表。
可以和 apply 对比,后者是把第一个参数一次性应用到所有剩余参数上。
(apply 的说明参见 See Readying a Graph。)
在 one-fiftieth 的定义中,第一个参数是匿名函数:
(lambda (arg) (/ arg 50))
第二个参数是 full-range,它会被绑定到 list-for-graph。
完整表达式如下
(mapcar (lambda (arg) (/ arg 50)) full-range))
关于 mapcar 的更多内容,See Mapping Functions in The GNU Emacs Lisp Reference Manual。
使用 one-fiftieth 函数,我们可以生成一个新列表,其中每个元素都是 list-for-graph 中对应元素的五十分之一。
(setq fiftieth-list-for-graph
(one-fiftieth list-for-graph))
得到的列表如下:
(10 20 19 15 11 9 6 5 4 3 3 2 2 1 1 1 1 0 1 0 0 0 0 0 0 0 0 0 0 0 4)
到这里,我们基本就可以打印图表了!(同时也能看出信息有所损失:很多更高区间的值都是 0,这表示对应数量单词或符号的函数定义少于 50 个——但并不代表完全没有。)
我刚才说的是 “基本可以打印(almost ready to print)”! 当然,print-graph 函数里还存在一个缺陷 … 它提供了 vertical-step 选项,却没有 horizontal-step 选项。top-of-range 刻度是以10为步长从10到300变化的,但 print-graph 函数目前只能按1为步长打印。
这是一个典型案例,常被称作最为隐蔽的一类缺陷,即遗漏型缺陷。这类缺陷无法通过阅读代码发现,因为问题并不在代码本身,而是缺失了某项功能。你最好的应对方式是尽早且频繁地测试程序;同时尽可能编写易于理解、易于修改的代码。无论何时都要记住,你写下的任何代码,迟早都会被重写,只是时间早晚而已。这是一条很难践行的准则。
需要修改的是 print-X-axis-numbered-line 函数,随后 print-X-axis 与 print-graph 函数也需要相应适配。工作量并不大,但有一处细节需要注意:数字应当与刻度标记对齐,这需要稍加思考。
下面是修正后的 print-X-axis-numbered-line:
(defun print-X-axis-numbered-line
(number-of-X-tics X-axis-leading-spaces
&optional horizontal-step)
"打印X轴刻度数字行"
(let ((number X-axis-label-spacing)
(horizontal-step (or horizontal-step 1)))
(insert X-axis-leading-spaces)
;; 删除多余的前置空格。
(delete-char
(- (1-
(length (number-to-string horizontal-step)))))
(insert (concat
(make-string
;; 插入空白字符。
(- (* symbol-width
X-axis-label-spacing)
(1-
(length
(number-to-string horizontal-step)))
2)
? )
(number-to-string
(* number horizontal-step))))
;; 插入剩余刻度数字。
(setq number (+ number X-axis-label-spacing))
(while (> number-of-X-tics 1)
(insert (X-axis-element
(* number horizontal-step)))
(setq number (+ number X-axis-label-spacing))
(setq number-of-X-tics (1- number-of-X-tics)))))
如果你正在 Info 中阅读本文,可以查看新版的 print-X-axis 和 print-graph 并对其求值。如果你阅读的是印刷版书籍,这里只展示修改过的代码行(完整内容篇幅过长)。
(defun print-X-axis (numbers-list horizontal-step) "打印与 NUMBERS-LIST 长度匹配的X轴刻度标签。 可选参数 HORIZONTAL-STEP 为正整数,用于指定 X轴每一列标签的递增步长。"
;; symbol-width 和 full-Y-label-width 的值
;; 由 print-graph 传入。
(let* ((leading-spaces
(make-string full-Y-label-width ? ))
;; symbol-width 由 graph-body-print 提供
(tic-width (* symbol-width X-axis-label-spacing))
(X-length (length numbers-list))
(X-tic
(concat
(make-string
;; 生成空白字符串。
(- (* symbol-width X-axis-label-spacing)
(length X-axis-tic-symbol))
? )
;; 将空白与刻度符号拼接。
X-axis-tic-symbol))
(tic-number
(if (zerop (% X-length tic-width))
(/ X-length tic-width)
(1+ (/ X-length tic-width)))))
(print-X-axis-tic-line
tic-number leading-spaces X-tic)
(insert "\n")
(print-X-axis-numbered-line
tic-number leading-spaces horizontal-step)))
(defun print-graph (numbers-list &optional vertical-step horizontal-step) "打印带标签的NUMBERS-LIST柱状图。 numbers-list为Y轴数值列表。
可选参数 VERTICAL-STEP 为正整数,用于指定 Y轴每一行标签的递增步长。例如,步长5表示 每一行代表5个单位。
可选参数 HORIZONTAL-STEP 为正整数,用于指定
X轴每一列标签的递增步长。"
(let* ((symbol-width (length graph-blank))
;; height 既是列表最大值,
;; 也是位数最多的数字。
(height (apply 'max numbers-list))
(height-of-top-line
(if (zerop (% height Y-axis-label-spacing))
height
;; 否则
(* (1+ (/ height Y-axis-label-spacing))
Y-axis-label-spacing)))
(vertical-step (or vertical-step 1))
(full-Y-label-width
(length
(concat
(number-to-string
(* height-of-top-line vertical-step))
Y-axis-tic))))
(print-Y-axis
height-of-top-line full-Y-label-width vertical-step)
(graph-body-print
numbers-list height-of-top-line symbol-width)
(print-X-axis numbers-list horizontal-step)))
编写并加载完成后,你可以像这样调用 print-graph 命令:
(print-graph fiftieth-list-for-graph 50 10)
下面是生成的图表:
1000 - *
**
**
**
**
750 - ***
***
***
***
****
500 - *****
******
******
******
*******
250 - ********
********* *
*********** *
************* *
50 - ***************** * *
| | | | | | | |
10 50 100 150 200 250 300 350
数量最多的一类函数,每个包含 10–19 个单词与符号。
by Richard M. Stallman
自由操作系统最大的短板并不在软件本身,而是缺少能够纳入系统的优质自由手册。我们许多重要程序都没有配套的完整手册。文档是任何软件包不可或缺的部分;当一款重要的自由软件缺少自由手册时,这便是一个重大缺憾。如今我们仍有许多这样的缺憾。
多年以前,我曾想学习 Perl。我拿到了一份自由手册,却发现内容晦涩难读。当我向 Perl 用户询问替代方案时,他们告诉我有更好的入门手册——但那些并非自由文档。
为何会这样?优质手册的作者将其交付给奥莱利公司出版,而该公司采用限制性条款:禁止复制、禁止修改、不提供源文件,这使得它们被排除在自由软件社区之外。
这类事情并非首次发生,而且(对我们社区而言损失惨重)也远非最后一次。此后,专有文档出版商引诱了大量作者对其手册施加限制。我曾多次听到 GNU 用户兴奋地告诉我,他正在编写一本手册,希望能为 GNU 项目贡献力量 — 可随后我的希望便会落空,因为他会接着说,自己已经与出版商签订了限制性合同,导致我们无法使用这份文档。
考虑到程序员中擅长撰写优质英文文档的人寥寥无几,我们实在承受不起这样的损失。
自由文档与自由软件一样,关乎自由而非价格。这些手册的问题并不在于奥莱利公司对印刷版收取费用——这本身并无不妥。自由软件基金会也销售自由GNU 手册的印刷版。但 GNU 手册提供源代码形式,而这些手册仅以纸质形式发行;GNU 手册允许复制与修改,而 Perl 相关手册则不允许。这些限制才是问题所在。
自由手册的判定标准与自由软件基本一致:向所有用户赋予特定自由。必须允许再分发(包括商业再分发),这样手册才能随程序的每一份副本一同发布,无论是线上还是纸质版。修改许可同样至关重要。
一般而言,我并不认为人们必须拥有修改各类文章与书籍的权限。文字作品的相关问题未必与软件相同。例如,我不认为你我有义务允许修改这类阐述我们行动与观点的文章。
但自由软件的文档必须允许修改,有其特殊原因:当人们行使修改软件的权利、增删或更改功能时,尽责的开发者会同步修改手册,从而为修改后的程序提供准确可用的文档。一份禁止开发者尽责完成工作的手册,或更确切地说,要求开发者在修改程序后必须从头重写手册的文档,无法满足我们社区的需求。
尽管全面禁止修改不可接受,但某些对修改方式的限制并不会造成问题。例如,要求保留原作者版权声明、分发条款或作者列表,这是合理的。要求修改版本标注修改信息,甚至保留部分不可删除、不可修改的章节,只要这些章节不涉及技术内容,也同样可行。(部分 GNU 手册便采用此类方式。)
这类限制不会带来问题,因为从实际角度看,它们不会阻止尽责的开发者适配手册以匹配修改后的程序。换言之,它们不会阻碍自由软件社区充分使用这份手册。
但必须允许修改手册的所有技术内容,并能通过常规媒介与渠道分发修改后的成果;否则这些限制便会阻碍社区使用,该手册也就算不上自由文档,我们便需要另寻替代。
遗憾的是,当存在专有手册时,往往很难找到人重新编写一份自由手册。阻碍在于,许多用户认为专有手册已经足够好用,因此看不到编写自由手册的必要性。他们没有意识到,自由操作系统存在亟待填补的空白。
为何用户会认为专有手册足够好用?有些人从未思考过这个问题。我希望本文能对此有所改观。
另一些用户接受专有手册,与许多人接受专有软件的原因相同:他们仅从实用角度评判,并未将自由作为标准。这些人有权持有自己的观点,但由于其价值观不包含自由,对我们这些珍视自由的人而言并无参考意义。
请将这一理念传播出去。我们仍在不断失去文档,使其沦为专有出版的牺牲品。如果我们能让更多人知道专有手册并不足够,或许下一位希望通过编写文档助力 GNU 的开发者,能在为时已晚之前意识到,他首先必须让文档成为自由文档。
我们也可以鼓励商业出版商发行自由的、采用 Copyleft 协议的手册,而非专有手册。你可以在购买前查看手册的分发条款,优先选择采用 Copyleft 协议的手册,以此提供支持。
注:自由软件基金会在其网站上维护了一个页面,列出其他出版商提供的自由书籍:
https://www.gnu.org/doc/other-free-books.html
Copyright © 2000, 2001, 2002, 2007, 2008 Free Software Foundation, Inc. https://fsf.org/ Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
The purpose of this License is to make a manual, textbook, or other functional and useful document free in the sense of freedom: to assure everyone the effective freedom to copy and redistribute it, with or without modifying it, either commercially or noncommercially. Secondarily, this License preserves for the author and publisher a way to get credit for their work, while not being considered responsible for modifications made by others.
This License is a kind of “copyleft”, which means that derivative works of the document must themselves be free in the same sense. It complements the GNU General Public License, which is a copyleft license designed for free software.
We have designed this License in order to use it for manuals for free software, because free software needs free documentation: a free program should come with manuals providing the same freedoms that the software does. But this License is not limited to software manuals; it can be used for any textual work, regardless of subject matter or whether it is published as a printed book. We recommend this License principally for works whose purpose is instruction or reference.
This License applies to any manual or other work, in any medium, that contains a notice placed by the copyright holder saying it can be distributed under the terms of this License. Such a notice grants a world-wide, royalty-free license, unlimited in duration, to use that work under the conditions stated herein. The “Document”, below, refers to any such manual or work. Any member of the public is a licensee, and is addressed as “you”. You accept the license if you copy, modify or distribute the work in a way requiring permission under copyright law.
A “Modified Version” of the Document means any work containing the Document or a portion of it, either copied verbatim, or with modifications and/or translated into another language.
A “Secondary Section” is a named appendix or a front-matter section of the Document that deals exclusively with the relationship of the publishers or authors of the Document to the Document’s overall subject (or to related matters) and contains nothing that could fall directly within that overall subject. (Thus, if the Document is in part a textbook of mathematics, a Secondary Section may not explain any mathematics.) The relationship could be a matter of historical connection with the subject or with related matters, or of legal, commercial, philosophical, ethical or political position regarding them.
The “Invariant Sections” are certain Secondary Sections whose titles are designated, as being those of Invariant Sections, in the notice that says that the Document is released under this License. If a section does not fit the above definition of Secondary then it is not allowed to be designated as Invariant. The Document may contain zero Invariant Sections. If the Document does not identify any Invariant Sections then there are none.
The “Cover Texts” are certain short passages of text that are listed, as Front-Cover Texts or Back-Cover Texts, in the notice that says that the Document is released under this License. A Front-Cover Text may be at most 5 words, and a Back-Cover Text may be at most 25 words.
A “Transparent” copy of the Document means a machine-readable copy, represented in a format whose specification is available to the general public, that is suitable for revising the document straightforwardly with generic text editors or (for images composed of pixels) generic paint programs or (for drawings) some widely available drawing editor, and that is suitable for input to text formatters or for automatic translation to a variety of formats suitable for input to text formatters. A copy made in an otherwise Transparent file format whose markup, or absence of markup, has been arranged to thwart or discourage subsequent modification by readers is not Transparent. An image format is not Transparent if used for any substantial amount of text. A copy that is not “Transparent” is called “Opaque”.
Examples of suitable formats for Transparent copies include plain ASCII without markup, Texinfo input format, LaTeX input format, SGML or XML using a publicly available DTD, and standard-conforming simple HTML, PostScript or PDF designed for human modification. Examples of transparent image formats include PNG, XCF and JPG. Opaque formats include proprietary formats that can be read and edited only by proprietary word processors, SGML or XML for which the DTD and/or processing tools are not generally available, and the machine-generated HTML, PostScript or PDF produced by some word processors for output purposes only.
The “Title Page” means, for a printed book, the title page itself, plus such following pages as are needed to hold, legibly, the material this License requires to appear in the title page. For works in formats which do not have any title page as such, “Title Page” means the text near the most prominent appearance of the work’s title, preceding the beginning of the body of the text.
The “publisher” means any person or entity that distributes copies of the Document to the public.
A section “Entitled XYZ” means a named subunit of the Document whose title either is precisely XYZ or contains XYZ in parentheses following text that translates XYZ in another language. (Here XYZ stands for a specific section name mentioned below, such as “Acknowledgements”, “Dedications”, “Endorsements”, or “History”.) To “Preserve the Title” of such a section when you modify the Document means that it remains a section “Entitled XYZ” according to this definition.
The Document may include Warranty Disclaimers next to the notice which states that this License applies to the Document. These Warranty Disclaimers are considered to be included by reference in this License, but only as regards disclaiming warranties: any other implication that these Warranty Disclaimers may have is void and has no effect on the meaning of this License.
You may copy and distribute the Document in any medium, either commercially or noncommercially, provided that this License, the copyright notices, and the license notice saying this License applies to the Document are reproduced in all copies, and that you add no other conditions whatsoever to those of this License. You may not use technical measures to obstruct or control the reading or further copying of the copies you make or distribute. However, you may accept compensation in exchange for copies. If you distribute a large enough number of copies you must also follow the conditions in section 3.
You may also lend copies, under the same conditions stated above, and you may publicly display copies.
If you publish printed copies (or copies in media that commonly have printed covers) of the Document, numbering more than 100, and the Document’s license notice requires Cover Texts, you must enclose the copies in covers that carry, clearly and legibly, all these Cover Texts: Front-Cover Texts on the front cover, and Back-Cover Texts on the back cover. Both covers must also clearly and legibly identify you as the publisher of these copies. The front cover must present the full title with all words of the title equally prominent and visible. You may add other material on the covers in addition. Copying with changes limited to the covers, as long as they preserve the title of the Document and satisfy these conditions, can be treated as verbatim copying in other respects.
If the required texts for either cover are too voluminous to fit legibly, you should put the first ones listed (as many as fit reasonably) on the actual cover, and continue the rest onto adjacent pages.
If you publish or distribute Opaque copies of the Document numbering more than 100, you must either include a machine-readable Transparent copy along with each Opaque copy, or state in or with each Opaque copy a computer-network location from which the general network-using public has access to download using public-standard network protocols a complete Transparent copy of the Document, free of added material. If you use the latter option, you must take reasonably prudent steps, when you begin distribution of Opaque copies in quantity, to ensure that this Transparent copy will remain thus accessible at the stated location until at least one year after the last time you distribute an Opaque copy (directly or through your agents or retailers) of that edition to the public.
It is requested, but not required, that you contact the authors of the Document well before redistributing any large number of copies, to give them a chance to provide you with an updated version of the Document.
You may copy and distribute a Modified Version of the Document under the conditions of sections 2 and 3 above, provided that you release the Modified Version under precisely this License, with the Modified Version filling the role of the Document, thus licensing distribution and modification of the Modified Version to whoever possesses a copy of it. In addition, you must do these things in the Modified Version:
If the Modified Version includes new front-matter sections or appendices that qualify as Secondary Sections and contain no material copied from the Document, you may at your option designate some or all of these sections as invariant. To do this, add their titles to the list of Invariant Sections in the Modified Version’s license notice. These titles must be distinct from any other section titles.
You may add a section Entitled “Endorsements”, provided it contains nothing but endorsements of your Modified Version by various parties—for example, statements of peer review or that the text has been approved by an organization as the authoritative definition of a standard.
You may add a passage of up to five words as a Front-Cover Text, and a passage of up to 25 words as a Back-Cover Text, to the end of the list of Cover Texts in the Modified Version. Only one passage of Front-Cover Text and one of Back-Cover Text may be added by (or through arrangements made by) any one entity. If the Document already includes a cover text for the same cover, previously added by you or by arrangement made by the same entity you are acting on behalf of, you may not add another; but you may replace the old one, on explicit permission from the previous publisher that added the old one.
The author(s) and publisher(s) of the Document do not by this License give permission to use their names for publicity for or to assert or imply endorsement of any Modified Version.
You may combine the Document with other documents released under this License, under the terms defined in section 4 above for modified versions, provided that you include in the combination all of the Invariant Sections of all of the original documents, unmodified, and list them all as Invariant Sections of your combined work in its license notice, and that you preserve all their Warranty Disclaimers.
The combined work need only contain one copy of this License, and multiple identical Invariant Sections may be replaced with a single copy. If there are multiple Invariant Sections with the same name but different contents, make the title of each such section unique by adding at the end of it, in parentheses, the name of the original author or publisher of that section if known, or else a unique number. Make the same adjustment to the section titles in the list of Invariant Sections in the license notice of the combined work.
In the combination, you must combine any sections Entitled “History” in the various original documents, forming one section Entitled “History”; likewise combine any sections Entitled “Acknowledgements”, and any sections Entitled “Dedications”. You must delete all sections Entitled “Endorsements.”
You may make a collection consisting of the Document and other documents released under this License, and replace the individual copies of this License in the various documents with a single copy that is included in the collection, provided that you follow the rules of this License for verbatim copying of each of the documents in all other respects.
You may extract a single document from such a collection, and distribute it individually under this License, provided you insert a copy of this License into the extracted document, and follow this License in all other respects regarding verbatim copying of that document.
A compilation of the Document or its derivatives with other separate and independent documents or works, in or on a volume of a storage or distribution medium, is called an “aggregate” if the copyright resulting from the compilation is not used to limit the legal rights of the compilation’s users beyond what the individual works permit. When the Document is included in an aggregate, this License does not apply to the other works in the aggregate which are not themselves derivative works of the Document.
If the Cover Text requirement of section 3 is applicable to these copies of the Document, then if the Document is less than one half of the entire aggregate, the Document’s Cover Texts may be placed on covers that bracket the Document within the aggregate, or the electronic equivalent of covers if the Document is in electronic form. Otherwise they must appear on printed covers that bracket the whole aggregate.
Translation is considered a kind of modification, so you may distribute translations of the Document under the terms of section 4. Replacing Invariant Sections with translations requires special permission from their copyright holders, but you may include translations of some or all Invariant Sections in addition to the original versions of these Invariant Sections. You may include a translation of this License, and all the license notices in the Document, and any Warranty Disclaimers, provided that you also include the original English version of this License and the original versions of those notices and disclaimers. In case of a disagreement between the translation and the original version of this License or a notice or disclaimer, the original version will prevail.
If a section in the Document is Entitled “Acknowledgements”, “Dedications”, or “History”, the requirement (section 4) to Preserve its Title (section 1) will typically require changing the actual title.
You may not copy, modify, sublicense, or distribute the Document except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, or distribute it is void, and will automatically terminate your rights under this License.
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, receipt of a copy of some or all of the same material does not give you any rights to use it.
The Free Software Foundation may publish new, revised versions of the GNU Free Documentation License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. See https://www.gnu.org/licenses/.
Each version of the License is given a distinguishing version number. If the Document specifies that a particular numbered version of this License “or any later version” applies to it, you have the option of following the terms and conditions either of that specified version or of any later version that has been published (not as a draft) by the Free Software Foundation. If the Document does not specify a version number of this License, you may choose any version ever published (not as a draft) by the Free Software Foundation. If the Document specifies that a proxy can decide which future versions of this License can be used, that proxy’s public statement of acceptance of a version permanently authorizes you to choose that version for the Document.
“Massive Multiauthor Collaboration Site” (or “MMC Site”) means any World Wide Web server that publishes copyrightable works and also provides prominent facilities for anybody to edit those works. A public wiki that anybody can edit is an example of such a server. A “Massive Multiauthor Collaboration” (or “MMC”) contained in the site means any set of copyrightable works thus published on the MMC site.
“CC-BY-SA” means the Creative Commons Attribution-Share Alike 3.0 license published by Creative Commons Corporation, a not-for-profit corporation with a principal place of business in San Francisco, California, as well as future copyleft versions of that license published by that same organization.
“Incorporate” means to publish or republish a Document, in whole or in part, as part of another Document.
An MMC is “eligible for relicensing” if it is licensed under this License, and if all works that were first published under this License somewhere other than this MMC, and subsequently incorporated in whole or in part into the MMC, (1) had no cover texts or invariant sections, and (2) were thus incorporated prior to November 1, 2008.
The operator of an MMC Site may republish an MMC contained in the site under CC-BY-SA on the same site at any time before August 1, 2009, provided the MMC is eligible for relicensing.
To use this License in a document you have written, include a copy of the License in the document and put the following copyright and license notices just after the title page:
Copyright (C) year your name. Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled ``GNU Free Documentation License''.
If you have Invariant Sections, Front-Cover Texts and Back-Cover Texts, replace the “with…Texts.” line with this:
with the Invariant Sections being list their titles, with
the Front-Cover Texts being list, and with the Back-Cover Texts
being list.
If you have Invariant Sections without Cover Texts, or some other combination of the three, merge those two alternatives to suit the situation.
If your document contains nontrivial examples of program code, we recommend releasing these examples in parallel under your choice of free software license, such as the GNU General Public License, to permit their use in free software.
| Jump to: | .
'
*
/
%
<
>
A B C D E F G H I K L M N O P Q R S T U V W X Y Z 代 模 |
|---|
| Jump to: | .
'
*
/
%
<
>
A B C D E F G H I K L M N O P Q R S T U V W X Y Z 代 模 |
|---|
Robert J. Chassell (1946–2017)于1985年开始使用 GNU Emacs。他撰写、编辑并讲授 Emacs 与 Emacs Lisp,在全球各地宣讲软件自由理念。查塞尔是自由软件基金会的创始理事兼财务主管,也是《Texinfo》手册的合著者,还编辑过十余部其他著作。他毕业于英国剑桥大学,长期关注社会与经济史,并持有私人飞机驾驶执照。
单引号是特殊形式 quote 的简写;
你现在不必考虑特殊形式。
See 复杂情况。
Emacs 会以十进制、八进制、十六进制同时显示整数,还会显示对应字符, 但我们暂时忽略这一便捷特性。
追溯“argument”一词为何同时拥有数学和日常英语两种不同含义是件很有趣的事。根据《牛津英语词典》,该词源自拉丁语,意为“阐明、证明”;沿一条脉络演变后,意为“作为证据提出的依据”,即“提供的信息”,这也对应了它在 Lisp 中的含义。而在另一条演变脉络中,它意为“以可能引发反驳的方式主张”,进而演变为“争论”之意。(注意,英语单词可以同时拥有两个不同定义,而在 Emacs Lisp 中,一个符号不能同时拥有两个不同的函数定义。)
(quote hello) 是简写 'hello 的展开形式。
实际上用 %s 也能打印数字,它不限制类型。%d 只会打印数字小数点左侧的部分,非数字内容则不显示。
实际上,默认情况下,如果你刚从其切换的缓冲区在另一个窗口中可见,other-buffer 会选择你看不到的最近缓冲区——这是一个我常忘记的细节。
或者,为了少输入一些字符,如果默认缓冲区就是 *scratch*,你可能只输入了 RET;如果不是,你可能只输入了名称的一部分,比如 *sc,然后按 TAB 键将其补全为完整名称,再输入 RET。
记住,这个表达式会将你切换到最近的、你看不到的另一个缓冲区。如果你真的想切换到最近选择的缓冲区,即使它仍然可见,你需要求值以下更复杂的表达式:
(switch-to-buffer (other-buffer (current-buffer) t))
在这种情况下,other-buffer 的第一个参数指定要跳过的缓冲区 — 即当前缓冲区;第二个参数告诉 other-buffer,可以切换到可见的缓冲区。在常规使用中,switch-to-buffer 会将你带到窗口中不可见的缓冲区,因为你通常会使用 C-x o(other-window)来切换到其他可见的缓冲区。
原生函数的用法与用 Emacs Lisp 编写的函数完全相同,表现也一样。它们用 C 编写是为了让 GNU Emacs 可以轻松地在任何有足够性能、能运行 C 语言的计算机上运行。
这里描述的是采用 “词法绑定(lexical binding)” 风格时
let 的行为(see let 绑定变量的方式)。
根据贾雷德·戴蒙德在《枪炮、病菌与钢铁》中的描述:“… 斑马长大后 会变得异常危险”,但这里想表达的是它们不会像老虎一样凶猛。 (1997, W. W. Norton and Co., ISBN 0-393-03894-2, 第 171 页)
若 Emacs 未显示 Lisp 函数的源代码,而是询问要访问哪个标签表,可从主模式为 Emacs Lisp 或 Lisp Interaction 的缓冲区中调用 M-.。
这相当于一次性调用 (save-excursion (set-buffer …) …),尽管其定义略有不同,感兴趣的读者可通过 describe-function 查看。
实际上,你也可以将一个元素 cons 到一个原子上,从而生成点对。点对不在本章讨论范围内,参见 Dotted Pair Notation in The GNU Emacs Lisp Reference Manual。
编写递归函数时,可以选择节省或消耗计算与心智资源。
有趣的是,人类易于理解、节省心智的写法,
有时会占用较多计算机资源。Emacs 设计之初运行在
如今看来配置有限的机器上,默认设置较为保守。
你可能需要增大 max-lisp-eval-depth 的值。
我在 .emacs 文件中将其设为默认值的 30 倍。
尾递归(tail recursive) 这一术语即用于描述这类占用常量空间的过程。
这种说法略微容易混淆:triangle-recursive-helper 在一个递归过程中使用了迭代式的执行流程。称其过程为迭代,是因为计算机只需记录 sum、counter 和 number 三个值;称其过程为递归,是因为函数会调用自身。而 triangle-recursively 的执行流程与过程本身均被称为递归。“递归(recursive)”一词在不同语境下含义不同。
If current-time-list is
nil the three timestamps are (1351051674579989697
. 1000000000), (1173477761000000000 . 1000000000), and
(1351050967734791805 . 1000000000), respectively.
你也可以在 ~/.emacs 后添加 .el,命名为 ~/.emacs.el。过去不建议使用较长的 ~/.emacs.el 文件名,但现在已允许。新格式符合 Emacs Lisp 文件命名规范,旧格式则更节省输入。
本节给出的配置更适合文字写作者。 对于程序员,Emacs 会根据文件类型自动将默认模式设为对应的编程模式(prog-mode)。 如果你希望保留基础模式作为默认模式,也完全没有问题。
这里使用 setq-default 是因为 text-mode 是缓冲区局部变量。
如果使用 setq,设置只会作用于当前缓冲区;
而 setq-default 会同时作用于新建的缓冲区。
程序员通常不建议这样设置。
启动不加载 .emacs 和站点配置的 Emacs 实例时,我也会关闭闪烁:
emacs -q --no-site-file -eval '(blink-cursor-mode nil)'
如今还可以使用更完善的选项组合:
我也会使用更新的窗口管理器,如 Enlightenment、Gnome 或 KDE;这种情况下我通常会指定图片而非纯色。