很多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”,再按Ctrl+Z,你希望文档回到“加粗,但没有插入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与历史栈:Ctrl+Z到底怎么实现
有了ICommand和具体命令,接下来需要历史管理器。这个类就是典型Invoker,负责执行命令、记录历史、提供撤销与重做:
class History { public: void Execute(std::unique_ptr<ICommand> 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::vector<std::unique_ptr<ICommand>> done_; std::vector<std::unique_ptr<ICommand>> 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_unique<InsertTextCommand>(buffer, 0, "Hi")); // 用户继续输入 "!" history.Execute( std::make_unique<InsertTextCommand>(buffer, 2, "!")); // 用户在 UI 上按了 Ctrl+Z,撤销最后一步 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::function<void()>; 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::function<void()>只有一个执行动作,撤销怎么办?办法很简单,把命令对象扩展成两个函数:
struct UndoableAction { std::function<void()> Do; std::function<void()> Undo; };历史管理器里的存储类型从std::unique_ptr<ICommand>变成std::vector<UndoableAction>,逻辑几乎不变:
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::vector<UndoableAction> done_; std::vector<UndoableAction> 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() { /* 发起攻击 */ } };在经典写法中你想把成员函数包装成命令,至少要写一个适配器类。而现在只要一行lambda:
GameUI ui; std::function<void()> attackCmd = [&ui] { ui.OnAttack(); };如果非要用std::bind,也能跑,但可读性明显差一些:
std::function<void()> attackCmd = std::bind(&GameUI::OnAttack, &ui);函数对象更是直接兼容:
struct ResetCommand { void operator()() const { /* 重置某种状态 */ } }; std::function<void()> 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::function<void()> 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_map<std::string, std::function<void()>> bindings_; };按键和技能完全解耦。玩家把“跳跃”改成别的键,只需要修改一份配置表,不需要动Player::Jump()的任何代码。键位配置文件甚至可以是文本格式,启动时解析进bindings_即可。
组合连招则需要“宏命令”概念,也就是把多个命令打包成一个整体:
class MacroCommand final : public ICommand { public: void Add(std::unique_ptr<ICommand> 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::vector<std::unique_ptr<ICommand>> cmds_; };注意UnExec()的顺序是逆序的。组合命令执行顺序是A→B→C,要回退就必须C→B→A。不理解这点的人写出的撤销系统往往在单步操作时正常,一到组合操作就错乱。
游戏里另有一个很典型的应用:录制回放。每次玩家输入都被封装成一条命令并存下来,回放时依次执行命令。这套机制和录像重放、断线重连的“对账”思路完全一致。
4.3 任务队列与线程池:命令模式最高频的隐藏应用
第三个场景是任务队列。很多人可能没意识到,线程池里最常见的std::function<void()>队列,本质上就是命令模式:生产者把任务封装成命令对象放进队列,消费者线程取出并执行。这就是命令模式“排队执行”能力的典型体现。
一个最小可用的线程池核心结构如下:
class ThreadPool { public: void Post(std::function<void()> task) { std::lock_guard<std::mutex> lock(mtx_); tasks_.push_back(std::move(task)); cv_.notify_one(); } void WorkerLoop() { for (;;) { std::function<void()> task; { std::unique_lock<std::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::deque<std::function<void()>> 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::future<typename std::invoke_result_t<F, Args...>> { using R = typename std::invoke_result_t<F, Args...>; std::packaged_task<R()> task( std::bind(std::forward<F>(f), std::forward<Args>(args)...)); auto future = task.get_future(); { std::lock_guard<std::mutex> lock(mtx_); tasks_.emplace_back( std::make_shared<std::packaged_task<R()>>(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_ptr:
std::weak_ptr<Player> 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_shared<std::vector<int>>(1000000)] { // 只捕获一个 shared_ptr,拷贝开销极小 };如果确实需要捕获一个外部对象,又不想拷贝,可以用std::ref:
LargeState state; std::function<void()> cmd = [&state] { state.Update(); };但std::ref方案本质上还是引用捕获,必须保证引用目标存活。这一点和5.1的悬垂问题是一体两面。
性能方面要额外说明:命令模式大多用于交互和异步场景,一次执行几千条命令已经算极端情况,相比数据库读写、网络I/O这类开销,命令分发的间接调用成本可以忽略。真正性能敏感的循环里,优先考虑函数指针数组或手写switch,不要为了模式而模式。我见过有人把每帧刷新的UI命令队列从std::function改成手动函数表,最后耗时几乎没变化,反而增加了一堆维护成本。优化要用profiler说话,别靠“感觉”。
5.3 序列化与持久化:没有反射,命令日志也得写
命令模式天然适合把历史操作持久化下来,比如数据库事务日志、编辑器崩溃恢复、交易系统审计。但C++没有Java那种反射机制,想“按命令名字自动反序列化”并不是开箱即用的。
实际工程里最常用的做法是构建一个命令注册表:每个命令类对应一个全局唯一的字符串ID和一个构造工厂函数。反序列化时,通过ID找到对应的工厂,再利用参数数据重建命令对象。
using CommandBuilder = std::function<std::unique_ptr<ICommand>(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_ptr<ICommand> 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_map<std::string, CommandBuilder> builders_; };给具体命令注册:
// 通常在某个静态初始化函数里执行 CommandRegistry::Instance().Register( "shoot", [](const JsonObject& params) -> std::unique_ptr<ICommand> { return std::make_unique<ShootCommand>( params.targetId, params.damage); });每个命令需要自己实现字段的序列化和反序列化,这部分没有自动魔法,都是手写。但注册表模式已经把“按名称重建对象”的骨架搭好了,适合订单、财务、游戏对战记录这类必须能审计回放的系统。
5.4 命令的粒度:不是每个动作都适合一条命令
最后一个容易犯的设计错误是命令粒度失控。一个复杂操作可能拆成十几条子操作,每条子操作都单独入历史栈,结果用户Ctrl+Z要按十几次才能回到操作前状态,体验极差。
正确做法是:按用户语义定义命令粒度。用户点击“改变整个段落格式”时,哪怕内部改了字体、颜色、缩进多处属性,也应该合并为一条命令。实现上主要有两种方式:
- 宏命令包装,也就是前面提到的
MacroCommand,把一组子命令打包成一条。 - 命令合并,执行新命令前判断是否能与上一条命令合并。比如用户在输入框连续打字,相邻的字符插入命令可以合并成一条“连续输入”动作,撤销时一次性删掉整个输入片段。
这两个方向我都建议在设计阶段就考虑清楚,等历史栈已经堆了几百条命令再回头改粒度,改动成本会大得多。
6. 什么时候真的不需要命令模式
6.1 一个命令对应一个函数时,别套模式
命令模式不是万能药。如果某个操作只需要一次同步调用,没有撤销需求,没有排队需求,也不能被组合,那直接调用函数就是最优解。强行套模式的后果是类数量倍增、调用链变长、断点调试变得繁琐,收益却几乎为零。
每次评估是否要用命令模式前,建议先问三个问题:
- 这个操作需要被延迟/排队执行吗?
- 需要撤销/重做的历史记录吗?
- 需要把操作写成日志,以便回放或审计吗?
三个问题都是否,那就直接用普通函数调用。如果至少一个答案是“是”,命令模式才有基本的使用价值。
6.2 和其他模式划清边界
命令模式经常被拿来和策略模式、观察者模式对比,但它们解决的问题其实并不相同。
策略模式关注“算法可替换”,调用方在运行时选择一个算法执行,算法本身不会被排队,也不需要撤销。命令模式关注“请求被封装后传递、排队、撤销、组合”,执行顺序和时机才是核心。同一个操作,既可以用策略封装算法,也可以用命令封装请求,但两者面对的是不同层次的问题。
观察者模式则是“一对多通知”,一个事件广播给很多订阅者,订阅者是同时响应。命令模式通常是“一对一请求”,即使有宏命令组合,也只是串行执行多个请求。混用这两种模式的代码,往往在需求变更时最先暴露出混乱。
6.3 给项目决策的一个快速清单
如果要给团队一个可以直接用的决策清单,我大概会这样列:
| 判断点 | 建议意向 |
|---|---|
| 操作需要异步/排队执行 | 用命令模式封装任务,放入队列 |
| 操作需要撤销/重做 | 用命令模式,命令带上逆操作 |
| 操作需要组合录制 | 用宏命令方案 |
| 操作需要落盘审计/回放 | 命令模式配合注册表和序列化 |
| 只是同步调用且无需历史 | 直接调用,别加模式 |
| 团队对模式不熟 | 先小范围试点,优选std::function方案 |
在重构老代码时,也不要一口气把所有调用点都改成命令模式。挑一个撤销需求明确的子系统开始,比如把编辑器历史栈做出来,让团队看到实际收益,再逐步推广。这个推进策略比“大爆炸式重构”稳得多。
我个人在实际工作中的体会是,命令模式最迷人的地方,不是那个“接口加实现类”的固定模板,而是它揭示了“把动作变成对象之后,动作就获得了被记录、被延迟、被回放的能力”这一层抽象价值。std::function和lambda的引入,让C++开发者能几乎零成本地获得这种能力,而不再需要写一堆样板类。
如果这篇文章只让你记住一点,我会说:先从任务队列或者撤销栈入手,把一次操作封装成对象,第二步再考虑宏命令和序列化。踩过几次坑之后你会发现,命令模式不是一个需要背的UML图,而是一种改变思维方式的设计习惯。
最后分享一个命名小技巧:命令类不要全部用XxxCommand结尾,否则项目里一搜“Command”一大片,看名字完全分辨不出业务语义。像SendEmailAction、DeductBalanceStep、MovePieceOperation这样带上业务动作的命名,比泛化的“Command”好维护得多。