原型模式完全指南:从浅克隆到深拷贝再到不可变共享

原型模式(Prototype)是 GoF 23 个模式里最容易被误解的一个 —— 听起来玄,实际上做的事就是"克隆"。但要把克隆做对、做安全、做高效,牵扯到深浅拷贝、序列化陷阱、循环引用、不可变共享一连串细节。这篇文章从最朴素的需求讲到 JS 原型链、Spring Prototype Scope,让你看到这个模式如何在不同语言里以不同样貌出现。

原型模式要解决什么问题

正常创建对象有两条路:new ClassName(...) 或者经过工厂方法。但有些场景这两条都不优雅:

  • 对象创建非常昂贵(要读文件、查数据库、做复杂计算),但你需要很多结构相同的副本。
  • 对象的具体类型在运行时才知道,你只有一个"已经在手的实例",想要"再来一个一模一样的"。
  • 对象有大量字段,挨个 set 既啰嗦又容易漏。
  • 对象需要保留历史快照(撤销/重做),每次操作前先克隆一份。

原型模式说:不要 new,从一个"原型"对象克隆出新副本。这样既复用了已有对象的全部初始化成本,又能给每个副本独立的状态。

最朴素版:实现 Cloneable

// Java 内置的 Cloneable 接口 + Object.clone()
public class GameNpc implements Cloneable {
    String name;
    int hp;
    int attack;
    List<Item> inventory;

    @Override
    public GameNpc clone() {
        try {
            return (GameNpc) super.clone();      // 浅拷贝
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(e);
        }
    }
}

GameNpc prototype = loadFromDB();           // 昂贵
GameNpc enemy1 = prototype.clone();          // 几乎免费
GameNpc enemy2 = prototype.clone();
enemy1.name = "elite goblin";                // 各自独立修改

super.clone()浅拷贝—— 它把字段的值复制过去,引用类型字段(List<Item>)的值是引用,新旧对象共享同一个列表。改 enemy1 的 inventory,enemy2 跟着变。这就是浅拷贝的陷阱。

深浅拷贝的边界

// 浅拷贝
GameNpc shallow = prototype.clone();
shallow.inventory.add(newItem);             // prototype.inventory 也变了!

// 深拷贝:递归克隆所有引用字段
@Override
public GameNpc clone() {
    GameNpc copy = (GameNpc) super.clone();
    copy.inventory = new ArrayList<>();
    for (Item item : this.inventory) {
        copy.inventory.add(item.clone());   // 递归克隆每个 Item
    }
    return copy;
}

规则简单粗暴:需要独立修改的字段必须深拷贝,只读 / 不可变的字段可以浅拷贝共享。这是一个性能与正确性的权衡 —— 越深拷贝越安全,但越慢、越费内存。

不可变对象自动免疫

如果 Item 是不可变对象(所有字段 final,没有 setter),那共享同一个 Item 引用是安全的 —— 谁都改不了它。这就是为什么"不可变设计"在 Java / Scala / Rust 圈很受推崇:克隆从"递归深拷贝"退化成"共享引用",免费且正确。

通用深拷贝的几种实现

方式 1:逐字段手写

最精确,完全可控,但样板代码多,加字段容易漏。

方式 2:序列化反序列化

// 简单粗暴:写出来再读回来
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);
    }
}

// 或者用 Jackson / Gson
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(obj);
T copy = mapper.readValue(json, (Class<T>) obj.getClass());

优点:无需为每个类写 clone。缺点:

  • 性能差(序列化 + 反序列化都是 O(对象大小) 的开销)。
  • 对象图必须能被序列化(没有 transient 字段、没有不可序列化的成员)。
  • 循环引用:Jackson 默认会无限递归,需要专门配置。ObjectMapper@JsonIdentityInfo 可以处理。

方式 3:Apache Commons Lang / Spring BeanUtils

import org.apache.commons.lang3.SerializationUtils;
GameNpc copy = SerializationUtils.clone(prototype);   // 内部走 Java 序列化

// Spring 的浅拷贝(只拷字段,不递归)
BeanUtils.copyProperties(prototype, target);

