前言
命令模式(Command Pattern)解决的问题是:把「一个请求」封装成对象,让调用者不需要知道请求怎么执行、由谁执行,也能对请求做排队、记录、撤销、组合。
最典型的场景是界面上的按钮。按钮被按下时,它不应该知道自己是开灯、保存文件还是发送邮件;它只需要持有一个「命令」对象,调用它的execute()。这样按钮和业务逻辑解耦,同一套按钮可以配置不同的命令,命令也可以被记录进历史栈实现撤销。
常见的误解是「命令模式就是函数指针」或「就是std::function」。用回调确实能实现「按下即执行」,但命令模式还要求命令是对象:有类型、有状态、可以多态、可以被存储和复制、可以支持撤销。std::function适合简单的回调,命令模式适合需要撤销、队列、日志、宏组合的场景。
本文按「角色划分 → C++17 实现 → 撤销机制 → 宏命令」的顺序讲,最后给出完整示例和常见坑点。
一、角色划分
命令模式有五个角色:
| 角色 | 职责 |
|---|
| Command(命令接口) | 声明execute(),通常还有undo() |
| ConcreteCommand | 绑定一个接收者,实现具体的执行与撤销 |
| Receiver(接收者) | 真正干活的对象,知道业务细节 |
| Invoker(调用者) | 持有命令,在合适的时机触发 |
| Client(客户端) | 创建命令、绑定接收者、装配到调用者 |
关键关系是:调用者只依赖 Command 接口,不依赖 Receiver。接收者的类型和调用方式被封装在具体命令里。
这和策略模式(Strategy)容易混淆。策略模式关注「同一件事的不同算法」,调用者知道自己在选算法;命令模式关注「把请求本身对象化」,调用者通常不知道请求的具体内容,甚至可以在之后才决定是否执行。
二、接口设计
// 适用于 C++17
class Command {
public:
virtual ~Command() = default;
virtual void execute() = 0;
virtual void undo() = 0;
};三个要点:
- 虚析构函数必须有。调用者通常用
std::unique_ptr<Command>持有命令,删除时通过基类指针,没有虚析构就是未定义行为。 execute()通常是void。如果需要返回值,说明它更像一次查询而不是命令。undo()不是所有命令都需要。不需要撤销时可以不放在接口里,或者提供一个空实现。放在接口里会让所有具体命令都被迫实现,接口更统一,但不够灵活。
如果某些命令不支持撤销,可以把接口拆成Command和UndoableCommand两层,调用者在记录历史前用dynamic_cast判断。这引入了运行期类型检查,是否值得取决于具体需求。
三、具体命令与接收者
以智能灯为例:
// 适用于 C++17
#include <iostream>
class Light {
public:
void on() { on_ = true; std::cout << "light on\n"; }
void off() { on_ = false; std::cout << "light off\n"; }
bool is_on() const { return on_; }
private:
bool on_ = false;
};
class LightOnCommand : public Command {
public:
explicit LightOnCommand(Light& light) : light_(light) {}
void execute() override { light_.on(); }
void undo() override { light_.off(); }
private:
Light& light_;
};LightOnCommand持有Light&,这是一个非拥有引用。接收者必须比命令活得久。如果接收者可能提前销毁,就要改用std::shared_ptr<Light>或std::weak_ptr,并在执行前检查有效性。
命令对象本身可以是无状态的(除了接收者引用),这意味着同一个命令实例可以被反复执行。如果命令需要记录「执行前的状态」来支持撤销,就必须为每次执行保存快照,此时命令是有状态的,不能随意复用。
四、调用者、撤销与宏命令
调用者持有一个命令,并可选地维护历史:
// 适用于 C++17
#include <memory>
#include <utility>
#include <vector>
class RemoteControl {
public:
void set_command(std::unique_ptr<Command> cmd) {
command_ = std::move(cmd);
}
void press() {
if (!command_) return;
command_->execute();
history_.push_back(std::move(command_)); // 交给历史,command_ 置空
}
void undo_last() {
if (history_.empty()) return;
history_.back()->undo();
history_.pop_back();
}
private:
std::unique_ptr<Command> command_;
std::vector<std::unique_ptr<Command>> history_;
};press()里用std::move把命令移进历史。移动后command_变为空,下一次press()什么也不做。这防止同一个命令被重复执行两次,但也意味着「按一次按钮就消耗一个命令」。如果希望反复执行同一个命令,就不要移动,而是保存历史副本——但Command是多态类型,不能直接拷贝,需要提供clone()。
另一种设计是历史只记录「已执行的命令」,命令由调用者长期持有,press()执行但不转移所有权。两种都可以,取决于命令的生命周期语义。
undo_last()只撤销最近一条,符合栈式历史。若要支持重做(redo),需要两个栈:撤销时把命令从 undo 栈移到 redo 栈,重做时反向移动。
4.1 宏命令
命令对象可以组合:一个宏命令持有一组命令,execute()依次执行,undo()逆序撤销。
// 适用于 C++17
class MacroCommand : public Command {
public:
void add(std::unique_ptr<Command> 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<Command>> commands_;
};逆序撤销是关键:如果执行顺序是「开灯、开空调」,撤销必须「关空调、关灯」,否则中间状态可能不一致。这也是组合模式(Composite)与命令模式结合的典型用法。
完整示例
// 适用于 C++17
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
class Command {
public:
virtual ~Command() = default;
virtual void execute() = 0;
virtual void undo() = 0;
};
class Light {
public:
void on() { on_ = true; std::cout << "[light] on\n"; }
void off() { on_ = false; std::cout << "[light] off\n"; }
private:
bool on_ = false;
};
class LightOnCommand : public Command {
public:
explicit LightOnCommand(Light& light) : light_(light) {}
void execute() override { light_.on(); }
void undo() override { light_.off(); }
private:
Light& light_;
};
class LightOffCommand : public Command {
public:
explicit LightOffCommand(Light& light) : light_(light) {}
void execute() override { light_.off(); }
void undo() override { light_.on(); }
private:
Light& light_;
};
int main() {
Light light;
std::vector<std::unique_ptr<Command>> history;
history.push_back(std::make_unique<LightOnCommand>(light));
history.back()->execute();
history.push_back(std::make_unique<LightOffCommand>(light));
history.back()->execute();
std::cout << "-- undo --\n";
while (!history.empty()) {
history.back()->undo();
history.pop_back();
}
return 0;
}按标准语义,输出依次是[light] on、[light] off、-- undo --、[light] on、[light] off。撤销顺序与执行顺序相反。
这里light定义在main中,生命周期覆盖整个history,所以命令里的引用始终有效。如果Light是动态创建又提前销毁的,历史里的命令就会持有悬垂引用。
常见坑点
- ❌ 命令接口没有虚析构:
``cpp class Command { public: virtual void execute() = 0; // 缺少 virtual ~Command() };`✅ 加virtual ~Command() = default;`,否则通过基类指针删除是 UB。
- ❌ 命令持有接收者的引用,但接收者先销毁:
``cpp auto cmd = std::make_unique<LightOnCommand>(*tempLight); delete tempLight; cmd->execute(); // 悬垂引用`✅ 用shared_ptr` 延长生命周期,或确保接收者活得更久。
- ❌ 试图拷贝多态命令:
``cpp std::unique_ptr<Command> copy = original; // 编译错误`✅ 提供clone()虚函数,返回std::unique_ptr<Command>`。
- ❌ 用
std::function<void()>代替命令,却需要撤销:
``cpp std::function<void()> cmd = [&] { light.on(); }; // 无法 undo`` ✅ 需要撤销就定义命令类,把「执行」和「反向操作」成对封装。
- ❌ 撤销时按正序执行:
``cpp for (auto& c : commands_) c->undo(); // 顺序错了`` ✅ 宏命令逆序撤销。
- ❌ 命令执行后仍保留在调用者手里,导致重复执行:
``cpp void press() { command_->execute(); } // 连按两次执行两次`` ✅ 明确语义:要么执行后转移所有权,要么允许重复执行并在文档中说明。
- ❌ 在命令里保存接收者的值拷贝,导致撤销作用在副本上:
``cpp Light light_; // 拷贝,撤销改的是副本`` ✅ 保存引用或指针,确保操作的是同一个对象。
- ❌ 为了「将来可能扩展」给每个操作都套一层命令,导致简单调用被过度抽象。
✅ 只在需要排队、撤销、日志、组合时引入命令模式。
总结
| 组成 | 作用 | 注意 |
|---|
| Command | 统一接口 | 虚析构必须有 |
| ConcreteCommand | 绑定接收者 | 注意接收者生命周期 |
| Receiver | 执行业务 | 与调用者解耦 |
| Invoker | 触发命令 | 可维护历史栈 |
| MacroCommand | 组合命令 | 撤销要逆序 |
命令模式的价值在于把「请求」提升为一等对象。一旦请求是对象,就能被存储、排队、序列化、撤销和组合。代价是每个操作多一个类、多一层间接。判断是否值得,只需要问一句:这些请求需不需要被当作数据来管理。需要,就用;不需要,一个std::function或直接调用更合适。