C++命令模式实战:解耦调用与实现撤销重做功能
2026/7/9 22:54:35 网站建设 项目流程

1. 项目概述:为什么我们需要命令模式?

在C++项目里,尤其是开发图形界面、游戏引擎或者需要实现撤销/重做功能的编辑器时,我们经常会遇到一个头疼的问题:一个对象(比如一个按钮)需要触发另一个对象(比如一个文档编辑器)执行某个操作。最直接的想法是,按钮类里直接包含一个文档编辑器对象的指针,然后调用它的Save()方法。但这样做,按钮就和文档编辑器紧紧地耦合在了一起。如果明天需求变了,这个按钮要改成触发网络请求,或者同时触发多个操作,你就得回来修改按钮类的代码。这违反了设计原则中的“开闭原则”——对扩展开放,对修改关闭。

命令模式就是为了解决这个“紧耦合”和“请求多样化”的问题而生的。它的核心思想非常巧妙:把“请求”本身封装成一个独立的对象。这个请求对象里包含了执行操作所需的所有信息(比如调用哪个对象、调用它的哪个方法、需要什么参数)。这样一来,发出请求的对象(比如按钮)就完全不需要知道具体要执行什么,它只需要知道有一个“命令”对象,并告诉这个对象“执行”就行了。

想象一下餐厅点餐。你(客户)不需要冲进厨房告诉厨师怎么做菜。你只需要写一张包含菜名和要求的“订单”(命令对象),交给服务员(调用者)。服务员把订单放到后厨的队列里(命令队列),厨师(接收者)看到订单后,按照上面的指示开始烹饪。这个过程中,服务员完全不需要懂烹饪,厨师也只需要看订单干活,你和厨师之间没有直接交流,整个流程非常清晰、灵活。命令模式在软件里扮演的就是这个“订单”的角色。

2. 命令模式的核心角色与UML类图解析

要彻底理解命令模式,我们必须先搞清楚参与其中的几个关键角色,以及它们之间的关系。这就像理解一部戏剧,得先认识每个演员和他们的职责。

2.1 四大核心角色

1. 命令 (Command):这是一个抽象基类或接口,它定义了一个所有具体命令都必须实现的方法,通常是Execute()。这个方法就是命令的“执行”接口。它相当于餐厅订单的通用格式,规定了订单上必须要有“开始制作”这个动作项。

2. 具体命令 (ConcreteCommand):这是命令接口的具体实现类。它有几个关键职责:

  • 绑定接收者:在它的构造函数或初始化方法中,它会关联一个或多个“接收者”对象。这个接收者就是最终干活的那个对象。
  • 实现Execute():在这个方法里,它会调用接收者对象的一个或一系列具体方法,来完成实际的业务逻辑。它知道“做什么”和“对谁做”。这就像一张具体的“鱼香肉丝炒单”,上面写着:交给“王厨师”,执行“炒鱼香肉丝”方法,参数是“少辣”。

3. 接收者 (Receiver):这是真正执行业务逻辑的对象。它知道如何完成具体的操作。一个系统中可以有多个接收者。在我们的例子里,Receiver类就是接收者,它有DoSomethingDoSomethingElse这些实际干活的方法。厨师就是接收者。

4. 调用者/请求者 (Invoker):这个对象负责触发命令。它持有一个或多个命令对象的引用(或指针)。当某个事件发生时(比如按钮被点击、定时器到期),调用者就会调用所持命令对象的Execute()方法。它完全不知道这个命令具体做了什么,它只负责“发号施令”。服务员就是调用者。

(可选)客户端 (Client):创建具体命令对象,并设置其接收者的对象。它负责组装命令和接收者,并把组装好的命令交给调用者。在示例的main函数里,创建SimpleCommandComplexCommand,并将它们设置给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) | +-------------------------------+ +-------------------------------+

