撤销和重做功能为什么常用两个栈设计?
简化版
撤销/重做可以用两个栈:undo 栈保存已执行操作,redo 栈保存刚撤销的操作。执行新操作时压入 undo 并清空 redo;撤销时从 undo 弹出并反向执行,再压入 redo;重做时从 redo 弹出重新执行,再压回 undo。
详细版
撤销恢复的是最近一次操作,所以天然适合栈。重做恢复的是最近被撤销的操作,也适合栈。
关键规则:
- 每个操作最好封装成命令,包含
execute和undo。 - 新操作执行成功后进入 undo 栈。
- 撤销时调用
undo,操作进入 redo 栈。 - 重做时调用
execute,操作回到 undo 栈。 - 一旦执行新操作,redo 栈清空。
难点在于操作必须可逆,或者至少能保存足够快照恢复旧状态。
完整版教学
一、撤销为什么是后进先出
用户连续输入 A、B、C,第一次撤销应该撤销 C,第二次撤销 B,第三次撤销 A。最近发生的操作最先被撤销,这就是 LIFO。
undoStack: [A, B, C]
撤销顺序:C -> B -> A
所以撤销栈保存的是“已经执行且可以反向恢复”的操作。栈顶永远是下一次撤销的目标。
二、redo 栈保存什么
撤销 C 后,C 不应该丢失,因为用户可能点击重做。redo 栈保存的是“刚被撤销、可以重新执行”的操作。
撤销 C 后:
undoStack: [A, B]
redoStack: [C]
如果再撤销 B,redo 栈变成 [C, B],栈顶 B 应该先被重做。因为用户撤销顺序是 C、B,重做时应该先把 B 恢复,再恢复 C,线性历史才一致。
三、为什么新操作会清空 redo
假设执行 A、B、C 后撤销 C,此时可以重做 C。如果用户不重做,而是执行新操作 D,那么历史变成 A、B、D。原来的 C 不再是当前历史的未来。
旧历史:A -> B -> C
撤销到:A -> B
新操作:A -> B -> D
这和浏览器历史类似:从过去某点走出新路径,原来的前进路径失效。因此执行新操作后必须清空 redo 栈。
四、命令对象应该保存哪些信息
一个可撤销命令通常要保存两类信息:如何执行,以及如何恢复。
例如文本插入命令:
command = {
execute() { insert(position, text); },
undo() { deleteRange(position, text.length); }
}
如果是替换文本,则需要保存旧内容:
replace(pos, oldText, newText)
undo 时用 oldText 恢复
这说明撤销系统不是只有两个栈,栈里存的命令本身也要设计好状态。
五、快照和反向操作怎么选择
实现撤销有两种常见思路:保存反向操作,或保存操作前后的快照。
| 方式 | 优点 | 缺点 |
|---|---|---|
| 反向操作 | 空间小,适合细粒度编辑 | 操作必须可逆 |
| 状态快照 | 恢复简单 | 空间大 |
| 增量快照 | 折中 | 实现复杂 |
文本编辑器常保存增量操作;图像编辑或复杂文档有时会用快照或分层快照。选型取决于状态大小和操作可逆性。
六、异常和合并操作要考虑
如果 execute 失败,不应该压入 undo 栈。否则撤销一个没有成功执行的操作会造成状态错乱。
另外,连续输入字符可能不希望每个字符都成为一次撤销。工程上会把短时间连续输入合并成一个命令,例如 1 秒内输入的 hello 合成一次撤销单位。
记忆钩子:undo 栈管过去,redo 栈管刚撤销的未来;新操作会开新历史线,所以 redo 必清空。
七、常见误区与追问
- 误区:撤销栈只需要保存操作名字。 必须保存足够恢复状态的信息,比如位置、旧值、新值。
- 误区:执行新操作后 redo 还能保留。 新操作改变历史分支,redo 路径已经失效。
- 误区:所有操作都天然可撤销。 外部副作用如发邮件、扣款、网络请求需要补偿机制,不能简单 undo。
- 追问:撤销失败怎么办? 要有事务边界或错误恢复策略,避免 undo 栈和真实状态不一致。
- 追问:空间复杂度如何控制? 限制历史步数、合并小操作、使用增量记录或周期性快照。
八、加强记忆
撤销重做的两个栈模型很干净:执行进 undo,撤销进 redo,重做回 undo,新操作清 redo。真正难点在栈元素,不是栈本身;命令必须知道怎么执行、怎么反向恢复,以及失败时怎么保持状态一致。