C++命令模式实战:从撤销重做到多线程任务队列
有人问我C到底还要不要学设计模式我的回答一直是设计模式不是让你背八股而是让你在遇到重复踩坑的场景时能直接拿出一套成熟方案。命令模式就是最典型的例子。但凡你写过带菜单的编辑器、做过多线程任务队列、或者给游戏写过按键映射你迟早会撞上它。它解决的最核心问题是把“调用”本身从“调用者”的代码里拆出去让请求可以被存储、传递、排队、撤销。这篇文章我就用C的实际代码把命令模式从头到尾拆一遍从最经典的纯虚接口写法到std::function和lambda的现代化写法再到编辑器撤销、游戏指令、多线程队列三个真实场景最后把我在项目里踩过的坑一并倒出来。适合刚上手设计模式的C新人也适合准备面试想找点真实经验的人。1. 命令模式到底在解决什么问题1.1 一个真实场景给画图程序加“撤销”按钮先讲一个我早年做桌面工具时遇到的需求。一个简单的图形编辑器里面有画线、改颜色、填充、删除图形这些操作。第一版代码很容易写成这样void MainWindow::onToolButtonClicked(const std::string action) { if (action drawLine) { Line line canvas.GetCurrentLine(); lineManager.Draw(line); } else if (action changeColor) { int color colorDialog.GetSelectedColor(); canvas.SetColor(color); } else if (action deleteShape) { Shape* shape canvas.GetSelectedShape(); shapeManager.Remove(shape); } }这段代码跑起来没问题但产品经理第二天就会提需求“每个操作都要能撤销”。这时候你会发现麻烦大了撤销画线需要删除这条线撤销改色需要恢复原来的色值撤销删除需要恢复对象。撤销逻辑和操作逻辑一样多而且每个分支都要单独维护“你刚才干了什么”的记录。这个函数会从20行膨胀到200行而且每次新增一个工具都要在好几个地方同步修改。命令模式的做法是把每个操作变成命令对象。一个画线命令内部持有“画线需要的参数”和“负责实际画线的Receiver”执行的时候调用它撤销的时候调用它对应的反向操作。调用者比如菜单按钮只负责持有命令对象并调用它的Execute至于这个命令内部操作了谁、怎么操作调用者一概不管。这样按钮、快捷键、脚本宏、任务队列、远程命令都可以无差别地操作同一批命令对象。1.2 四个核心角色以及C里的特殊处理命令模式的经典结构是四个角色Command抽象命令定义统一接口最常见的是Execute和Undo。ConcreteCommand具体命令实现一个真实操作持有Receiver引用和必要参数。Invoker触发者持有命令对象并触发执行不关心命令内部逻辑。Receiver接收者真正干活的对象比如画板、图形管理器和颜色管理器。但我在C项目里经常省略Receiver。因为C里命令对象本身就可以是一个lambda或者一个持有全部数据和逻辑的小对象。比如“改变全局音量”这种操作命令对象内部直接调全局函数或者静态接口就够了没必要硬塞一个Receiver进去。有Receiver时具体命令就是“请求发送者和业务接收者之间的中间层”没有Receiver时命令自己承担了所有职责。这个点在面试里可以提一下属于你能把模式讲活的加分项而不是只会背UML。1.3 为什么不是直接传一个回调函数很多人会问既然C有函数指针有std::function和lambda为什么不直接传回调我的理解是命令对象是回调的“重量级升级版”。普通回调只封装了“做什么”命令对象除此之外还能封装撤销和重做动作执行与反执行可以成对出现状态和上下文比如执行所需的参数、执行前的旧值、执行时间组合能力比如一个宏命令内部包含多个子命令序列化能力命令对象可以被写成日志或网络包之后重新执行实现回放。举个例子用std::function可以很轻量地把一个操作传出去“这有个任务你帮我执行”。但如果你想要撤销单纯一个无返回的std::function办不到你必须额外记录这个任务对应的反向任务。命令对象把正向和反向都装在一个对象里这就是本质区别。后面我会演示如何用std::function配合lambda做出轻量级命令但这属于简化版功能完整度不如类对象版。提示如果你只需要“延迟执行”或者“事件分发”std::function就够了如果你还需要撤销、重做、组合、序列化优先用完整命令对象。2. C里实现命令模式的三种姿态2.1 教科书写法纯虚接口 具体命令类最经典的实现是定义一个抽象基类然后每个操作写一个子类。这里我拿一个“修改文档字体颜色”的命令举例#include memory #include vector #include iostream // 抽象命令 class ICommand { public: virtual ~ICommand() default; virtual void Execute() 0; virtual void Undo() 0; }; // Receiver真实业务对象 class Document { public: void SetColor(int color) { lastColor_ currentColor_; currentColor_ color; } void RestoreColor(int color) { currentColor_ color; } int GetCurrentColor() const { return currentColor_; } private: int currentColor_{ 0 }; int lastColor_{ 0 }; }; // 具体命令改颜色 class SetColorCommand : public ICommand { public: SetColorCommand(Document* doc, int newColor) : doc_(doc), newColor_(newColor) {} void Execute() override { oldColor_ doc_-GetCurrentColor(); doc_-SetColor(newColor_); } void Undo() override { doc_-RestoreColor(oldColor_); } private: Document* doc_; int newColor_; int oldColor_{ 0 }; }; // Invoker触发者 class Editor { public: void SetCommand(std::shared_ptrICommand cmd) { cmd_ cmd; } void Do() { if (cmd_) cmd_-Execute(); } private: std::shared_ptrICommand cmd_; }; int main() { Document doc; Editor editor; editor.SetCommand(std::make_sharedSetColorCommand(doc, 0xFF0000)); editor.Do(); std::cout current color: std::hex doc.GetCurrentColor() std::endl; auto cmd std::make_sharedSetColorCommand(doc, 0x00FF00); cmd-Execute(); cmd-Undo(); std::cout after undo: std::hex doc.GetCurrentColor() std::endl; return 0; }这种写法的优点是结构高度一致任何命令类都实现同样的接口Invoker可以统一持有它们。缺点是类数量爆炸。一个五六个操作的小程序就要五六个子类每个子类还有大量重复的构造、析构和成员声明。项目早期还能忍操作一多维护成本就上来了。我现在的建议是如果只做两三个命令可以这么写如果命令数量超过十个或者你会频繁增删命令就需要考虑下一节的现代写法。2.2 现代化简化std::function lambdaC11之后的代码风格完全可以用std::function替代抽象基类。所谓命令接口就是一个可调用对象#include functional #include iostream #include map using Command std::functionvoid(); class Light { public: void On() { std::cout light on std::endl; } void Off() { std::cout light off std::endl; } }; int main() { Light light; Command turnOn [light]() { light.On(); }; Command turnOff [light]() { light.Off(); }; std::mapstd::string, Command cmdMap { { on, turnOn }, { off, turnOff } }; std::string input; while (std::cin input) { auto it cmdMap.find(input); if (it ! cmdMap.end()) it-second(); } return 0; }lambda捕获了light的引用所以执行时能直接调用Receiver。这段代码比第二章的纯虚接口版本短很多而且好处是命令定义和使用处挨在一起阅读代码时上下文非常清晰。这个写法的代价是Command只是void()签名无法表达撤销、重做、是否有返回值等信息。想撤销就得多传一个反向lambda或自己定义一种“命令对”。我的经验是这种轻量级命令最适合按键映射、事件分发、简单任务队列它们只需要“触发”。不适合需要复杂状态保存和操作历史管理的场景。2.3 进阶写法泛型命令模板和类型擦除如果你既想要类对象版本的完整能力又不想为每个操作写一堆子类可以用模板把参数也泛化。定义一个模板类template typename TReceiver class CommandT : public ICommand { public: using Action std::functionvoid(TReceiver*); CommandT(TReceiver* receiver, Action action, Action undoAction) : receiver_(receiver), action_(std::move(action)), undoAction_(std::move(undoAction)) {} void Execute() override { if (action_) action_(receiver_); } void Undo() override { if (undoAction_) undoAction_(receiver_); } private: TReceiver* receiver_; Action action_; Action undoAction_; };使用的时候就不用写子类了一个lambda搞定Document doc; auto cmd std::make_sharedCommandTDocument( doc, [](Document* d) { d-SetColor(0xFF0000); }, [](Document* d) { d-RestoreColor(0); } ); cmd-Execute(); cmd-Undo();这种写法既有标准命令接口Execute/Undo又压缩了大量子类模板代码。缺点是你得为每一种命令签名维护模板。命令如果要携带返回值或者要接收参数就得另写一套。这也引出了一个在设计上的选择是让命令无参无返回只通过Receiver间接访问数据还是让命令携带参数对象我推荐前者因为这样所有命令接口才能统一方便被队列、栈和Invoker统一管理。参数数据封装在命令对象内部或Receiver内部不破坏统一签名。注意不管用哪种写法只要你要做撤销栈命令对象就要保存足够的旧状态否则Undo无从下手。保存状态时优先存值不要存指向可变对象的引用。3. 实战落地三个我做过或拆过的高频场景3.1 场景一编辑器撤销/重做系统撤销重做是命令模式最经典的栖息地。实现思路很简单两个栈一个撤销栈和一个重做栈。每次Execute时把当前命令压入撤销栈同时清空重做栈因为历史已经分叉新操作之后已撤销的旧命令不应再被重做。执行Undo时从撤销栈弹出一个命令调用它的Undo再压入重做栈。Redo反之。我刚才写的SetColorCommand其实就已经具备撤销基础了。但在真实编辑器里一个命令往往要保存更多信息。以“删除图形”为例你要在命令对象里保存被删除图形的完整数据而不是指向它的指针因为图形一旦从场景里移除指针就无效了。保存完整对象数据后Undo就能把图形原样重建出来。这条规矩我复盘过很多次凡是撤销出问题的编辑器八成是保存了指向可变对象的引用或指针。还有一点容易踩坑撤销栈的大小控制。如果用户连续操作几千步每个命令都保存了一份大对象快照内存很快就会告急。我的处理方式是限制撤销深度比如只保留最近100步。超过的从栈底淘汰。如果某个命令体积特别大可以合并或者改为保存操作增量而不是全量快照。此外如果想支持“宏录制”也就是把用户连续执行的多个操作录成一条命令再回放可以用组合命令class MacroCommand : public ICommand { public: void Add(std::shared_ptrICommand 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::shared_ptrICommand commands_; };注意Undo要按倒序执行因为正向执行是先画线再改色撤销就应该先恢复颜色再删除线这样才能把状态还原到位。这个倒序撤销的细节极其容易忽略。3.2 场景二游戏角色指令与AI行为队列游戏开发里命令模式也到处都是只是名字可能变了叫“输入指令”或者“行动命令”。一个角色有移动、跳跃、攻击、防御等操作。玩家按键产生输入输入映射成命令对象再统一喂给角色的行动执行器。AI也可以产生同类型的命令对象因为AI决策就是计算出“下一个应该做什么动作”它和玩家的按键输入在本质上是一样的。我拆过一个简化的格斗游戏逻辑大概长这样class MoveLeftCommand : public ICommand { public: MoveLeftCommand(Actor* actor, float distance) : actor_(actor), distance_(distance) {} void Execute() override { actor_-Move(-distance_, 0.f); } void Undo() override { actor_-Move(distance_, 0.f); } private: Actor* actor_; float distance_; };动作命令如果只是往前走一步撤销就是往回走一步。但有些动作不可逆比如消耗了MP的魔法攻击。这类命令的Undo要么做成“尽量补偿”要么直接空实现并且明确标注。实际落地时动作命令往往还会按照帧来执行比如命令进入一个队列每帧执行一部分不影响游戏主循环的节奏。这其实就是把“执行请求”和“主循环逻辑”解耦了是命令模式在时序上带来的另一个好处。玩家连招可以看作一条组合命令把多个子命令组装成一个MacroCommand放到队列里按顺序播放。AI在开放世界里的状态机每帧决策输出一个命令对象放进行为队列这样AI逻辑和玩家逻辑共用一套执行管线测试和调试都省很多事。3.3 场景三多线程任务队列和线程池如果你手头有一个生产者-消费者模型任务队列里的任务本身就可以建模成命令对象。尤其是C多线程场景下一条命令就是一个可延迟执行的操作单元。投递线程只负责创建命令对象并放进队列工作线程取出命令后执行。这样比直接把函数指针丢进线程要灵活得多因为命令可以携带更复杂的数据和上下文。一个简化的任务队列可以这样设计#include queue #include mutex #include condition_variable #include thread #include functional class TaskQueue { public: using Task std::functionvoid(); void Post(Task task) { { std::lock_guardstd::mutex lock(mutex_); tasks_.push(std::move(task)); } cv_.notify_one(); } Task Take() { std::unique_lockstd::mutex lock(mutex_); cv_.wait(lock, [this] { return !tasks_.empty(); }); Task task std::move(tasks_.front()); tasks_.pop(); return task; } private: std::queueTask tasks_; std::mutex mutex_; std::condition_variable cv_; };投递端和消费端代码如下TaskQueue queue; std::thread worker([queue] { while (true) { auto task queue.Take(); task(); // 执行命令 } }); queue.Post([] { std::cout task A std::endl; }); queue.Post([] { std::cout task B std::endl; }); worker.join();这种写法把“任务内容”和“何时何地执行”彻底分开了。投递端不会因为任务还没执行就被阻塞工作线程也不用关心任务是谁投的。实际项目里我还会在Task里加入超时、取消回调、优先级等字段但这些扩展一定要放在命令封装内部而不是散落在队列代码里。跨线程使用命令时最要紧的是生命周期。lambda在捕获时如果捕获了对象的裸指针或引用对象析构后任务才执行程序直接崩溃。稳妥的做法是捕获std::shared_ptr或拷贝对象或者在任务执行时先检测weak_ptr是否还存活。这是我看过最多人翻车的地方后面一章详细讲。4. 写在实战之后的坑命令模式常见问题与排查4.1 生命周期与悬垂引用我用std::function和lambda做命令时踩过最狠的坑就是lambda捕获了裸指针。比如class Player { /* ... */ }; auto player std::make_sharedPlayer(); queue.Post([player] { /* 安全 */ }); // 持有shared_ptr安全 queue.Post([player] { /* 不安全 */ }); // 如果player先析构悬垂捕获shared_ptr是拷贝一份引用计数任务执行时对象一定还活着。捕获引用则没有所有权纯裸指针效果。涉及多线程时这个坑尤其隐蔽因为崩溃不必然每次发生只有恰好对象被销毁且内存被复用的时候才崩排查起来非常费时间。我的经验法则是命令对象要被延迟执行或跨线程传递时一律捕获shared_ptr或值拷贝不要捕获引用和裸指针。如果你确实不想增加引用计数的开销那就要用weak_ptr在执行时lock发现为空就直接跳过。4.2 撤销状态保存了不该保存的东西撤销系统的数据损坏往往不是因为命令结构写错而是因为保存了“外部可变状态”的引用。比如给图形改颜色你先在命令对象里保存了doc_-GetCurrentColor()然后执行SetColor。如果执行过程中别的地方把currentColor改了或者你保存的是一个指针指向的颜色缓冲区那个缓冲区随后被覆盖Undo恢复的就是错误的颜色。正确的做法是在执行命令前把需要恢复的状态拷贝成值保存到命令内部。能存基本类型就存基本类型能std::string就std::string不要在命令里保存指向共享缓冲区的指针。换句话说命令要具备“自包含性”它自身携带的旧状态应当与外部环境解耦。4.3 性能敏感场景下的对象泛滥命令模式最常被吐槽的就是“每个操作都要new命令对象”。在大量高频操作场景下对象的分配和释放会成为热点。比如游戏里一秒钟几十次攻击指令如果每个指令都make_shared一次堆分配开销会干扰主循环。我常用的三个缓解办法对象池预创建一批可复用的命令对象用完重置内部状态再放回池子批量命令把多个连续操作合并成一个宏命令减少命令数量轻量化命令类型如果能用std::function就直接用不一定非要做虚函数对象避免虚表跳转和动态分配的额外开销。命令模式的初衷是解耦和灵活性性能问题通常出现在“过度建模”上。如果命令数量不多这点开销可以完全忽略。真正要担心的不是命令对象本身而是滥用大对象快照导致的内存膨胀。4.4 撤销重做栈的边界问题撤销重做栈不是纯机械地压栈弹栈。几个容易漏的逻辑执行新命令时必须清空重做栈。否则用户撤销两步后执行新命令再点Redo会把原本已撤销的旧命令重新执行状态就乱了。撤销栈要限制深度。无限增长不仅吃内存还可能让用户点几百次才能回到很久以前的状态多数产品也没这个需求。命令执行结果失败时不应压入撤销栈。如果Execute抛了异常命令状态可能不一致此时压栈会让后续Undo也处于异常状态。4.5 面试里被追问的几点命令模式是C面试里“八股”频次很高的设计模式。我整理几个高频追问你们可以自己演一遍命令模式和策略模式有什么区别我的答法二者结构相似都是封装并解耦但意图不同。策略模式想解决“算法族的切换”比如排序策略命令模式想解决“请求与调用者解耦”核心是请求可以被记录、撤销、排队。命令模式和回调有什么关系我的答法回调是命令模式的轻量实现命令对象是回调整的升级版多了状态、撤销、组合、序列化这些能力。撤销重做中Redo栈什么时候清空我的答法执行一个新命令时清空因为新命令产生新的历史分支。如何给命令加日志回放我的答法命令对象实现序列化接口执行时把命令输出到日志恢复时从日志反序列化命令并重放。这个叫命令日志常用于需要审计和容错的系统。命令对象里保存了哪些信息才算合格我的答法至少包含执行目标、执行参数、撤销所需旧状态和执行上下文同时不持有对外部可变资源的裸引用。5. 命令模式的边界什么时候该用什么时候别硬上5.1 五类需求一命中就值得用我判断要不要上命令模式只看业务里是否出现这五类需求之一撤销/重做这是最直接的需求编辑器、表单、配置面板都常见延迟执行/任务排队命令可以存起来等到合适的时机再执行请求的替换与组合比如把多个请求组合成一个宏或者把某一步替换成另一套实现日志与回放命令对象可以被序列化重放后恢复现场事务和补偿类似数据库事务的思路每一个操作对应一个补偿操作如果中间失败就依次撤销前面已经执行的操作。命中其中一条尤其多条同时命中时命令模式几乎是最自然的解。特别是“事务和补偿”这条在业务里尤其实用。5.2 哪些场景其实不需要命令模式如果你的程序里操作只有一处调用没有撤销需求也没有队列需求那硬上命令模式就是给自己找麻烦。比如一个配置文件读取函数内部就有且只有一处调用给它加个Command基类、写个ConfigCommand类再配一个Invoker纯属过度设计。我见过很多代码把命令模式当“万能解药”每个业务函数都包一层命令。最后代码里到处都是Command后缀的类数量多到和业务类一样多阅读成本反而大增。设计模式的价值在于“按需使用”不在“全量使用”。我的习惯是先写简单直白的调用代码当出现第二个调用场景、撤销需求或异步需求时才顺势重构到命令模式。这样既不会提前抽象也不会错过重构时机。5.3 与相近模式的对比速查面试和架构设计里命令模式经常被拿来和策略、职责链、观察者、备忘录对比。我整理一个简单的对照表模式核心意图典型场景命令模式把请求封装成对象支持撤销、排队、日志编辑器撤销、任务队列、宏命令策略模式封装算法允许运行时替换排序算法、压缩算法、渲染策略职责链模式请求沿链传递直到有人处理日志级别处理、UI事件冒泡、拦截器观察者模式状态变化时通知依赖方事件订阅、模型与视图同步备忘录模式捕获对象内部状态用于恢复游戏存档、编辑器快照注意命令模式和备忘录模式经常搭配使用。命令自己保存增量状态再用备忘录保存大状态快照两者结合可以兼顾撤销能力和内存开销。真实项目的架构很少只用一个模式很多都是组合拳。写在最后的一点项目体会如果让我只选一个最值得上手的C设计模式抛开出项目特定的限制我会选命令模式。不是因为它的UML有多好看而是它能在很短时间里让你感受到“解耦”带来的实际收益。我在真实项目里用得最多的反而不是图形编辑器而是把业务动作封装成命令后放入队列执行再配合补偿逻辑处理失败场景。写代码的时候你会明显感觉到新增一个操作只需要新增一个命令类调用端统一不变测试也变得更好写因为每个命令都能独立构造、独立执行、独立验证。最后再送一个小技巧如果你在重构一个杂乱无章的if-else功能调用不用一次性把所有分支都改成命令。先选两三个最常改动、最需要撤销或延迟执行的操作改成命令跑通整个流程再把剩下的分支逐步迁过去。这样风险小回归范围可控也更容易让同事接受这次重构。设计模式本来就是服务于人的工具别让模式反过来绑架你的代码。