1. 项目概述为什么我们需要命令模式在C项目里尤其是开发图形界面、游戏引擎或者需要实现撤销/重做功能的编辑器时我们经常会遇到一个头疼的问题一个对象比如一个按钮需要触发另一个对象比如一个文档编辑器执行某个操作。最直接的想法是按钮类里直接包含一个文档编辑器对象的指针然后调用它的Save()方法。但这样做按钮就和文档编辑器紧紧地耦合在了一起。如果明天需求变了这个按钮要改成触发网络请求或者同时触发多个操作你就得回来修改按钮类的代码。这违反了设计原则中的“开闭原则”——对扩展开放对修改关闭。命令模式就是为了解决这个“紧耦合”和“请求多样化”的问题而生的。它的核心思想非常巧妙把“请求”本身封装成一个独立的对象。这个请求对象里包含了执行操作所需的所有信息比如调用哪个对象、调用它的哪个方法、需要什么参数。这样一来发出请求的对象比如按钮就完全不需要知道具体要执行什么它只需要知道有一个“命令”对象并告诉这个对象“执行”就行了。想象一下餐厅点餐。你客户不需要冲进厨房告诉厨师怎么做菜。你只需要写一张包含菜名和要求的“订单”命令对象交给服务员调用者。服务员把订单放到后厨的队列里命令队列厨师接收者看到订单后按照上面的指示开始烹饪。这个过程中服务员完全不需要懂烹饪厨师也只需要看订单干活你和厨师之间没有直接交流整个流程非常清晰、灵活。命令模式在软件里扮演的就是这个“订单”的角色。2. 命令模式的核心角色与UML类图解析要彻底理解命令模式我们必须先搞清楚参与其中的几个关键角色以及它们之间的关系。这就像理解一部戏剧得先认识每个演员和他们的职责。2.1 四大核心角色1. 命令 (Command):这是一个抽象基类或接口它定义了一个所有具体命令都必须实现的方法通常是Execute()。这个方法就是命令的“执行”接口。它相当于餐厅订单的通用格式规定了订单上必须要有“开始制作”这个动作项。2. 具体命令 (ConcreteCommand):这是命令接口的具体实现类。它有几个关键职责绑定接收者在它的构造函数或初始化方法中它会关联一个或多个“接收者”对象。这个接收者就是最终干活的那个对象。实现Execute()在这个方法里它会调用接收者对象的一个或一系列具体方法来完成实际的业务逻辑。它知道“做什么”和“对谁做”。这就像一张具体的“鱼香肉丝炒单”上面写着交给“王厨师”执行“炒鱼香肉丝”方法参数是“少辣”。3. 接收者 (Receiver):这是真正执行业务逻辑的对象。它知道如何完成具体的操作。一个系统中可以有多个接收者。在我们的例子里Receiver类就是接收者它有DoSomething和DoSomethingElse这些实际干活的方法。厨师就是接收者。4. 调用者/请求者 (Invoker):这个对象负责触发命令。它持有一个或多个命令对象的引用或指针。当某个事件发生时比如按钮被点击、定时器到期调用者就会调用所持命令对象的Execute()方法。它完全不知道这个命令具体做了什么它只负责“发号施令”。服务员就是调用者。可选客户端 (Client):创建具体命令对象并设置其接收者的对象。它负责组装命令和接收者并把组装好的命令交给调用者。在示例的main函数里创建SimpleCommand和ComplexCommand并将它们设置给Invoker的代码就是客户端的行为。你就是下订单的顾客。2.2 UML类图与协作关系理解了角色我们来看它们是如何协作的。下面这个类图清晰地展示了它们之间的关系---------------- 持有 ---------------------- | Client |---------------| Invoker | ---------------- ---------------------- | | - command: Command* | | 创建并组装 | SetCommand(cmd) | V | ExecuteCommand() | ---------------- --------------------- | Command |--------------------------- ---------------- 持有并调用 | Execute()0 | ---------------- ^ | 实现 ---------------------------------- | | ------------------------------- ------------------------------- | ConcreteCommandA | | ConcreteCommandB | ------------------------------- ------------------------------- | - receiver: Receiver* | | - receiver: Receiver* | | - state: string | | - state_a: string | | | | - state_b: string | ------------------------------- ------------------------------- | Execute() override | | Execute() override | | { receiver-Action(state); }| | { receiver-DoSomething(a); | | | | receiver-DoSomethingElse(b);} ------------------------------- ------------------------------- | | | 知晓并调用 | 知晓并调用 V V ------------------------------- ------------------------------- | Receiver | | AnotherReceiver | ------------------------------- ------------------------------- | Action(string) | | DoSomething(string) | | AnotherAction(string) | | DoSomethingElse(string) | ------------------------------- -------------------------------协作流程通常如下客户端创建一个Receiver对象接收者。客户端创建一个ConcreteCommand对象具体命令并在构造时传入上一步的Receiver以及任何必要的参数。客户端将创建好的ConcreteCommand对象设置给Invoker调用者。在未来的某个时刻客户端或其他代码触发Invoker。Invoker调用其持有的Command对象的Execute()方法。ConcreteCommand对象的Execute()方法被调用它内部调用Receiver对象的方法执行实际的操作。注意在实际的C代码中你需要特别注意对象的所有权和生命周期管理。上面的UML图中使用原始指针是为了清晰但在现代C中更推荐使用智能指针如std::unique_ptr或std::shared_ptr来管理这些动态分配的对象避免内存泄漏。例如Invoker持有Command时可以使用std::unique_ptrCommand。3. 从零开始一个完整的C命令模式实现示例理论讲得再多不如一行代码来得实在。让我们抛开那些简单的cout示例来构建一个更贴近实战的场景一个简单的文本编辑器它支持撤销Undo功能。撤销功能是命令模式的“杀手级”应用。3.1 场景定义与类设计假设我们的编辑器有一个Document类接收者它支持插入文本和删除文本。我们需要实现一个InsertCommand和一个DeleteCommand。此外还需要一个CommandHistory类一个增强版的Invoker来管理命令的执行和撤销。第一步定义接收者Receiver—— Document类// Document.h / Document.cpp #include string class Document { private: std::string text_; public: Document() : text_() {} void Insert(const std::string newText, size_t position) { // 在实际项目中这里需要更严谨的边界检查 if (position text_.length()) position text_.length(); text_.insert(position, newText); std::cout 文档在位置 position 插入了: \ newText \\n; std::cout 当前文档内容: \ text_ \\n; } void Delete(size_t position, size_t length) { if (position text_.length()) { std::cout 删除位置超出文档长度。\n; return; } if (position length text_.length()) { length text_.length() - position; // 删除到末尾 } std::string deleted text_.substr(position, length); text_.erase(position, length); std::cout 文档从位置 position 删除了 length 个字符: \ deleted \\n; std::cout 当前文档内容: \ text_ \\n; } const std::string GetText() const { return text_; } };这个Document类很简单就是封装了一个std::string作为文本内容并提供了插入和删除两个核心方法。注意这里的实现为了清晰忽略了部分错误处理实际开发中必须补全。第二步定义抽象命令接口Command// Command.h #include memory class Command { public: virtual ~Command() default; // 基类虚析构函数确保正确释放派生类资源 virtual void Execute() 0; virtual void Undo() 0; // 新增Undo接口用于支持撤销 virtual std::string GetName() const 0; // 可选用于调试 };这里的关键是增加了Undo()纯虚函数。一个完整的、支持撤销的命令必须知道如何逆转自己的Execute()操作。第三步实现具体命令ConcreteCommand—— InsertCommand// InsertCommand.h #include “Command.h” #include “Document.h” #include string class InsertCommand : public Command { private: Document* document_; // 接收者 std::string textToInsert_; size_t insertPosition_; // 为了支持Undo我们需要记录插入的文本和位置。 // 实际上Undo就是删除我们刚刚插入的文本。 public: InsertCommand(Document* doc, const std::string text, size_t pos) : document_(doc), textToInsert_(text), insertPosition_(pos) { // 这里可以预先计算或保存一些状态用于Undo。 // 对于InsertUndo需要知道插入的文本和位置。 } void Execute() override { if (document_) { document_-Insert(textToInsert_, insertPosition_); } } void Undo() override { if (document_) { // 撤销插入操作 从原位置删除插入的文本 // 注意这里假设文档在执行此命令后insertPosition_之后的内容没有因其他命令而改变。 // 在复杂场景下需要更严谨的状态管理。 document_-Delete(insertPosition_, textToInsert_.length()); } } std::string GetName() const override { return “插入命令 (\” textToInsert_ “\” 于位置 ” std::to_string(insertPosition_) “)”; } };InsertCommand在构造时绑定了Document接收者并记录了要插入的文本和位置。Execute()就是调用Document::Insert。Undo()的逻辑则是调用Document::Delete来删除刚刚插入的文本。这里有一个重要的隐含前提在Execute()和Undo()之间文档在相关位置没有发生其他变化。在单线程、顺序执行的简单编辑器中这成立但在复杂并发环境下需要引入更复杂的“快照”或“操作变换(OT)”机制。第四步实现命令历史管理器Invoker的增强版—— CommandHistory// CommandHistory.h #include “Command.h” #include stack #include memory class CommandHistory { private: std::stackstd::unique_ptrCommand history_; // 使用智能指针管理命令对象 std::stackstd::unique_ptrCommand redoStack_; // 重做栈 public: void ExecuteCommand(std::unique_ptrCommand cmd) { if (cmd) { cmd-Execute(); history_.push(std::move(cmd)); // 命令执行后入栈 // 执行新命令时清空重做栈因为产生了新的历史分支 while (!redoStack_.empty()) { redoStack_.pop(); } std::cout “命令已执行并存入历史。\n”; } } void Undo() { if (history_.empty()) { std::cout “没有可以撤销的命令。\n”; return; } auto cmd std::move(history_.top()); history_.pop(); cmd-Undo(); redoStack_.push(std::move(cmd)); // 撤销的命令放入重做栈 std::cout “已撤销。\n”; } void Redo() { if (redoStack_.empty()) { std::cout “没有可以重做的命令。\n”; return; } auto cmd std::move(redoStack_.top()); redoStack_.pop(); cmd-Execute(); history_.push(std::move(cmd)); // 重做的命令再次进入历史栈 std::cout “已重做。\n”; } bool CanUndo() const { return !history_.empty(); } bool CanRedo() const { return !redoStack_.empty(); } };这个CommandHistory类是命令模式威力的集中体现。它维护了两个栈history_存放所有已执行且尚未被撤销的命令。redoStack_存放所有被撤销的命令。ExecuteCommand方法不仅执行命令还将其压入历史栈并清空重做栈因为新的命令执行后之前撤销的“未来”路径已经无效了。Undo和Redo就是简单的栈操作调用命令的相应接口。使用std::unique_ptr自动管理命令对象的生命周期非常安全。第五步客户端代码与演示// main.cpp #include “Document.h” #include “InsertCommand.h” // 假设还有一个 DeleteCommand.h实现类似此处省略 #include “CommandHistory.h” #include iostream int main() { Document doc; CommandHistory history; std::cout “ 开始文本编辑 \n”; // 客户端创建命令并交给历史管理器执行 // 在位置0插入”Hello” auto cmd1 std::make_uniqueInsertCommand(doc, “Hello”, 0); history.ExecuteCommand(std::move(cmd1)); // 在位置5”Hello”末尾插入”, World!” auto cmd2 std::make_uniqueInsertCommand(doc, “, World!”, 5); history.ExecuteCommand(std::move(cmd2)); std::cout “\n 执行一次撤销 \n”; history.Undo(); // 撤销插入”, World!” std::cout “文档最终内容: \”” doc.GetText() “\”\n”; std::cout “\n 执行一次重做 \n”; history.Redo(); // 重做插入”, World!” std::cout “文档最终内容: \”” doc.GetText() “\”\n”; // 尝试多次撤销 std::cout “\n 尝试撤销到最初状态 \n”; history.Undo(); // 撤销”, World!” history.Undo(); // 撤销”Hello” std::cout “文档最终内容: \”” doc.GetText() “\”\n”; std::cout “\n 尝试在空历史下撤销 \n”; history.Undo(); // 应输出”没有可以撤销的命令。” return 0; }运行这个程序你会看到完整的编辑、撤销、重做流程。客户端main函数的角色非常清晰创建具体的命令对象将它们交给CommandHistory这个调用者去调度和执行。Document对象和CommandHistory对象之间没有直接依赖命令对象InsertCommand充当了完美的中介。4. 命令模式的进阶应用与变体掌握了基础实现后我们来看看命令模式在实际项目中那些更高级、更巧妙的用法。这些变体解决了单一命令模式无法覆盖的复杂场景。4.1 宏命令Composite Command宏命令也叫组合命令是组合模式Composite Pattern与命令模式结合的产物。它允许你将多个命令组合成一个更大的命令这个宏命令的Execute()和Undo()会按顺序调用其包含的所有子命令的对应方法。应用场景批量操作。例如在图形编辑器中“全选”然后“删除”可以封装成一个宏命令在IDE中“格式化代码”“优化导入”也可以是一个宏命令。C实现示例class MacroCommand : public Command { private: std::vectorstd::unique_ptrCommand subCommands_; public: void AddCommand(std::unique_ptrCommand cmd) { if (cmd) { subCommands_.push_back(std::move(cmd)); } } void Execute() override { // 顺序执行所有子命令 for (const auto cmd : subCommands_) { cmd-Execute(); } std::cout “宏命令执行完毕包含 ” subCommands_.size() “ 个子命令。\n”; } void Undo() override { // 逆序撤销所有子命令这是关键 for (auto it subCommands_.rbegin(); it ! subCommands_.rend(); it) { (*it)-Undo(); } std::cout “宏命令已撤销。\n”; } std::string GetName() const override { return “宏命令 (” std::to_string(subCommands_.size()) “ 个子命令)”; } }; // 使用示例 auto macro std::make_uniqueMacroCommand(); macro-AddCommand(std::make_uniqueInsertCommand(doc, “First Part ”, 0)); macro-AddCommand(std::make_uniqueInsertCommand(doc, “Second Part ”, 11)); // 在”First Part “后插入 history.ExecuteCommand(std::move(macro)); // 一次Undo即可撤销整个宏命令的两个插入操作 history.Undo();实操心得实现宏命令的Undo()时必须逆序执行子命令的撤销操作后进先出LIFO这样才能正确恢复到执行前的状态。这是实现组合命令时最容易出错的地方。4.2 支持参数化的命令与命令工厂有时命令的参数在创建时并不完全确定或者我们希望命令更具通用性。我们可以将命令设计成可参数化的。一种常见做法是使用“命令工厂”或“参数对象”。// 一个更通用的命令接口支持设置参数 class ParametricCommand : public Command { public: virtual void SetParameter(const std::string key, const std::variantint, std::string, double value) 0; }; // 具体命令实现时内部存储这些参数并在Execute时使用。 // 或者更简单的方式客户端直接创建带有不同参数的命令对象。 // 使用函数对象或std::function也是一种轻量级的“命令”。 class FunctionalCommand : public Command { private: std::functionvoid() executeFunc_; std::functionvoid() undoFunc_; public: FunctionalCommand(std::functionvoid() exec, std::functionvoid() undo) : executeFunc_(std::move(exec)), undoFunc_(std::move(undo)) { if (!executeFunc_ || !undoFunc_) { throw std::invalid_argument(“执行和撤销函数不能为空”); } } void Execute() override { executeFunc_(); } void Undo() override { undoFunc_(); } std::string GetName() const override { return “函数式命令”; } }; // 使用lambda表达式创建命令非常灵活 auto funcCmd std::make_uniqueFunctionalCommand( [doc]() { doc.Insert(“Lambda Text”, 0); }, // Execute [doc]() { doc.Delete(0, 11); } // Undo (“Lambda Text”长度11) ); history.ExecuteCommand(std::move(funcCmd));使用std::function和lambda表达式的“函数式命令”非常灵活特别适合一次性、简单的命令无需为每个小操作都创建一个具体的命令类。但它牺牲了部分类型安全性和可调试性命令对象没有具体的类型名。4.3 命令队列与异步执行这是命令模式另一个强大的特性。调用者Invoker可以不立即执行命令而是将其放入一个队列中。另一个线程或同一个线程在稍后的时间点可以从队列中取出命令并执行。应用场景线程池任务调度将任务封装成命令对象提交到任务队列由工作线程消费执行。动画序列在游戏或UI中将一系列动画效果封装成命令按顺序放入队列播放。网络请求缓冲将网络请求封装成命令在网络可用时批量发送。C简单示例基于std::queue和线程#include queue #include mutex #include condition_variable #include thread #include atomic class AsyncCommandInvoker { private: std::queuestd::unique_ptrCommand commandQueue_; std::mutex queueMutex_; std::condition_variable queueCV_; std::thread workerThread_; std::atomicbool stopFlag_{false}; void WorkerThread() { while (!stopFlag_) { std::unique_ptrCommand cmd; { std::unique_lockstd::mutex lock(queueMutex_); // 等待队列非空或停止信号 queueCV_.wait(lock, [this]() { return !commandQueue_.empty() || stopFlag_; }); if (stopFlag_ commandQueue_.empty()) break; cmd std::move(commandQueue_.front()); commandQueue_.pop(); } if (cmd) { cmd-Execute(); // 注意异步执行的命令其Undo操作需要更复杂的设计如记录结果状态。 // 简单的历史栈可能不适用需要考虑线程安全。 } } } public: AsyncCommandInvoker() { workerThread_ std::thread(AsyncCommandInvoker::WorkerThread, this); } ~AsyncCommandInvoker() { stopFlag_ true; queueCV_.notify_all(); if (workerThread_.joinable()) { workerThread_.join(); } } void SubmitCommand(std::unique_ptrCommand cmd) { { std::lock_guardstd::mutex lock(queueMutex_); commandQueue_.push(std::move(cmd)); } queueCV_.notify_one(); // 通知工作线程有新的命令 } };注意事项异步命令队列引入了并发这使得撤销/重做功能变得极其复杂。因为命令的执行顺序可能不再是确定的且执行结果可能依赖于外部并发状态。在实现异步命令系统时通常需要放弃全局的、严格的撤销栈转而采用其他补偿机制如为每个命令记录执行前后的状态快照或使用Saga模式等分布式事务模式。5. 命令模式的优缺点与适用场景分析没有一种设计模式是银弹命令模式也不例外。了解它的优缺点才能决定在什么情况下该用它什么情况下可能有更好的选择。5.1 核心优势解耦调用者与接收者最重要的优点调用者如UI按钮完全不知道最终执行操作的对象是谁、具体做什么。它只依赖于抽象的Command接口。这使得调用者的代码非常稳定易于维护和扩展。支持可撤销操作如前所述通过在命令接口中增加Undo()方法并配合一个历史栈可以轻松实现撤销/重做功能。这是命令模式最经典的应用。支持命令的序列化与日志由于命令本身是一个对象它可以被序列化例如转换成JSON或二进制数据。这意味着你可以记录操作日志将执行过的命令序列化后保存到文件或数据库用于审计或调试。实现操作重放将保存的命令序列重新执行用于自动化测试或演示。实现远程操作将命令对象通过网络发送在另一台机器上执行远程过程调用RPC的一种实现思路。支持命令队列与延迟执行如前文“异步执行”部分所述命令可以很容易地被放入队列实现任务的调度、缓冲和异步化。支持宏命令通过组合模式可以轻松地将简单命令组合成复杂命令实现批量操作。5.2 主要缺点类的数量爆炸这是命令模式最显著的缺点。每一个操作甚至只是参数不同的同一操作都可能需要创建一个新的具体命令类。在一个大型系统中这会导致类的数量急剧增加增加代码的复杂性和维护成本。增加了代码的间接性原本可能只是一个简单的函数调用现在需要创建命令对象、设置接收者、传递给调用者等多个步骤。这在一定程度上降低了代码的直观性增加了理解难度。实现撤销/重做的复杂性虽然模式支持撤销但正确实现Undo()方法并非易事。特别是当命令的执行有副作用、或者系统状态在命令执行后发生其他改变时确保撤销操作能准确恢复到之前的状态是一个挑战。对于异步命令实现撤销更是难上加难。5.3 典型适用场景根据其优缺点命令模式在以下场景中大放异彩需要实现撤销/重做功能文本编辑器、图形图像软件、任何有编辑操作的应用。这是命令模式的“主场”。需要将操作参数化并排队执行任务调度系统、线程池、消息队列。将任务封装为命令对象是常见做法。需要支持事务操作数据库操作、需要原子性的一组动作。可以将一组操作封装成一个宏命令要么全部成功要么利用Undo全部回滚。GUI事件处理与按钮/菜单功能绑定将按钮的点击事件绑定到一个命令对象而不是直接调用业务逻辑。这使得UI层与业务逻辑层清晰分离并且可以动态改变按钮的行为。需要记录操作历史或支持脚本化/宏游戏中的回放系统、自动化测试脚本、办公软件的宏功能。命令对象可以被记录和序列化。5.4 何时考虑其他方案如果操作极其简单且永远不需要撤销、队列、日志等功能直接函数调用更简单高效。不要为了使用模式而使用模式。如果系统命令类型极少且固定也许使用简单的switch语句或策略枚举就够了引入完整的命令模式可能过度设计。如果性能是极端关键因素命令对象的创建、销毁和间接调用会带来微小的开销。在性能敏感的底层代码如高频交易引擎的核心循环中需要谨慎评估。作为回调的替代方案当你需要比普通函数指针或std::function更丰富的功能如撤销、序列化时才选择命令模式。如果只是需要回调C11的std::function和lambda表达式通常是更轻量、更现代的选择。命令模式是一种强大的行为型模式它通过将“请求”对象化为软件设计带来了极大的灵活性。在C中实现时要特别注意利用智能指针管理资源并仔细权衡其带来的抽象益处与增加的复杂度。在需要撤销、队列、日志或解耦调用链的场景下它是无可替代的优秀选择。
C++命令模式实战:解耦调用与实现撤销重做功能
发布时间:2026/7/9 22:54:55
1. 项目概述为什么我们需要命令模式在C项目里尤其是开发图形界面、游戏引擎或者需要实现撤销/重做功能的编辑器时我们经常会遇到一个头疼的问题一个对象比如一个按钮需要触发另一个对象比如一个文档编辑器执行某个操作。最直接的想法是按钮类里直接包含一个文档编辑器对象的指针然后调用它的Save()方法。但这样做按钮就和文档编辑器紧紧地耦合在了一起。如果明天需求变了这个按钮要改成触发网络请求或者同时触发多个操作你就得回来修改按钮类的代码。这违反了设计原则中的“开闭原则”——对扩展开放对修改关闭。命令模式就是为了解决这个“紧耦合”和“请求多样化”的问题而生的。它的核心思想非常巧妙把“请求”本身封装成一个独立的对象。这个请求对象里包含了执行操作所需的所有信息比如调用哪个对象、调用它的哪个方法、需要什么参数。这样一来发出请求的对象比如按钮就完全不需要知道具体要执行什么它只需要知道有一个“命令”对象并告诉这个对象“执行”就行了。想象一下餐厅点餐。你客户不需要冲进厨房告诉厨师怎么做菜。你只需要写一张包含菜名和要求的“订单”命令对象交给服务员调用者。服务员把订单放到后厨的队列里命令队列厨师接收者看到订单后按照上面的指示开始烹饪。这个过程中服务员完全不需要懂烹饪厨师也只需要看订单干活你和厨师之间没有直接交流整个流程非常清晰、灵活。命令模式在软件里扮演的就是这个“订单”的角色。2. 命令模式的核心角色与UML类图解析要彻底理解命令模式我们必须先搞清楚参与其中的几个关键角色以及它们之间的关系。这就像理解一部戏剧得先认识每个演员和他们的职责。2.1 四大核心角色1. 命令 (Command):这是一个抽象基类或接口它定义了一个所有具体命令都必须实现的方法通常是Execute()。这个方法就是命令的“执行”接口。它相当于餐厅订单的通用格式规定了订单上必须要有“开始制作”这个动作项。2. 具体命令 (ConcreteCommand):这是命令接口的具体实现类。它有几个关键职责绑定接收者在它的构造函数或初始化方法中它会关联一个或多个“接收者”对象。这个接收者就是最终干活的那个对象。实现Execute()在这个方法里它会调用接收者对象的一个或一系列具体方法来完成实际的业务逻辑。它知道“做什么”和“对谁做”。这就像一张具体的“鱼香肉丝炒单”上面写着交给“王厨师”执行“炒鱼香肉丝”方法参数是“少辣”。3. 接收者 (Receiver):这是真正执行业务逻辑的对象。它知道如何完成具体的操作。一个系统中可以有多个接收者。在我们的例子里Receiver类就是接收者它有DoSomething和DoSomethingElse这些实际干活的方法。厨师就是接收者。4. 调用者/请求者 (Invoker):这个对象负责触发命令。它持有一个或多个命令对象的引用或指针。当某个事件发生时比如按钮被点击、定时器到期调用者就会调用所持命令对象的Execute()方法。它完全不知道这个命令具体做了什么它只负责“发号施令”。服务员就是调用者。可选客户端 (Client):创建具体命令对象并设置其接收者的对象。它负责组装命令和接收者并把组装好的命令交给调用者。在示例的main函数里创建SimpleCommand和ComplexCommand并将它们设置给Invoker的代码就是客户端的行为。你就是下订单的顾客。2.2 UML类图与协作关系理解了角色我们来看它们是如何协作的。下面这个类图清晰地展示了它们之间的关系---------------- 持有 ---------------------- | Client |---------------| Invoker | ---------------- ---------------------- | | - command: Command* | | 创建并组装 | SetCommand(cmd) | V | ExecuteCommand() | ---------------- --------------------- | Command |--------------------------- ---------------- 持有并调用 | Execute()0 | ---------------- ^ | 实现 ---------------------------------- | | ------------------------------- ------------------------------- | ConcreteCommandA | | ConcreteCommandB | ------------------------------- ------------------------------- | - receiver: Receiver* | | - receiver: Receiver* | | - state: string | | - state_a: string | | | | - state_b: string | ------------------------------- ------------------------------- | Execute() override | | Execute() override | | { receiver-Action(state); }| | { receiver-DoSomething(a); | | | | receiver-DoSomethingElse(b);} ------------------------------- ------------------------------- | | | 知晓并调用 | 知晓并调用 V V ------------------------------- ------------------------------- | Receiver | | AnotherReceiver | ------------------------------- ------------------------------- | Action(string) | | DoSomething(string) | | AnotherAction(string) | | DoSomethingElse(string) | ------------------------------- -------------------------------协作流程通常如下客户端创建一个Receiver对象接收者。客户端创建一个ConcreteCommand对象具体命令并在构造时传入上一步的Receiver以及任何必要的参数。客户端将创建好的ConcreteCommand对象设置给Invoker调用者。在未来的某个时刻客户端或其他代码触发Invoker。Invoker调用其持有的Command对象的Execute()方法。ConcreteCommand对象的Execute()方法被调用它内部调用Receiver对象的方法执行实际的操作。注意在实际的C代码中你需要特别注意对象的所有权和生命周期管理。上面的UML图中使用原始指针是为了清晰但在现代C中更推荐使用智能指针如std::unique_ptr或std::shared_ptr来管理这些动态分配的对象避免内存泄漏。例如Invoker持有Command时可以使用std::unique_ptrCommand。3. 从零开始一个完整的C命令模式实现示例理论讲得再多不如一行代码来得实在。让我们抛开那些简单的cout示例来构建一个更贴近实战的场景一个简单的文本编辑器它支持撤销Undo功能。撤销功能是命令模式的“杀手级”应用。3.1 场景定义与类设计假设我们的编辑器有一个Document类接收者它支持插入文本和删除文本。我们需要实现一个InsertCommand和一个DeleteCommand。此外还需要一个CommandHistory类一个增强版的Invoker来管理命令的执行和撤销。第一步定义接收者Receiver—— Document类// Document.h / Document.cpp #include string class Document { private: std::string text_; public: Document() : text_() {} void Insert(const std::string newText, size_t position) { // 在实际项目中这里需要更严谨的边界检查 if (position text_.length()) position text_.length(); text_.insert(position, newText); std::cout 文档在位置 position 插入了: \ newText \\n; std::cout 当前文档内容: \ text_ \\n; } void Delete(size_t position, size_t length) { if (position text_.length()) { std::cout 删除位置超出文档长度。\n; return; } if (position length text_.length()) { length text_.length() - position; // 删除到末尾 } std::string deleted text_.substr(position, length); text_.erase(position, length); std::cout 文档从位置 position 删除了 length 个字符: \ deleted \\n; std::cout 当前文档内容: \ text_ \\n; } const std::string GetText() const { return text_; } };这个Document类很简单就是封装了一个std::string作为文本内容并提供了插入和删除两个核心方法。注意这里的实现为了清晰忽略了部分错误处理实际开发中必须补全。第二步定义抽象命令接口Command// Command.h #include memory class Command { public: virtual ~Command() default; // 基类虚析构函数确保正确释放派生类资源 virtual void Execute() 0; virtual void Undo() 0; // 新增Undo接口用于支持撤销 virtual std::string GetName() const 0; // 可选用于调试 };这里的关键是增加了Undo()纯虚函数。一个完整的、支持撤销的命令必须知道如何逆转自己的Execute()操作。第三步实现具体命令ConcreteCommand—— InsertCommand// InsertCommand.h #include “Command.h” #include “Document.h” #include string class InsertCommand : public Command { private: Document* document_; // 接收者 std::string textToInsert_; size_t insertPosition_; // 为了支持Undo我们需要记录插入的文本和位置。 // 实际上Undo就是删除我们刚刚插入的文本。 public: InsertCommand(Document* doc, const std::string text, size_t pos) : document_(doc), textToInsert_(text), insertPosition_(pos) { // 这里可以预先计算或保存一些状态用于Undo。 // 对于InsertUndo需要知道插入的文本和位置。 } void Execute() override { if (document_) { document_-Insert(textToInsert_, insertPosition_); } } void Undo() override { if (document_) { // 撤销插入操作 从原位置删除插入的文本 // 注意这里假设文档在执行此命令后insertPosition_之后的内容没有因其他命令而改变。 // 在复杂场景下需要更严谨的状态管理。 document_-Delete(insertPosition_, textToInsert_.length()); } } std::string GetName() const override { return “插入命令 (\” textToInsert_ “\” 于位置 ” std::to_string(insertPosition_) “)”; } };InsertCommand在构造时绑定了Document接收者并记录了要插入的文本和位置。Execute()就是调用Document::Insert。Undo()的逻辑则是调用Document::Delete来删除刚刚插入的文本。这里有一个重要的隐含前提在Execute()和Undo()之间文档在相关位置没有发生其他变化。在单线程、顺序执行的简单编辑器中这成立但在复杂并发环境下需要引入更复杂的“快照”或“操作变换(OT)”机制。第四步实现命令历史管理器Invoker的增强版—— CommandHistory// CommandHistory.h #include “Command.h” #include stack #include memory class CommandHistory { private: std::stackstd::unique_ptrCommand history_; // 使用智能指针管理命令对象 std::stackstd::unique_ptrCommand redoStack_; // 重做栈 public: void ExecuteCommand(std::unique_ptrCommand cmd) { if (cmd) { cmd-Execute(); history_.push(std::move(cmd)); // 命令执行后入栈 // 执行新命令时清空重做栈因为产生了新的历史分支 while (!redoStack_.empty()) { redoStack_.pop(); } std::cout “命令已执行并存入历史。\n”; } } void Undo() { if (history_.empty()) { std::cout “没有可以撤销的命令。\n”; return; } auto cmd std::move(history_.top()); history_.pop(); cmd-Undo(); redoStack_.push(std::move(cmd)); // 撤销的命令放入重做栈 std::cout “已撤销。\n”; } void Redo() { if (redoStack_.empty()) { std::cout “没有可以重做的命令。\n”; return; } auto cmd std::move(redoStack_.top()); redoStack_.pop(); cmd-Execute(); history_.push(std::move(cmd)); // 重做的命令再次进入历史栈 std::cout “已重做。\n”; } bool CanUndo() const { return !history_.empty(); } bool CanRedo() const { return !redoStack_.empty(); } };这个CommandHistory类是命令模式威力的集中体现。它维护了两个栈history_存放所有已执行且尚未被撤销的命令。redoStack_存放所有被撤销的命令。ExecuteCommand方法不仅执行命令还将其压入历史栈并清空重做栈因为新的命令执行后之前撤销的“未来”路径已经无效了。Undo和Redo就是简单的栈操作调用命令的相应接口。使用std::unique_ptr自动管理命令对象的生命周期非常安全。第五步客户端代码与演示// main.cpp #include “Document.h” #include “InsertCommand.h” // 假设还有一个 DeleteCommand.h实现类似此处省略 #include “CommandHistory.h” #include iostream int main() { Document doc; CommandHistory history; std::cout “ 开始文本编辑 \n”; // 客户端创建命令并交给历史管理器执行 // 在位置0插入”Hello” auto cmd1 std::make_uniqueInsertCommand(doc, “Hello”, 0); history.ExecuteCommand(std::move(cmd1)); // 在位置5”Hello”末尾插入”, World!” auto cmd2 std::make_uniqueInsertCommand(doc, “, World!”, 5); history.ExecuteCommand(std::move(cmd2)); std::cout “\n 执行一次撤销 \n”; history.Undo(); // 撤销插入”, World!” std::cout “文档最终内容: \”” doc.GetText() “\”\n”; std::cout “\n 执行一次重做 \n”; history.Redo(); // 重做插入”, World!” std::cout “文档最终内容: \”” doc.GetText() “\”\n”; // 尝试多次撤销 std::cout “\n 尝试撤销到最初状态 \n”; history.Undo(); // 撤销”, World!” history.Undo(); // 撤销”Hello” std::cout “文档最终内容: \”” doc.GetText() “\”\n”; std::cout “\n 尝试在空历史下撤销 \n”; history.Undo(); // 应输出”没有可以撤销的命令。” return 0; }运行这个程序你会看到完整的编辑、撤销、重做流程。客户端main函数的角色非常清晰创建具体的命令对象将它们交给CommandHistory这个调用者去调度和执行。Document对象和CommandHistory对象之间没有直接依赖命令对象InsertCommand充当了完美的中介。4. 命令模式的进阶应用与变体掌握了基础实现后我们来看看命令模式在实际项目中那些更高级、更巧妙的用法。这些变体解决了单一命令模式无法覆盖的复杂场景。4.1 宏命令Composite Command宏命令也叫组合命令是组合模式Composite Pattern与命令模式结合的产物。它允许你将多个命令组合成一个更大的命令这个宏命令的Execute()和Undo()会按顺序调用其包含的所有子命令的对应方法。应用场景批量操作。例如在图形编辑器中“全选”然后“删除”可以封装成一个宏命令在IDE中“格式化代码”“优化导入”也可以是一个宏命令。C实现示例class MacroCommand : public Command { private: std::vectorstd::unique_ptrCommand subCommands_; public: void AddCommand(std::unique_ptrCommand cmd) { if (cmd) { subCommands_.push_back(std::move(cmd)); } } void Execute() override { // 顺序执行所有子命令 for (const auto cmd : subCommands_) { cmd-Execute(); } std::cout “宏命令执行完毕包含 ” subCommands_.size() “ 个子命令。\n”; } void Undo() override { // 逆序撤销所有子命令这是关键 for (auto it subCommands_.rbegin(); it ! subCommands_.rend(); it) { (*it)-Undo(); } std::cout “宏命令已撤销。\n”; } std::string GetName() const override { return “宏命令 (” std::to_string(subCommands_.size()) “ 个子命令)”; } }; // 使用示例 auto macro std::make_uniqueMacroCommand(); macro-AddCommand(std::make_uniqueInsertCommand(doc, “First Part ”, 0)); macro-AddCommand(std::make_uniqueInsertCommand(doc, “Second Part ”, 11)); // 在”First Part “后插入 history.ExecuteCommand(std::move(macro)); // 一次Undo即可撤销整个宏命令的两个插入操作 history.Undo();实操心得实现宏命令的Undo()时必须逆序执行子命令的撤销操作后进先出LIFO这样才能正确恢复到执行前的状态。这是实现组合命令时最容易出错的地方。4.2 支持参数化的命令与命令工厂有时命令的参数在创建时并不完全确定或者我们希望命令更具通用性。我们可以将命令设计成可参数化的。一种常见做法是使用“命令工厂”或“参数对象”。// 一个更通用的命令接口支持设置参数 class ParametricCommand : public Command { public: virtual void SetParameter(const std::string key, const std::variantint, std::string, double value) 0; }; // 具体命令实现时内部存储这些参数并在Execute时使用。 // 或者更简单的方式客户端直接创建带有不同参数的命令对象。 // 使用函数对象或std::function也是一种轻量级的“命令”。 class FunctionalCommand : public Command { private: std::functionvoid() executeFunc_; std::functionvoid() undoFunc_; public: FunctionalCommand(std::functionvoid() exec, std::functionvoid() undo) : executeFunc_(std::move(exec)), undoFunc_(std::move(undo)) { if (!executeFunc_ || !undoFunc_) { throw std::invalid_argument(“执行和撤销函数不能为空”); } } void Execute() override { executeFunc_(); } void Undo() override { undoFunc_(); } std::string GetName() const override { return “函数式命令”; } }; // 使用lambda表达式创建命令非常灵活 auto funcCmd std::make_uniqueFunctionalCommand( [doc]() { doc.Insert(“Lambda Text”, 0); }, // Execute [doc]() { doc.Delete(0, 11); } // Undo (“Lambda Text”长度11) ); history.ExecuteCommand(std::move(funcCmd));使用std::function和lambda表达式的“函数式命令”非常灵活特别适合一次性、简单的命令无需为每个小操作都创建一个具体的命令类。但它牺牲了部分类型安全性和可调试性命令对象没有具体的类型名。4.3 命令队列与异步执行这是命令模式另一个强大的特性。调用者Invoker可以不立即执行命令而是将其放入一个队列中。另一个线程或同一个线程在稍后的时间点可以从队列中取出命令并执行。应用场景线程池任务调度将任务封装成命令对象提交到任务队列由工作线程消费执行。动画序列在游戏或UI中将一系列动画效果封装成命令按顺序放入队列播放。网络请求缓冲将网络请求封装成命令在网络可用时批量发送。C简单示例基于std::queue和线程#include queue #include mutex #include condition_variable #include thread #include atomic class AsyncCommandInvoker { private: std::queuestd::unique_ptrCommand commandQueue_; std::mutex queueMutex_; std::condition_variable queueCV_; std::thread workerThread_; std::atomicbool stopFlag_{false}; void WorkerThread() { while (!stopFlag_) { std::unique_ptrCommand cmd; { std::unique_lockstd::mutex lock(queueMutex_); // 等待队列非空或停止信号 queueCV_.wait(lock, [this]() { return !commandQueue_.empty() || stopFlag_; }); if (stopFlag_ commandQueue_.empty()) break; cmd std::move(commandQueue_.front()); commandQueue_.pop(); } if (cmd) { cmd-Execute(); // 注意异步执行的命令其Undo操作需要更复杂的设计如记录结果状态。 // 简单的历史栈可能不适用需要考虑线程安全。 } } } public: AsyncCommandInvoker() { workerThread_ std::thread(AsyncCommandInvoker::WorkerThread, this); } ~AsyncCommandInvoker() { stopFlag_ true; queueCV_.notify_all(); if (workerThread_.joinable()) { workerThread_.join(); } } void SubmitCommand(std::unique_ptrCommand cmd) { { std::lock_guardstd::mutex lock(queueMutex_); commandQueue_.push(std::move(cmd)); } queueCV_.notify_one(); // 通知工作线程有新的命令 } };注意事项异步命令队列引入了并发这使得撤销/重做功能变得极其复杂。因为命令的执行顺序可能不再是确定的且执行结果可能依赖于外部并发状态。在实现异步命令系统时通常需要放弃全局的、严格的撤销栈转而采用其他补偿机制如为每个命令记录执行前后的状态快照或使用Saga模式等分布式事务模式。5. 命令模式的优缺点与适用场景分析没有一种设计模式是银弹命令模式也不例外。了解它的优缺点才能决定在什么情况下该用它什么情况下可能有更好的选择。5.1 核心优势解耦调用者与接收者最重要的优点调用者如UI按钮完全不知道最终执行操作的对象是谁、具体做什么。它只依赖于抽象的Command接口。这使得调用者的代码非常稳定易于维护和扩展。支持可撤销操作如前所述通过在命令接口中增加Undo()方法并配合一个历史栈可以轻松实现撤销/重做功能。这是命令模式最经典的应用。支持命令的序列化与日志由于命令本身是一个对象它可以被序列化例如转换成JSON或二进制数据。这意味着你可以记录操作日志将执行过的命令序列化后保存到文件或数据库用于审计或调试。实现操作重放将保存的命令序列重新执行用于自动化测试或演示。实现远程操作将命令对象通过网络发送在另一台机器上执行远程过程调用RPC的一种实现思路。支持命令队列与延迟执行如前文“异步执行”部分所述命令可以很容易地被放入队列实现任务的调度、缓冲和异步化。支持宏命令通过组合模式可以轻松地将简单命令组合成复杂命令实现批量操作。5.2 主要缺点类的数量爆炸这是命令模式最显著的缺点。每一个操作甚至只是参数不同的同一操作都可能需要创建一个新的具体命令类。在一个大型系统中这会导致类的数量急剧增加增加代码的复杂性和维护成本。增加了代码的间接性原本可能只是一个简单的函数调用现在需要创建命令对象、设置接收者、传递给调用者等多个步骤。这在一定程度上降低了代码的直观性增加了理解难度。实现撤销/重做的复杂性虽然模式支持撤销但正确实现Undo()方法并非易事。特别是当命令的执行有副作用、或者系统状态在命令执行后发生其他改变时确保撤销操作能准确恢复到之前的状态是一个挑战。对于异步命令实现撤销更是难上加难。5.3 典型适用场景根据其优缺点命令模式在以下场景中大放异彩需要实现撤销/重做功能文本编辑器、图形图像软件、任何有编辑操作的应用。这是命令模式的“主场”。需要将操作参数化并排队执行任务调度系统、线程池、消息队列。将任务封装为命令对象是常见做法。需要支持事务操作数据库操作、需要原子性的一组动作。可以将一组操作封装成一个宏命令要么全部成功要么利用Undo全部回滚。GUI事件处理与按钮/菜单功能绑定将按钮的点击事件绑定到一个命令对象而不是直接调用业务逻辑。这使得UI层与业务逻辑层清晰分离并且可以动态改变按钮的行为。需要记录操作历史或支持脚本化/宏游戏中的回放系统、自动化测试脚本、办公软件的宏功能。命令对象可以被记录和序列化。5.4 何时考虑其他方案如果操作极其简单且永远不需要撤销、队列、日志等功能直接函数调用更简单高效。不要为了使用模式而使用模式。如果系统命令类型极少且固定也许使用简单的switch语句或策略枚举就够了引入完整的命令模式可能过度设计。如果性能是极端关键因素命令对象的创建、销毁和间接调用会带来微小的开销。在性能敏感的底层代码如高频交易引擎的核心循环中需要谨慎评估。作为回调的替代方案当你需要比普通函数指针或std::function更丰富的功能如撤销、序列化时才选择命令模式。如果只是需要回调C11的std::function和lambda表达式通常是更轻量、更现代的选择。命令模式是一种强大的行为型模式它通过将“请求”对象化为软件设计带来了极大的灵活性。在C中实现时要特别注意利用智能指针管理资源并仔细权衡其带来的抽象益处与增加的复杂度。在需要撤销、队列、日志或解耦调用链的场景下它是无可替代的优秀选择。