设计模式面试题8:实现一个命令模式,支持撤销操作
命令模式,说白了就是把「请求」封装成一个对象。这样你就可以把请求排队、记录日志,或者——像我们今天要聊的——支持撤销操作。
我记得有一次面试,面试官让我在白板上写一个支持撤销的文本编辑器。我当时心想,这不就是命令模式的经典应用吗?结果一紧张,把命令的存储和撤销逻辑写反了。嗯,从那以后我对这个模式的理解就特别深。
什么是命令模式?
命令模式的核心思想很简单:把操作封装成对象。每个对象都知道自己做了什么,也知道怎么撤销。
你想想看,如果没有命令模式,你的撤销逻辑会写成什么样?
// 糟糕的设计
class TextEditor {
std::string text;
std::string lastText; // 只保存上一次状态
public:
void append(const std::string& s) {
lastText = text;
text += s;
}
void undo() {
text = lastText;
}
};
这有什么问题?只能撤销一次。而且如果操作是「删除」而不是「追加」,你连上一次状态都存不对。
命令模式的标准结构
我们先看一个干净的命令模式设计。它包含四个角色:
| 角色 | 说明 |
|---|---|
| Command(命令接口) | 声明 execute() 和 undo() 方法 |
| ConcreteCommand(具体命令) | 实现具体的操作和撤销逻辑 |
| Receiver(接收者) | 真正干活的对象(比如文本编辑器) |
| Invoker(调用者) | 管理命令队列,触发执行和撤销 |
下面我用一个简单的文本编辑器来演示。这个编辑器支持「追加文本」和「删除文本」两种操作,并且都可以撤销。
#include <iostream>
#include <string>
#include <vector>
#include <memory>
// 接收者:文本编辑器
class TextEditor {
std::string text;
public:
void append(const std::string& s) {
text += s;
}
void remove(size_t len) {
if (len > text.size()) len = text.size();
text.erase(text.size() - len);
}
const std::string& getText() const {
return text;
}
};
// 命令接口
class Command {
public:
virtual ~Command() = default;
virtual void execute() = 0;
virtual void undo() = 0;
};
// 具体命令:追加文本
class AppendCommand : public Command {
TextEditor& editor;
std::string text;
public:
AppendCommand(TextEditor& e, const std::string& t)
: editor(e), text(t) {}
void execute() override {
editor.append(text);
}
void undo() override {
editor.remove(text.size());
}
};
// 具体命令:删除文本
class DeleteCommand : public Command {
TextEditor& editor;
size_t len;
std::string deletedText; // 保存被删的内容,用于撤销
public:
DeleteCommand(TextEditor& e, size_t l)
: editor(e), len(l) {}
void execute() override {
// 先保存被删的内容
const std::string& current = editor.getText();
size_t start = (current.size() > len) ? current.size() - len : 0;
deletedText = current.substr(start);
editor.remove(len);
}
void undo() override {
editor.append(deletedText);
}
};
// 调用者:管理命令历史
class CommandManager {
std::vector<std::unique_ptr<Command>> history;
public:
void executeCommand(std::unique_ptr<Command> cmd) {
cmd->execute();
history.push_back(std::move(cmd));
}
void undo() {
if (history.empty()) return;
history.back()->undo();
history.pop_back();
}
};
使用起来也很直观:
int main() {
TextEditor editor;
CommandManager manager;
manager.executeCommand(
std::make_unique<AppendCommand>(editor, "Hello "));
manager.executeCommand(
std::make_unique<AppendCommand>(editor, "World!"));
std::cout << editor.getText() << "\n"; // Hello World!
manager.undo();
std::cout << editor.getText() << "\n"; // Hello
manager.executeCommand(
std::make_unique<DeleteCommand>(editor, 3));
std::cout << editor.getText() << "\n"; // He
manager.undo();
std::cout << editor.getText() << "\n"; // Hello
}
撤销操作的核心设计要点
我个人习惯把撤销分为两种类型:
- 状态恢复型:保存操作前的状态,撤销时恢复。适合小数据量的操作。
- 反向操作型:记录操作的逆操作,撤销时执行逆操作。适合大数据量或复杂状态。
上面的例子用的是反向操作型。追加的逆操作是删除,删除的逆操作是追加。这样做的好处是:不需要复制整个文本状态,内存开销小。
命令模式与撤销的进阶话题
面试官通常不会只满足于「能撤销」。他们还会问:
- 如何支持多级撤销? 用栈保存命令历史,每次 undo 弹出栈顶。
- 如何支持重做(redo)? 再加一个栈,undo 时把命令压入 redo 栈。
- 如何限制历史记录大小? 设置最大深度,超过时丢弃最旧的命令。
- 如何合并连续的同类型操作? 比如用户连续输入字符,可以合并成一个命令。
我曾经在做一个绘图软件时,就遇到了「连续移动物体」的撤销问题。用户拖动物体时,每帧都会产生一个移动命令。如果每个帧都保存,undo 时就得一步步退回去,用户体验很差。后来我把连续移动合并成一个命令,只保存起点和终点,问题就解决了。
命令模式的适用场景
不是所有地方都需要命令模式。我个人觉得,以下场景特别适合:
- 需要记录操作历史(撤销/重做)
- 需要将操作排队或延迟执行
- 需要支持宏命令(组合多个命令)
- 需要将操作日志持久化(比如数据库的事务日志)
命令模式的核心逻辑图
下面这张图展示了命令模式在支持撤销时的完整流程:
这张图展示了命令模式的核心流程:
- 左侧:CommandManager 管理历史记录栈,执行命令时压栈,撤销时弹栈。
- 中间:具体命令(AppendCommand、DeleteCommand)实现了 execute() 和 undo()。
- 右侧:TextEditor 是真正的干活对象,命令只是委托它执行操作。
总结
命令模式实现撤销,说白了就是「把操作变成对象,每个对象都知道怎么反悔」。我个人觉得,这是设计模式中最实用的一种——你几乎在所有需要「后悔药」的地方都能用到它。
面试时,如果能把这个模式讲清楚,再结合一个实际的撤销例子,基本就能过关了。如果再能聊到「合并命令」「限制历史深度」这些进阶话题,那面试官对你的印象会更深。
- 命令模式将请求封装为对象,支持 execute() 和 undo()
- 反向操作型撤销比状态恢复型更节省内存
- 用栈管理命令历史,支持多级撤销
- 注意合并连续操作,避免历史记录爆炸
公众号:蓝海资料掘金营,微信deep3321