每个编辑器都有撤销与反撤销的操作。Emacs中也不例外。我们今天来介绍Emacs中撤销相关的操作
知识铺垫
buffer-undo-list 的理解
在Emacs中,撤销操作使用undo、而反撤销使用的是undo-redo。它们比较容易理解,也没什么参数。但是今天我们深入理解建立在这两个函数背后的数据维护以及基于此的现场保护与还原。
在Emacs的底层,为每个buffer都维护了一个 buffer-undo-list 的结构,用于记录该buffer的修改历史。Emacs将每一次的操作,包括文本内容的变化以及文本属性的变化作为一条记录插入这个结构中。
我们想象一下,如果我们设计一个编辑器,用户有操作,例如当前有一个 hello 的字符,用户删除了了最后一个o,此时buffer变成了 hell。后面有加了一个 word 单词那么我们会怎么记录呢?有的编辑器会记录每一次改动之后buffer的内容。例如:
- 第一次用户输入完了,此时保存一条
hello记录 - 第二次用户编辑,删除了
o,此时添加一条hell记录 - 第三次用户编辑,插入
word这个单词,此时添加一条hellword记录。
后续每一次undo 和 redo 操作都是在向前或者向后遍历记录列表,恢复buffer。
这是一个思路,但是Emacs采用的是另一个思路,它不记录当前buffer是什么样子,它只记录每次在哪里,做了哪些改动。我们查看 buffer-undo-list,它的每一条记录主要内容如下:
(START . END)用于记录一次插入操作产生的文本范围,undo 时删除这个范围string类型的 undo record 主要用于记录被删除的文本。nil中间可能会插入一个有nil的节点,表示一个 撤销边界 ,一次 undo 通常会处理一个 change group,也就是从当前 undo 位置向后处理 undo records,直到遇到下一个 boundary
从上面的一些结构我们就可以看出一些执行undo 时的思路,还是以上面的例子为了,假设我们将 hello 改成了 hellworld,那么Emacs会记录一个类似于 ((5 . 9) "o") 的结构,下一次undo时,删除buffer中point从5到9这个位置的字符,然后在后面插入一个 "o" 字符串。
但是节点只是有可能包含这些,并不一定每个节点都是一样的结构。根据操作的不同,可能每个节点会出现不同的数据。它比较复杂因为Emacs支持针对 undo 的操作千奇百怪,除了普通文本插入、删除和文本属性修改之外,undo 系统还需要处理 point、marker、文本属性以及 region 中的 selective undo 等复杂情况,因此 undo record 的结构并不是固定的。
为什么我会将 undo 之后的 redo 作为它支持的一种 undo 操作呢?这与Emacs的另一种设计思路有关。常规的编辑器可能将 undo 和 redo 视为不同的操作,可能维护两个列表或者像上面那样undo、redo将当前状态在列表中往不同的方向移动。但是Emacs不这么认为,它认为redo是另一种形式的文本修改,与我们手动修改文本没什么两样,所以传统 Emacs undo 机制并没有把“undo”和“redo”设计成两个相互独立的历史栈。一次 undo 本身会修改 Buffer,因此它也会影响 undo 历史。
例如,有三次修改记录 A->B->C。如果我们执行两次 undo 操作,回到了A状态,此时历史大致变成了
A->B->C->(undo)B->(undo)A如果我们中间插入一些非undo 操作,哪怕只是移动光标,Emacs会将这些记录插入,变成固定的记录。如果我们再执行undo,那么就变成了再往回走,变成 undo A 的 undo,就能回到B,同理再次执行undo 就回到了初始状态的 C
从某种程度上来说,几次undo实际上能回到redo上。所以Emacs早期并没有提供专门的 redo 函数,现代的Emacs(28+)虽然提供了 undo-redo 来执行反撤销,但是这个函数只是为了与其他编辑器有相同的体验,它的内部仍然采用的是 buffer-undo-list 的实现方式
因为 buffer-redo-list 的结构比较复杂,每一项数据也都不固定,所以我们实际上无法通过像当初介绍 mark-ring 那样打印 mark-ring 来深入理解。如果你想要深入理解,可以参阅 undo、primitive-undo、undo-more
undo-boundary
前面提到 nil 实际上组成了一个 boundary 边界,每一次执行撤销实际是以这个边界为准,回到了边界的位置。例如我们输入hello world 可能记录了第一次插入 h、第二次插入 e、第三次插入 l 等等操作。但是执行撤销之后它并没有每次删除一个字符,它可能直接把整个 hello world 字符串都给删除了。这个就是 boundary 在起作用
从某种程度上来说,这个机制简化了多次输入undo进行撤销的尴尬。但是Emacs默认给的 boundary 并不一定好用,例如hello word 我想改成 hello emacs,我希望我通过 undo 撤销后面的单词,我直接输入 emacs 。但是Emacs把整个字符串都删除了。只是这种简单的错误还好,如果是输入了一大段文本,我发现最后一两个词用的不准确想要重写,结果 undo 之后,整段都没了,用户心态可能直接原地爆炸了。
好在Emacs提供了 undo-boundary 让我们自己提供这个边界。
我们可以尝试一下下面两个函数
(defun my/insert-text()
(interactive)
(goto-char (point-max))
(insert "hello")
(insert " world"))Emacs 默认会在一个命令执行前后插入一个 boundary,将执行前后的状态分开,所以上面一个函数执行之后,如果再执行 undo 会将整个插入的 hello world 都清空
(defun my/insert-text()
(interactive)
(goto-char (point-max))
(insert "hello")
(undo-boundary)
(insert " world"))下面一个函数,我们手动调用 undo-boundary ,在两次插入字符串之间插入了一个 boundary,这样调用 undo 只会删除后面插入的 world
原子化操作
实际上,用户的编辑操作千变万化,不光是Emacs,甚至连我们自己都无法事先预料到,我下一次undo想要把buffer恢复成什么状态。那么Emacs提供 undo-boundary 有什么用呢?实际上它常用于我们上面的标题说的 原子化操作 这样一种模式。
很多插件都会改变buffer的内容,比如各种mode的语法高亮、代码的格式化。它们本质上就是修改了buffer、产生了一条条的undo记录。但是如果插件中途报错,导致这个变化只变了一半,甚至有的变化来不及完全变更导致根本就是错的,就很影响用户体验。这个机制就像我们学习数据库的时候,提到的事务回滚。
提到这个,我们至今为止了解了各种级别的保存与恢复现场。save-excursion、save-restriction、with-current-buffer。原子化操作的本质和要解决的问题与上面几个大致先当。同样的也有对应的宏进行处理 atomic-change-group。它主要做两件事:
- 将宏包裹的buffer 操作打包成一个undo 单元,并且中间的操作不产生新的记录,用户事后反悔了可以一次undo回到之前的状态
- 如果中途出错了,能回到原来的状态
练习
今天的练习,我们基于之前markdown标题转org mode标题的例子,在上面添加关于恢复现场的操作。
(defun my/markdown-title-to-orgmode-title()
(interactive)
(save-excursion
(save-restriction
(widen)
(goto-char (point-min))
(let ((header-regex (rx
line-start (group (repeat 1 6 "#")) (one-or-more space))))
(atomic-change-group
(while (re-search-forward header-regex nil t)
(let* ((hashes (match-string 1))
(has-count (length hashes))
(start (make-string has-count ?*))
(replace (concat start " ")))
(replace-match replace t t))))))))总结
本节我们了解了Emacs的undo机制,同时介绍了如何进行原子化操作,本节介绍的api如下:
undoundo-redoundo-boundaryatomic-change-group
评论 (0)