21、命令模式:模式动机与定义、角色与结构、C++代码实现、撤销与重做
模式动机:为什么我们需要命令模式?
先聊个实际场景。我在几年前接手过一个编辑器项目,里面有个“操作历史”功能。一开始代码很简单:每个按钮点一下,直接调用对应的函数。比如“保存”按钮就调 SaveFile(),“撤销”按钮就调 UndoLastAction()。
但问题很快就来了。产品经理说:“我们要支持宏录制。” 嗯,就是把用户一连串操作录下来,以后一键回放。接着又说:“我们要支持操作队列,比如批量处理时按顺序执行。” 再后来:“撤销要支持多步,而且每个操作都要能redo。”
你想想看,如果每个操作都是直接调用函数,那这些需求怎么实现?根本没法做。因为函数调用是“一次性”的,你没法把“保存”这个动作存下来,也没法把它排进队列里。
说白了,命令模式要解决的核心问题就是:把“请求”封装成对象。这样一来,你就可以把请求存起来、传参、排队、记录日志,甚至支持撤销和重做。
核心思想:将“调用操作的对象”与“执行操作的对象”解耦,中间加一层“命令对象”作为媒介。
定义与角色结构
命令模式的定义其实很简洁:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。
嗯,这里有几个关键角色,我列个表给你看:
| 角色 | 职责 | 类比 |
|---|---|---|
| Command(抽象命令) | 声明执行操作的接口,通常包含 Execute() 和 Undo() |
遥控器上的“按钮接口” |
| ConcreteCommand(具体命令) | 实现命令接口,绑定一个接收者对象,调用接收者的对应操作 | “开灯”按钮的具体逻辑 |
| Invoker(调用者/请求者) | 持有命令对象,并在某个时机调用命令的 Execute() |
遥控器本身 |
| Receiver(接收者) | 真正执行业务逻辑的对象 | 电灯 |
| Client(客户端) | 创建具体命令对象,并设置其接收者 | 你——把遥控器和灯关联起来的人 |
结构图我画了一张SVG,你看一眼就明白了:
C++ 代码实现:一个文本编辑器示例
光说不练假把式。我们直接写一个文本编辑器的“插入字符”和“删除字符”命令,并支持撤销重做。
先定义接收者——文本缓冲区:
// Receiver:文本缓冲区
class TextBuffer {
public:
void InsertChar(char ch, size_t pos) {
if (pos > text_.size()) pos = text_.size();
text_.insert(text_.begin() + pos, ch);
}
void DeleteChar(size_t pos) {
if (pos < text_.size()) {
text_.erase(text_.begin() + pos);
}
}
std::string GetText() const { return text_; }
private:
std::string text_;
};
然后定义抽象命令接口:
// Command 抽象基类
class Command {
public:
virtual ~Command() = default;
virtual void Execute() = 0;
virtual void Undo() = 0;
};
接下来是具体命令。这里有个细节:撤销操作需要保存“反向操作”所需的状态。比如插入字符,撤销时就要删除那个字符,所以得记住插入的位置和字符本身。
// ConcreteCommand:插入字符
class InsertCharCommand : public Command {
public:
InsertCharCommand(TextBuffer& buffer, char ch, size_t pos)
: buffer_(buffer), ch_(ch), pos_(pos) {}
void Execute() override {
buffer_.InsertChar(ch_, pos_);
}
void Undo() override {
buffer_.DeleteChar(pos_);
}
private:
TextBuffer& buffer_;
char ch_;
size_t pos_;
};
// ConcreteCommand:删除字符
class DeleteCharCommand : public Command {
public:
DeleteCharCommand(TextBuffer& buffer, size_t pos)
: buffer_(buffer), pos_(pos) {
// 执行前先保存被删除的字符,用于撤销
// 注意:这里需要在 Execute 之前获取字符
}
void Execute() override {
// 先保存被删字符(实际项目中可能在构造时获取)
// 这里简化处理,假设外部已经设置 deletedChar_
buffer_.DeleteChar(pos_);
}
void SetDeletedChar(char ch) { deletedChar_ = ch; }
void Undo() override {
buffer_.InsertChar(deletedChar_, pos_);
}
private:
TextBuffer& buffer_;
size_t pos_;
char deletedChar_ = '\0';
};
个人经验:我在做编辑器时,撤销操作最容易被忽略的一点是——命令执行前和执行后的状态都要保存。比如删除操作,你得在真正删除之前就把字符读出来存好。否则删完了你上哪找去?
撤销与重做的实现
撤销和重做,说白了就是维护两个栈:撤销栈和重做栈。
- 执行一个命令时:压入撤销栈,清空重做栈(因为新操作会打断重做历史)。
- 撤销时:从撤销栈弹出,调用
Undo(),然后压入重做栈。 - 重做时:从重做栈弹出,调用
Execute(),然后压入撤销栈。
代码实现如下:
class CommandHistory {
public:
void ExecuteCommand(std::unique_ptr<Command> cmd) {
cmd->Execute();
undoStack_.push(std::move(cmd));
// 新操作清空重做栈
while (!redoStack_.empty()) redoStack_.pop();
}
void Undo() {
if (undoStack_.empty()) return;
auto cmd = std::move(undoStack_.top());
undoStack_.pop();
cmd->Undo();
redoStack_.push(std::move(cmd));
}
void Redo() {
if (redoStack_.empty()) return;
auto cmd = std::move(redoStack_.top());
redoStack_.pop();
cmd->Execute();
undoStack_.push(std::move(cmd));
}
private:
std::stack<std::unique_ptr<Command>> undoStack_;
std::stack<std::unique_ptr<Command>> redoStack_;
};
我曾经踩过一个坑:如果命令对象里持有的是指针或引用,撤销时一定要确保接收者对象还活着。否则 Undo() 调用时接收者已经被销毁了,程序直接崩溃。我的建议是:用 shared_ptr 管理接收者生命周期,或者在命令执行前做有效性检查。
使用示例
最后,看看客户端怎么用:
int main() {
TextBuffer buffer;
CommandHistory history;
// 插入字符 'A' 在位置 0
history.ExecuteCommand(
std::make_unique<InsertCharCommand>(buffer, 'A', 0));
// 插入字符 'B' 在位置 1
history.ExecuteCommand(
std::make_unique<InsertCharCommand>(buffer, 'B', 1));
std::cout << buffer.GetText() << std::endl; // 输出 "AB"
history.Undo(); // 撤销插入 'B'
std::cout << buffer.GetText() << std::endl; // 输出 "A"
history.Redo(); // 重做插入 'B'
std::cout << buffer.GetText() << std::endl; // 输出 "AB"
return 0;
}
输出结果:
AB
A
AB
总结一下
命令模式说白了就是一层“中间人”。它把操作请求封装成对象,让你能存、能传、能排队、能撤销。我个人觉得,这是设计模式里“性价比”最高的几个之一——学起来不难,用起来效果立竿见影。
你想想看,如果没有命令模式,你要实现撤销重做,得在每个业务函数里写一堆历史记录代码,耦合度极高。有了命令模式,每个命令自己管自己的撤销逻辑,调用者只管按顺序执行或回退,清爽多了。
一句话记住:命令模式 = 把“做事”变成“对象”,然后你就可以对这个对象为所欲为——存它、传它、排队它、撤销它。