提起“命令模式”(Command Pattern),很多人的第一反应是设计模式书里那张UML图:Command、ConcreteCommand、Receiver、Invoker,四个框框几条箭头,看着挺抽象。但真正在C++工程里把它用顺手之后,你会发现这套东西的核心理念非常朴素——把“要做什么”和“由谁来做”彻底拆开,把一次请求封装成一个对象,让它可以被传递、被排队、被记录下来,甚至被撤销和重放。
这篇文章我想用实战的视角来拆解C++中的命令模式:先从一次真实的文本编辑器需求入手,聊聊为什么直接调函数会越写越难受;然后给出完整的C++实现,从接口设计到撤销重做;再聊宏命令、任务队列、序列化这些工程化扩展;最后把我在实际项目里踩过的几个坑整理成排查清单。无论你是在做GUI客户端、游戏系统还是后台服务,命令模式的思路都能直接迁移过来。
1. 命令模式要解决的核心问题
1.1 从“按钮直接调函数”聊起
先设想一个最常见的场景:一个文本编辑器,界面上有一个“插入文字”按钮、一个“删除选中内容”按钮、一个“撤销”按钮、一个“重做”按钮。
最粗暴的实现,是让按钮的点击回调里直接写业务代码:拿到数据对象的指针,调用insert()、erase(),然后刷新界面。刚开始这么做完全没问题,代码直白、调试方便,一两百行就能跑起来。
但需求开始叠加时问题就来了。比如“撤销”需要知道上一次操作是什么、改变了哪些位置、把什么内容插进去了;用户连续输入了一百个字符,撤销应该逐字回退还是整段回退;“删除”时要把删掉的内容缓存起来,否则撤销时根本不知道恢复了什么;如果还想做宏录制,把用户十几步操作录制成一个可重放序列,直接用函数调用根本没法记录。
一句话总结:当你需要的不是“执行一次”,而是“执行、回退、重放、排队”这整套行为时,函数调用就不够用了。你需要把一次操作本身变成数据。
1.2 痛点清单:耦合、回滚、排队
我把这类场景下的痛点归纳成三类,后面所有的设计都围绕它们展开。
第一是耦合。界面组件直接依赖业务类的具体接口。今天按钮A调插入接口,明天按钮B调追加接口,后天领域逻辑变了,所有按钮都要跟着改。界面层知道得太多了,这会让业务变化传导到整个上层。
第二是无法回滚。函数调用是单向的,调用完就结束了。想做撤销,就得靠一堆临时变量和特殊标记,代码里到处都是“上一个位置”“上一次长度”这种状态,维护起来非常痛苦。更麻烦的是,回滚逻辑和正向执行逻辑散落在各个回调里,状态一多就顾此失彼。
第三是无法排队和组合。想在后台线程逐个执行一系列操作、想把多个操作绑成一个复合操作、想记录操作日志……函数引用都没法直接塞进队列,更别说序列化存储。一旦系统需要异步批量处理,这条路基本走不通。
这些痛点背后其实指向同一个需求:请求必须是一个独立的对象,而不是函数调用这个瞬时行为。
1.3 命令模式登场:请求也是对象
命令模式的经典定义是:将请求封装为对象,从而使你可以用不同的请求对客户端进行参数化,并支持请求的排队、记录日志以及撤销操作。翻译成大白话就是:
- 把“插入一段文字”这件事,做成了一个InsertCommand对象;
- 把“删除一段文字”这件事,做成了一个EraseCommand对象;
- 这些对象统一实现了同一个接口:execute()表示执行,undo()表示撤销;
- 调用按钮只负责把一个命令对象交给“执行器”,执行器调用execute(),并把命令压进历史栈。
这样一来,界面层不需要知道接收者的内部细节;历史栈可以无差别地维护任何命令;撤销就是弹出历史栈并调用undo();重做就是再调用execute()。命令对象是数据,数据可以排序、可以存储、可以复制,功能一下子就打开了。
2. C++里的四种角色怎么落地
2.1 命令接口:execute 和 undo 两件事
在C++里,命令模式的第一步通常是定义一个抽象基类:
class Command { public: virtual ~Command() = default; virtual void execute() = 0; virtual void undo() = 0; };只有execute没有undo,也可以工作,但就浪费了命令模式一半的能力。我建议一开始就把undo设计进去,哪怕你的业务暂时用不上。原因有两个:第一,撤销几乎是所有编辑类、操作类系统的标配需求,早设计比晚重构省事;第二,命令对象有了undo语义,写测试的时候非常方便——执行一次再撤销一次,对象状态应该回到原样,这就是一个天然的可验证性质。
需要注意析构函数必须声明为virtual。这是老生常谈,但C++面试题里天天考,实际项目里因为漏掉virtual析构导致删除基类指针时行为错误的情况,我见过不止一次。另外,如果命令对象内部持有资源,优先用智能指针,让默认析构函数自己干活,不要手动new/delete。
2.2 接收者:真正干活的类
接收者(Receiver)是最后真正操作数据的地方。在文本编辑器例子里就是数据缓冲区。命令对象里保存的是“在什么位置、插入什么内容”,而真正调用insert()/erase()的是接收者。
这样做有一个重要的好处:命令对象不直接操作具体数据,而是通过接收者去操作。当你的业务逻辑变了,比如从std::string换成自定义的文档模型,你只需要改接收者和对应命令,调用者完全无感。
在C++里接收者通常是引用或指针传入命令对象。这里就有一个生命周期的隐患:如果接收者先被销毁,命令对象还活着,命令就悬空了。后面第5章我会专门讲怎么处理。
2.3 调用者:历史栈与执行入口
调用者(Invoker)负责执行命令并管理历史,最典型的就是维护一个撤销栈和一个重做栈。核心逻辑只有三段:
- 执行命令:调用命令的execute(),压入撤销栈,同时清空重做栈;
- 撤销:从撤销栈弹出命令,调用undo(),压入重做栈;
- 重做:从重做栈弹出命令,调用execute(),压入撤销栈。
清空重做栈这一步很关键:一旦用户在撤销之后做了新操作,重做栈就作废了,这是所有编辑器产品的标准行为。如果忘记清空,用户会发现在撤销、执行新操作之后还能重做到之前的状态,整个历史就乱了。
有人可能会把命令模式理解成“调用者直接调execute()就行”,其实不对。调用者的核心价值就是维护这套状态机。把栈操作完全封装在调用者里,外部只暴露执行、撤销、重做三个接口,这是很干净的设计。
2.4 std::function 能替代命令模式吗
聊到C++命令模式,绕不开一个话题:有了std::function和lambda,还需要专门建Command类吗?
我的观点是:简单场景可以,复杂场景不行。用std::function包一个lambda做任务队列非常轻量,如果是“一次性执行后不再需要撤销”的场景,完全没有问题。但它的局限也很明显:
- lambda自身不区分“执行”和“撤销”,你只能存两个std::function配对管理,代码会很快变得散乱;
- lambda没有类型标识,没法做序列化、没法按命令类型筛选统计;
- lambda捕获的上下文往往是引用,生命周期问题一样存在,甚至因为隐式捕获更难发现。
所以我的建议是:批量任务、后台队列这类简单的场景直接用std::function加lambda;涉及撤销重做、宏录制、状态回滚的系统,老老实实用命令类,收益远超那一点样板代码的开销。
3. 文本编辑器撤销/重做完整实战
3.1 需求拆解
实战目标:做一个支持插入、删除的迷你文本缓冲区,配套完整的撤销和重做。
具体需求:
- 在位置0插入"Hello",缓冲区变成"Hello";
- 在位置5插入" World",缓冲区变成"Hello World";
- 删除位置5开始的6个字符,缓冲区回到"Hello";
- 撤销删除操作,缓冲区恢复成"Hello World";
- 撤销插入操作,缓冲区回到"Hello";
- 重做插入操作,缓冲区又变成"Hello World"。
从这个需求能拆出两个命令类:InsertCommand和EraseCommand。InsertCommand执行时在指定位置插入字符串,撤销时删除同样长度;EraseCommand执行时需要先截取被删内容保存成快照,撤销时把快照插回去。
3.2 完整代码实现
下面是完整的C++17实现。接收者、命令接口、具体命令、调用者四层结构明确,可以直接抄进项目里做原型。
#include <algorithm> #include <iostream> #include <memory> #include <stack> #include <string> #include <utility> // ========== 接收者:真正处理文本的类 ========== class TextBuffer { public: void insert(const std::string& s, size_t pos) { if (pos > text_.size()) pos = text_.size(); text_.insert(pos, s); } void erase(size_t pos, size_t len) { if (pos >= text_.size()) return; len = std::min(len, text_.size() - pos); text_.erase(pos, len); } const std::string& content() const { return text_; } private: std::string text_; }; // ========== 命令接口 ========== class Command { public: virtual ~Command() = default; virtual void execute() = 0; virtual void undo() = 0; }; // ========== 具体命令:插入 ========== class InsertCommand : public Command { public: InsertCommand(TextBuffer& buf, std::string s, size_t pos) : buffer_(buf), text_(std::move(s)), pos_(pos) {} void execute() override { buffer_.insert(text_, pos_); } void undo() override { buffer_.erase(pos_, text_.size()); } private: TextBuffer& buffer_; std::string text_; size_t pos_; }; // ========== 具体命令:删除 ========== class EraseCommand : public Command { public: EraseCommand(TextBuffer& buf, size_t pos, size_t len) : buffer_(buf), pos_(pos), len_(len) {} void execute() override { snapshot_ = buffer_.content().substr(pos_, len_); buffer_.erase(pos_, len_); } void undo() override { buffer_.insert(snapshot_, pos_); } private: TextBuffer& buffer_; size_t pos_; size_t len_; std::string snapshot_; // 记录被删除的内容 }; // ========== 调用者:负责执行命令并维护历史 ========== class CommandHistory { public: void execute(std::unique_ptr<Command> cmd) { cmd->execute(); undoStack_.push(std::move(cmd)); // 新命令会清空重做栈 std::stack<std::unique_ptr<Command>> empty; std::swap(redoStack_, empty); } void undo() { if (undoStack_.empty()) { std::cout << "[history] 没有可撤销的操作\n"; return; } auto cmd = std::move(undoStack_.top()); undoStack_.pop(); cmd->undo(); redoStack_.push(std::move(cmd)); } void redo() { if (redoStack_.empty()) { std::cout << "[history] 没有可重做的操作\n"; return; } auto cmd = std::move(redoStack_.top()); redoStack_.pop(); cmd->execute(); undoStack_.push(std::move(cmd)); } private: std::stack<std::unique_ptr<Command>> undoStack_; std::stack<std::unique_ptr<Command>> redoStack_; }; // ========== 调用端示范 ========== int main() { TextBuffer doc; CommandHistory history; history.execute(std::make_unique<InsertCommand>(doc, "Hello", 0)); std::cout << doc.content() << "\n"; // Hello history.execute(std::make_unique<InsertCommand>(doc, " World", 5)); std::cout << doc.content() << "\n"; // Hello World history.execute(std::make_unique<EraseCommand>(doc, 5, 6)); std::cout << "after erase: " << doc.content() << "\n"; // Hello history.undo(); std::cout << "after undo erase: " << doc.content() << "\n"; // Hello World history.undo(); std::cout << "after undo insert: " << doc.content() << "\n"; // Hello history.redo(); std::cout << "after redo: " << doc.content() << "\n"; // Hello World return 0; }3.3 运行效果与行为验证
这段代码的关键验证点有两个。第一个是EraseCommand执行时保存被删内容的快照,这样undo时才能原样恢复;第二个是CommandHistory对两套栈的操作顺序,特别是redo时压入撤销栈而不是重做栈,否则后续的撤销链路就断了。
在代码里加几行打印,运行结果应该是:
Hello Hello World after erase: Hello after undo erase: Hello World after undo insert: Hello after redo: Hello World我用“执行、撤销、再执行”这样的序列去验证命令的对称性,发现大多数对称性问题都能在这个阶段暴露出来。
3.4 接口设计上的几个思考
EraseCommand的构造函数只传位置和长度,被删内容在execute时快照。这样做的好处是:命令对象在构造阶段不依赖接收者的状态,执行和撤销都是自洽的。缺点是快照是运行时才产生的,如果想在执行前就预览命令效果,做不到。通常我会选择这种懒快照,因为接收者状态在命令真正执行前有变化的可能,提前快照反而容易拿到旧数据。
所有命令对象都用std::unique_ptr管理,所有权唯一。抛出异常时栈会自动清理,不需要在调用者里写大量手动释放代码。如果同一份命令需要被多个调用者共享,再考虑shared_ptr,但共享命令会让生命周期管理复杂不少,不建议默认使用。
execute和undo方法没有返回值,因为调用者不关心细节。如果你需要让调用者知道命令是否成功,可以返回bool,但那样调用者就要做分支处理,接口复杂度会上一个台阶。我建议默认void,需要反馈时用异常或回调。
4. 工程化扩展三板斧
4.1 宏命令:把多个操作打包成一个
宏命令(MacroCommand)本质是组合模式:一个命令内部包含一组命令,执行时依次执行,撤销时逆序撤销。
class MacroCommand : public Command { public: void add(std::unique_ptr<Command> cmd) { children_.push_back(std::move(cmd)); } void execute() override { for (auto& child : children_) { child->execute(); } } void undo() override { for (auto it = children_.rbegin(); it != children_.rend(); ++it) { (*it)->undo(); } } private: std::vector<std::unique_ptr<Command>> children_; };这里有两个细节值得注意。第一个:撤销必须逆序。比如宏操作是“插入A,再插入B”,撤销时就要先撤B再撤A,顺序倒了状态就乱了。第二个:子命令的执行和撤销必须对称。宏命令本身不关心子命令内部逻辑,它只保证调用顺序正确。
宏命令最常见的场景就是录制用户操作:用户一边操作,一边把每个操作包装成命令丢进一个MacroCommand,结束时就把整次交互变成一个可以一键重放、一键撤销的“大命令”。很多游戏里的回放系统、编辑器里的宏录制都是这个套路。
4.2 命令队列:异步、批量、线程安全
命令对象是数据,数据天然可以进队列。当系统需要异步处理操作时,把命令塞进一个线程安全的队列,由工作线程依次执行,就能做到:主线程只负责提交命令不阻塞,工作线程按顺序执行命令,还天然支持FIFO批量处理。想支持优先级时,把std::deque换成std::priority_queue即可。
一个可用的简单队列实现大致长这样:
#include <condition_variable> #include <deque> #include <mutex> #include <thread> class TaskQueue { public: void enqueue(std::unique_ptr<Command> cmd) { { std::lock_guard<std::mutex> lock(mtx_); tasks_.push_back(std::move(cmd)); } cv_.notify_one(); } void stop() { { std::lock_guard<std::mutex> lock(mtx_); stop_ = true; } cv_.notify_all(); } void runWorker() { while (true) { std::unique_ptr<Command> cmd; { std::unique_lock<std::mutex> lock(mtx_); cv_.wait(lock, [this] { return !tasks_.empty() || stop_; }); if (stop_ && tasks_.empty()) break; cmd = std::move(tasks_.front()); tasks_.pop_front(); } cmd->execute(); } } private: std::mutex mtx_; std::condition_variable cv_; std::deque<std::unique_ptr<Command>> tasks_; bool stop_ = false; };用命令模式做任务队列有个隐形收益:队列里每个任务都知道自己是干什么的,可以对任务做统计、限流、甚至为某类命令定制重试策略。如果用裸lambda,这些能力都要靠额外包装才能实现。
注意一点:命令里如果引用了共享资源,工作线程执行时就要加锁,或者确保资源是线程独占的。命令模式的“封装”不等于“线程安全”,它只把请求封装成了对象,执行的并发安全还是要你来管。
4.3 命令序列化:日志与离线重放
因为命令对象携带了执行所需的全部参数(比如插入位置、插入内容),它天然可以序列化。我可以把每次命令序列化成JSON或二进制,写进日志文件,之后在另一台机器上重放这些命令,就能还原整个操作过程。
序列化在C++里一般需要给命令类增加有类型信息的接口,比如:
virtual std::string serialize() const = 0;我给每种命令定义一个唯一的类型ID,序列化时先写ID再写参数;反序列化时先读ID,再用工厂函数创建对应的命令对象。这套机制配合命令模式,就是一套简化版的“操作日志”系统。我在实际项目里用这个思路做过一个操作重放器,用几百行代码就实现了把用户操作序列化成文本、再在测试环境离线回放的功能,排查现场问题非常有用。
数据库的redo log、分布式系统的操作日志,底层思路其实都长这样。命令模式之所以适合做这个,是因为它天然把“操作意图”和“操作参数”绑在了一起,而函数调用做不到这一点。
5. 实战中踩过的坑与排查清单
5.1 生命周期:悬空引用是第一杀手
命令对象内部通常保存接收者的引用或指针。如果接收者被销毁后命令还留在历史栈里,一旦执行撤销就会解引用悬空指针,轻则崩溃,重则内存被静默破坏,非常难排查。
我的经验是三条原则:
- 用shared_ptr管理接收者,命令里保存weak_ptr,执行时先lock()再操作。如果lock失败,说明接收者已经销毁,命令直接跳过或丢弃。
- 如果接收者的生命周期明确比历史栈长,比如接收者是程序生命周期内的对象,那用裸引用也问题不大,但要在注释里写明这个前提。
- 历史栈本身也要有清理机制。编辑器关闭文档时,应该把该文档相关的命令从历史栈清掉,否则文档对象销毁了命令还悬着。
我见过一个真实事故:某个工具软件里用户在编辑文档时关闭了文档,但历史栈没清空,点“撤销”时直接崩溃。排查了很久才发现是悬空引用。这个坑一定要提前设计掉。
5.2 撤销的对称性:状态对不上的问题
命令的执行和撤销必须严格对称。最常见的错误是:execute做了A和B两步,undo只撤销了A;或者execute里保存的现场数据在undo时被错误使用,导致第二次撤销行为异常。
以EraseCommand为例,执行时保存被删内容快照,撤销时插回内容。如果你把一个命令对象手动执行了两次再撤销两次,就会出问题——第二次执行时快照已经是删过一遍之后的内容,快照内容和第一次不同,撤销结果自然不同。
这里给一个很实用的设计技巧:不要指望命令对象的execute是幂等的,但至少保证“执行、撤销、再执行”这个循环是等价的。写单元测试时,用“执行、撤销、重做、再撤销”这样的序列去验证接收者状态是否回到预期值,能发现绝大多数对称性问题。
5.3 性能与过度设计
命令模式不是免费的。每个命令都是一个堆分配对象,历史栈要持有所有执行过的命令,这意味着内存占用会随操作次数线性增长。对长时间运行的编辑器来说,几百上千个命令没什么压力,但百万级别的操作就要考虑了。
缓解手段有几个:
- 合并连续的同类型命令。比如连续插入字符,可以合并成一个InsertCommand,撤销时一次全撤;
- 给历史栈设置上限。超出上限时把最老的一批命令丢弃,并接受“无法撤销到更早状态”的后果;
- 对大数据量的操作,命令里不要存整个数据集,只存变更的增量。比如图片编辑器里,撤销一次滤镜操作存储的是滤镜参数而不是整张图片。
反过来也要警惕过度设计。如果系统里根本没有撤销、队列、日志这些需求,只有一处“按钮点了要调用一下某个函数”,那直接用lambda就够了。设计模式的价值在于解决真实问题,不是为了给代码增加仪式感。判断标准很简单:如果你说不出命令模式给你带来了哪三个实在的好处,就不要在本该用函数调用的地方硬套。
5.4 问题速查表
下面是我整理的一个排查清单,基本覆盖了实战里最容易翻车的场景:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 撤销后内容错乱 | 命令状态与接收者实际状态不一致 | 检查执行时快照位置,确认撤销顺序是否对称 |
| 撤销/重做时崩溃 | 命令引用了已销毁对象 | 检查接收者生命周期和历史栈清理逻辑,改用weak_ptr |
| 重做行为怪异 | 新命令执行后未清空重做栈 | 在执行新命令的入口清空重做栈 |
| 多个命令乱序执行 | 队列线程安全问题 | 检查入队/出队是否加锁,用condition_variable协调 |
| 内存持续增长 | 历史栈无限增长 | 增加上限、合并同类命令、只存增量数据 |
| 序列化重放结果不一致 | 缺少命令类型ID或参数不完整 | 先写ID再写参数,反序列化用工厂统一创建 |
这个表不是大全,但覆盖了我自己从文本编辑器、任务调度到录制回放系统几个项目里踩过的大部分坑。
6. 一点经验之谈
说实话,命令模式是我在实际项目里用得最频繁的设计模式之一,因为它解决的不是某个算法的效率问题,而是系统的可扩展性问题。我最早对它没好感,觉得多写一大堆类很麻烦。直到接手一个带撤销功能的编辑器,第一版用裸函数硬扛,改到第三轮就彻底放弃了,老老实实重构为命令模式。之后新增“自动换行”“字体设置”这类操作都是几分钟的事——每种新操作只要写一个新的命令类,调用者、历史栈、界面层一行都不用改。
最后分享一个小技巧:调试命令模式相关代码时,给每个命令类加一个调试描述字段,比如"insert Hello at 0",然后在执行和撤销时打印出来。历史栈的进出顺序一眼就能看清,定位撤销错乱的问题会比对着堆栈猜快很多。这个习惯帮我节省过大量时间,希望对你有用。