设计模式面试题8:实现一个命令模式,支持撤销操作

命令模式,说白了就是把「请求」封装成一个对象。这样你就可以把请求排队、记录日志,或者——像我们今天要聊的——支持撤销操作。

我记得有一次面试,面试官让我在白板上写一个支持撤销的文本编辑器。我当时心想,这不就是命令模式的经典应用吗?结果一紧张,把命令的存储和撤销逻辑写反了。嗯,从那以后我对这个模式的理解就特别深。

什么是命令模式?

命令模式的核心思想很简单:把操作封装成对象。每个对象都知道自己做了什么,也知道怎么撤销。

你想想看,如果没有命令模式,你的撤销逻辑会写成什么样?

// 糟糕的设计
class TextEditor {
    std::string text;
    std::string lastText;  // 只保存上一次状态
public:
    void append(const std::string& s) {
        lastText = text;
        text += s;
    }
    void undo() {
        text = lastText;
    }
};

这有什么问题?只能撤销一次。而且如果操作是「删除」而不是「追加」,你连上一次状态都存不对。

注意: 用「保存快照」的方式实现撤销,在复杂系统中会爆炸。每个操作都复制整个状态,内存和性能都扛不住。

命令模式的标准结构

我们先看一个干净的命令模式设计。它包含四个角色:

角色 说明
Command(命令接口) 声明 execute() 和 undo() 方法
ConcreteCommand(具体命令) 实现具体的操作和撤销逻辑
Receiver(接收者) 真正干活的对象(比如文本编辑器)
Invoker(调用者) 管理命令队列,触发执行和撤销

下面我用一个简单的文本编辑器来演示。这个编辑器支持「追加文本」和「删除文本」两种操作,并且都可以撤销。

#include <iostream>
#include <string>
#include <vector>
#include <memory>

// 接收者:文本编辑器
class TextEditor {
    std::string text;
public:
    void append(const std::string& s) {
        text += s;
    }
    void remove(size_t len) {
        if (len > text.size()) len = text.size();
        text.erase(text.size() - len);
    }
    const std::string& getText() const {
        return text;
    }
};

// 命令接口
class Command {
public:
    virtual ~Command() = default;
    virtual void execute() = 0;
    virtual void undo() = 0;
};

// 具体命令:追加文本
class AppendCommand : public Command {
    TextEditor& editor;
    std::string text;
public:
    AppendCommand(TextEditor& e, const std::string& t)
        : editor(e), text(t) {}
    void execute() override {
        editor.append(text);
    }
    void undo() override {
        editor.remove(text.size());
    }
};

// 具体命令:删除文本
class DeleteCommand : public Command {
    TextEditor& editor;
    size_t len;
    std::string deletedText;  // 保存被删的内容,用于撤销
public:
    DeleteCommand(TextEditor& e, size_t l)
        : editor(e), len(l) {}
    void execute() override {
        // 先保存被删的内容
        const std::string& current = editor.getText();
        size_t start = (current.size() > len) ? current.size() - len : 0;
        deletedText = current.substr(start);
        editor.remove(len);
    }
    void undo() override {
        editor.append(deletedText);
    }
};

// 调用者:管理命令历史
class CommandManager {
    std::vector<std::unique_ptr<Command>> history;
public:
    void executeCommand(std::unique_ptr<Command> cmd) {
        cmd->execute();
        history.push_back(std::move(cmd));
    }
    void undo() {
        if (history.empty()) return;
        history.back()->undo();
        history.pop_back();
    }
};

使用起来也很直观:

int main() {
    TextEditor editor;
    CommandManager manager;

    manager.executeCommand(
        std::make_unique<AppendCommand>(editor, "Hello "));
    manager.executeCommand(
        std::make_unique<AppendCommand>(editor, "World!"));
    std::cout << editor.getText() << "\n";  // Hello World!

    manager.undo();
    std::cout << editor.getText() << "\n";  // Hello 

    manager.executeCommand(
        std::make_unique<DeleteCommand>(editor, 3));
    std::cout << editor.getText() << "\n";  // He

    manager.undo();
    std::cout << editor.getText() << "\n";  // Hello 
}