协作流程通常如下:

  1. 客户端创建一个Receiver对象(接收者)。
  2. 客户端创建一个ConcreteCommand对象(具体命令),并在构造时传入上一步的Receiver以及任何必要的参数。
  3. 客户端将创建好的ConcreteCommand对象设置给Invoker(调用者)。
  4. 在未来的某个时刻,客户端或其他代码触发Invoker
  5. Invoker调用其持有的Command对象的Execute()方法。
  6. ConcreteCommand对象的Execute()方法被调用,它内部调用Receiver对象的方法,执行实际的操作。

注意:在实际的C++代码中,你需要特别注意对象的所有权和生命周期管理。上面的UML图中使用原始指针是为了清晰,但在现代C++中,更推荐使用智能指针(如std::unique_ptrstd::shared_ptr)来管理这些动态分配的对象,避免内存泄漏。例如,Invoker持有Command时,可以使用std::unique_ptr<Command>

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。 // 对于Insert,Undo需要知道插入的文本和位置。 } 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::InsertUndo()的逻辑则是调用Document::Delete来删除刚刚插入的文本。这里有一个重要的隐含前提:在Execute()Undo()之间,文档在相关位置没有发生其他变化。在单线程、顺序执行的简单编辑器中这成立,但在复杂并发环境下,需要引入更复杂的“快照”或“操作变换(OT)”机制。

第四步:实现命令历史管理器(Invoker的增强版)—— CommandHistory

// CommandHistory.h #include “Command.h” #include <stack> #include <memory> class CommandHistory { private: std::stack<std::unique_ptr<Command>> history_; // 使用智能指针管理命令对象 std::stack<std::unique_ptr<Command>> redoStack_; // 重做栈 public: void ExecuteCommand(std::unique_ptr<Command> 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方法不仅执行命令,还将其压入历史栈,并清空重做栈(因为新的命令执行后,之前撤销的“未来”路径已经无效了)。UndoRedo就是简单的栈操作,调用命令的相应接口。使用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_unique<InsertCommand>(&doc, “Hello”, 0); history.ExecuteCommand(std::move(cmd1)); // 在位置5(”Hello”末尾)插入”, World!” auto cmd2 = std::make_unique<InsertCommand>(&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::vector<std::unique_ptr<Command>> subCommands_; public: void AddCommand(std::unique_ptr<Command> 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_unique<MacroCommand>(); macro->AddCommand(std::make_unique<InsertCommand>(&doc, “First Part ”, 0)); macro->AddCommand(std::make_unique<InsertCommand>(&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::variant<int, std::string, double>& value) = 0; }; // 具体命令实现时,内部存储这些参数,并在Execute时使用。 // 或者,更简单的方式:客户端直接创建带有不同参数的命令对象。 // 使用函数对象或std::function也是一种轻量级的“命令”。 class FunctionalCommand : public Command { private: std::function<void()> executeFunc_; std::function<void()> undoFunc_; public: FunctionalCommand(std::function<void()> exec, std::function<void()> 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_unique<FunctionalCommand>( [&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::queue<std::unique_ptr<Command>> commandQueue_; std::mutex queueMutex_; std::condition_variable queueCV_; std::thread workerThread_; std::atomic<bool> stopFlag_{false}; void WorkerThread() { while (!stopFlag_) { std::unique_ptr<Command> cmd; { std::unique_lock<std::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_ptr<Command> cmd) { { std::lock_guard<std::mutex> lock(queueMutex_); commandQueue_.push(std::move(cmd)); } queueCV_.notify_one(); // 通知工作线程有新的命令 } };

注意事项:异步命令队列引入了并发,这使得撤销/重做功能变得极其复杂。因为命令的执行顺序可能不再是确定的,且执行结果可能依赖于外部并发状态。在实现异步命令系统时,通常需要放弃全局的、严格的撤销栈,转而采用其他补偿机制(如为每个命令记录执行前后的状态快照,或使用Saga模式等分布式事务模式)。

5. 命令模式的优缺点与适用场景分析

没有一种设计模式是银弹,命令模式也不例外。了解它的优缺点,才能决定在什么情况下该用它,什么情况下可能有更好的选择。

