写命令模式的文章前,我先讲个真实场景。早些年我在做一个编辑器插件,菜单栏里挂着一堆功能按钮:插入文字、删除、格式化、批量替换。最开始我偷懒,按钮直接调用各自模块的函数,比如buffer.insertText(...)、formatter.run(),结果不到两周就崩了——产品要求加撤销快捷键,我得给每个操作记录相反的动作,可“插入”的反操作是“删除”,“格式化”的反操作是“恢复原样”,这些逻辑散落在不同模块里,我压根没法统一管理。后来才意识到,问题就出在“把操作直接绑死在具体函数上”。
命令模式解决的就是这件事:把“操作”本身变成对象。这样你能把它塞进队列、存进历史栈、随时执行或者回滚,甚至还能序列化后传输。本文从原理讲到C++实战,最后附带我踩过的一些坑,代码可以直接抄进自己项目里。
1. 命令模式到底在解决什么问题
1.1 没有命令模式时的痛点
先看一段典型的反面代码。假设你的程序里有保存文档和关闭窗口两个动作,按钮逻辑大概长这样:
void onSaveButtonClick() { Document::save(); } void onCloseButtonClick() { WindowManager::closeCurrent(); }单看这段没问题,需求一变就有问题。比如要新增“快捷键触发保存”,那还得再写onSaveKeyPress(),里面还是调用Document::save()。要新增“批量操作”,比如执行五个动作后统一撤销,那五个动作的调用散落各处,你没法把它们打包成一个整体。要加操作历史、宏录制、异步任务队列,全都无从下手。
究其根本,调用者(Invoker)和具体服务(Receiver)之间耦合太紧,操作本身没有任何“结构性身份”。代码里只能看到一个个被调用的函数,看不到“这个操作可以被存储、回放、回滚”的抽象。
1.2 命令模式的核心角色拆解
命令模式的标准结构里有四个角色,我用餐厅点菜的类比帮你建立直觉:
- 命令(Command):菜单上的一道菜,它知道制作流程,但自己不动手,洗碗、炒菜都是后厨(Receiver)的事。
- 接收者(Receiver):后厨厨师,负责真正干活的组件,比如文档、缓冲区、渲染器。
- 调用者(Invoker):服务员,只负责把“点菜单”递给后厨,具体怎么炒他不管,也不关心谁来做。
- 客户端(Client):顾客,负责创建命令、组装菜单,把命令交给服务员。
在C++代码里,命令通常是一个类,里面至少有一个execute()方法。调用者持有的是命令对象的接口,而不是具体函数。于是“保存”和“另存为”可以都用同一个调用入口,只是内部执行的细节不同。
class Command { public: virtual ~Command() = default; virtual void execute() = 0; };这样一来,按钮、快捷键、宏录制器、日志系统统统只认识Command这个接口,它们不需要改代码,就能驱动任意新增命令。这是命令模式最核心的收益:调用逻辑与业务逻辑解耦,且耦合边界被推到了“命令对象”这一层。
1.3 适合用命令模式的四个信号
什么时候值得引入命令模式?我一般只看这四个信号:
- 需要撤销/重做。命令对象天然能携带逆操作,历史栈一压,撤销就做完了。
- 需要操作队列或延迟执行。比如用户点的按钮不一定要立即执行,可以放进队列排队,命令对象在队列里是标准元素。
- 需要宏命令或批量事务。多个命令可以组合成一个复合命令,整体执行、整体回滚。
- 需要操作日志或远程调用。把命令序列化成字节流,发到另一个进程执行,这不就是RPC的基本形态。
如果你只是简单调用一个函数,不需要以上任何场景,那直接调用就行,别上模式。后面第六节我会专门讲过度设计的坑。
2. C++里命令模式的三种实现方式
2.1 经典接口实现(虚函数版)
这是GoF书里的经典写法,定义抽象基类,每个命令写一个子类。我最初也是用它,代码结构化最强,适合团队协作和代码审查。
class TextBuffer; // Receiver 前置声明 class Command { public: virtual ~Command() = default; virtual void execute() = 0; virtual void undo() = 0; }; class InsertCommand : public Command { public: InsertCommand(TextBuffer& buf, size_t pos, const std::string& text) : buffer_(buf), pos_(pos), text_(text) {} void execute() override { buffer_.insert(pos_, text_); } void undo() override { buffer_.erase(pos_, text_.size()); } private: TextBuffer& buffer_; size_t pos_; std::string text_; }; class DeleteCommand : public Command { public: DeleteCommand(TextBuffer& buf, size_t pos, size_t len) : buffer_(buf), pos_(pos), len_(len) {} void execute() override { deleted_ = buffer_.substr(pos_, len_); buffer_.erase(pos_, len_); } void undo() override { buffer_.insert(pos_, deleted_); } private: TextBuffer& buffer_; size_t pos_; size_t len_; std::string deleted_; // 执行时保存被删除的内容,便于撤销 };虚函数版的特点:每个命令都是一个具体类,可以在构造函数里做参数校验,可以加私有成员保存状态(比如deleted_),还能在类内部实现单元测试。缺点是类数量膨胀——每加一种操作就是新类,有时一个命令类代码量不过十几行,文件倒是一大堆。
2.2 现代C++实现(std::function + lambda)
C++11之后,我大部分新项目已经不用虚函数写命令了,直接用std::function和lambda表达式,代码会极度精简。
class Command { public: std::function<void()> execute; std::function<void()> undo; }; Command makeInsertCommand(TextBuffer& buf, size_t pos, std::string text) { return { [&] { buf.insert(pos, text); }, [&] { buf.erase(pos, text.size()); } }; }这里有一个细节得说明:lambda里我用了引用捕获[&],这要求buf的生命周期必须长于命令对象。如果你不确定,那就改成shared_ptr捕获,比如让TextBuffer变成std::shared_ptr<TextBuffer>,然后lambda值捕获一份。后面踩坑部分我会详细讲生命周期问题。
std::function版本最大的优点是写起来快,命令逻辑就地实现,不需要为每个操作单独建类。缺点是lambda捕获的变量生命周期管理需要你自己负责;另外调试时lambda的调用堆栈信息不如具名类直观,出错时没那么好定位。
2.3 函数指针与参数包(轻量版)
如果你只是需要一个简单的菜单映射,不涉及撤销,也可以用函数指针加std::bind(或者std::bind_front,C++20):
std::map<std::string, std::function<void()>> menuItems; menuItems["save"] = std::bind(&DocumentSaver::save, &saver); menuItems["close"] = std::bind(&WindowManager::close, &wm);这其实是命令模式的退化形态,把函数入口当作命令,适合极端简单场景。它没有命令对象状态,没办法记录deleted_这种中间数据,所以撤销基本做不了。
2.4 三种实现方式对比
| 实现方式 | 类数量 | 可撤销支持 | 灵活性 | 调试友好度 | 适用场景 |
|---|---|---|---|---|---|
| 虚函数接口版 | 多 | 良好 | 中等 | 高 | 团队大型项目、命令复杂度高、需要子类扩展 |
| std::function版 | 少 | 良好 | 高 | 中 | 个人项目、中小型系统、快速开发 |
| 函数指针/绑定版 | 无额外类 | 基本不支持 | 低 | 中 | 菜单绑定、轻量路由、一次性回调 |
我的平衡标准是:命令逻辑超过20行、需要复用或派生时用虚函数版;命令只是简单转发时用std::function版。两种风格混用也没问题,只要统一接口即可。
3. 实战:做一个支持撤销/重做的编辑器命令系统
3.1 系统需求定义
这块我们做个贴近现实的实践:一个命令行文本缓冲编辑器,支持插入、删除,并且可以用Ctrl+Z撤销、Ctrl+Y重做。界面交互我们忽略,聚焦核心命令体系。
先定义接收者,一个极简的文本缓冲区:
#include <iostream> #include <string> #include <vector> #include <memory> #include <functional> #include <algorithm> class TextBuffer { public: void insert(size_t pos, const std::string& text) { if (pos > content_.size()) pos = content_.size(); content_.insert(pos, text); cursor_ = pos + text.size(); } void erase(size_t pos, size_t len) { if (pos >= content_.size()) return; len = std::min(len, content_.size() - pos); content_.erase(pos, len); cursor_ = pos; } std::string substr(size_t pos, size_t len) const { if (pos >= content_.size()) return {}; return content_.substr(pos, std::min(len, content_.size() - pos)); } const std::string& content() const { return content_; } size_t cursor() const { return cursor_; } void setCursor(size_t pos) { cursor_ = std::min(pos, content_.size()); } private: std::string content_; size_t cursor_ = 0; };这个缓冲区只做两件事:插入字符串、删除一段。注意erase()里取std::min(len, content_.size() - pos)的细节,防止越界乱删数据。
3.2 命令对象定义
用虚函数接口方式写命令,因为编辑器命令往往数量多、逻辑杂,具名类更适合扩展。
class EditorCommand { public: virtual ~EditorCommand() = default; virtual void execute() = 0; virtual void undo() = 0; }; class InsertCommand : public EditorCommand { public: InsertCommand(TextBuffer& buf, size_t pos, std::string text) : buf_(buf), pos_(pos), text_(std::move(text)) {} void execute() override { buf_.insert(pos_, text_); } void undo() override { buf_.erase(pos_, text_.size()); } private: TextBuffer& buf_; size_t pos_; std::string text_; }; class DeleteCommand : public EditorCommand { public: DeleteCommand(TextBuffer& buf, size_t pos, size_t len) : buf_(buf), pos_(pos), len_(len) {} void execute() override { // 执行时记录被删除的内容,这是撤销的关键 deleted_ = buf_.substr(pos_, len_); buf_.erase(pos_, len_); } void undo() override { buf_.insert(pos_, deleted_); } private: TextBuffer& buf_; size_t pos_; size_t len_; std::string deleted_; };这里有个很重要的点:DeleteCommand在执行时才保存deleted_,而不是在构造函数里。因为命令构造时你只知道要删哪一段,万一删除范围已经变化,执行时实际情况可能不同。每次执行前先取一下实际内容,这样撤销才是准确的。
3.3 历史管理器(Invoker)
撤销/重做的核心是两套栈:撤销栈和重做栈。每次执行新命令,把它压入撤销栈,同时清空重做栈——因为旧的“重做”路径在状态改变后就失效了。
class History { public: void execute(std::unique_ptr<EditorCommand> cmd) { cmd->execute(); undoStack_.push_back(std::move(cmd)); // 新命令之后的重做记录全部失效 redoStack_.clear(); } void undo() { if (undoStack_.empty()) return; auto cmd = std::move(undoStack_.back()); undoStack_.pop_back(); cmd->undo(); redoStack_.push_back(std::move(cmd)); } void redo() { if (redoStack_.empty()) return; auto cmd = std::move(redoStack_.back()); redoStack_.pop_back(); cmd->execute(); undoStack_.push_back(std::move(cmd)); } private: std::vector<std::unique_ptr<EditorCommand>> undoStack_; std::vector<std::unique_ptr<EditorCommand>> redoStack_; };std::unique_ptr在这里很重要,它明确表达了所有权语义。History独占命令对象,栈弹出的过程就是所有权转移,不会出现多位置同时持有同一个命令导致重复执行的问题。
调用方式像这样:
int main() { TextBuffer buffer; History history; // 模拟输入 "hello" history.execute(std::make_unique<InsertCommand>(buffer, 0, "hello")); std::cout << buffer.content() << "\n"; // hello // 模拟删除最后两个字符 "lo" history.execute(std::make_unique<DeleteCommand>(buffer, 3, 2)); std::cout << buffer.content() << "\n"; // hel history.undo(); // 撤销删除,变回 "hello" history.undo(); // 撤销插入,变回 "" history.redo(); // 重做插入,变回 "hello" }3.4 优化:合并连续命令
真实编辑器里,你每次按键都会产生一个插入命令。比如输入“hello”五个字母,撤销栈里会有5条命令,用户按一次撤销只退一个字符,体验很糟糕。所以实战里要做命令合并。
合并策略通常有两种:时间合并和内容合并。时间合并是如果两次命令间隔小于某个阈值(比如500毫秒),就认为是同一次输入。内容合并是如果插入的位置连续、方向一致,就把文本拼到同一条命令里。
我用内容合并简单实现一下:在InsertCommand里加一个merge方法,如果新插入的位置等于当前命令末尾,并且类型相同,就把文本追加进来。
class InsertCommand : public EditorCommand { public: // ... 上述代码 ... bool canMerge(const InsertCommand& other) const { // 位置连续且操作方向相同 return (other.pos_ == pos_ + text_.size()); } void merge(const InsertCommand& other) { text_ += other.text_; } };在History::execute()里,先尝试和栈顶命令合并,合并失败才创建新命令。这只是单条命令内部的合并,更复杂的全局合并逻辑需要额外设计,我在后面常见问题里会提一句。
3.5 std::function版本的实现对照
用现代C++重写整个系统会短很多,尤其适合原型开发阶段。核心逻辑一样,只是命令不再是具名类。
using EditorCommand = std::pair<std::function<void()>, std::function<void()>>; class History2 { public: void execute(EditorCommand cmd) { cmd.first(); undoStack_.push_back(std::move(cmd)); redoStack_.clear(); } void undo() { if (undoStack_.empty()) return; auto cmd = std::move(undoStack_.back()); undoStack_.pop_back(); cmd.second(); // undo redoStack_.push_back(std::move(cmd)); } void redo() { if (redoStack_.empty()) return; auto cmd = std::move(redoStack_.back()); redoStack_.pop_back(); cmd.first(); // re-execute undoStack_.push_back(std::move(cmd)); } private: std::vector<EditorCommand> undoStack_; std::vector<EditorCommand> redoStack_; };调用侧:
History2 h2; TextBuffer buf2; h2.execute({ [&] { buf2.insert(0, "hi"); }, [&] { buf2.erase(0, 2); } });简洁是简洁,但注意一个问题:std::function的可读性完全取决于lambda写得规不规范。如果你的lambda把业务逻辑全塞进去,整个代码会变成一个巨大的匿名函数块,别人根本无从维护。我的建议是:lambda里只放一行调用,把真实逻辑留在具名函数或类里,让命令对象成为薄薄的转发层。
4. 进阶:让命令模式做更多事
4.1 宏命令与组合模式
宏命令的核心是把多个命令打包成一个复合命令。录制宏的时候,系统不断把新命令追加到一个列表里;播放宏时,一次性执行列表里的所有命令。宏命令本身也是一个命令,所以它能被撤销——撤销宏只需要逆序撤销所有子命令。
class MacroCommand : public EditorCommand { public: void add(std::unique_ptr<EditorCommand> 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(); } } private: std::vector<std::unique_ptr<EditorCommand>> commands_; };这里要特别强调撤销顺序。执行的顺序是正序,撤销的顺序必须是严格逆序。比如先插入文字再移动光标,撤销时必须先恢复光标位置,再删除文字,顺序反了轻则状态错乱,重则数据丢失。
组合模式经常和命令模式嵌套使用。宏命令内部还可以包含宏命令,形成树状结构。这种递归组合是命令模式最有威力的扩展方式。
4.2 事务式命令:要么全成功,要么全回滚
在数据库操作、文件批量处理等场景,命令执行到一半可能失败。如果只执行了一半,你要把所有已完成命令全部回滚,回到初始状态。
实现方式也不复杂,让每条命令提供bool canExecute()来提前校验,然后在MacroCommand::execute()里逐条执行,一旦失败就回滚已执行的命令。
class TransactionalMacro : public EditorCommand { public: void addOnce(std::unique_ptr<EditorCommand> cmd) { commands_.push_back(std::move(cmd)); } void execute() override { executed_.clear(); for (auto& cmd : commands_) { if (!canExecute(*cmd)) { rollback(); throw std::runtime_error("transaction failed"); } cmd->execute(); executed_.push_back(cmd.get()); } } void undo() override { // 同样逆序回滚 for (auto it = executed_.rbegin(); it != executed_.rend(); ++it) { (*it)->undo(); } } private: bool canExecute(const EditorCommand& cmd) { // 这里可以做前置条件检查 return true; } void rollback() { for (auto it = executed_.rbegin(); it != executed_.rend(); ++it) { (*it)->undo(); } executed_.clear(); } std::vector<std::unique_ptr<EditorCommand>> commands_; std::vector<EditorCommand*> executed_; };注意这个实现中的异常安全:throw前先rollback,保证发生异常时内存和业务状态都是干净的。
4.3 延迟执行与后台线程
命令模式也是异步任务队列的天然基础。你可以把命令对象丢进一个线程池,让它排队执行。这种场景下,命令最好只捕获值类型或者shared_ptr,千万别捕获this指针,否则线程退出时对象可能早已析构。
class TaskQueue { public: explicit TaskQueue(size_t threads) { for (size_t i = 0; i < threads; ++i) { workers_.emplace_back([this] { while (true) { std::function<void()> task; { std::unique_lock<std::mutex> lock(mutex_); cv_.wait(lock, [this] { return !tasks_.empty() || stop_; }); if (stop_ && tasks_.empty()) return; task = std::move(tasks_.front()); tasks_.pop_front(); } task(); } }); } } void post(std::function<void()> task) { { std::lock_guard<std::mutex> lock(mutex_); tasks_.push_back(std::move(task)); } cv_.notify_one(); } ~TaskQueue() { { std::lock_guard<std::mutex> lock(mutex_); stop_ = true; } cv_.notify_all(); for (auto& worker : workers_) worker.join(); } private: std::deque<std::function<void()>> tasks_; std::vector<std::thread> workers_; std::mutex mutex_; std::condition_variable cv_; bool stop_ = false; };命令模式在这里的体现:每个待执行的任务本质就是一个std::function,它内部可以是命令的execute。队列只是容器,任务才是核心。
4.4 命令序列化与日志回放
这是命令模式容易被忽视的一个大用途。由于命令对象携带了参数信息(比如插入位置、文本内容),你可以把它序列化成JSON或二进制流,写入日志。系统重启后,读取日志,按顺序重放命令,就能恢复之前的状态。
// 伪代码级示意 class SerializableCommand : public EditorCommand { public: virtual std::string serialize() const = 0; }; class InsertCommand : public SerializableCommand { public: std::string serialize() const override { return "{\"type\":\"insert\",\"pos\":" + std::to_string(pos_) + ",\"text\":\"" + text_ + "\"}"; } }; // 回放逻辑 for (auto& line : logFile) { auto cmd = deserializeCommand(line); // 解析JSON重建命令 cmd->execute(); }日志回放方案常用于金融交易、多人协同编辑、数据库同步场景。它把“状态”替换成“一系列操作”,换来简单可靠的历史记录。代价是需要保证每条命令可重放且幂等,或者重放时按固定序号管理,避免重复执行。
5. 常见问题与排查技巧实录
5.1 生命周期坑:引用悬空
命令对象里存了TextBuffer&引用,如果TextBuffer在命令执行前被销毁,调用时就是悬空引用,轻则崩溃,重则静默数据错乱。
我见过最典型的错误是:在函数内部创建局部TextBuffer,把命令存在全局History里,函数返回后History还在,但引用已经失效。
排查经验:先看崩溃堆栈是否出现在命令的执行或撤销调用链上;再检查接收者的生命周期是否覆盖了整个History对象。稳妥做法是用std::shared_ptr<TextBuffer>,命令捕获指针而不是引用:
class InsertCommand : public EditorCommand { public: InsertCommand(std::shared_ptr<TextBuffer> buf, size_t pos, std::string text) : buf_(std::move(buf)), pos_(pos), text_(std::move(text)) {} void execute() override { buf_->insert(pos_, text_); } private: std::shared_ptr<TextBuffer> buf_; size_t pos_; std::string text_; };5.2 lambda捕获陷阱
这坑太经典了。在函数里写[&]捕获局部变量,然后命令对象被存储到其他地方,函数退出后局部变量已经销毁:
// 错误示范 History h; auto badCommand = [&] { std::string localText = "temporary"; buffer.insert(0, localText); // 这里的buffer如果是局部引用,危险 };正确做法要么用值捕获并std::move:
std::string text = "hello"; auto goodCommand = [text = std::move(text)]() mutable { buffer.insert(0, text); // text是lambda内部持有的拷贝 };要么在lambda里再捕获一个shared_ptr副本。反正核心原则是:不要在lambda里依赖外部栈对象的引用,除非你能用代码证明它的生命周期足够长。
5.3 undo状态不对称
撤销命令必须和正向执行完全对称。我写DeleteCommand时曾经只记录了位置和长度,忘记保存被删除的实际内容。如果删除时内容和构造函数预想的不一致,撤销时就会把错误的数据插回去,越撤越乱。
对称性检查有个土办法:对同一个命令连续执行undo、redo时,缓冲区内容应该完全回到初始状态。我每次写完一个新命令都会做这个循环验证——execute后比较内容,undo后再比较内容,redo再比较,任何一步不一致就说明状态模型有漏洞。
5.4 深拷贝性能陷阱
命令模式实现撤销有两种路线:操作日志式(保存逆操作)和全量快照式(保存对象完整状态)。操作日志式省内存但需要精确的逆操作实现;快照式简单粗暴但每次撤销都要恢复整份数据,代价大。
比如一个3MB的文本缓冲区,每次按键都做一次全量快照,几分钟就爆内存。我的做法是:大对象用操作日志式,小且需要严格一致性的对象用快照式。如果实在要走快照,至少做增量快照或者压缩存储。
5.5 多线程下的命令并发
命令对象在多线程里共享是有风险的。如果同一个History被两个线程同时执行命令,栈操作就需要加锁。更稳妥的设计是每个线程单独维护一份History,各干各的,互不干扰。
如果是线程池消费任务队列,尽量保证命令对象的幂等性。同一个命令被提交两次,输出应该完全一致,这样才能在失败重试时不出乱子。
5.6 撤销栈无限增长问题
长会话程序里,命令对象一个个堆积,内存会涨到不可接受。实用方案是给撤销栈设上限,比如最多保留100条命令。超出后丢弃最老的命令,但代价是那条命令对应的状态无法再撤销。
我见过比较优雅的变体做法:把老命令序列化到磁盘暂存,撤销到磁盘领域时再从文件加载,这样既控制内存,又不丢历史。代价是磁盘IO延迟,一般只对大型文档编辑类应用有意义。
6. 设计建议:什么时候果断放弃命令模式
6.1 不需要历史管理时直接用函数
命令模式不是银弹。如果你的操作是一次性的,用完即忘,不需要撤销、不需要异步、不需要队列,那直接调用函数远比创建一个命令类更加直接简单。冗长的抽象层会拉低开发效率,增加维护成本。
// 这种情况直接用函数,别造命令 void renameFile(const std::string& oldName, const std::string& newName) { std::filesystem::rename(oldName, newName); }6.2 命令粒度如何把握
粒度太小,一条命令只做半个操作,撤销栈膨胀,合并逻辑复杂;粒度太大,一条命令做了十几件事,撤销时责任过重,出错概率上升。
我的一般原则:用“业务动作”而不是“底层操作”作为命令粒度。比如“批量替换所有关键词”是一个命令,而不是把每一次替换都当成一条命令。粒度选择的核心标准是用户感知——用户点一次撤销,期望撤销掉的是一个他看得懂的完整动作。
6.3 从实战中总结的判断矩阵
| 业务需求 | 是否建议用命令模式 | 理由 |
|---|---|---|
| 简单的按钮点击后执行一个动作 | 否 | 直接调用函数即可,抽象成本大于收益 |
| 需要撤销/重做 | 是 | 命令对象的逆操作是撤销系统的天然载体 |
| 需要操作队列/异步任务 | 是 | 命令对象是队列的标准元素,天然可选 |
| 需要宏录制/组合操作 | 是 | 宏命令就是命令的递归组合 |
| 需要远程执行/日志回放 | 是 | 命令可序列化为协议消息 |
| 代码规模很小,团队新手居多 | 谨慎 | 先保证大家理解,再上模式 |
我个人在项目里的经验是:命令模式带来的不只是代码结构,更多是一种思维转变。写命令类的时候,我强制自己思考每个操作的“逆操作”和“副作用”,这能帮我提前发现很多业务逻辑漏洞。如果团队成员能接受这种思维方式,模式才能发挥真正价值。
这篇内容从命令模式的原理讲到了C++的三种实现、完整编辑器案例、进阶扩展和坑位排查。最后再分享一个个人体会:真正用好命令模式,关键不在于背出四个角色的定义,而在于你在写第一个需求时就想清楚“这个操作需不需要被记住、被回放、被组合”。把这个问题想透,命令模式用起来就会非常顺手。