撤销操作的核心设计要点

我个人习惯把撤销分为两种类型:

  1. 状态恢复型:保存操作前的状态,撤销时恢复。适合小数据量的操作。
  2. 反向操作型:记录操作的逆操作,撤销时执行逆操作。适合大数据量或复杂状态。

上面的例子用的是反向操作型。追加的逆操作是删除,删除的逆操作是追加。这样做的好处是:不需要复制整个文本状态,内存开销小。

经验之谈: 我在项目中遇到过一个问题——删除命令的撤销需要保存被删的内容。如果用户删了 100MB 的文本,undo 时就要把这 100MB 存下来。这时候可以考虑「增量快照」或者限制历史记录深度。

命令模式与撤销的进阶话题

面试官通常不会只满足于「能撤销」。他们还会问:

  • 如何支持多级撤销? 用栈保存命令历史,每次 undo 弹出栈顶。
  • 如何支持重做(redo)? 再加一个栈,undo 时把命令压入 redo 栈。
  • 如何限制历史记录大小? 设置最大深度,超过时丢弃最旧的命令。
  • 如何合并连续的同类型操作? 比如用户连续输入字符,可以合并成一个命令。

我曾经在做一个绘图软件时,就遇到了「连续移动物体」的撤销问题。用户拖动物体时,每帧都会产生一个移动命令。如果每个帧都保存,undo 时就得一步步退回去,用户体验很差。后来我把连续移动合并成一个命令,只保存起点和终点,问题就解决了。

命令模式的适用场景

不是所有地方都需要命令模式。我个人觉得,以下场景特别适合:

  • 需要记录操作历史(撤销/重做)
  • 需要将操作排队或延迟执行
  • 需要支持宏命令(组合多个命令)
  • 需要将操作日志持久化(比如数据库的事务日志)
避坑指南: 我曾经在一个项目中过度使用命令模式,把每个 getter 都封装成命令。结果代码变得又臭又长,性能还下降了。记住:命令模式是为「可撤销」和「可记录」服务的,不是用来包装所有方法的。

命令模式的核心逻辑图

下面这张图展示了命令模式在支持撤销时的完整流程:

命令模式支持撤销的核心流程 CommandManager (调用者) Command 接口 execute() / undo() AppendCommand 保存 text,undo 时删除 DeleteCommand 保存 deletedText,undo 时追加 TextEditor (接收者) 历史记录栈 AppendCommand("World!") AppendCommand("Hello ") DeleteCommand(3) ← 栈顶(最新命令) 调用 execute() 实现 委托操作 压入命令 undo() 弹出栈顶并执行逆操作

这张图展示了命令模式的核心流程:

  • 左侧:CommandManager 管理历史记录栈,执行命令时压栈,撤销时弹栈。
  • 中间:具体命令(AppendCommand、DeleteCommand)实现了 execute() 和 undo()。
  • 右侧:TextEditor 是真正的干活对象,命令只是委托它执行操作。

总结

命令模式实现撤销,说白了就是「把操作变成对象,每个对象都知道怎么反悔」。我个人觉得,这是设计模式中最实用的一种——你几乎在所有需要「后悔药」的地方都能用到它。

面试时,如果能把这个模式讲清楚,再结合一个实际的撤销例子,基本就能过关了。如果再能聊到「合并命令」「限制历史深度」这些进阶话题,那面试官对你的印象会更深。

核心要点回顾:
  • 命令模式将请求封装为对象,支持 execute() 和 undo()
  • 反向操作型撤销比状态恢复型更节省内存
  • 用栈管理命令历史,支持多级撤销
  • 注意合并连续操作,避免历史记录爆炸

公众号:蓝海资料掘金营,微信deep3321