方式 4:第三方深拷贝库

// Kryo:高效二进制序列化,深拷贝也快
Kryo kryo = new Kryo();
GameNpc copy = kryo.copy(prototype);   // 比 Java 序列化快 5-10 倍

实战推荐:对小对象、可控字段,手写;对大对象图、配置型对象,用 JSON 序列化往返;追求性能就上 Kryo 这种二进制方案

原型注册表:按 key 找原型

GoF 经典原型模式加了一个"原型管理器":

class ShapeRegistry {
    private Map<String, Shape> prototypes = new HashMap<>();

    public void register(String key, Shape proto) {
        prototypes.put(key, proto);
    }

    public Shape create(String key) {
        Shape proto = prototypes.get(key);
        if (proto == null) throw new IllegalArgumentException();
        return proto.clone();
    }
}

// 启动期一次性注册"标准款"
registry.register("red-circle", new Circle("red", 10));
registry.register("blue-square", new Square("blue", 20));

// 使用时只要 key,不需要知道具体类
Shape s1 = registry.create("red-circle");   // 拿到独立副本
Shape s2 = registry.create("red-circle");   // 另一个独立副本

这种结构在游戏(角色/怪物模板)、表单(字段模板)、画板(图形预设)里都很常见。本质上是"工厂 + 原型"组合—— 工厂用 key 决定造什么,造的方式是克隆。

JavaScript 的原型链:语言级别的原型模式

JS 是原生原型继承的语言。每个对象都有一个 [[Prototype]] 指针指向另一个对象,属性查找沿这条链上溯。这意味着 JS 不需要专门的"克隆模式" —— 创建对象时直接用现有对象做原型即可:

const animal = {
    type: 'animal',
    breathe() { console.log(`${this.name} is breathing`); }
};

// Object.create:以 animal 为原型创建新对象
const dog = Object.create(animal);
dog.name = 'Lucky';
dog.bark = function() { console.log('woof'); };

dog.breathe();   // Lucky is breathing —— 通过原型链找到 breathe
dog.bark();      // woof —— 自己的方法

// 浅克隆:Object.assign 或扩展运算符
const dog2 = Object.assign({}, dog);
const dog3 = { ...dog };

// 深克隆:JSON 法(简单但有限)
const deep = JSON.parse(JSON.stringify(obj));    // 丢失函数、undefined、Symbol、Date 变字符串

// 现代浏览器 + Node:structuredClone(全面深拷贝)
const real = structuredClone(obj);    // 处理循环引用、Map、Set、Date、TypedArray

structuredClone 是 2022 年起浏览器和 Node 都标准支持的深拷贝 API,性能远比 JSON 法好,且支持几乎所有内置类型。日常 JS 深拷贝默认就该用它。

Spring 的 Prototype Scope

Spring 容器默认 bean 是单例(每次注入同一实例)。@Scope("prototype") 让容器每次注入新实例:

@Component
@Scope("prototype")
public class HttpRequest {
    private final String requestId = UUID.randomUUID().toString();
    public String getRequestId() { return requestId; }
}

@RestController
public class Controller {
    @Autowired
    private ObjectProvider<HttpRequest> reqProvider;

    @GetMapping("/")
    public String handle() {
        HttpRequest req = reqProvider.getObject();   // 每次新建
        return req.getRequestId();
    }
}

这其实就是"原型模式"在 IoC 容器层面的实现 —— 你声明一次"原型 bean",容器负责"每次拿一份新的"。比手写工厂 + clone 更声明式。

实战 1:撤销 / 重做

编辑器的 undo / redo 经典实现:每次操作前先克隆一份当前状态压栈,撤销时弹栈复原。

class Editor {
    private Document doc;
    private Deque<Document> undoStack = new ArrayDeque<>();
    private Deque<Document> redoStack = new ArrayDeque<>();

    public void execute(Command cmd) {
        undoStack.push(deepCopy(doc));    // 保存当前快照
        redoStack.clear();                 // 新操作清空 redo
        cmd.apply(doc);
    }

