资讯动态

C++命令模式实战:从原理到撤销重做系统设计

发布时间:2026/10/9 14:51:48 来源:尧图企业网站定制
写命令模式的文章前我先讲个真实场景。早些年我在做一个编辑器插件菜单栏里挂着一堆功能按钮插入文字、删除、格式化、批量替换。最开始我偷懒按钮直接调用各自模块的函数比如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 lambdaC11之后我大部分新项目已经不用虚函数写命令了直接用std::function和lambda表达式代码会极度精简。class Command { public: std::functionvoid() execute; std::functionvoid() 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_ptrTextBuffer然后lambda值捕获一份。后面踩坑部分我会详细讲生命周期问题。std::function版本最大的优点是写起来快命令逻辑就地实现不需要为每个操作单独建类。缺点是lambda捕获的变量生命周期管理需要你自己负责另外调试时lambda的调用堆栈信息不如具名类直观出错时没那么好定位。2.3 函数指针与参数包轻量版如果你只是需要一个简单的菜单映射不涉及撤销也可以用函数指针加std::bind或者std::bind_frontC20std::mapstd::string, std::functionvoid() 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 系统需求定义这块我们做个贴近现实的实践一个命令行文本缓冲编辑器支持插入、删除并且可以用CtrlZ撤销、CtrlY重做。界面交互我们忽略聚焦核心命令体系。先定义接收者一个极简的文本缓冲区#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_ptrEditorCommand 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::vectorstd::unique_ptrEditorCommand undoStack_; std::vectorstd::unique_ptrEditorCommand redoStack_; };std::unique_ptr在这里很重要它明确表达了所有权语义。History独占命令对象栈弹出的过程就是所有权转移不会出现多位置同时持有同一个命令导致重复执行的问题。调用方式像这样int main() { TextBuffer buffer; History history; // 模拟输入 hello history.execute(std::make_uniqueInsertCommand(buffer, 0, hello)); std::cout buffer.content() \n; // hello // 模拟删除最后两个字符 lo history.execute(std::make_uniqueDeleteCommand(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::pairstd::functionvoid(), std::functionvoid(); 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::vectorEditorCommand undoStack_; std::vectorEditorCommand 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_ptrEditorCommand 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::vectorstd::unique_ptrEditorCommand commands_; };这里要特别强调撤销顺序。执行的顺序是正序撤销的顺序必须是严格逆序。比如先插入文字再移动光标撤销时必须先恢复光标位置再删除文字顺序反了轻则状态错乱重则数据丢失。组合模式经常和命令模式嵌套使用。宏命令内部还可以包含宏命令形成树状结构。这种递归组合是命令模式最有威力的扩展方式。4.2 事务式命令要么全成功要么全回滚在数据库操作、文件批量处理等场景命令执行到一半可能失败。如果只执行了一半你要把所有已完成命令全部回滚回到初始状态。实现方式也不复杂让每条命令提供bool canExecute()来提前校验然后在MacroCommand::execute()里逐条执行一旦失败就回滚已执行的命令。class TransactionalMacro : public EditorCommand { public: void addOnce(std::unique_ptrEditorCommand 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::vectorstd::unique_ptrEditorCommand commands_; std::vectorEditorCommand* 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::functionvoid() task; { std::unique_lockstd::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::functionvoid() task) { { std::lock_guardstd::mutex lock(mutex_); tasks_.push_back(std::move(task)); } cv_.notify_one(); } ~TaskQueue() { { std::lock_guardstd::mutex lock(mutex_); stop_ true; } cv_.notify_all(); for (auto worker : workers_) worker.join(); } private: std::dequestd::functionvoid() tasks_; std::vectorstd::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_ptrTextBuffer命令捕获指针而不是引用class InsertCommand : public EditorCommand { public: InsertCommand(std::shared_ptrTextBuffer 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_ptrTextBuffer buf_; size_t pos_; std::string text_; };5.2 lambda捕获陷阱这坑太经典了。在函数里写[]捕获局部变量然后命令对象被存储到其他地方函数退出后局部变量已经销毁// 错误示范 History h; auto badCommand [] { std::string localText temporary; buffer.insert(0, localText); // 这里的buffer如果是局部引用危险 };正确做法要么用值捕获并std::movestd::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的三种实现、完整编辑器案例、进阶扩展和坑位排查。最后再分享一个个人体会真正用好命令模式关键不在于背出四个角色的定义而在于你在写第一个需求时就想清楚“这个操作需不需要被记住、被回放、被组合”。把这个问题想透命令模式用起来就会非常顺手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