命令模式:把请求装进盒子里
命令模式,说白了就是把「你要做什么」封装成一个对象。我刚开始学设计模式时,觉得这玩意儿有点多余——直接调用函数不就行了?直到我在一个项目中遇到了撤销操作的需求,才明白命令模式有多香。
为什么需要命令模式?
想象一下这个场景:你写了一个文本编辑器,用户点击「保存」按钮,直接调用了 saveFile() 方法。一切看起来都很美好。但突然有一天,产品经理说:「我们要支持撤销操作!」
嗯,问题来了。你没法撤销一个已经执行完的函数调用。你想想看,函数调用就像泼出去的水,收不回来的。
命令模式解决的就是这个问题。它把「执行一个操作」这件事,变成了一个对象。这个对象里存着所有需要的信息——谁调用、调什么、参数是什么。这样一来,你就可以把这个对象存起来,需要的时候再执行,甚至还能反着执行一遍来实现撤销。
命令模式的三个角色
命令模式的结构其实很简单,就三个核心角色:
- Command(命令接口):声明执行操作的接口,通常就一个
execute()方法 - ConcreteCommand(具体命令):实现命令接口,持有接收者的引用,在
execute()中调用接收者的具体方法 - Invoker(调用者):持有命令对象,在某个时间点调用命令的
execute()方法 - Receiver(接收者):真正干活的对象,知道如何执行具体的业务逻辑
我习惯把调用者和接收者分开。调用者只管「什么时候执行」,接收者只管「怎么执行」,命令对象负责把两者桥接起来。
Java 实现:从 Runnable 说起
其实 Java 里早就内置了命令模式的影子——Runnable 接口。你看:
// 这就是命令接口
public interface Runnable {
public void run();
}
// 具体命令
public class SaveCommand implements Runnable {
private Document document;
public SaveCommand(Document document) {
this.document = document;
}
@Override
public void run() {
document.save();
}
}
// 调用者
public class MenuItem {
private Runnable command;
public void setCommand(Runnable command) {
this.command = command;
}
public void click() {
command.run();
}
}
你看,Runnable 就是命令接口,run() 就是 execute()。线程池、按钮点击、定时任务,本质上都是命令模式的应用。
我在项目中遇到过这样一个坑:有人把业务逻辑直接写在 Runnable 的 run() 方法里,而不是委托给接收者。这样做的问题在于,你没法复用接收者的逻辑,也没法轻松地做撤销。记住,命令对象只负责「调用」,不负责「实现」。
C++ 实现:函数对象与 lambda
C++ 里实现命令模式就更灵活了。函数对象(functor)和 lambda 表达式都是天然的命令封装:
#include <iostream>
#include <vector>
#include <functional>
#include <stack>
// 接收者
class Document {
public:
void save() {
std::cout << "文档已保存" << std::endl;
}
void undo() {
std::cout << "撤销保存" << std::endl;
}
};
// 命令接口(用 std::function 替代)
using Command = std::function<void()>;
// 调用者
class MenuItem {
private:
Command command;
std::stack<Command> history;
public:
void setCommand(Command cmd) {
command = cmd;
}
void click() {
if (command) {
command();
history.push(command);
}
}
void undo() {
if (!history.empty()) {
history.pop();
std::cout << "已撤销上一步操作" << std::endl;
}
}
};
int main() {
Document doc;
MenuItem saveButton;
// 用 lambda 创建命令
saveButton.setCommand([&doc]() {
doc.save();
});
saveButton.click(); // 输出:文档已保存
saveButton.undo(); // 输出:已撤销上一步操作
return 0;
}
C++ 的 std::function 加上 lambda,写命令模式简直不要太爽。你想想看,连具体的命令类都不用定义,一个 lambda 就搞定了。
撤销操作的实现技巧
撤销操作是命令模式最经典的应用。实现思路其实很简单:
- 每个命令除了
execute(),再加一个undo()方法 - 执行命令时,把命令对象压入历史栈
- 撤销时,从历史栈弹出命令,调用它的
undo()方法
我曾经在一个图形编辑器中实现过撤销功能。当时踩了一个坑:有些操作是不可逆的,比如「删除文件」。对于这类操作,要么不允许撤销,要么在 undo() 里做补偿操作(比如把文件从回收站恢复)。
命令模式的更多应用场景
| 场景 | 说明 | 我的经验 |
|---|---|---|
| 事务处理 | 将一系列操作封装为命令,要么全部成功,要么全部回滚 | 在数据库迁移工具中用过,每个迁移脚本就是一个命令 |
| 宏录制 | 记录一系列命令,然后可以回放 | 游戏回放系统就是典型的命令模式 |
| 任务队列 | 将命令放入队列,由工作线程依次执行 | 线程池的 Runnable 就是命令 |
| 菜单系统 | 每个菜单项对应一个命令,点击时执行 | 几乎所有 GUI 框架都在用这个模式 |
命令模式的优缺点
任何模式都不是银弹。我用了这么多年命令模式,总结了几点:
优点:
- 解耦调用者和接收者,调用者不需要知道接收者的具体实现
- 容易扩展,新增命令不需要修改现有代码
- 支持撤销、重做、事务等高级功能
- 可以将多个命令组合成复合命令(宏命令)
缺点:
- 每个具体操作都要创建一个命令类,类数量会膨胀
- 命令对象需要保存状态信息,可能占用较多内存
- 对于简单操作,引入命令模式反而增加了复杂度
SVG 结构图:命令模式的核心流程
这张图展示了命令模式的核心流程。调用者只认识命令接口,具体命令知道该找谁干活,接收者只管埋头做事。三者各司其职,互不干扰。
总结
命令模式把「请求」变成了「对象」,这个转变带来了巨大的灵活性。你可以把命令存起来、排队、记录日志、甚至撤销。Java 的 Runnable、C++ 的 std::function 都是命令模式的体现。
我个人习惯在项目初期就引入命令模式,哪怕当时只需要一个简单的操作。因为我知道,产品经理迟早会过来说:「我们要加撤销功能。」到那时候再重构,成本就高了。
嗯,记住一点:命令模式不是为了炫技,而是为了应对变化。当你的系统需要支持撤销、重做、事务回滚、任务队列这些功能时,命令模式就是你的不二之选。