主题
冷门模式补遗:原型、备忘录、中介者与访问者——面试能答、工程不硬套
本文是设计模式系统学习系列的收尾篇,补齐 GoF 23 种中最后四个「低频但面试会问」的模式。前置:设计模式全景与选型。
工厂、单例、代理、策略、观察者等主力模式前几篇全讲完了。但还有四个模式面试官可能会问:原型模式的深浅拷贝怎么实现、命令模式怎么配合备忘录做撤销、中介者和消息队列什么关系、访问者模式的双分派是什么。不能只回一句「冷门,不常用」。
这四个业务代码一年写不了一次,但工程价值藏在框架源码和工具链里。这篇的目标:面试问到能说清意图、给个最小例子、知道哪里真用上了。
原型模式:克隆的深浅与工程替代
意图与场景
原型模式用「克隆」代替「new 构造」,适合对象创建成本高(比如从数据库加载配置)或运行时需要复制「当前状态快照」的场景。
java
// 标准原型代码
public class Order implements Cloneable {
private List<OrderItem> items;
private Address address;
private String orderNo;
@Override
public Order clone() {
try {
return (Order) super.clone(); // 浅拷贝
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}深浅拷贝的陷阱
super.clone() 是浅拷贝:基本类型和 String 字段复制值,引用字段复制引用。上面 Order.items 和 Address 还是指向同一个对象,修改克隆对象的 items 会连带改原始对象。
java
Order original = new Order();
original.setItems(List.of(new OrderItem("SKU001", 2)));
Order cloned = original.clone();
cloned.getItems().get(0).setQuantity(5); // 改的是同一个 OrderItem 对象
System.out.println(original.getItems().get(0).getQuantity()); // 输出 5,不是 2深拷贝三种实现方式:
| 方式 | 原理 | 适用场景 | 坑 |
|---|---|---|---|
| 逐层手动 new | 递归 clone 每个引用字段 | 对象结构已知且稳定 | 嵌套深了代码爆炸 |
| 序列化 | 对象流序列化再反序列化 | 任意深度的通用方案 | 所有类必须 Serializable,性能差 |
| JSON round-trip | 对象→JSON→对象 | 前端/API 场景 | 循环引用炸,类型擦除 |
java
// 序列化深拷贝
public class DeepCloneUtils {
@SuppressWarnings("unchecked")
public static <T extends Serializable> T deepCopy(T obj) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(obj);
try (ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis)) {
return (T) ois.readObject();
}
} catch (Exception e) {
throw new RuntimeException("深拷贝失败", e);
}
}
}为什么业务代码很少直接用原型
Spring 的 @Scope("prototype") 只是名字像,语义是「每次注入返回新实例」,不是克隆。实际业务场景中,需要「复制当前对象状态」时,JSON 序列化或 copyProperties 字段映射更可控。原型模式真正发光的地方是原型注册表(Prototype Registry)——缓存一个原型对象,需要时克隆,避免重复构造,但多数场景用工厂+默认值也能搞定。
面试追问:如果一个对象有 final 字段,clone() 后能否修改?不能,clone() 不允许修改 final 字段,所以原型模式不适合有 final 字段的类。
备忘录模式:快照与撤销
意图与结构
备忘录模式把对象状态保存到外部,供后续恢复。三个角色:发起人(Originator,需要保存状态的对象)、备忘录(Memento,保存的快照)、管理者(Caretaker,管理快照生命周期)。
java
// 编辑器快照
public class Editor {
private String content;
private int cursorPosition;
public Memento save() {
return new Memento(content, cursorPosition);
}
public void restore(Memento m) {
this.content = m.getContent();
this.cursorPosition = m.getCursorPosition();
}
// 内部类,外部只能持有,不能修改
public static class Memento {
private final String content;
private final int cursorPosition;
private Memento(String content, int cursorPosition) {
this.content = content;
this.cursorPosition = cursorPosition;
}
private String getContent() { return content; }
private int getCursorPosition() { return cursorPosition; }
}
}
// 管理者:维护历史栈
public class History {
private final Deque<Editor.Memento> stack = new ArrayDeque<>(100);
public void push(Editor.Memento m) { stack.push(m); }
public Editor.Memento pop() { return stack.pop(); }
}命令+备忘录的经典组合
第 8 篇讲命令模式时留了个钩子:「命令+备忘录做撤销」。执行命令时先存快照,撤销时恢复。
java
public class SetTextCommand extends Command {
private Editor editor;
private String oldText;
private String newText;
public void execute() {
oldText = editor.getText();
editor.setText(newText);
}
public void undo() {
editor.setText(oldText); // 恢复旧值
}
}工程上直接存全量快照,内存开销看对象大小。如果对象很大(比如 1MB 的文档),每步都存全量会导致内存暴涨。优化方案:增量快照(只存变化 diff)或限制历史栈深度(比如 50 步)。Git 的底层逻辑就是备忘录模式——每次 commit 保存项目快照(实际是 diff + 引用),可以 checkout 回到任意历史版本。
中介者模式:调用链的中央调度
意图:多对多变成一对多
没有中介者时,多个对象互相通信形成网状结构,每个对象都知道其他对象的存在。中介者引入一个中央调度点,对象只和中介者通信。
java
// 聊天室
public class ChatRoom {
private final Map<String, User> users = new ConcurrentHashMap<>();
public void register(User user) {
users.put(user.getName(), user);
}
public void send(String from, String to, String message) {
User receiver = users.get(to);
if (receiver != null) {
receiver.receive(from, message);
}
}
}
public class User {
private final String name;
private final ChatRoom room;
public User(String name, ChatRoom room) {
this.name = name;
this.room = room;
}
public void send(String to, String message) {
room.send(name, to, message);
}
public void receive(String from, String message) {
System.out.println(from + " -> " + name + ": " + message);
}
}为什么不用显式写 Mediator
事实是:MQ 和 Controller 已经是中介者了。A 服务发消息到 RabbitMQ,B 服务消费,它们之间不需要知道对方存在。Controller 接收 HTTP 请求,分发给 Service,Service 之间也不直接调。在这两个基础设施面前,显式写一个 Mediator 类反而多余。
什么时候值得显式抽一个 Mediator?GUI 组件协调(窗口里按钮、输入框、下拉框互相联动)、通讯协议多路复用(一个连接处理多个消息类型)。但大多数业务系统——MQ 就是你的 Mediator。
访问者模式:双分派与 AST 遍历
最难理解的模式
访问者模式的核心是双分派(Double Dispatch)。Java 是单分派语言:方法重载在编译期根据静态类型决定,方法重写在运行期根据实际类型决定。访问者模式通过两次方法调用,模拟出「根据两个对象的实际类型来选择行为」的效果。
java
// 元素接口:接受访问者
public interface Element {
void accept(Visitor visitor);
}
// 两个具体元素
public class TitleElement implements Element {
public void accept(Visitor visitor) { visitor.visit(this); }
}
public class BodyElement implements Element {
public void accept(Visitor visitor) { visitor.visit(this); }
}
// 访问者接口:为每种元素准备一个 visit 方法
public interface Visitor {
void visit(TitleElement title);
void visit(BodyElement body);
}
// 实际访问者
public class HtmlExporter implements Visitor {
public void visit(TitleElement title) {
System.out.println("<h1>" + title.getText() + "</h1>");
}
public void visit(BodyElement body) {
System.out.println("<p>" + body.getText() + "</p>");
}
}
// 客户端调用
Element element = getElement(); // 实际可能是 TitleElement 或 BodyElement
element.accept(visitor);
// 1. element.accept() 根据元素实际类型分派(第一次分派)
// 2. accept() 内部调用 visitor.visit(this),this 是具体类型(第二次分派)谁在用访问者
ASM 字节码操作框架。ASM 提供一个 ClassVisitor 接口,里面定义了 visitMethod()、visitField()、visitAnnotation() 等方法。你把一个 ClassReader 传给 ClassVisitor,它遍历字节码文件时自动调用对应方法。
java
// ASM 修改字节码的最小例子
ClassReader reader = new ClassReader("com/example/MyService");
ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if ("execute".equals(name)) {
// 在方法前后插入日志
return new AdviceAdapter(Opcodes.ASM9, mv, access, name, desc) {
@Override
protected void onMethodEnter() {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("execute called");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
};
}
return mv;
}
};
reader.accept(visitor, 0);
byte[] modified = writer.toByteArray();除了 ASM,Lombok 的注解处理器、Java 编译器内部 AST 都是在用访问者模式遍历语法树。面试官问起来,能说出 ASM 或 Lombok 的例子就够了——业务代码里基本不会手写。
常见误区与小结
- 原型模式不是 Spring 的 prototype scope:名字像但语义不同,
@Scope("prototype")是每次创建新实例,不是克隆已有对象。 - 备忘录 vs 保存到数据库:备忘录存内存快照,用于运行时撤销/重做。持久化到数据库那是普通的 CRUD,不是备忘录。
- 中介者不是中间件:中间件是基础设施(MQ/Redis),中介者是代码层面的协同模式。MQ 可以充当中介者,但中介者模式不要求 MQ。
- 访问者模式不在业务代码里写:除非你在写解析器、编译器、字节码工具,否则不需要。知道它的存在和双分派原理,面试能答,不写业务代码即可。
小结:原型模式补充了对象创建的「克隆」维度,备忘录解决了撤销/快照问题,中介者回归了「中央调度」这个 MQ 出现前的古老思路,访问者展示了双分派的语言机制。四者都不是日常编码的主角,但在框架源码、工具链和面试题里会出现。能举一个最小例子,说清工程替代方案——面试不至于冷场,写代码也不会上头硬套。
参考
GoF 书第 3 章创建型(Prototype)、第 5 章行为模式(Memento/Mediator/Visitor)
java.lang.Object.clone()规范:浅拷贝语义与 Cloneable 标记接口的约定 ASM 4.0 Guide:https://asm.ow2.io/asm4-guide.pdf Git 设计原理:底层对象存储与快照模型 本系列第 8 篇:责任链与命令模式(命令+备忘录撤销钩子回收)