16. 命令模式(Command):请求封装,撤销/重做,C++中的菜单命令系统

命令模式,说白了就是把「请求」包装成一个对象。你想想看,在菜单系统里,用户点一下「保存」,背后其实是一堆操作。传统做法是写个 switch-case,每个菜单项对应一个分支。但这样耦合太紧,新增一个菜单就要改代码。

我早年接手过一个编辑器项目,那代码里光菜单处理就写了上千行 if-else。每次加功能,大家都得小心翼翼,生怕改崩了别的菜单项。后来重构时用了命令模式,整个世界清净了。

什么是命令模式?

命令模式的核心思想很简单:把操作封装成对象。这个对象里包含了执行操作所需的所有信息——调用哪个对象、调用什么方法、传什么参数。

它的结构通常包含这几个角色:

  • Command(抽象命令):声明执行操作的接口,通常有个 execute() 方法
  • ConcreteCommand(具体命令):实现 Command 接口,绑定接收者对象和具体操作
  • Invoker(调用者):持有命令对象,在需要时调用命令的 execute()
  • Receiver(接收者):真正干活的对象,知道如何执行具体的业务逻辑

我习惯把命令模式理解成「外卖订单」。你(调用者)下单(命令),餐厅(接收者)做菜。你不需要知道厨房里怎么炒菜,你只需要知道点了什么菜就行。

一个菜单命令系统的例子

咱们直接看代码。假设我们要做一个文本编辑器,有「复制」「粘贴」「撤销」功能。

// 接收者:文本编辑器
class TextEditor {
public:
    void copy() {
        clipboard = text.substr(selectionStart, selectionEnd - selectionStart);
        std::cout << "复制: " << clipboard << std::endl;
    }
    
    void paste() {
        text.insert(cursorPos, clipboard);
        std::cout << "粘贴后文本: " << text << std::endl;
    }
    
    void deleteSelection() {
        text.erase(selectionStart, selectionEnd - selectionStart);
        std::cout << "删除后文本: " << text << std::endl;
    }
    
private:
    std::string text = "Hello World";
    std::string clipboard;
    int selectionStart = 0, selectionEnd = 5;
    int cursorPos = 5;
};

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

// 具体命令:复制
class CopyCommand : public Command {
public:
    explicit CopyCommand(TextEditor* editor) : editor(editor) {}
    void execute() override { editor->copy(); }
    void undo() override { /* 复制操作不需要撤销 */ }
private:
    TextEditor* editor;
};

// 具体命令:粘贴
class PasteCommand : public Command {
public:
    PasteCommand(TextEditor* editor, const std::string& text) 
        : editor(editor), pastedText(text) {}
    
    void execute() override {
        previousState = editor->getText(); // 保存状态用于撤销
        editor->paste();
    }
    
    void undo() override {
        editor->setText(previousState);
    }
    
private:
    TextEditor* editor;
    std::string pastedText;
    std::string previousState;
};

// 调用者:菜单
class Menu {
public:
    void setCommand(const std::string& name, Command* cmd) {
        commands[name] = cmd;
    }
    
    void click(const std::string& name) {
        if (commands.count(name)) {
            commands[name]->execute();
            history.push(commands[name]);
        }
    }
    
    void undo() {
        if (!history.empty()) {
            history.top()->undo();
            history.pop();
        }
    }
    
private:
    std::unordered_map<std::string, Command*> commands;
    std::stack<Command*> history;
};

你看,菜单类根本不知道具体操作是什么。它只负责调用命令的 execute()。新增一个「剪切」功能,只需要写个 CutCommand 类,然后注册到菜单里就行。完全不用改菜单类的代码。

撤销与重做的实现

命令模式最妙的地方,就是天然支持撤销和重做。每个命令都保存了执行前的状态,undo() 方法负责恢复。

我在项目中遇到过一个问题:有些操作撤销起来很麻烦。比如「批量替换」操作,可能改了上百处。如果每个命令都保存完整快照,内存会爆炸。

我的解决方案是:使用命令组合 + 增量存储。把批量操作拆成多个原子命令,每个原子命令只保存自己的变更。撤销时按逆序逐个撤销原子命令。

核心要点:命令模式把「做什么」和「谁来做」解耦了。调用者只关心命令接口,不关心具体实现。这为撤销、重做、日志、事务等高级功能打下了基础。

命令模式的知识体系

下面这张图展示了命令模式的核心逻辑和扩展方向:

命令模式知识体系 核心概念 关键角色 应用场景 请求封装为对象 操作与调用解耦 支持撤销/重做 Command(抽象命令) ConcreteCommand Invoker + Receiver 菜单/工具栏系统 事务/操作日志 宏录制/批处理 核心价值:将请求封装为对象,实现操作与调用的完全解耦

命令模式的优缺点

优点 缺点
调用者和接收者完全解耦 每个操作都要写一个命令类,类数量膨胀
天然支持撤销和重做 如果命令保存的状态太多,内存开销大
可以组合命令实现宏操作 调试时命令链可能很长,追踪困难
新增命令不影响现有代码 简单场景下显得过度设计

我的建议:如果你的系统只有两三个操作,别用命令模式,直接写函数调用更简单。但一旦操作超过五个,或者你需要撤销功能,命令模式就是最佳选择。

避坑指南

我曾经在一个项目中犯过这样的错误:把所有命令的状态都保存为完整快照。结果用户操作了几百次后,内存直接飙到几个G。后来改成只保存增量变更,问题就解决了。

还有一点要注意:命令对象的生命周期管理。如果命令里持有接收者的指针,要确保接收者不会在命令执行前被销毁。我习惯用 shared_ptr 来管理,或者确保接收者的生命周期长于所有命令。

警告:不要在命令的 execute() 里抛出异常。如果命令链中某个命令执行失败,整个撤销栈的状态就乱了。我建议在 execute() 里捕获所有异常,并返回一个状态码。

什么时候用命令模式?

我总结了几条经验:

  • 需要把操作参数化,比如菜单项、工具栏按钮
  • 需要支持撤销和重做
  • 需要记录操作日志,用于审计或恢复
  • 需要支持宏录制,把一系列操作组合成一个命令
  • 需要在不同时间点调度执行操作,比如任务队列

嗯,其实很多框架底层都在用命令模式。比如 Qt 的 QUndoCommand,Java Swing 的 Action 接口,都是命令模式的典型应用。你下次写菜单系统的时候,不妨试试这个模式,真的能省不少心。

专注资料整理