    public void undo() {
        if (undoStack.isEmpty()) return;
        redoStack.push(deepCopy(doc));
        doc = undoStack.pop();
    }

    public void redo() {
        if (redoStack.isEmpty()) return;
        undoStack.push(deepCopy(doc));
        doc = redoStack.pop();
    }
}

性能优化:大文档每步全量克隆昂贵。可以用不可变数据结构(Clojure 的 persistent data structure,Java 的 PCollections)或命令记录(只存 diff 而非全量) —— 后者就是备忘录模式的领地了。

实战 2:React 不可变更新

// React 状态更新必须返回新对象,本质就是原型模式的浅克隆
const [user, setUser] = useState({ name: 'mores', profile: { age: 30 } });

// 错误:直接改不会触发重渲(React 用 === 比较)
user.profile.age = 31;
setUser(user);     // 引用没变,React 跳过更新

// 正确:克隆 + 修改
setUser({ ...user, profile: { ...user.profile, age: 31 } });

// 深结构改起来烦,用 Immer 解决
import produce from 'immer';
setUser(produce(user, (draft) => {
    draft.profile.age = 31;        // 看起来"修改",Immer 在背后克隆
}));

实战 3:测试数据工厂

// 测试里经常需要"用户对象,但密码字段不同"这种变体
const baseUser = {
    id: 1, name: 'test', email: 't@x.com',
    password: 'p1', role: 'user', active: true,
};

// 用展开做"原型 + 覆盖"
const admin = { ...baseUser, role: 'admin' };
const banned = { ...baseUser, active: false };

// 工厂函数封装,更复用
function makeUser(overrides = {}) {
    return { ...baseUser, ...overrides };
}
makeUser({ role: 'admin' });

常见坑

坑 1:clone() 没考虑继承。 父类的 clone 只克隆自己定义的字段,子类新增的字段被遗漏。规范:每个有可变字段的类都重写 clone,逐字段处理。

坑 2:循环引用导致深拷贝爆栈。 A 引用 B,B 引用 A,递归克隆栈溢出。修复:用一个 IdentityHashMap 记录"已克隆对象 → 新对象的映射",遇到已克隆的直接返回新引用。structuredClone / Kryo 内部都做了这件事。

坑 3:克隆"绑定到外部资源"的对象。 文件句柄、数据库连接、网络 socket 这种带"操作系统句柄"的对象不能简单克隆 —— 克隆出来的两个对象会同时操作同一个句柄,造成混乱。这类对象通常应该禁止 clone(标 final 或抛异常)。

坑 4:JSON 法当成万能。 JSON.parse(JSON.stringify(...)) 在简单 plain object 上能用,但碰到 Date / Map / Set / undefined / 函数 / 循环引用全失效。能用就用 structuredClone

原型模式 vs 工厂模式 vs Builder

  • 工厂:不关心已有实例,凭参数从头创建。
  • 建造者:逐步参数化一个复杂对象。
  • 原型:从一个已有实例克隆,新对象继承原型的状态。

三者经常组合:工厂方法返回 Builder,Builder 内部有一个原型对象,build 时克隆原型再应用配置。Spring 的 BeanDefinition 体系就是这种思路。

写在最后

原型模式的本质是"用克隆代替构造"。在 Java / C# / Go 这种"new + 构造函数"主导的语言里,它表现为 clone 接口和深浅拷贝技巧;在 JS / Lua 里它内化为语言机制(原型链);在容器化 / 不可变数据生态里它被声明式地表达(prototype scope、structuredClone、immer)。无论形式如何,核心问题始终是:你需要的新对象,跟一个已有的对象高度相似

给一个判断标准:当你写 new X(...) 时连续传一堆"从另一个 X 那里抄来的"参数 —— 那就是原型模式喊你的信号。停下来,用克隆替换构造,代码会立刻短一半,且未来加字段不需要每个 new 点同步改。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

建造者模式完全指南:从构造函数地狱到流式 API 的工程演化

2026-5-15 11:35:35

技术教程

适配器模式完全指南:从插头类比到支付网关接入的工程实战

2026-5-15 11:50:57

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索