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() 方法负责恢复。
我在项目中遇到过一个问题:有些操作撤销起来很麻烦。比如「批量替换」操作,可能改了上百处。如果每个命令都保存完整快照,内存会爆炸。
我的解决方案是:使用命令组合 + 增量存储。把批量操作拆成多个原子命令,每个原子命令只保存自己的变更。撤销时按逆序逐个撤销原子命令。
核心要点:命令模式把「做什么」和「谁来做」解耦了。调用者只关心命令接口,不关心具体实现。这为撤销、重做、日志、事务等高级功能打下了基础。
命令模式的知识体系
下面这张图展示了命令模式的核心逻辑和扩展方向:
命令模式的优缺点
| 优点 | 缺点 |
|---|---|
| 调用者和接收者完全解耦 | 每个操作都要写一个命令类,类数量膨胀 |
| 天然支持撤销和重做 | 如果命令保存的状态太多,内存开销大 |
| 可以组合命令实现宏操作 | 调试时命令链可能很长,追踪困难 |
| 新增命令不影响现有代码 | 简单场景下显得过度设计 |
我的建议:如果你的系统只有两三个操作,别用命令模式,直接写函数调用更简单。但一旦操作超过五个,或者你需要撤销功能,命令模式就是最佳选择。
避坑指南
我曾经在一个项目中犯过这样的错误:把所有命令的状态都保存为完整快照。结果用户操作了几百次后,内存直接飙到几个G。后来改成只保存增量变更,问题就解决了。
还有一点要注意:命令对象的生命周期管理。如果命令里持有接收者的指针,要确保接收者不会在命令执行前被销毁。我习惯用 shared_ptr 来管理,或者确保接收者的生命周期长于所有命令。
警告:不要在命令的 execute() 里抛出异常。如果命令链中某个命令执行失败,整个撤销栈的状态就乱了。我建议在 execute() 里捕获所有异常,并返回一个状态码。
什么时候用命令模式?
我总结了几条经验:
- 需要把操作参数化,比如菜单项、工具栏按钮
- 需要支持撤销和重做
- 需要记录操作日志,用于审计或恢复
- 需要支持宏录制,把一系列操作组合成一个命令
- 需要在不同时间点调度执行操作,比如任务队列
嗯,其实很多框架底层都在用命令模式。比如 Qt 的 QUndoCommand,Java Swing 的 Action 接口,都是命令模式的典型应用。你下次写菜单系统的时候,不妨试试这个模式,真的能省不少心。