前阵子接了个小项目,一个仿植物大战僵尸的塔防小游戏,功能不复杂,但有个需求特别折磨人:所有的玩家操作都要能回放、能撤销,按键绑定还得支持自定义。最初我图省事,直接在一个巨大的 handler 函数里堆 if-else,每个按钮对应一个分支,结果产品经理第三天就提了个需求——“把空格键也绑到暂停上,顺便再增加一个跳过回合的快捷键”。我打开那个两百行的 switch 语句,深呼吸了三次,最终还是决定重写。这时候我才真正痛下决心,把 C++ 里的命令模式从头到尾梳理了一遍。
这文章不是讲设计模式教科书,是我自己重构这个项目时的完整记录。看完你能明白命令模式的核心价值、在 C++ 里有哪些落地写法(包括经典 C++98 的接口继承版和现代 C++17 的 std::function 版),以及撤销、重做、命令队列这些真正实用的进阶玩法。适合写过一些 C++、但还没系统用过设计模式的朋友,也适合那些想把代码重构得更干净但不知道从哪下手的同学。
1. 命令模式到底解决的是什么问题
1.1 为什么一个简单的 if-else 会失控
先看一个最常见的反例。假设你要做一个文本编辑器,支持复制、粘贴、撤销、加粗、插入图片这些操作。很多人第一个版本会长这样:
void Editor::handleAction(ActionType type, const std::string& data) { if (type == ActionType::Copy) { // 复制逻辑,几十行 } else if (type == ActionType::Paste) { // 粘贴逻辑,几十行 } else if (type == ActionType::Bold) { // 加粗逻辑 } else if (type == ActionType::InsertImage) { // 处理图片文件,又是一堆 } // 每加一个新功能,这里就多一个分支 // 每加一个快捷键,这里就多一个 case }这种写法在小规模 demo 里完全没问题,但一旦功能多起来,它会同时踩中三个坑:
第一,操作逻辑和触发条件强耦合。键盘快捷键要调这段逻辑,菜单栏也要调,工具栏按钮还要调,每加一个入口就把这段逻辑再包一层。第二,没法回放和撤销。如果你想知道用户刚才做了哪几步操作,或者想按顺序重放一遍,这种代码完全帮不上忙。第三,代码膨胀速度远超想象。我见过一个真实项目,核心 Controller 里塞了三千行的 switch,没人敢动那文件,因为牵一发而动全身。
1.2 命令模式的本质:把请求变成对象
命令模式做的事情特别朴素:它把“执行一个操作”这件事本身,封装成一个对象。以前你调用的是函数,现在你创建的是一个命令对象,对象里面藏着执行这个操作需要的全部信息和动作。
打个比方,你以前是当面跟厨师说“来一份宫保鸡丁”,命令模式则是把这句话写到一张纸上,交给传菜员。纸可以排队、可以记录、可以作废、可以重新提交,但最终厨师看到的还是那张纸,他不需要知道是谁写的、什么时候写的。C++ 里实现命令模式,本质就是定义了一个统一的接口,所有命令都遵守这个接口,调用方只需要操作接口指针或 std::function 对象,根本不需要关心背后是哪个类在干活。
这个转变带来三个直接好处:
- 解耦:请求的发出者(Invoker)和执行者(Receiver)互不认识,中间全靠命令对象搭桥。
- 可组合:命令对象是普通对象,你可以把它丢进 vector、扔进队列、存进文件、甚至可以组合成宏命令。
- 可扩展:新增一个操作只需要新增一个命令类,不需要改动调用方任何代码。
这也是为什么红点指挥系统、游戏技能释放、编辑器撤销重做、任务调度框架都离不开命令模式。
1.3 什么时候该用,什么时候不该用
必须说实话,命令模式不是银弹。如果你的项目只有三五个操作且永远不会扩展,硬套命令模式反而显得很傻。我的判断标准很简单:
当你需要满足以下任意一条时,才值得引入命令模式:
- 需要支持撤销/重做
- 需要把操作记录下来做日志、回放、审计
- 需要把多个操作排队、调度、延迟执行
- 需要把一组操作当成一个整体来执行(宏命令)
- 需要在不修改现有代码的前提下新增操作类型
反过来,那些简单的 CRUD、一次性脚本、内部工具,用命令模式反而加重了代码负担。新手最常犯的错就是把所有地方都塞进设计模式,最后调试起来比不用还痛苦。
2. 经典实现:接口、命令与调用者
2.1 四个核心角色,缺一不可
命令模式在 C++ 里的经典结构由四个角色组成,搞清楚这四个角色,整张图纸就清晰了:
- Command(命令基类):定义执行操作的统一接口。在 C++ 里通常是一个抽象基类,核心方法是 execute()。
- ConcreteCommand(具体命令):继承 Command,把某个具体的操作和它的接收者绑定在一起。比如 CopyCommand 就持有 Editor 对象的引用,execute() 里写具体的复制代码。
- Invoker(调用者):持有命令对象并触发执行。比如一个按钮、一个键盘处理器,它只管调用 command->execute()。
- Receiver(接收者):真正干活的业务逻辑类。文本编辑器、游戏角色、播放器,都算接收者。
2.2 一个完整的 C++98 风格示例
为了照顾还在维护老项目的朋友,先用经典写法演示一遍。假设我们做一个简单的文本编辑器,支持插入文字和删除文字两个操作。
#include <iostream> #include <string> #include <vector> // 1. 接收者:真正干活的文本编辑器 class TextEditor { public: void insert(const std::string& text) { content_ += text; } void erase(int count) { if (count > static_cast<int>(content_.size())) { count = static_cast<int>(content_.size()); } content_.erase(content_.size() - count, count); } const std::string& content() const { return content_; } private: std::string content_; }; // 2. 命令基类 class Command { public: virtual ~Command() = default; virtual void execute() = 0; }; // 3. 具体命令:插入文字 class InsertCommand : public Command { public: InsertCommand(TextEditor& editor, std::string text) : editor_(editor), text_(std::move(text)) {} void execute() override { editor_.insert(text_); } private: TextEditor& editor_; std::string text_; }; // 4. 具体命令:删除文字 class EraseCommand : public Command { public: EraseCommand(TextEditor& editor, int count) : editor_(editor), count_(count) {} void execute() override { editor_.erase(count_); } private: TextEditor& editor_; int count_; }; // 5. 调用者:按钮,不管你是什么命令,我只会点 class Button { public: void setCommand(Command* cmd) { command_ = cmd; } void click() { if (command_) command_->execute(); } private: Command* command_ = nullptr; }; int main() { TextEditor editor; InsertCommand insertCmd(editor, "Hello, "); EraseCommand eraseCmd(editor, 7); Button btn; btn.setCommand(&insertCmd); btn.click(); btn.setCommand(&eraseCmd); btn.click(); std::cout << editor.content() << std::endl; // 输出空字符串 return 0; }看到没,Button 完全不知道 editor 的存在,它只知道 Command 有一个 execute() 方法。以后想给按钮换个功能,比如把删除改成插入,只需要 setCommand 一个新的命令对象就行,Button 类一行代码都不用改。
2.3 经典方案的三个痛点
上面的代码结构很标准,但我实际用下来,经典方案有三个明显的痛点。
第一是类爆炸。每新增一种操作,就得新建一个类。删除、插入、加粗、斜体、改颜色……类数量线性增长,而且每个类的代码量都不大,但文件管理和命名就够你烦的。第二个痛点是接口太死板。经典 Command 接口只有一个 execute(),但实际需求往往还需要 undo()——撤销操作。如果你一开始没设计好,后面加一个撤销方法,所有命令类都要改一遍。第三个痛点是样板代码太多。每个命令类无非就是存字段 + 调 receiver 的方法,代码高度相似,写起来非常枯燥。
所以我在新项目里改用了一个更现代的姿势:std::function。
3. 现代 C++ 的正确打开方式:std::function 与 lambda
3.1 为什么 std::function 能替代虚基类
C++11 之后有了 lambda 表达式和 std::function,一个命令完全可以用一个函数对象表示,不再需要为每个操作单独建类。这背后的逻辑很简单:命令模式的本质是“把操作封装为对象”,而 C++ 里函数对象本身就是一个对象。std::function 可以存 lambda、函数指针、仿函数,用法统一,还支持拷贝和移动,天然适合放进容器里做队列和栈。
对比一下就清楚了。经典方案里,你要给一个按钮绑定“插入文字”命令,得先写一个 InsertCommand 类,然后在 main 里 new 出来。现代方案里,直接一行 lambda 就完事:
button.setCommand([&editor, text = "Hello"]() { editor.insert(text); });代码量少了三分之二,而且 lambda 捕获列表就是命令的状态清单,你一眼就能看出这个命令依赖哪些数据。这对于小项目简直是降维打击。
3.2 用 std::function 重构命令模式
如果把上一节的编辑器用 std::function 重写,结构会清爽很多。直接把命令对象定义为 std::function<void()>,调用者里保存的不再是 Command* 而是一个可调用对象。
#include <functional> #include <iostream> #include <string> class TextEditor { public: void insert(const std::string& text) { content_ += text; } void erase(int count) { if (count > static_cast<int>(content_.size())) { count = static_cast<int>(content_.size()); } content_.erase(content_.size() - count, count); } const std::string& content() const { return content_; } private: std::string content_; }; class Button { public: using Command = std::function<void()>; void setCommand(Command cmd) { command_ = std::move(cmd); } void click() { if (command_) command_(); } private: Command command_; }; int main() { TextEditor editor; Button btn; // 插入命令 btn.setCommand([&editor]() { editor.insert("Hello, "); }); btn.click(); // 换成删除命令,按钮类一行没改 btn.setCommand([&editor]() { editor.erase(5); }); btn.click(); std::cout << editor.content() << std::endl; // 输出 "H" return 0; }这个版本我实测下来,在团队协作里有巨大优势:新同事接手代码,不需要维护几十个命令类,只需要看 lambda 捕获了什么、调用了什么,逻辑一目了然。而且 lambda 天生支持按值捕获和按引用捕获,命令状态的保存方式比类成员变量灵活得多。
3.3 lambda 捕获与生命周期,新手最容易翻车
用 std::function 实现命令模式虽爽,但有几个细节必须注意,不然程序极其容易崩溃。
最经典的问题是栈上对象被 lambda 捕获后悬空。你在一个函数里创建了一个局部对象,它的生命周期马上要结束,lambda 里却按引用捕获了它,然后没事,把 lambda 存到别处,回头调用时这个引用已经指向一块已经析构的内存。别问我怎么知道的,我调试了整整一个下午,最后用 AddressSanitizer 才逮住它。
正确的做法是:如果命令可能会在捕获对象生命周期之外执行,必须按值捕获,或者用 shared_ptr 包裹接收者。比如:
auto editorPtr = std::make_shared<TextEditor>(); Button btn; btn.setCommand([editorPtr]() { editorPtr->insert("safe"); });这个模式下,只要命令对象还活着,接收者就一定还活着,不会出现悬空引用。
另一个坑是std::function 的开销。std::function 内部可能产生堆分配,如果命令对象的创建和销毁极其频繁(比如游戏里每帧都生成命令),性能会比虚函数调用慢。不过绝大多数业务场景下这个开销可以忽略不计。真到需要压榨性能的时候,再考虑手写小函数对象,而不是针对 std::function 过早优化。
4. 实战:命令栈与撤销重做系统
4.1 撤销的核心不在命令,在状态
说实话,命令模式最经典的应用就是撤销和重做。但很多教程只讲了一个单向 execute,根本没提怎么撤销。我在实际做小游戏的时候发现,撤销的核心不是命令本身,而是状态的保存和恢复。
两种常见的撤销策略:
- 命令对象自带反向操作:每条命令除了 execute(),还有一个 undo(),撤销时调用 undo() 恢复上一步状态。适合操作开销小、容易反算的场景,比如文本插入的撤销就是删除。
- 快照式撤销:执行操作前先保存整个对象状态(比如整个编辑器的字符串),撤销时整体恢复。适合操作复杂、反算难的场景,比如图片滤镜、复杂排版。
我采用的策略是给命令增加一个 undo(),因为游戏操作大多可以反向计算。比如“移动角色到 (3,4)”这条命令的撤销,就是“把角色移回 (2,4)”。这样撤销栈里存的是命令对象,而不是每个命令都要保存一遍完整快照。
4.2 完整的撤销重做实现
直接上代码。这次我用 std::function + 双向栈来实现一个支持撤销重做的编辑器核心,正好用到了 C++ 现代特性。
#include <functional> #include <iostream> #include <stack> #include <string> #include <memory> class TextEditor { public: void insert(int pos, const std::string& text) { if (pos > static_cast<int>(content_.size())) pos = content_.size(); content_.insert(pos, text); } void erase(int pos, int count) { if (pos + count > static_cast<int>(content_.size())) { count = static_cast<int>(content_.size()) - pos; } content_.erase(pos, count); } const std::string& content() const { return content_; } private: std::string content_; }; // 一条可撤销的命令 struct Command { std::function<void()> execute; std::function<void()> undo; }; class CommandHistory { public: // 执行并记录命令 void execute(const Command& cmd) { cmd.execute(); undoStack_.push(cmd); // 新命令执行后,重做栈被清空 while (!redoStack_.empty()) redoStack_.pop(); } bool canUndo() const { return !undoStack_.empty(); } bool canRedo() const { return !redoStack_.empty(); } void undo() { if (!canUndo()) return; auto cmd = undoStack_.top(); undoStack_.pop(); cmd.undo(); redoStack_.push(cmd); // 注意这里存的是还能再重做的命令 } void redo() { if (!canRedo()) return; auto cmd = redoStack_.top(); redoStack_.pop(); cmd.execute(); undoStack_.push(cmd); } private: std::stack<Command> undoStack_; std::stack<Command> redoStack_; }; int main() { TextEditor editor; CommandHistory history; // 插入 "Hello" 到位置 0 std::string inserted = "Hello"; history.execute(Command{ [&editor, inserted]() { editor.insert(0, inserted); }, [&editor, inserted]() { editor.erase(0, inserted.size()); } }); // 再插入 " World" 到位置 5 std::string inserted2 = " World"; history.execute(Command{ [&editor, inserted2]() { editor.insert(5, inserted2); }, [&editor, inserted2]() { editor.erase(5, inserted2.size()); } }); std::cout << editor.content() << std::endl; // "Hello World" history.undo(); std::cout << editor.content() << std::endl; // "Hello" history.undo(); std::cout << editor.content() << std::endl; // "" history.redo(); std::cout << editor.content() << std::endl; // "Hello" history.redo(); std::cout << editor.content() << std::endl; // "Hello World" return 0; }这里有个很重要的细节:当有新的编辑操作发生时,重做栈应该被清空。这是所有编辑器的标准行为——你不能在撤销之后做新操作,然后再点重做,期望它把旧操作重放一遍。新旧操作一旦混合,状态就会错乱。代码里我在 execute() 里已经处理了这个逻辑。
4.3 撤销系统常见的致命坑
讲几个真踩过的坑。
第一个坑是命令对象的生命周期。日志系统里我最初用裸指针管理命令,结果命令队列里还没执行,接收者已经被释放了,程序直接段错误。后来全部换成 shared_ptr,这个问题才根治。
第二个坑是** undo 函数里不能用当前状态来推断旧状态**。比如删除操作的撤销是插入被删的内容,但你得提前把被删内容存到命令对象里,而不是删除的时候去编辑器里再读一遍——因为此时编辑器内容可能已经被后续命令修改过了。命令对象必须是一个自包含的“时光胶囊”,存好自己需要的全部信息。
第三个坑是撤销栈的内存。如果每次操作都保存大对象快照,撤销栈的内存可能膨胀到几百兆。解决方案是:限制撤销栈深度(比如最多 100 步)、用差分存储或压缩快照。游戏里尤其要注意,毕竟内存有限。
5. 进阶玩法:宏命令、队列与日志
5.1 宏命令:把多个操作打包成一个操作
宏命令是命令模式里最实用的组合技巧。它把一组命令顺序执行,对外表现出一个命令的外观。比如“格式化文档”这个操作,内部可能要执行十几个子命令,但在调用者眼里,它就是一个命令。
用 std::function 实现宏命令特别优雅:
class MacroCommand { public: void addCommand(const std::function<void()>& cmd) { commands_.push_back(cmd); } void execute() const { for (const auto& cmd : commands_) { cmd(); } } private: std::vector<std::function<void()>> commands_; };把命令塞进 MacroCommand 之后,忘掉它是个组合体,继续用调用者那一套接口。如果你的宏命令也要支持撤销,就得让子命令各自携带 undo 函数,撤销时逆序调用每个子命令的 undo。这里有个细节:撤销的顺序必须和执行的顺序相反。先插入文字再删除文字,撤销时就得先恢复删除,再恢复插入。这个逆序逻辑非常好理解,但代码里特别容易被人忽略,很多 bug 就是这么来的。
5.2 命令队列:业务解耦和异步调度
命令模式的另一个高频场景是命令队列。你接收了很多操作请求,但不想立刻执行,可以先把命令对象存进队列,由专门的调度器决定什么时候执行、按什么顺序执行。
这在实际项目里的价值非常大。拿游戏开发来说,玩家点击按钮、按键操作和网络回包可能同时到达,如果立刻执行,UI 线程会被冻结;更稳妥的做法是把所有操作先塞进一个线程安全的命令队列,后台线程逐个取出来执行,UI 线程只负责入队和刷新。我给小游戏加的“回放系统”就是这么干的:把玩家每一步操作序列化成命令对象放进队列,回放时按固定节奏依次 execute()。
线程安全方面,C++ 里可以给队列加互斥锁,或者用无锁队列。我实测下来,对大多数项目而言,直接在 CommandQueue 里放一个 std::mutex 就足够了,比花大力气做无锁方案稳得多。
class CommandQueue { public: void push(const std::function<void()>& cmd) { std::lock_guard<std::mutex> lock(mutex_); commands_.push(cmd); } void drainAll() { while (true) { std::function<void()> cmd; { std::lock_guard<std::mutex> lock(mutex_); if (commands_.empty()) break; cmd = commands_.front(); commands_.pop(); } cmd(); } } private: std::queue<std::function<void()>> commands_; std::mutex mutex_; };5.3 把命令变成日志:回放与审计
命令模式一个比较高级的玩法是命令日志化。思路是:既然命令已经是自包含的对象了,那你有没有想过把它序列化存到文件里?以后无论是做调试回放、做数据恢复,还是做玩家行为审计,都可以从日志里重新构建出完整的操作序列。
比如游戏里玩家遇到 bug,你可以在客户端记录下每一条命令日志,错误上报时附上日志,测试人员就能精确复现整个操作路径,不用再靠用户一句模糊的“我好像点了什么然后它就崩了”。
序列化命令需要给每个命令加一个唯一标识和参数列表,这一步工程量大一些,但收益极高。我记得有一次线上 bug,正是靠命令日志定位到的——用户说什么都没干就闪退了,日志显示他在 3 分钟内连续调用了 2 万多次“跳过动画”命令,直接导致了栈溢出。
6. 避坑清单与我的实操建议
6.1 最容易踩的六个坑,速查版
为了让你少走弯路,我把踩过的坑整理成一张速查表,你可以直接贴在工位旁:
| 坑 | 现象 | 原因 | 对策 |
|---|---|---|---|
| lambda 捕获悬空 | 运行时随机崩溃 | 按引用捕获了已析构对象 | 按值捕获或 shared_ptr 包裹接收者 |
| undo 用当前状态推断旧值 | 撤销结果错误 | 命令对象没有自包含所需数据 | 执行前把需要的数据快照存在命令对象里 |
| 撤销栈无上限 | 内存暴涨,程序卡顿 | 没有限制撤销深度 | 限制栈深度或采用差分快照 |
| 新操作后没清空 redo 栈 | 重做换来错误历史 | 违反了编辑器标准行为 | execute() 里强制清空 redo 栈 |
| 命令对象共享状态 | 多个命令互相干扰 | 捕获了同一个可变对象 | 用值拷贝或 immutable 设计 |
| 回调函数里定义命令 | 作用域结束后命令失效 | 忽略了生命周期 | 命令对象和接收者要用 shared_ptr 管理 |
6.2 三点实操心得,帮你少走弯路
第一点,先想清楚 undo 的语义再动手写 execute。大多数教程只讲 execute,但现实项目通常命令一写就要考虑撤销。如果你先写 execute 再补 undo,很可能发现状态已经对不上了,最后不得不用快照方案强行补救。我的习惯是:写命令前先问自己,这个操作的可逆行为是什么?被覆盖的旧值需要提前保存吗?然后一并写进命令里。
第二点,命令对象尽量做到“小而自包含”。一个命令只做一件事,命令里不要调其他业务模块的复杂接口,更不要往命令对象里塞一大堆无关的上下文。自包含的命令容易测试、容易序列化、容易复用。测试命令模式最爽的一点就是可以单元测试每个命令——不需要模拟 UI,不需要启动整个 app,new 出命令,设置接收者,然后 execute,然后验证状态,干净利落。
第三点,适时拆分命令数据与命令执行器。如果命令的 execute 逻辑特别重,可以把执行逻辑抽到一个单独的类里,命令对象里只存参数数据和一个执行器的引用。这样做的好处是执行器可以被多个命令复用,而命令对象变得非常轻量化,拷贝和序列化都更快。
至于“什么时候用 std::function,什么时候用虚基类”,我在项目实践中总结了一条不成文的经验:如果命令种类少(比如 20 个以内)、变化慢,用 std::function 足够;如果命令框架是一个公共基础设施,要支持插件扩展,别人需要注册自己的命令类型,那就保留虚基类 Command 作为公共接口,因为它能更好地跨模块传递,也方便做反射和动态加载。两条路线并不互斥,遇到复杂系统可以先定义 Command 接口,再提供 std::function 到该接口的适配器。
最后再分享一个我个人的小习惯:我习惯在每个命令对象上提供一个 toDebugString() 方法,把命令名和关键参数转成字符串。调试排查时配合日志输出命令序列,效率高一倍不止。这个方法只有一行代码的价值,但关键时刻能救命。