命令模式如何实现撤销和重做?
简化版
命令模式实现撤销重做的关键是让每个命令既能执行 execute(),也能反向恢复 undo()。执行成功后把命令压入撤销栈,撤销时弹出并调用 undo(),同时放入重做栈,重做时再调用 execute()。
详细版
撤销重做通常需要两个栈:
undoStack:保存已执行、可撤销的命令
redoStack:保存已撤销、可重做的命令
流程:
- 执行新命令:调用
execute(),成功后压入undoStack,清空redoStack; - 撤销:从
undoStack弹出命令,调用undo(),压入redoStack; - 重做:从
redoStack弹出命令,调用execute(),压回undoStack。
注意:只有真正执行成功的命令才应该进入撤销栈。对于不可逆操作,比如真实付款、发送短信、删除远程资源,不能简单用 undo() 假装能完全恢复,往往要用补偿动作。
完整版教学
一、为什么命令模式适合撤销重做
撤销重做的本质是:系统要记住用户做过什么,并且知道如何反向恢复。
如果代码是散落的方法调用:
editor.insertText("hello");
editor.deleteText(0, 2);
系统很难统一记录每种操作的恢复方式。
命令对象可以把执行和撤销封装在一起:
interface Command {
void execute();
void undo();
}
这样历史记录里保存的不是一段描述文字,而是一个可执行对象。
二、用两个栈管理撤销和重做
典型实现:
class CommandManager {
private final Deque<Command> undoStack = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
void execute(Command command) {
command.execute();
undoStack.push(command);
redoStack.clear();
}
void undo() {
if (undoStack.isEmpty()) {
return;
}
Command command = undoStack.pop();
command.undo();
redoStack.push(command);
}
void redo() {
if (redoStack.isEmpty()) {
return;
}
Command command = redoStack.pop();
command.execute();
undoStack.push(command);
}
}
执行新命令后要清空重做栈,因为用户产生了新的操作分支,旧的重做历史已经不再符合当前状态。
三、命令对象要保存撤销所需的信息
以文本编辑器插入文本为例:
class InsertTextCommand implements Command {
private final Editor editor;
private final int position;
private final String text;
InsertTextCommand(Editor editor, int position, String text) {
this.editor = editor;
this.position = position;
this.text = text;
}
public void execute() {
editor.insert(position, text);
}
public void undo() {
editor.delete(position, text.length());
}
}
命令必须保存 position 和 text,否则撤销时不知道删哪里、删多少。
对于删除命令,则要保存被删除的原始内容。
四、撤销不等于把方法反着调一下
有些操作看起来有反向动作,但实际未必安全。
例如:
- 转账不能简单反向转账,可能涉及手续费、风控和账务流水;
- 发短信无法真正撤回;
- 删除云端文件可能需要回收站或快照;
- 扣库存要考虑并发和版本号。
这类场景更适合设计“补偿命令”,而不是简单 undo()。
五、撤销操作也可能失败
撤销不是天然可靠的。undo() 本身可能遇到数据库异常、网络失败、状态已变化等问题。
工程里要考虑:
- 撤销失败后命令栈如何处理;
- 是否需要事务包裹;
- 是否需要重试;
- 是否要提示用户手动处理;
- 是否要记录审计日志。
对于本地编辑器,撤销相对简单;对于分布式业务系统,撤销通常演化成 Saga 补偿或状态机回滚。
六、常见误区与追问
双栈模型要用明确序列验证:依次执行 A、B、C 后,undo 栈顶是 C;撤销 C 后它进入 redo 栈;此时若执行 D,redo 栈必须清空。命令还要保存执行前值,例如把字号从12改为18,撤销依据应是旧值12,而不是猜测一个“相反操作”。
| 检查维度 | 判定依据 |
|---|---|
| 新命令成功 | 压入 undo,清空 redo |
| 撤销成功 | undo 弹出并压入 redo |
易错点:一旦撤销后产生新分支,旧 redo 历史已经不再对应当前状态。
- 误区:所有操作都可以无损撤销。 外部发送、支付和不可逆删除需要快照、软删除或补偿语义。
- 追问:命令执行失败要不要入 undo 栈? 不应入栈;只有确认生效的命令才拥有可撤销历史。
- 误区:redo 只是再调用一次 execute。 若环境变化或 execute 非幂等,重做需要保存确定参数并重新校验前置条件。
- 追问:历史栈无限增长怎么办? 设置数量或内存上限,并为大对象保存差量而非完整快照。
- 追问:多个操作能否合并为一次撤销? 可以用宏命令或合并连续输入,但必须定义清晰的事务边界。
七、加强记忆
命令模式做撤销重做的关键是“命令对象自己知道怎么执行,也知道怎么恢复”。执行成功进撤销栈,撤销后进重做栈,新操作清空重做栈。遇到真实业务里的不可逆操作,要用补偿思路处理。