Skip to content

冷门模式补遗:原型、备忘录、中介者与访问者——面试能答、工程不硬套

本文是设计模式系统学习系列的收尾篇,补齐 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.itemsAddress 还是指向同一个对象,修改克隆对象的 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 篇:责任链与命令模式(命令+备忘录撤销钩子回收)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1