← 返回题目列表

命令模式如何实现撤销和重做?

高频 中等 第 5 / 25 题 更新于 2026/07/28
命令模式撤销重做undoredo历史栈

简化版

命令模式实现撤销重做的关键是让每个命令既能执行 execute(),也能反向恢复 undo()。执行成功后把命令压入撤销栈,撤销时弹出并调用 undo(),同时放入重做栈,重做时再调用 execute()

详细版

撤销重做通常需要两个栈:

undoStack:保存已执行、可撤销的命令
redoStack:保存已撤销、可重做的命令

流程:

  1. 执行新命令:调用 execute(),成功后压入 undoStack,清空 redoStack
  2. 撤销:从 undoStack 弹出命令,调用 undo(),压入 redoStack
  3. 重做:从 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());
    }
}

命令必须保存 positiontext,否则撤销时不知道删哪里、删多少。

对于删除命令,则要保存被删除的原始内容。

四、撤销不等于把方法反着调一下

有些操作看起来有反向动作,但实际未必安全。

例如:

  • 转账不能简单反向转账,可能涉及手续费、风控和账务流水;
  • 发短信无法真正撤回;
  • 删除云端文件可能需要回收站或快照;
  • 扣库存要考虑并发和版本号。

这类场景更适合设计“补偿命令”,而不是简单 undo()

五、撤销操作也可能失败

撤销不是天然可靠的。undo() 本身可能遇到数据库异常、网络失败、状态已变化等问题。

工程里要考虑:

  • 撤销失败后命令栈如何处理;
  • 是否需要事务包裹;
  • 是否需要重试;
  • 是否要提示用户手动处理;
  • 是否要记录审计日志。

对于本地编辑器,撤销相对简单;对于分布式业务系统,撤销通常演化成 Saga 补偿或状态机回滚。

六、常见误区与追问

双栈模型要用明确序列验证:依次执行 A、B、C 后,undo 栈顶是 C;撤销 C 后它进入 redo 栈;此时若执行 D,redo 栈必须清空。命令还要保存执行前值,例如把字号从12改为18,撤销依据应是旧值12,而不是猜测一个“相反操作”。

检查维度判定依据
新命令成功压入 undo,清空 redo
撤销成功undo 弹出并压入 redo

易错点:一旦撤销后产生新分支,旧 redo 历史已经不再对应当前状态。

  • 误区:所有操作都可以无损撤销。 外部发送、支付和不可逆删除需要快照、软删除或补偿语义。
  • 追问:命令执行失败要不要入 undo 栈? 不应入栈;只有确认生效的命令才拥有可撤销历史。
  • 误区:redo 只是再调用一次 execute。 若环境变化或 execute 非幂等,重做需要保存确定参数并重新校验前置条件。
  • 追问:历史栈无限增长怎么办? 设置数量或内存上限,并为大对象保存差量而非完整快照。
  • 追问:多个操作能否合并为一次撤销? 可以用宏命令或合并连续输入,但必须定义清晰的事务边界。

七、加强记忆

命令模式做撤销重做的关键是“命令对象自己知道怎么执行,也知道怎么恢复”。执行成功进撤销栈,撤销后进重做栈,新操作清空重做栈。遇到真实业务里的不可逆操作,要用补偿思路处理。