命令这东西。
比“设计模式”这个词老得多。
我第一次听到“命令”这个词。
不是在 UML。
也不是在 GoF。
是在机房。
最早的“命令”不是对象。
也不是函数。
甚至不是代码。
它是一张纸。
一摞打孔卡。
或者操作员手里那本“作业单”。
你把“要做什么”写下来。
签个名。
交给值班的师傅。
等机器有空再做。
这其实就是把“动作”从现在挪到以后。
后来到了 Unix。
命令变成了一行行字符串。
你敲下回车。
系统替你干活。
你开始写脚本。
把一串命令塞进文件里。
让机器半夜跑。
再后来 GUI 流行。
鼠标一点。
事情发生了。
发生完就没了。
直到某天。
有人问:
“能不能撤销?”
你别笑。
这句问得很像“能不能时光倒流”。
但用户就是会反悔。
而且反悔得理直气壮。
再往后你做 Windows。
或者你维护过那种年代久远的 MFC。
你会看到更直接的版本。
不管你点的是菜单。
还是工具栏。
还是按下快捷键。
最后都落到一条 WM_COMMAND。
框架拿着一个 id。
去找对应的处理函数。
它不跟按钮谈业务。
按钮也不需要认识文档对象。
那套东西不一定写在 GoF 里。
但它已经把“动作”从 UI 上剥离出来了。
那一刻你才意识到。
动作如果只是一瞬间的调用。
它就没法被管理。
更别说记录、排队、回放、撤销。
GoF 在 1994 年给这种做法起了名字。
叫 Command。
我第一次在项目里被它救过命。
是在一个编辑器里。
产品同学拍桌子。
“要撤销。”
“要重做。”
“还要把一串操作录成宏。”
你听完就知道。
你以前写的那些函数调用。
太“裸”了。
它们会发生。
但不会留下痕迹。
命令模式做的事很朴素。
把一次操作。
从“瞬间发生”。
变成“可以被拿在手里”。
顺手还能塞进抽屉。
需要的时候再拿出来用。
一开始我们都这么写
函数调用本身没错。
错在它太“即时”。
比如你有一套 UI。
按钮一按。
就直接调用业务对象:
class Document {
public:
void boldSelection();
void insertText(std::string_view text);
};
void onBoldClicked(Document& doc) {
doc.boldSelection();
}
这段代码当然能跑。
你当年写的时候。
甚至可能觉得挺优雅。
而且写起来特别快。
老板也爱看。
因为 Demo 当场就能动。
问题出在后来。
你想知道用户到底点过什么。
不是为了八卦。
是为了线上出事时能回放现场。
你想给用户一个 Ctrl+Z。
让他敢试错。
你想把某些操作丢到后台。
别把 UI 卡成 PPT。
这时候你发现。
你缺的不是更多的回调函数。
你缺的是一个“可以保存、可以传递、可以排队”的动作。
命令模式里有句老话。
我一直觉得很准。
把“调用”变成“数据”。
GoF 口味的 Command:给动作一个壳
GoF 的 Command 很规矩。
那是个“虚函数走天下”的年代。
你不写一堆 virtual。
都不好意思说自己在写 C++。
通常你会有一个接口。
里面至少有 execute()。
如果要撤销,就再加 undo()。
struct Command {
virtual ~Command() = default;
virtual void execute() = 0;
virtual void undo() = 0;
};
然后你会写具体命令。
每个命令内部通常拿着一个“接收者” (Receiver)。
也就是那个真正干活的人。
class Document {
public:
void insert(std::size_t pos, std::string text);
void erase(std::size_t pos, std::size_t len);
};
class InsertTextCommand final : public Command {
public:
InsertTextCommand(Document& doc, std::size_t pos, std::string text)
: doc_(doc), pos_(pos), text_(std::move(text)) {}
void execute() override {
doc_.insert(pos_, text_);
}
void undo() override {
doc_.erase(pos_, text_.size());
}
private:
Document& doc_;
std::size_t pos_{};
std::string text_;
};
你注意看。
这时候“按钮”就不直接调 Document 了。
按钮只负责创建命令。
然后把命令交给某个地方。
那个地方叫 Invoker。
Invoker 这个词听起来像论文。
在工程里它更像“发号施令的人”。
你把命令交给它。
它决定什么时候按下扳机。
它可能是快捷键系统。
也可能是菜单。
也可能是任务调度器。
名字不重要。
重要的是。
它只认识 Command。
它不需要认识 Document。
这就够了。
撤销/重做:Ctrl+Z 背后的土办法
撤销/重做最常见的结构很朴素。
朴素到你都不好意思在分享会上讲。
通常就是两个容器当栈用。
做过的压进 done_。
撤回的先放 undone_。
class CommandHistory {
public:
void execute(std::unique_ptr<Command> cmd) {
cmd->execute();
done_.push_back(std::move(cmd));
undone_.clear();
}
void undo() {
if (done_.empty()) return;
auto cmd = std::move(done_.back());
done_.pop_back();
cmd->undo();
undone_.push_back(std::move(cmd));
}
void redo() {
if (undone_.empty()) return;
auto cmd = std::move(undone_.back());
undone_.pop_back();
cmd->execute();
done_.push_back(std::move(cmd));
}
private:
std::vector<std::unique_ptr<Command>> done_;
std::vector<std::unique_ptr<Command>> undone_;
};
这套结构很老。
老到你去翻早年的编辑器。
几乎都能看到它的影子。
因为写编辑器的人很早就明白。
用户是会反悔的。
而且反悔得理直气壮。
Ctrl+Z 不是“高级功能”。
是心理安全感。
撤销不是特性。 它是安全带。
很多人第一次写撤销。
会把 undo() 理解成“反向再做一遍”。
有时候确实可以。
比如插入文本的撤销就是删除。
但更多时候不行。
删除文本的撤销不是再删一次。
而是把原来的内容插回去。
所以命令对象里往往要保存一点状态。
保存到你能回到过去。
保存到你敢在凌晨三点回放线上现场。
日志是给人看的。
历史是给机器回放的。
命令对象本身。
就是那段历史。
当然。
也不是所有命令都能撤销。
比如“发邮件”“删库跑路”这种。
你得在设计上提前说清楚:哪些能撤,哪些只能补救。
宏命令:老编辑器的“录制动作”
宏命令听起来像高级功能。
实现起来往往是最朴素的代码。
一个命令里装一堆命令。
class MacroCommand final : public Command {
public:
void add(std::unique_ptr<Command> c) {
cmds_.push_back(std::move(c));
}
void execute() override {
for (auto& c : cmds_) c->execute();
}
void undo() override {
for (auto it = cmds_.rbegin(); it != cmds_.rend(); ++it) {
(*it)->undo();
}
}
private:
std::vector<std::unique_ptr<Command>> cmds_;
};
宏这个词。
很多人是在 Emacs 里学会的。
你敲一串键。
按下录制。
再按下回放。
它就一模一样再跑一遍。
后来 Photoshop 叫它 Actions。
本质一样。
你会发现它和组合模式 (Composite) 很像。
只是这次组合的不是树。
而是一段时间。
而且 undo() 要倒着来。
不然你会在“撤销宏”这件事上吃个大亏。
还有一件更现实的事。
宏命令一旦中途失败。
你要不要回滚?
要回滚到哪一步?
这就不是模式图里那两根箭头能讲清的了。
任务队列与异步:把执行从“现在”挪到“以后”
当你把动作变成对象。
你就可以把它扔进队列。
让另一个线程慢慢执行。
class CommandQueue {
public:
void push(std::unique_ptr<Command> c) {
q_.push(std::move(c));
}
void drain() {
while (!q_.empty()) {
auto c = std::move(q_.front());
q_.pop();
c->execute();
}
}
private:
std::queue<std::unique_ptr<Command>> q_;
};
命令模式在工程里有个很现实的价值。
它把“执行时机”这件事拆出来了。
你想起大型机时代的作业队列。
再想想打印机的 spooling。
其实味道都一样。
你以前直接调用函数。
想排队只能排参数。
最后多半会走向 switch。
命令对象出现以后。
你排的是动作本身。
动作里带着它需要的一切。
不过别被示例骗了。
上面这个队列没有锁。
也没处理异常。
在真实工程里。
你要考虑的往往是“谁来保证顺序”“谁来保证只执行一次”。
以及最要命的那句:命令里捕获的对象,到执行那一刻还活着吗?
现代 C++ 版本:有时候一个 lambda 就够了
GoF 年代大家喜欢“接口 + 子类”。
现代 C++ 多了一个更省事的选择。
把命令写成可调用对象。
比如 lambda。
using Fn = std::function<void()>;
struct SimpleCommand {
Fn execute;
Fn undo;
};
然后你就可以很快地组装。
动作在这里不再是一个虚函数。
而是两个可调用对象。
SimpleCommand cmd{
[&] { doc.insert(pos, text); },
[&] { doc.erase(pos, text.size()); },
};
这写起来很爽。
但也很容易埋雷。
因为“写得像脚本”不代表“生命周期也像脚本”。
因为 std::function 默认要求可拷贝。
而你很多命令其实想要“只能移动”。
比如它捕获了 std::unique_ptr。
再比如撤销要保存一大块快照。
你不想让它被无意复制。
这种坑有时候很隐蔽。
比如你写了一个捕获 unique_ptr 的 lambda。
它自己没问题。
一旦你想把它塞进 std::function。
编译器就会提醒你:不行。
auto p = std::make_unique<int>(7);
auto f = [p = std::move(p)] {};
std::function<void()> g = std::move(f);
这段就是“典型写法”。
也是“典型报错”。
你会得到一串很长的模板错误。
大意是:这个 callable 不能被拷贝。
这时候你要么回到传统的多态对象。
要么就自己做一层“move-only function”风格的封装。
再不济。
你就别用 std::function。
用模板把调用对象存起来。
或者干脆让命令自己是个类。
如果你用的是 C++23。
标准库也开始提供 std::move_only_function 这样的东西。
但老项目里。
你大概率还是得自己想办法。
还有另一类坑。
更常见。
也更阴。
就是捕获。
你为了省事。
写了 [&]。
编译器也不拦你。
但是你把命令丢进队列以后。
doc、pos、text 这些“引用”到了执行那一刻。
很可能已经不是你以为的那个世界了。
所以我更喜欢把这句话写在脑门上。
命令捕获的东西。
要么是意图。
要么是快照。
千万别是“某个变量的当前地址”。
命令模式不是让你死守 UML。
它真正想让你学会的是。
把“动作”当成一等公民。
这样它就能被塞进容器。
能被传来传去。
还能被调度系统拎出去执行。
坑:撤销不是免费的,状态也不是空气
命令模式的坑不复杂。
复杂的是你以为它很简单。
最常见的翻车方式是。
你把 undo() 当成“反向调用”。
但你没有保存任何状态。
于是撤销只能靠猜。
它会在某个夜里把你叫醒。
我见过最经典的一次。
撤销靠“再执行一遍反向操作”。
结果线上数据结构早就变了。
撤销一按。
把别人的内容也给“顺手撤”了。
那天之后。
大家对“保存快照”这个词突然就不敏感了。
也有人把命令写得太大。
一个命令里干太多事。
还顺手去摸各种全局单例。
这种命令可以执行。
但很难回放。
更难测试。
还有个更阴的坑。
叫时序。
当你把命令放进队列。
执行就不再是“现在”。
世界也可能已经不是“当时”。
你得想清楚。
命令捕获的是“当时的意图”。
还是“当时的快照”。
这两种写法差得很远。
意图的意思是。
“把当前选区加粗”。
快照的意思是。
“把第 120 到 135 个字符这段文本加粗”。
前者更像人在说话。
后者更像机器在记账。
要哪一个。
取决于你要不要回放得一模一样。
也取决于你愿不愿意为这个“一模一样”付出内存与复杂度。
Command vs Strategy:别把“选择算法”和“封装动作”混了
策略模式 (Strategy) 像是在说。
“同一个问题有多种算法。”
我在运行时选一种。
命令模式 (Command) 更像是在说。
“同一个系统里有很多动作。”
我把动作装起来。
然后记录、排队、回放。
再说得土一点。
Strategy 往往是“常驻的”。
它像一个可替换的脑子。
Command 往往是“一次性的”。
它像一张可归档的工单。
你在 code review 里要是分不清。
我给你一句土判断。
你关心的是“怎么做”。
多半是 Strategy。
你关心的是“做了什么,以及之后怎么管理它”。
多半是 Command。
小结:把系统里那些“不可控的瞬间”,变成可控的对象
命令模式不神秘。
它也不一定需要一堆类。
它只是让你把一次操作。
从“瞬间发生”。
变成“可以被拿在手里”。
把它当成一张工单。
或者一段可回放的录像。
下次需求评审又有人拍桌子。
先是要撤销。
接着要重做。
再来一句。
“顺便录个宏。”
过两天他又补刀。
“这个操作太慢了,丢后台吧。”
你别急着到处写 switch。
也别把历史逻辑硬塞进业务对象。
你先把动作装起来。
剩下的事。
就都好谈了。