☰
C++命令模式实战:从原理到撤销重做系统设计
2026/10/9 14:51:43 网站建设 项目流程

写命令模式的文章前,我先讲个真实场景。早些年我在做一个编辑器插件,菜单栏里挂着一堆功能按钮:插入文字、删除、格式化、批量替换。最开始我偷懒,按钮直接调用各自模块的函数,比如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 适合用命令模式的四个信号

什么时候值得引入命令模式?我一般只看这四个信号:

  1. 需要撤销/重做。命令对象天然能携带逆操作,历史栈一压,撤销就做完了。
  2. 需要操作队列或延迟执行。比如用户点的按钮不一定要立即执行,可以放进队列排队,命令对象在队列里是标准元素。
  3. 需要宏命令或批量事务。多个命令可以组合成一个复合命令,整体执行、整体回滚。
  4. 需要操作日志或远程调用。把命令序列化成字节流,发到另一个进程执行,这不就是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 + lambda)

C++11之后,我大部分新项目已经不用虚函数写命令了,直接用std::function和lambda表达式,代码会极度精简。

class Command { public: std::function<void()> execute; std::function<void()> 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_ptr<TextBuffer>,然后lambda值捕获一份。后面踩坑部分我会详细讲生命周期问题。

std::function版本最大的优点是写起来快,命令逻辑就地实现,不需要为每个操作单独建类。缺点是lambda捕获的变量生命周期管理需要你自己负责;另外调试时lambda的调用堆栈信息不如具名类直观,出错时没那么好定位。

2.3 函数指针与参数包(轻量版)

如果你只是需要一个简单的菜单映射,不涉及撤销,也可以用函数指针加std::bind(或者std::bind_front,C++20):

std::map<std::string, std::function<void()>> 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 系统需求定义

这块我们做个贴近现实的实践:一个命令行文本缓冲编辑器,支持插入、删除,并且可以用Ctrl+Z撤销、Ctrl+Y重做。界面交互我们忽略,聚焦核心命令体系。

先定义接收者,一个极简的文本缓冲区:

#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_ptr<EditorCommand> 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::vector<std::unique_ptr<EditorCommand>> undoStack_; std::vector<std::unique_ptr<EditorCommand>> redoStack_; };

std::unique_ptr在这里很重要,它明确表达了所有权语义。History独占命令对象,栈弹出的过程就是所有权转移,不会出现多位置同时持有同一个命令导致重复执行的问题。

调用方式像这样:

int main() { TextBuffer buffer; History history; // 模拟输入 "hello" history.execute(std::make_unique<InsertCommand>(buffer, 0, "hello")); std::cout << buffer.content() << "\n"; // hello // 模拟删除最后两个字符 "lo" history.execute(std::make_unique<DeleteCommand>(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::pair<std::function<void()>, std::function<void()>>; 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::vector<EditorCommand> undoStack_; std::vector<EditorCommand> 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_ptr<EditorCommand> 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::vector<std::unique_ptr<EditorCommand>> commands_; };

这里要特别强调撤销顺序。执行的顺序是正序,撤销的顺序必须是严格逆序。比如先插入文字再移动光标,撤销时必须先恢复光标位置,再删除文字,顺序反了轻则状态错乱,重则数据丢失。

组合模式经常和命令模式嵌套使用。宏命令内部还可以包含宏命令,形成树状结构。这种递归组合是命令模式最有威力的扩展方式。

4.2 事务式命令:要么全成功,要么全回滚

在数据库操作、文件批量处理等场景,命令执行到一半可能失败。如果只执行了一半,你要把所有已完成命令全部回滚,回到初始状态。

实现方式也不复杂,让每条命令提供bool canExecute()来提前校验,然后在MacroCommand::execute()里逐条执行,一旦失败就回滚已执行的命令。

class TransactionalMacro : public EditorCommand { public: void addOnce(std::unique_ptr<EditorCommand> 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::vector<std::unique_ptr<EditorCommand>> commands_; std::vector<EditorCommand*> 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::function<void()> task; { std::unique_lock<std::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::function<void()> task) { { std::lock_guard<std::mutex> lock(mutex_); tasks_.push_back(std::move(task)); } cv_.notify_one(); } ~TaskQueue() { { std::lock_guard<std::mutex> lock(mutex_); stop_ = true; } cv_.notify_all(); for (auto& worker : workers_) worker.join(); } private: std::deque<std::function<void()>> tasks_; std::vector<std::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_ptr<TextBuffer>,命令捕获指针而不是引用:

class InsertCommand : public EditorCommand { public: InsertCommand(std::shared_ptr<TextBuffer> 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_ptr<TextBuffer> buf_; size_t pos_; std::string text_; };

5.2 lambda捕获陷阱

这坑太经典了。在函数里写[&]捕获局部变量,然后命令对象被存储到其他地方,函数退出后局部变量已经销毁:

// 错误示范 History h; auto badCommand = [&] { std::string localText = "temporary"; buffer.insert(0, localText); // 这里的buffer如果是局部引用,危险 };

正确做法要么用值捕获并std::move:

std::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++的三种实现、完整编辑器案例、进阶扩展和坑位排查。最后再分享一个个人体会:真正用好命令模式,关键不在于背出四个角色的定义,而在于你在写第一个需求时就想清楚“这个操作需不需要被记住、被回放、被组合”。把这个问题想透,命令模式用起来就会非常顺手。

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

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

立即咨询