命令模式在C++里被讨论得很多,但真正能把它用得漂亮的代码其实不多。我见过不少项目把业务逻辑直接写在按钮回调里,或者把撤销功能做成一个堆满if-else的状态机,等需求一多就彻底失控。这个模式的核心就一句话:把"要执行的操作"封装成一个对象,听起来简单,却能解决请求与执行解耦、撤销重做、任务排队、日志恢复这一连串问题。这篇文章我会从最经典的GOF写法讲起,一路讲到现代C++用std::function、lambda和智能指针的改造方式,最后用一个带撤销重做的文本操作模块把整个流程串起来,适合正在学设计模式、或者想在实际项目中落地命令模式的C++开发者。
1. 命令模式到底解决了什么问题
1.1 一个让人头疼的真实场景
假设你在写一个绘图软件,工具栏上有"画圆""画方""删除"三个按钮。最直接的做法是给每个按钮绑一个回调,回调里直接调用对应的绘图函数。运行起来没有任何问题,但产品经理第二天就提了新需求:要支持撤销和重做。麻烦随之而来,每个按钮的回调里只写了"执行",没有任何"反向操作"的记录。你想给每个操作补一个undo,就得在每个回调里额外写一套恢复逻辑,而且按钮和具体业务逻辑已经完全耦合在一起,改一个功能可能要牵连好几个按钮。
命令模式换了个思路:不要在调用点直接执行函数,而是把"画圆这个操作"本身做成一个对象。这个对象知道自己要操作谁、怎么执行、怎么撤销。按钮只需要说"我触发了一个命令",至于命令具体做了什么、怎么恢复现场,按钮完全不关心。这样一来,新增操作只需要加一种命令对象,按钮、菜单、快捷键这些调用方一行都不用改。
1.2 四个核心角色
命令模式的参与者通常可以分成四个角色,理解它们之间的关系比记住定义更重要:
- Command(命令接口):定义execute()和undo()(如果支持撤销)的抽象接口,是所有命令对象的共同契约。
- ConcreteCommand(具体命令):实现某个具体操作,持有接收者的引用和操作所需的参数。
- Receiver(接收者):真正干活的对象,比如文档、图形对象、数据库连接。
- Invoker(调用者):持有命令对象并在合适的时机触发它,比如按钮、菜单项、任务队列。
很多人刚接触时容易把Invoker和Client搞混。Client负责创建命令对象、装配接收者,属于"装配者";Invoker只负责持有命令并扣动扳机,属于"执行触发者"。这个边界很重要,一旦模糊,调用者就会开始依赖具体命令类型,模式就退化成普通的函数指针。
1.3 为什么C++尤其适合实现命令模式
C++实现命令模式有天然优势。虚函数原生支持多态,可以用基类指针统一管理一堆具体命令;析构函数和智能指针让命令对象的生命周期管理比很多语言更可控;移动语义让命令对象可以在队列和栈之间高效转移。再加上std::function、lambda表达式这些现代特性,几乎可以用比GOF原始写法更简洁的方式达到同样效果。
我记得有个项目里,原本用一个巨型switch-case分发操作,每个case里塞了几十行业务逻辑。重构后每个操作变成一个命令类,分发逻辑变成"找到对应命令、执行、入栈"三行代码,后续加功能只需要新增文件,回归范围小了很多。这种收益不是立竿见影的,但维护三个月之后你会明显感觉到差别。
2. 经典GOF写法在C++中的落地
2.1 定义Command接口
先看最经典的写法:定义一个抽象基类,里面至少有一个execute()方法,如果支持撤销,再加一个undo()。
#include <string> class ICommand { public: virtual ~ICommand() = default; virtual void execute() = 0; virtual void undo() = 0; virtual std::string description() const { return "Command"; } };这里的析构函数一定要定义成virtual。为什么?因为后面我们会用ICommand*去delete派生类对象,如果析构函数不是虚函数,delete动作属于未定义行为,编译器往往不报警,但运行时可能只析构了基类部分,派生类里的std::string、std::vector就泄漏了。我见过不少新手在第一步就栽在这上面。
2.2 接收者与具体命令
接收者是一个文本编辑器的Document类,提供真正修改内容的操作:
class Document { public: void insertText(size_t pos, const std::string& text) { if (pos > content_.size()) { pos = content_.size(); } content_.insert(pos, text); } void eraseText(size_t pos, size_t len) { if (pos + len > content_.size()) { len = content_.size() - pos; } content_.erase(pos, len); } const std::string& content() const { return content_; } private: std::string content_; };具体命令类要持有接收者和操作参数。以插入命令为例:
class InsertCommand : public ICommand { public: InsertCommand(std::shared_ptr<Document> doc, size_t pos, std::string text) : doc_(std::move(doc)), pos_(pos), text_(std::move(text)) {} void execute() override { doc_->insertText(pos_, text_); } void undo() override { doc_->eraseText(pos_, text_.size()); } std::string description() const override { return "Insert(" + text_ + ")"; } private: std::shared_ptr<Document> doc_; size_t pos_; std::string text_; };这里有个关键决策:Command持有的是std::shared_ptr 而不是裸指针。裸指针会让所有命令依赖"调用者必须保证Document活得比命令久"这个隐式约定,一旦某个命令在历史栈里躺了很久,接收者已经销毁,undo时就是典型的悬垂指针崩溃。用shared_ptr以后,命令自己就能保证接收者存活,代价是引用计数的原子操作,但对于UI命令这种低频路径完全可以接受。
另一个细节在undo()的实现上。插入命令的撤销是"反着删除",这依赖插入和删除是对称操作这个事实。现实世界往往没有这么完美,一个"格式化文本"命令的撤销需要保存格式化前的完整内容,这种命令属于"重操作",内存占用大,必须在设计阶段考虑历史栈上限。
2.3 调用者的职责与边界
Invoker可以是一个按钮、一个菜单项,也可以是一个任务队列。最简单的形式:
#include <memory> class Button { public: void setCommand(std::unique_ptr<ICommand> cmd) { command_ = std::move(cmd); } void onClick() { if (command_) { command_->execute(); } } private: std::unique_ptr<ICommand> command_; };注意这里使用unique_ptr而不是裸指针,意图很清晰:Button拥有这个命令的独占所有权,生命周期不需要外部操心。这个设计还有一个额外的好处:Button类对具体的命令类型零依赖,完全符合开闭原则。以后新增十种操作,Button一行都不用改。这就是命令模式最基础、最扎实的收益。
3. 现代C++实现:用std::function和lambda重新思考命令
3.1 用std::function替代多态接口
经典写法里每个操作都要定义一个类,操作一多会显得啰嗦,尤其是那些只需要几行逻辑的一次性操作。现代C++里可以用std::function直接封装"可调用对象",配合lambda就地描述命令行为。
比如定义这样一个LambdaCommand,它不关心具体做什么,只要求你提供执行体和撤销体:
#include <functional> #include <memory> class LambdaCommand : public ICommand { public: LambdaCommand(std::function<void()> exec, std::function<void()> unexec) : exec_(std::move(exec)), unexec_(std::move(unexec)) {} void execute() override { exec_(); } void undo() override { unexec_(); } private: std::function<void()> exec_; std::function<void()> unexec_; };然后创建命令就变成这样:
auto cmd = std::make_unique<LambdaCommand>( [weakDoc]() { if (auto sp = weakDoc.lock()) { sp->insertText(0, "hello"); } }, [weakDoc]() { if (auto sp = weakDoc.lock()) { sp->eraseText(0, 5); } } );仔细看这段代码,我特意用std::weak_ptr<Document>来捕获接收者,而不是直接捕获shared_ptr或引用。原因在下一节展开。
3.2 生命周期管理:智能指针与引用捕获
命令对象通常会被放进历史栈、任务队列里,它的生命周期可能比创建它的作用域更长。如果lambda用引用捕获[&doc],而命令对象转手存进了队列,等它真正执行时,原本的局部变量已经离开作用域,引用就成了悬空引用。这个坑非常隐蔽,因为它不是在编译期报错,而是在运行时某个不确定的时刻崩溃。
我的处理习惯是:凡是可能被存进队列、历史栈的命令,捕获对象统一使用shared_ptr值捕获,或者用weak_ptr并在执行时lock确认对象还活着。weak_ptr的好处是命令不额外延长接收者的生命周期,接收者被释放后命令也能安全跳过,不会产生悬垂访问。这在高并发、插件化架构里尤其重要。
3.3 移动语义对命令对象的影响
C++11之后,命令对象经常在栈、队列、vector之间搬运,移动语义因此成了刚需。如果你自定义了命令类,记得把移动构造和移动赋值正确处理,否则容器扩容时会强制走拷贝构造,性能下降不说,某些资源管理不当还会造成重复释放。
这里有个容易被忽略的点:std::function内部有[[cppreference:小对象优化|SBO]]机制,小的可调用对象直接存储在std::function内部,没有堆分配;大的lambda才会触发堆分配。所以在LambdaCommand里塞两个大lambda时,每个std::function都可能是一次堆分配,再加上unique_ptr本身,命令对象的内存开销会比虚函数版本高一些。如果这是高频路径,就值得注意了。后面我会专门对比几种实现的性能差异。
还有一个更极致的玩法:把命令的execute和undo设计成可移动的内部状态。比如插入命令持有待插入的文本,移动构造时文本资源被"偷"走而不是复制,这在大量命令批量入队时能明显减少内存分配次数。
4. 实战:一个支持撤销重做的文本编辑模块
4.1 需求分析与整体设计
前面的内容比较散,现在拼成一个完整的小模块。需求如下:
- 支持插入、删除两种文本操作
- 支持撤销(undo)和重做(redo)
- 撤销历史最多保留N步,超出部分自动丢弃
- 支持把多个命令组合成"宏命令",一步执行、一步撤销
结构上分四层:Document是接收者;InsertCommand、DeleteCommand是具体命令;EditorController持有撤销栈和重做栈,既当Client又当Invoker;MacroCommand作为复合命令,内部维护一组子命令。
4.2 核心代码实现
Document和命令类前面已经写过,这里直接看删除命令,因为它比插入命令多一个关键细节:
class DeleteCommand : public ICommand { public: DeleteCommand(std::shared_ptr<Document> doc, size_t pos, size_t len) : doc_(std::move(doc)), pos_(pos), len_(len) {} void execute() override { deleted_ = doc_->content().substr(pos_, len_); doc_->eraseText(pos_, len_); } void undo() override { doc_->insertText(pos_, deleted_); } std::string description() const override { return "Delete(" + std::to_string(len_) + ")"; } private: std::shared_ptr<Document> doc_; size_t pos_; size_t len_; std::string deleted_; };DeleteCommand在execute时先把即将删除的内容存进deleted_,这样undo才有依据把内容恢复回去。这是撤销功能的地基:每个命令内部必须保存足以反向执行的状态信息。插入命令不需要额外保存,因为插入和删除天然对称;但一旦命令不是对称操作,比如"格式化""批量替换",就一定要考虑在命令对象里保存操作前的快照。
4.3 撤销重做栈与控制器
控制器负责执行命令、维护历史栈、处理撤销重做。这里给出deque版本,它比std::stack更实用,因为需要从头部淘汰旧命令:
#include <deque> #include <memory> #include <vector> class EditorController { public: explicit EditorController(size_t maxHistory = 100) : maxHistory_(maxHistory) {} void executeCommand(std::unique_ptr<ICommand> cmd) { cmd->execute(); undoDeque_.push_back(std::move(cmd)); // 新操作执行后,重做分支全部失效 redoDeque_.clear(); if (undoDeque_.size() > maxHistory_) { undoDeque_.pop_front(); } } void undo() { if (undoDeque_.empty()) return; auto cmd = std::move(undoDeque_.back()); undoDeque_.pop_back(); cmd->undo(); redoDeque_.push_back(std::move(cmd)); } void redo() { if (redoDeque_.empty()) return; auto cmd = std::move(redoDeque_.back()); redoDeque_.pop_back(); cmd->execute(); undoDeque_.push_back(std::move(cmd)); } void clearHistory() { undoDeque_.clear(); redoDeque_.clear(); } private: std::deque<std::unique_ptr<ICommand>> undoDeque_; std::deque<std::unique_ptr<ICommand>> redoDeque_; size_t maxHistory_; };有几个设计决策值得说明。第一,执行新命令时必须清空重做栈。这是撤销重做系统的标准语义:你走了新分支,历史中的"重做"路径就失效了。第二,用deque而不是stack,是因为淘汰最老历史时pop_front是O(1)操作,用std::stack就得整体倒出来再重建,代价太大。第三,undo和redo在栈之间转移命令时全程使用移动语义,命令对象没有被拷贝,历史记录不会因为搬运而产生额外内存开销。
4.4 宏命令:组合的威力
宏命令是命令模式最实用的变体之一。比如"自动排版"这个操作,内部由十几个删除和插入组成,用户撤销时希望一步回到排版前,而不是点十几次撤销。实现方式是把子命令放进vector,执行时顺序遍历,撤销时倒序遍历:
class MacroCommand : public ICommand { public: void addCommand(std::unique_ptr<ICommand> cmd) { commands_.push_back(std::move(cmd)); } void execute() override { for (auto& cmd : commands_) { cmd->execute(); } } void undo() override { for (auto it = commands_.rbegin(); it != commands_.rend(); ++it) { (*it)->undo(); } } std::string description() const override { std::string desc = "Macro["; for (size_t i = 0; i < commands_.size(); ++i) { if (i > 0) desc += ", "; desc += commands_[i]->description(); } desc += "]"; return desc; } private: std::vector<std::unique_ptr<ICommand>> commands_; };注意undo必须使用反向迭代器,这是"后进先出"的语义。如果按顺序撤销,先执行的命令反而先被反向,组合效果就完全错了。这个错误我在代码评审时见过不止一次,而且往往要等用户误操作很久以后才暴露出来。
把整个过程串起来测试一下:
#include <iostream> int main() { auto doc = std::make_shared<Document>(); EditorController ctrl; auto insert = std::make_unique<InsertCommand>(doc, 0, "hello"); ctrl.executeCommand(std::move(insert)); auto insert2 = std::make_unique<InsertCommand>(doc, 5, " world"); ctrl.executeCommand(std::move(insert2)); std::cout << doc->content() << "\n"; // hello world ctrl.undo(); std::cout << doc->content() << "\n"; // hello ctrl.redo(); std::cout << doc->content() << "\n"; // hello world }输出符合预期。这个小模块虽然精简,但已经是图形编辑器、IDE、数据库客户端里撤销重做功能的骨架。
5. 常见问题、性能考量与避坑实录
5.1 对象切片与虚函数陷阱
第一个高频坑是对象切片。如果你把具体命令以值形式放进容器,比如std::vector<InsertCommand>,再试图通过std::vector<ICommand>接收,派生类部分会被"切掉",虚函数调用链路断裂。我自己在一次重构中把unique_ptr容器误改成值容器,结果execute调用的全是基类的空实现,排查了半天才反应过来。原则很明确:多态容器必须是指针容器,而且最好是智能指针容器。
第二个坑是基类缺少虚析构函数,前面已经强调过,这里再补一句:这不是"可能导致"的问题,而是"一定不能"的问题。只要通过基类指针删除派生类对象,就必须保证析构函数是virtual,没有例外。
5.2 命令历史的内存与性能
每个命令对象都持有接收者引用和操作数据,历史栈无限增长时内存会持续膨胀。我上面用maxHistory限制历史长度,但真正商用系统里还会有更细的策略,比如"合并连续输入":用户连续输入多个字符,撤销时应该一次性撤回整个输入过程,而不是一个一个字符退。这种优化在编辑器里非常常见,实现方式是在push命令时检查栈顶命令与当前命令是否可以合并(相同的命令类型、相邻的位置),能合并就更新栈顶命令的内容。
大对象的移动语义也不能忽视。如果命令内部保存了图像快照、大文本块,务必用移动而不是拷贝。还有一个实战技巧:把命令的description()做成可访问的调试信息,排查问题时在日志里打印命令序列,比对着内存猜高效得多。
5.3 线程安全与并发执行
命令模式在单线程环境最简单,但真实项目的命令队列往往由工作线程池处理。这时候至少要注意三点:
第一,接收者对象必须线程安全。Document的成员函数内部如果涉及状态修改,要么加锁,要么用无锁数据结构,否则两个命令同时执行时内容直接乱套。
第二,命令对象本身如果被多线程共享,execute()里不应含有非同步的可变状态。最稳妥的约定是命令只在线程间转移一次,最终由单一线程执行。
第三,lambda捕获的变量要同步考虑线程安全。lambda捕获了一个shared_ptr 并修改它,多个线程通过命令同时修改就构成数据竞争,必须用std::atomic或互斥锁包裹。
5.4 性能开销对比与取舍
命令模式带来灵活性的代价是额外的间接调用。几种常见实现方式的取舍如下:
| 实现方式 | 单次调用开销 | 内存开销 | 灵活性 | 调试友好度 |
|---|---|---|---|---|
| 虚函数多态 | 一次虚表查询 + 跳转 | 每个对象多一个vptr | 高 | 高,调试器能看到具体类型 |
| std::function | 可能一次堆分配 + 一次间接调用 | 小对象优化兜底,大lambda堆分配 | 高 | 一般,类型被擦除 |
| 裸函数指针 | 一次间接调用 | 极小 | 低 | 低 |
我的建议是:默认优先虚函数版本,它最直观,调试器里能看清每次调用的具体命令类型;如果命令类数量爆炸、样板代码多到影响维护,再换成std::function版本;如果是逐帧触发的高频热点路径,可以考虑命令对象池化,复用Command对象避免反复分配。命令模式最大的性能风险往往不在虚函数调用本身,而在对象频繁分配,池化能直接解决这个问题。
6. 命令模式的进阶玩法:序列化、日志重放与事件溯源
6.1 把命令变成数据:序列化与日志
命令对象本质上是"操作的数据化",这意味着它可以被序列化保存。给命令接口加一个serialize()方法,把命令类型和参数转成JSON或二进制,执行前先写日志,执行后再写结果日志。这套机制最典型的应用是崩溃恢复:程序异常退出后,重新启动时读取命令日志,按顺序重放一遍就能恢复现场。
这个思路在数据库和分布式系统里是核心机制,术语叫"预写日志"。C++里实现时注意:命令参数必须能无损序列化,字符串、二进制块要带长度;命令执行要尽可能幂等,或者反过来强依赖顺序,两者必须选一个并在文档里写清楚。
6.2 日志重放与恢复现场
重放命令日志时有一个细节:重放过程中可能触发新的通知、更新UI、写入新的日志,形成递归。标准做法是重放时设置一个"重放模式"标志,期间抑制一切副作用,只恢复核心状态。我在做一个存档系统时踩过这个坑,重放历史命令导致UI反复刷新,界面卡顿明显,加了抑制标志后问题立刻消失。
日志重放还给命令模式带来一个额外价值:审计。谁在什么时候执行了什么操作,全部有据可查。这在金融、编辑、协同办公类产品里是硬需求,而命令模式的日志天然满足。
6.3 什么时候不该用命令模式
命令模式不是银弹。如果只是一次性简单调用,直接函数调用就行,封装成命令只会增加类数量和维护成本。判断标准有三个:需要撤销重做吗?需要把操作排队或延迟执行吗?需要记录操作日志吗?三个问题全否,就不要用命令模式。如果只满足一个,也别急着全盘命令化,可以针对性地在局部引入。
我之前在一个小工具里把所有的setter都包装成了命令,结果代码量翻倍,还看不出任何收益,最后又改回去了。设计模式的核心是解决问题,不是展示技巧。
6.4 我的一点使用体会
用命令模式重构过几个项目之后,我的体会是这样的:它的价值不在写代码的那一刻,而在需求变更的那一刻。最典型的例子是那个两周没敢动的switch-case分发器,改成命令类之后,新增操作只需新增一个文件,主流程代码再也没动过。这种"不改主流程就能扩展"的安心感,是命令模式给我最大的回报。
如果你刚开始尝试,建议从最小的场景入手:找一个有多个调用点、有撤销需求的地方,先只封装一个操作看看效果。跑通以后,你会发现整个设计模式系列里,命令模式是性价比最高、最容易出成果的一个。