ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++命令模式实战:撤销操作、任务队列与游戏输入映射

C++命令模式实战:撤销操作、任务队列与游戏输入映射 很多C项目做到一定规模都会撞上一个绕不开的需求同一个动作可能需要撤销可能需要重做可能要录制成宏可能要丢进任务队列异步执行还要支持玩家自定义按键。如果一开始就在UI层直接调用业务函数初期跑得很欢等到这些需求叠加起来改一个功能要戳七八个地方代码就逐渐失控了。C中的命令模式就是解决这类问题的一套成熟思路把“做一件事”本身封装成对象让请求变成可以被传递、排队、撤销、组合的实体。这篇博文不谈空理论直接围绕可撤销操作、任务队列、游戏输入映射三个真实场景讲清楚命令模式在C里怎么落地哪些地方用std::function和lambda可以大幅简化哪些坑必须提前知道。适合正在写C项目、想真正能把设计模式用起来的开发者也适合刚学完C语法但面对“为什么要学模式”一脸困惑的同学。1. 命令模式的基本逻辑——先搞清楚它到底解决什么问题1.1 没有命令模式之前代码是怎么写的先看一段大量项目里真实存在的代码。一个编辑器界面有加粗、斜体、插入文字几个按钮点击后直接操作文档对象void Editor::OnButtonClick(ButtonId id) { switch (id) { case ButtonId::Bold: document_-SetBold(true); break; case ButtonId::Italic: document_-SetItalic(true); break; case ButtonId::InsertHello: document_-InsertAt(pos_, Hello); break; default: break; } }这段代码在功能简单的时候没有任何问题按钮就是干这件事的直接调用底层方法逻辑清楚、性能也高。但只要你接着加三个需求问题立刻暴露撤销没法做。用户点击了“加粗”又点击了“插入Hello”再按CtrlZ你希望文档回到“加粗但没有插入Hello”的状态。上面的写法里OnButtonClick执行完什么都不留下你拿什么来撤销操作没法排队。用户点击速度很快系统来不及立刻响应你希望把操作记录成一系列待办任务依次执行。直接调用业务函数这一步也做不了。操作没法组合。想录一个宏加粗、插入文字、再取消斜体你会发现自己不得不为每个组合写新的case分支代码指数级膨胀。说白了问题根源在于请求没有被封装成对象而是以函数调用的形式直接发生了。函数调用是即时且不可留存的操作的一切痕迹在调用结束后就消失了。1.2 命令模式把一次操作变成一张“发票”命令模式的思路可以用餐厅点菜来理解。顾客走进餐厅不会直接冲进后厨抢过锅铲炒菜——那是消息发送方直接操作接收方。真实流程是顾客把需求告诉服务员服务员把“番茄炒蛋一份”记在单据上单据传到后厨厨师按照单据做菜。这里出现了四个角色顾客是客户端Client提出需求负责组装命令。服务员是调用者Invoker持有单据负责在合适的时机触发执行但不知道菜怎么做。单据是命令Command封装了“做什么”和“找谁做”的全部信息。厨师是接收者Receiver真正执行操作的对象。单据的意义在于它把“动作”和“执行”分开了。服务员可以攒一批单据再一起去后厨可以按顺序执行可以记录哪些单据已做、哪些未做甚至可以通知后厨“刚才那张番茄炒蛋不要做了”。这些能力都是在“请求变成对象”之后才获得的。回到C里命令模式的经典结构其实是这样的ICommand抽象命令接口声明Exec()和UnExec()有些地方叫execute和undo。ConcreteCommand具体命令类内部持有接收者引用以及执行所需的参数实现接口方法。Receiver真正干活的类比如TextBuffer、Player、Document。Invoker持有命令对象在适当时机调用命令的Exec()。Client创建具体命令设置其接收者交给调用者。很多C新手在理解这个结构时容易把Invoker和Client搞混。两者最大的区别是Client负责“组装关系”Invoker只负责“到点触发”。组装和触发分离正是命令模式解耦的第一层含义。2. 从零实现一个可撤销的文本编辑器命令2.1 经典接口设计比教科书多一个undo先实现一个最基础的版本。假设我们有一个TextBuffer类负责存放文本内容这就是接收者class TextBuffer { public: void Insert(std::size_t pos, const std::string text) { data_.insert(pos, text); } void Erase(std::size_t pos, std::size_t count) { data_.erase(pos, count); } const std::string Contents() const { return data_; } private: std::string data_; };然后定义命令接口。很多教科书只写execute()但在实战中只要你想做撤销就必须再加一个逆操作class ICommand { public: virtual ~ICommand() default; virtual void Exec() 0; virtual void UnExec() 0; };接着写一个插入文本的具体命令class InsertTextCommand final : public ICommand { public: InsertTextCommand(TextBuffer buffer, std::size_t pos, std::string text) : buffer_(buffer), pos_(pos), text_(std::move(text)) { } void Exec() override { buffer_.Insert(pos_, text_); } void UnExec() override { buffer_.Erase(pos_, text_.size()); } private: TextBuffer buffer_; std::size_t pos_; std::string text_; };这里有个非常容易踩的坑必须单独说明。插入命令在Exec()之前就知道要插入什么文本所以UnExec()可以自然地写成“在那个位置删掉同样数量的字符”。但删除命令不一样用户在编辑器里选中一段文字然后按删除键时你是不能提前知道被删内容的。所以删除命令的Exec()内部必须先备份被删除的文本之后再提供UnExec()时才能恢复。这种“执行时记录状态”的细节是命令模式从玩具代码走向真实系统时最关键的一步。2.2 Invoker与历史栈CtrlZ到底怎么实现有了ICommand和具体命令接下来需要历史管理器。这个类就是典型Invoker负责执行命令、记录历史、提供撤销与重做class History { public: void Execute(std::unique_ptrICommand cmd) { cmd-Exec(); done_.push_back(std::move(cmd)); undone_.clear(); } void Undo() { if (done_.empty()) return; done_.back()-UnExec(); undone_.push_back(std::move(done_.back())); done_.pop_back(); } void Redo() { if (undone_.empty()) return; undone_.back()-Exec(); done_.push_back(std::move(undone_.back())); undone_.pop_back(); } private: std::vectorstd::unique_ptrICommand done_; std::vectorstd::unique_ptrICommand undone_; };有几个设计点值得咀嚼一下为什么用std::unique_ptr因为命令对象不能被复制时移动语义配合智能指针能清晰表达所有权转移避免手动new和delete带来的内存管理负担。每次执行新命令时为什么要undone_.clear()因为撤销之后如果插入了一个新操作原来的Redo历史就变得无意义了。这是绝大多数编辑器的标准行为。Execute里先调cmd-Exec()再push_back这个顺序很重要。如果先入栈、再执行而Exec()抛了异常历史栈里就会留下一个根本没有成功执行的命令。实际工程里通常还会包一层try/catch这里为演示清晰先不提。2.3 客户端怎么组装谁创建、谁持有、谁触发客户端代码大概是这个样子TextBuffer buffer; History history; // 用户在某处输入了 Hi history.Execute( std::make_uniqueInsertTextCommand(buffer, 0, Hi)); // 用户继续输入 ! history.Execute( std::make_uniqueInsertTextCommand(buffer, 2, !)); // 用户在 UI 上按了 CtrlZ撤销最后一步 history.Undo();到这里命令模式的经典版本就完整跑通了。TextBuffer是被动的它自己不知道任何历史信息History只管触发和存储也不知道插入的具体字符是什么InsertTextCommand则把两者桥接起来。调用链是单向的每层职责都相对单一。但说实话这种写法最大的毛病是“类爆炸”一个操作一个类一个功能动辄多出三四个文件。如果项目里有几十种操作类数量会非常可观。所以很多现代C工程并不直接用这套经典结构而是用std::function做轻量化替代。这就是下一节要说的事。3. std::function lambda把命令模式从“写类”变成“写表达式”3.1 类型擦除为什么std::function可以替代接口经典命令模式用继承体系强制“所有命令都遵循统一协议”你继承ICommand实现Exec和UnExec。std::function则走了另一条路——类型擦除。它不管实际的命令是什么类型只要可调用对象的签名能对应就能被包装成统一对象。这其实就是一个隐藏的Command接口只不过这个接口只约定了“怎么调用”不约定了“你是什么类型”。看代码就清楚了using Action std::functionvoid(); Action insertHi [buffer] { buffer.Insert(0, Hi); };lambda把“接收者是谁、参数是什么、执行什么”全部捕获在一个闭包里std::function负责把这个闭包包装成一个可以自由传递的对象。相比为InsertTextCommand单独建一个类这种写法简洁得多。要提一句性能认知std::function并不是每次调用都必然堆分配。标准库虽然没有强制要求但主流的libstdc和libc实现里都做了小对象优化SBO小型可调用对象直接在std::function内部存储不会产生额外堆分配。把std::function想象成“能装可调用对象并可能内部直接存放的盒子”更准确。3.2 带撤销的std::function命令怎么玩有人会问std::functionvoid()只有一个执行动作撤销怎么办办法很简单把命令对象扩展成两个函数struct UndoableAction { std::functionvoid() Do; std::functionvoid() Undo; };历史管理器里的存储类型从std::unique_ptrICommand变成std::vectorUndoableAction逻辑几乎不变class History { public: void Execute(UndoableAction action) { action.Do(); done_.push_back(std::move(action)); undone_.clear(); } void Undo() { if (done_.empty()) return; done_.back().Undo(); undone_.push_back(std::move(done_.back())); done_.pop_back(); } // Redo 同理 private: std::vectorUndoableAction done_; std::vectorUndoableAction undone_; };客户端组装命令时把正向操作和逆向操作一次配对好History history; auto pos buffer.Contents().size(); auto insertAction UndoableAction{ [buffer, pos] { buffer.Insert(pos, !); }, [buffer, pos] { buffer.Erase(pos, 1); } }; history.Execute(std::move(insertAction)); history.Undo();这套写法并不是要取代经典接口而是适合“命令只有一个执行入口一个撤销入口”的场景。如果命令本身有很多方法要对外暴露比如需要支持Serialize()序列化、CanMerge()批量合并那还是老老实实建类更合适。必须指出lambda的一个局限它不适合需要“执行时记录状态”的命令。前面提到的删除命令就是典型例子——你必须在Execute()时把被删文本备份下来但lambda闭包在创建时就被固定了无法在执行中往闭包里塞新数据。遇到这种需求建议回到class版本在对象内部保存可变状态。3.3 函数指针、成员函数、函数对象一起打工std::function的另一个好处是它能兼容几乎所有可调用物函数指针、lambda、std::bind包装的成员函数、重载了operator()的函数对象。比如UI层有一个成员函数class GameUI { public: void OnAttack() { /* 发起攻击 */ } };在经典写法中你想把成员函数包装成命令至少要写一个适配器类。而现在只要一行lambdaGameUI ui; std::functionvoid() attackCmd [ui] { ui.OnAttack(); };如果非要用std::bind也能跑但可读性明显差一些std::functionvoid() attackCmd std::bind(GameUI::OnAttack, ui);函数对象更是直接兼容struct ResetCommand { void operator()() const { /* 重置某种状态 */ } }; std::functionvoid() resetCmd ResetCommand{};这个特性在构建可配置行为表时非常有用。比如一个事件系统外部传入任意可调用对象内部统一用std::function存储和执行调用方根本不需要关心命令是从哪来的。4. 三个实战场景看得见效果的例子4.1 编辑器撤销/重做完整流程第一个实战场景就是编辑器。假设用户输入依次是“A”“B”“C”光标在末尾。每一步的操作都可以封装成命令插入命令保存位置和文本撤销时在同一位置删掉同样长度的字符。多步撤销的逻辑不难真正考验工程能力的是“光标位置”和“选区状态”怎么恢复。很多初学命令模式的人写的编辑器文字内容能撤销但光标会跑到奇怪的地方去。这是因为光标本身也是一种文档状态必须让命令同时负责修改光标位置或者额外保存一个光标偏移量。我建议的做法是命令对象里不仅记录文本位置还记录操作前后的光标数值在Undo和Redo中一并恢复。还面临方案选型快照方案和命令方案到底用哪一种这没有绝对答案看场景。维度快照方案命令方案内存开销每次操作保存整个文档副本大文档不友好只保存增量信息通常较小实现难度低直接拷贝副本即可高每个操作都要设计逆操作崩溃恢复直接恢复最近快照可能丢少量操作需要重放命令日志需要额外的持久化机制典型场景单人、小型文档、图元少的结构大型文档、长历史、需要精细撤销实际工程里还有一种折中方案每N步生成一个快照快照之间用命令增量。崩溃恢复时从最近的快照开始重放之后的命令日志。这种“快照增量”的设计在数据库领域已经是通用手法编辑器里同样适用。4.2 游戏输入映射与组合连招第二个场景是游戏开发。早期游戏代码常见的是if (key SDLK_SPACE) player.Jump(); if (key SDLK_j) player.Attack();这看着直接但一旦玩家要自定义按键你就得在输入层写大量判断一旦要支持组合技能、录制连招整个输入模块会被吹气球一样撑大。用命令模式重构后输入映射变成配置表class InputSystem { public: void Bind(const std::string keyName, std::functionvoid() action) { bindings_[keyName] std::move(action); } void Trigger(const std::string keyName) { auto it bindings_.find(keyName); if (it ! bindings_.end()) { it-second(); } } private: std::unordered_mapstd::string, std::functionvoid() bindings_; };按键和技能完全解耦。玩家把“跳跃”改成别的键只需要修改一份配置表不需要动Player::Jump()的任何代码。键位配置文件甚至可以是文本格式启动时解析进bindings_即可。组合连招则需要“宏命令”概念也就是把多个命令打包成一个整体class MacroCommand final : public ICommand { public: void Add(std::unique_ptrICommand cmd) { cmds_.push_back(std::move(cmd)); } void Exec() override { for (auto cmd : cmds_) cmd-Exec(); } void UnExec() override { for (auto it cmds_.rbegin(); it ! cmds_.rend(); it) (*it)-UnExec(); } private: std::vectorstd::unique_ptrICommand cmds_; };注意UnExec()的顺序是逆序的。组合命令执行顺序是A→B→C要回退就必须C→B→A。不理解这点的人写出的撤销系统往往在单步操作时正常一到组合操作就错乱。游戏里另有一个很典型的应用录制回放。每次玩家输入都被封装成一条命令并存下来回放时依次执行命令。这套机制和录像重放、断线重连的“对账”思路完全一致。4.3 任务队列与线程池命令模式最高频的隐藏应用第三个场景是任务队列。很多人可能没意识到线程池里最常见的std::functionvoid()队列本质上就是命令模式生产者把任务封装成命令对象放进队列消费者线程取出并执行。这就是命令模式“排队执行”能力的典型体现。一个最小可用的线程池核心结构如下class ThreadPool { public: void Post(std::functionvoid() task) { std::lock_guardstd::mutex lock(mtx_); tasks_.push_back(std::move(task)); cv_.notify_one(); } void WorkerLoop() { for (;;) { std::functionvoid() task; { std::unique_lockstd::mutex lock(mtx_); cv_.wait(lock, [this] { return !tasks_.empty() || stop_; }); if (stop_ tasks_.empty()) return; task std::move(tasks_.front()); tasks_.pop_front(); } task(); } } private: std::dequestd::functionvoid() tasks_; std::mutex mtx_; std::condition_variable cv_; bool stop_ false; };这里每个任务就是一条命令。命令对象完整保存了执行该任务所需的全部上下文这正是能够延迟执行的前提。如果任务需要返回值void()就不够用了。传统命令模式通常不讨论返回值因为命令对象往往是异步触发的。但在C里std::packaged_task恰好能弥补这一点template class F, class... Args auto Submit(F f, Args... args) - std::futuretypename std::invoke_result_tF, Args... { using R typename std::invoke_result_tF, Args...; std::packaged_taskR() task( std::bind(std::forwardF(f), std::forwardArgs(args)...)); auto future task.get_future(); { std::lock_guardstd::mutex lock(mtx_); tasks_.emplace_back( std::make_sharedstd::packaged_taskR()(std::move(task))); } cv_.notify_one(); return future; }注意std::packaged_task是不可拷贝的所以必须用std::move转移或者用std::shared_ptr包装再放进容器。基本每个在这个地方踩过坑的人都会对“可拷贝性”这回事印象深刻。5. C命令模式的高级细节与避坑5.1 生命周期lambda捕获this的悬垂问题这是一个非常容易翻车的地方。命令被投递到任务队列后队列可能在另一个模块、另一个线程里延迟执行。如果命令里捕获了this而对象已经提前销毁执行时就是一个未定义行为。典型错误示范class Player { public: void Spawn() { /* ... */ } }; // 假设这个 lambda 被存进任务队列 auto cmd [this] { player_-Spawn(); }; // 悬垂风险在单线程、对象生命周期明确比命令短、且能保证“命令执行时对象一定还活着”的场景下捕获裸指针问题不大。但跨模块、跨线程的异步场景必须更稳健。常用的方案有三种捕获shared_ptr副本延长对象生命周期。代价是命令会持有对象导致对象释放推迟可能影响资源回收时机。捕获weak_ptr并在执行时检查安全性好但每次执行多一次lock()调用。在对象析构时主动取消/清空命令适合任务队列可控的场景但实现复杂度高。游戏开发里最常配合使用weak_ptrstd::weak_ptrPlayer weakPlayer player; auto cmd [weakPlayer] { if (auto sp weakPlayer.lock()) { sp-Spawn(); } };这种写法牺牲了一点性能换来的是“命令执行时对象是否还活着”不再需要全局协调。我的建议很简单凡是命令要跨模块传递优先考虑弱引用捕获。5.2 复制、移动与开销std::function也是要花钱的对象std::function的拷贝代价取决于内部包装的可调用对象。如果lambda捕获了很大的容器比如一个一百万元素的std::vector那拷贝std::function就会连带拷贝这个大容器。避免的办法很直接auto cmd [data std::make_sharedstd::vectorint(1000000)] { // 只捕获一个 shared_ptr拷贝开销极小 };如果确实需要捕获一个外部对象又不想拷贝可以用std::refLargeState state; std::functionvoid() cmd [state] { state.Update(); };但std::ref方案本质上还是引用捕获必须保证引用目标存活。这一点和5.1的悬垂问题是一体两面。性能方面要额外说明命令模式大多用于交互和异步场景一次执行几千条命令已经算极端情况相比数据库读写、网络I/O这类开销命令分发的间接调用成本可以忽略。真正性能敏感的循环里优先考虑函数指针数组或手写switch不要为了模式而模式。我见过有人把每帧刷新的UI命令队列从std::function改成手动函数表最后耗时几乎没变化反而增加了一堆维护成本。优化要用profiler说话别靠“感觉”。5.3 序列化与持久化没有反射命令日志也得写命令模式天然适合把历史操作持久化下来比如数据库事务日志、编辑器崩溃恢复、交易系统审计。但C没有Java那种反射机制想“按命令名字自动反序列化”并不是开箱即用的。实际工程里最常用的做法是构建一个命令注册表每个命令类对应一个全局唯一的字符串ID和一个构造工厂函数。反序列化时通过ID找到对应的工厂再利用参数数据重建命令对象。using CommandBuilder std::functionstd::unique_ptrICommand(const JsonObject); class CommandRegistry { public: static CommandRegistry Instance() { static CommandRegistry instance; return instance; } void Register(std::string_view id, CommandBuilder builder) { builders_.emplace(id, std::move(builder)); } std::unique_ptrICommand Build(std::string_view id, const JsonObject params) const { auto it builders_.find(id); if (it builders_.end()) return nullptr; return it-second(params); } private: std::unordered_mapstd::string, CommandBuilder builders_; };给具体命令注册// 通常在某个静态初始化函数里执行 CommandRegistry::Instance().Register( shoot, [](const JsonObject params) - std::unique_ptrICommand { return std::make_uniqueShootCommand( params.targetId, params.damage); });每个命令需要自己实现字段的序列化和反序列化这部分没有自动魔法都是手写。但注册表模式已经把“按名称重建对象”的骨架搭好了适合订单、财务、游戏对战记录这类必须能审计回放的系统。5.4 命令的粒度不是每个动作都适合一条命令最后一个容易犯的设计错误是命令粒度失控。一个复杂操作可能拆成十几条子操作每条子操作都单独入历史栈结果用户CtrlZ要按十几次才能回到操作前状态体验极差。正确做法是按用户语义定义命令粒度。用户点击“改变整个段落格式”时哪怕内部改了字体、颜色、缩进多处属性也应该合并为一条命令。实现上主要有两种方式宏命令包装也就是前面提到的MacroCommand把一组子命令打包成一条。命令合并执行新命令前判断是否能与上一条命令合并。比如用户在输入框连续打字相邻的字符插入命令可以合并成一条“连续输入”动作撤销时一次性删掉整个输入片段。这两个方向我都建议在设计阶段就考虑清楚等历史栈已经堆了几百条命令再回头改粒度改动成本会大得多。6. 什么时候真的不需要命令模式6.1 一个命令对应一个函数时别套模式命令模式不是万能药。如果某个操作只需要一次同步调用没有撤销需求没有排队需求也不能被组合那直接调用函数就是最优解。强行套模式的后果是类数量倍增、调用链变长、断点调试变得繁琐收益却几乎为零。每次评估是否要用命令模式前建议先问三个问题这个操作需要被延迟/排队执行吗需要撤销/重做的历史记录吗需要把操作写成日志以便回放或审计吗三个问题都是否那就直接用普通函数调用。如果至少一个答案是“是”命令模式才有基本的使用价值。6.2 和其他模式划清边界命令模式经常被拿来和策略模式、观察者模式对比但它们解决的问题其实并不相同。策略模式关注“算法可替换”调用方在运行时选择一个算法执行算法本身不会被排队也不需要撤销。命令模式关注“请求被封装后传递、排队、撤销、组合”执行顺序和时机才是核心。同一个操作既可以用策略封装算法也可以用命令封装请求但两者面对的是不同层次的问题。观察者模式则是“一对多通知”一个事件广播给很多订阅者订阅者是同时响应。命令模式通常是“一对一请求”即使有宏命令组合也只是串行执行多个请求。混用这两种模式的代码往往在需求变更时最先暴露出混乱。6.3 给项目决策的一个快速清单如果要给团队一个可以直接用的决策清单我大概会这样列判断点建议意向操作需要异步/排队执行用命令模式封装任务放入队列操作需要撤销/重做用命令模式命令带上逆操作操作需要组合录制用宏命令方案操作需要落盘审计/回放命令模式配合注册表和序列化只是同步调用且无需历史直接调用别加模式团队对模式不熟先小范围试点优选std::function方案在重构老代码时也不要一口气把所有调用点都改成命令模式。挑一个撤销需求明确的子系统开始比如把编辑器历史栈做出来让团队看到实际收益再逐步推广。这个推进策略比“大爆炸式重构”稳得多。我个人在实际工作中的体会是命令模式最迷人的地方不是那个“接口加实现类”的固定模板而是它揭示了“把动作变成对象之后动作就获得了被记录、被延迟、被回放的能力”这一层抽象价值。std::function和lambda的引入让C开发者能几乎零成本地获得这种能力而不再需要写一堆样板类。如果这篇文章只让你记住一点我会说先从任务队列或者撤销栈入手把一次操作封装成对象第二步再考虑宏命令和序列化。踩过几次坑之后你会发现命令模式不是一个需要背的UML图而是一种改变思维方式的设计习惯。最后分享一个命名小技巧命令类不要全部用XxxCommand结尾否则项目里一搜“Command”一大片看名字完全分辨不出业务语义。像SendEmailAction、DeductBalanceStep、MovePieceOperation这样带上业务动作的命名比泛化的“Command”好维护得多。
返回列表