5.1 核心优势

  1. 解耦调用者与接收者(最重要的优点):调用者(如UI按钮)完全不知道最终执行操作的对象是谁、具体做什么。它只依赖于抽象的Command接口。这使得调用者的代码非常稳定,易于维护和扩展。
  2. 支持可撤销操作:如前所述,通过在命令接口中增加Undo()方法,并配合一个历史栈,可以轻松实现撤销/重做功能。这是命令模式最经典的应用。
  3. 支持命令的序列化与日志:由于命令本身是一个对象,它可以被序列化(例如,转换成JSON或二进制数据)。这意味着你可以:
    • 记录操作日志:将执行过的命令序列化后保存到文件或数据库,用于审计或调试。
    • 实现操作重放:将保存的命令序列重新执行,用于自动化测试或演示。
    • 实现远程操作:将命令对象通过网络发送,在另一台机器上执行(远程过程调用RPC的一种实现思路)。
  4. 支持命令队列与延迟执行:如前文“异步执行”部分所述,命令可以很容易地被放入队列,实现任务的调度、缓冲和异步化。
  5. 支持宏命令:通过组合模式,可以轻松地将简单命令组合成复杂命令,实现批量操作。

5.2 主要缺点

  1. 类的数量爆炸:这是命令模式最显著的缺点。每一个操作(甚至只是参数不同的同一操作)都可能需要创建一个新的具体命令类。在一个大型系统中,这会导致类的数量急剧增加,增加代码的复杂性和维护成本。
  2. 增加了代码的间接性:原本可能只是一个简单的函数调用,现在需要创建命令对象、设置接收者、传递给调用者等多个步骤。这在一定程度上降低了代码的直观性,增加了理解难度。
  3. 实现撤销/重做的复杂性:虽然模式支持撤销,但正确实现Undo()方法并非易事。特别是当命令的执行有副作用、或者系统状态在命令执行后发生其他改变时,确保撤销操作能准确恢复到之前的状态是一个挑战。对于异步命令,实现撤销更是难上加难。

5.3 典型适用场景

根据其优缺点,命令模式在以下场景中大放异彩:

  • 需要实现撤销/重做功能:文本编辑器、图形图像软件、任何有编辑操作的应用。这是命令模式的“主场”。
  • 需要将操作参数化并排队执行:任务调度系统、线程池、消息队列。将任务封装为命令对象是常见做法。
  • 需要支持事务操作:数据库操作、需要原子性的一组动作。可以将一组操作封装成一个宏命令,要么全部成功,要么利用Undo全部回滚。
  • GUI事件处理与按钮/菜单功能绑定:将按钮的点击事件绑定到一个命令对象,而不是直接调用业务逻辑。这使得UI层与业务逻辑层清晰分离,并且可以动态改变按钮的行为。
  • 需要记录操作历史或支持脚本化/宏:游戏中的回放系统、自动化测试脚本、办公软件的宏功能。命令对象可以被记录和序列化。

5.4 何时考虑其他方案?

  • 如果操作极其简单且永远不需要撤销、队列、日志等功能:直接函数调用更简单高效。不要为了使用模式而使用模式。
  • 如果系统命令类型极少且固定:也许使用简单的switch语句或策略枚举就够了,引入完整的命令模式可能过度设计。
  • 如果性能是极端关键因素:命令对象的创建、销毁和间接调用会带来微小的开销。在性能敏感的底层代码(如高频交易引擎的核心循环)中需要谨慎评估。
  • 作为回调的替代方案:当你需要比普通函数指针或std::function更丰富的功能(如撤销、序列化)时,才选择命令模式。如果只是需要回调,C++11的std::function和lambda表达式通常是更轻量、更现代的选择。

命令模式是一种强大的行为型模式,它通过将“请求”对象化,为软件设计带来了极大的灵活性。在C++中实现时,要特别注意利用智能指针管理资源,并仔细权衡其带来的抽象益处与增加的复杂度。在需要撤销、队列、日志或解耦调用链的场景下,它是无可替代的优秀选择。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询