备忘录模式如何实现撤销和重做?
简化版
撤销可以用一个 undo 栈保存历史备忘录,每次操作前保存快照,撤销时弹出最近快照恢复。重做通常再维护一个 redo 栈,撤销时把当前状态放入 redo 栈,重做时再恢复 redo 栈中的状态。
详细版
撤销流程:
- 执行操作前保存当前快照到 undo 栈。
- 用户执行操作,状态变化。
- 撤销时,把当前状态可选保存到 redo 栈。
- 从 undo 栈弹出上一个快照。
- Originator 恢复该快照。
重做流程:
- 用户撤销后,redo 栈中有状态。
- 重做前把当前状态放回 undo 栈。
- 从 redo 栈取出快照恢复。
如果用户撤销后又执行新操作,redo 栈通常要清空,因为历史分支已经改变。
完整版教学
一、为什么要两个栈
只支持撤销时,一个 undo 栈就够。但支持重做时,需要保存被撤销掉的状态。
undo 栈记录“可以回到哪里”,redo 栈记录“刚才撤销掉的未来状态”。用户撤销一步,当前状态进入 redo,历史状态从 undo 恢复。
二、执行新操作为什么清空 redo
假设用户从状态 A 到 B 到 C,然后撤销回 B。此时 redo 栈里有 C。
如果用户在 B 上执行新操作得到 D,那么历史路线变成 A → B → D。原来的 C 不再是当前历史分支的一部分,redo 应该清空。
这和编辑器行为一致:撤销后输入新内容,之前可重做内容会消失。
三、快照保存时机
一般在操作前保存快照,这样撤销时可以回到操作前状态。
但某些业务也可以在操作后保存,关键是要保证语义一致。比如命令模式结合备忘录时,可以在命令执行前保存状态,执行失败或撤销时恢复。
四、常见误区与工程判断
不要每个微小变化都保存完整快照。文本编辑器如果每输入一个字符保存整篇文档,内存会很快膨胀。
工程中可以按用户动作合并快照,例如连续输入合并成一次历史记录,或只保存增量变化。体验和性能要一起考虑。
五、撤销重做的关键不是两个栈,而是历史语义
undo/redo 面试题常被答成“一个 undo 栈、一个 redo 栈”,但真正容易出错的是历史语义。撤销表示从当前状态回到过去状态,所以撤销前的当前状态通常要进入 redo 栈;重做表示重新走向刚才撤销掉的未来状态,所以重做前的当前状态要回到 undo 栈。
执行新操作时为什么清空 redo?因为历史路线已经分叉。用户从 A 到 B 到 C,撤销回 B 后又输入新内容得到 D,此时有效历史变成 A、B、D,原来的 C 不再属于当前时间线。继续允许 redo 到 C 会让用户困惑,也会破坏编辑器常见行为。
工程上还要注意快照粒度。不是每输入一个字符都保存完整快照,也不是所有操作都合并成一个快照。常见做法是按用户意图合并,例如连续输入一段文字算一次操作,拖拽结束后保存一次状态,批量格式化算一次历史记录。
实现时还要注意异常情况:undo 栈为空时不能继续撤销,redo 栈为空时不能重做;执行新操作成功后再清空 redo,避免操作失败却丢掉可重做历史;批量操作要么整体生成一个快照,要么明确拆成多步,否则用户体验会很怪。
六、用工程约束检验答案
双栈语义必须准确:执行新编辑前把当前状态压入 undo;撤销时把当前状态压入 redo,再恢复 undo 顶;重做反向操作。若撤销 2 步后执行新编辑,redo 栈必须清空,否则会跳到已分叉的旧时间线。
| 检查项 | 核心判断 | 工程含义 |
|---|---|---|
| 新操作 | 当前状态入 undo | 清空 redo |
| 撤销 | 当前状态入 redo | 恢复 undo 顶 |
| 重做 | 当前状态入 undo | 恢复 redo 顶 |
把关键关系压缩成一条可复述的路径:
S0 --edit--> S1 --edit--> S2
undo: S2 -> redo, restore S1
new edit: S1 -> S3, clear redo
此后不能再 redo 到 S2
保存“操作前”还是“操作后”都可实现,但全系统必须采用同一约定,否则最容易出现错一位。
落地前可以再按下面 3 步复核:
- 先说明“新操作”的核心机制:当前状态入 undo;再交代边界:清空 redo。
- 接着分析“撤销”:当前状态入 redo;不能遗漏对应代价或结果:恢复 undo 顶。
- 最后用“重做”检查方案:当前状态入 undo;验收时确认恢复 redo 顶。
这三项构成完整判断链:先讲清新操作,再说明撤销,最后用重做检验实现是否越界。
面试中若能给出违反“恢复 redo 顶”的反例,再说明修正办法,答案就从模式定义落到了可验证的工程决策。
七、常见误区与追问
- 误区:只看到“新操作”就认为方案成立。 必须同时说明核心机制“当前状态入 undo”和工程边界“清空 redo”。
- 误区:把“撤销”当成无条件结论。 只有在“当前状态入 redo”成立时,才能据此讨论“恢复 undo 顶”。
- 追问:为什么新操作要清空 redo? 它创建了新的历史分支,原来的未来状态不再连续。
- 追问:第一次编辑前要保存吗? 要保留初始状态 S0,否则无法撤销第一次修改。
- 追问:撤销失败如何处理栈? 应先校验快照,恢复成功后再提交栈变更或做原子交换。
- 追问:连续输入每个字符都存吗? 可按时间窗口或语义操作合并,减少快照和用户撤销次数。
- 追问:跨会话还能重做吗? 只有持久化两组历史并处理版本兼容后才可能保证。
八、加强记忆
记忆时抓住这条主线:undo 栈保存历史状态;redo 栈保存被撤销的未来状态;撤销后执行新操作要清空 redo;快照粒度决定内存和体验。面试回答先给出模式意图,再用调用链或数据流说明角色协作,最后主动交代适用边界与工程代价。