2
0

原型模式:clone 的陷阱、深拷贝的三种做法与选型指南

「复制一个对象」听起来比「新建一个对象」简单,但它是创建型模式里最容易被写错的一个。这篇文章讲清原型模式(Prototype)的意图、Java 里 Cloneable + Object.clone() 的真实契约、浅拷贝为什么会污染原型、深拷贝的三种做法与代价,以及一个常常被忽略的结论:大多数时候你需要的不是克隆,而是一个拷贝构造器。

一、意图与适用场景

GoF 对原型模式的意图定义是:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

注意这句话的重心不在"复制",而在**"用实例指定种类"——它提供了一个把"创建"这件事从类名解耦到实例**的机制。这一点在下面会反复出现。

什么时候"复制"比"新建"划算?

场景为什么原型更合适
创建成本高对象初始化要读数据库、解析文件、跑大量计算(如一份预生成的报表样式、一棵装配好的配置树)——复制一个成品比重新算一遍便宜得多
需要"模板 + 微调"有一份标准配置,每次只用改一两个字段;用原型复制再改,比写一堆带参构造器清晰
运行时才知道具体类型框架里只拿到 Prototype 引用,不知道(也不该知道)具体是哪个子类——p.clone() 天然返回正确类型,不需要任何 if-else 或工厂子类
需要大量相似对象每个对象要独立可变,但初始状态高度相似(游戏里的怪物、文档里的样式)

反过来,如果对象不可变,那么"创建新对象"本身就可以退化成"共享同一个实例"——这时候任何拷贝都是浪费。这是现代 Java 里最常见的替代方案(见第九节)。

二、Java 的原型机制:Cloneable + Object.clone()

Java 不走"每个类自己实现复制逻辑"的路子,而是在 Object 上放了一个 native 方法:

public class Object {
    protected native Object clone() throws CloneNotSupportedException;
}

围绕它有三条必须记住的规则:

  1. clone() 是 protected 的:外部调用不了,子类必须重写并把它提升为 public(或提供别的公开入口)才有意义。
  2. Cloneable 是个空的标记接口:它不声明任何方法,作用只是让 Object.clone() 放行;没实现它,super.clone() 会抛 CloneNotSupportedException。(这是 Java 早期设计的一个众所周知的失误,见《Effective Java》Item 13。)
  3. Object.clone() 默认是浅拷贝:它做的是"逐字段位复制"(field-by-field, bitwise),引用字段复制的是引用本身。

clone() 的通用契约(来自 JDK 文档):

  • x.clone() != x——返回的是新对象,不是 this;
  • x.clone().getClass() == x.getClass()——类型不变;
  • x.clone().equals(x)——通常成立,但不是绝对要求;
  • 由约定俗成,x.clone() 内部的实现应当先 super.clone(),再按需修正字段。

还有一条不写在文档里、但必须知道的事实:clone() 不会调用任何构造器。它直接在堆上复制字节,因此构造器里的校验、注册、计数器、final 字段初始化——全部被绕过了。

三、结构原型与 UML

原型模式的结构比工厂模式简单得多——它只有一条核心关系:具体原型实现抽象原型的复制方法,客户端只面向抽象原型。

proto-1-structure

三个角色:

  • Prototype(抽象原型):声明复制自身的接口。Java 里可以是一个接口,也可以直接用 Cloneable + clone()。
  • ConcretePrototype(具体原型):实现复制,返回自己的副本;往往还要负责把可变字段深拷贝到副本里。
  • Client(客户):持有抽象原型引用,调用复制方法拿新对象——它不知道也不需要知道具体类型。

与「简单工厂」的关键差别:简单工厂把"用哪个类"写在一个方法里,工厂方法把它交给子类,原型把它交给一个实例。三者都在解决"创建谁"的问题,原型走的是最短的一条路:不用类名,直接复制。

四、浅拷贝的坑:副本改了,原件也变了

先看一个"看起来没问题"的实现:

import java.util.ArrayList;
import java.util.List;

/** 浅拷贝:Object.clone() 的默认行为 */
public class ShallowCopyDemo {

    static class Order implements Cloneable {
        String id;
        List<String> items = new ArrayList<>();

        Order(String id, String... items) {
            this.id = id;
            for (String s : items) this.items.add(s);
        }

        @Override
        public Order clone() {
            try {
                return (Order) super.clone();     // 逐字段位复制
            } catch (CloneNotSupportedException e) {
                throw new AssertionError(e);      // 实现了 Cloneable,走不到这里
            }
        }
    }

    public static void main(String[] args) {
        Order origin = new Order("A-1", "键盘", "鼠标");
        Order copy = origin.clone();

        System.out.println(origin != copy);                       // true:确实是两个对象
        System.out.println(origin.getClass() == copy.getClass()); // true:类型也一致
        System.out.println(origin.items == copy.items);           // true:⚠️ 同一个 List 实例

        copy.items.add("显示器");
        System.out.println(origin.items);                         // [键盘, 鼠标, 显示器] ← 原件被污染
    }
}

问题出在 origin.items == copy.items 为 true:String id 是引用类型,但 String 不可变,所以共享它无害;List<String> items 是可变引用字段,共享它意味着两个对象共用一份数据——修改副本等于修改原型。

原型的三种"可变字段"是重灾区:集合(List/Map/Set)、数组、以及对象自带的 Date/StringBuilder 这类可变类型。

proto-2-shallow-vs-deep

五、深拷贝的三种做法

做法 1:重写 clone(),手动深拷贝可变字段

最直接的原型模式写法——先 super.clone() 拿到浅拷贝,再逐个"补深":

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

public class ManualDeepCloneDemo {

    static class Order implements Cloneable, Serializable {
        private static final long serialVersionUID = 1L;

        String id;
        List<String> items;
        int[] quantity;

        Order(String id, List<String> items, int[] quantity) {
            this.id = id;
            this.items = items;
            this.quantity = quantity;
        }

        @Override
        public Order clone() {
            try {
                Order copy = (Order) super.clone();                 // 1) 先浅拷贝
                copy.items = new ArrayList<>(this.items);           // 2) 集合:新建一份
                copy.quantity = this.quantity.clone();              // 3) 数组:数组的 clone 是浅拷贝但够用
                return copy;                                        // 4) 若有嵌套对象,要递归拷贝
            } catch (CloneNotSupportedException e) {
                throw new AssertionError(e);
            }
        }
    }

    public static void main(String[] args) {
        Order origin = new Order("A-1", new ArrayList<>(List.of("键盘")), new int[]{1});
        Order copy = origin.clone();

        copy.items.add("显示器");
        copy.quantity[0] = 99;

        System.out.println(origin.items);      // [键盘] ← 不再被污染
        System.out.println(origin.quantity[0]); // 1
    }
}

要点:每一层可变引用都必须手动复制。如果对象图有三层嵌套,就得写三层——这是这种写法最大的维护负担。

做法 2:序列化中转(简单,但慢)

思路是把对象"写出去再读回来",内存里自然生成一份全新对象图,天然深拷贝,不用关心有几层嵌套:

import java.io.*;

public final class DeepCopy {
    private DeepCopy() {}

    @SuppressWarnings("unchecked")
    public static <T extends Serializable> T of(T obj) {
        try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
             ObjectOutputStream oos = new ObjectOutputStream(bos)) {
            oos.writeObject(obj);
            try (ObjectInputStream ois =
                         new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()))) {
                return (T) ois.readObject();
            }
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalStateException("深拷贝失败", e);
        }
    }
}

代价与限制:

  • 必须全链路可序列化:transient 字段会丢、没有 serialVersionUID 的类易受版本变化影响、不可序列化的字段(如 Thread、Stream、某些框架对象)直接失败;
  • 性能最差:一次深拷贝至少要过一遍编解码,通常比手写慢一个数量级(也正因如此,它适合"偶尔用一次"的场景,而不是热路径);
  • 好处是真的省心:递归、循环引用都由序列化机制处理,你不会漏掉任何一层。

工程里常见的变体是用 JSON 中转(Jackson / Gson readValue(writeValueAsString(o)))——同样是"编码再解码",但依赖更轻、可跨语言;缺点是精度与类型信息可能丢失(如泛型、LocalDate、多态)。

做法 3:拷贝构造器 / 拷贝工厂(推荐)

《Effective Java》Item 13 的建议就是:别实现 Cloneable,改用拷贝构造器或拷贝工厂。

import java.util.ArrayList;
import java.util.List;

public class CopyConstructorDemo {

    static final class Report {
        private final String title;
        private final List<String> rows;      // final 字段也能正常拷贝

        Report(String title, List<String> rows) {
            this.title = title;
            this.rows = new ArrayList<>(rows);   // 构造时做防御性拷贝
        }

        /** 拷贝构造器:语义显式,不需要 Cloneable,不抛受检异常 */
        Report(Report other) {
            this(other.title, other.rows);
        }

        /** 拷贝工厂:静态方法版本,可以配合泛型做类型转换 */
        static Report copyOf(Report other) {
            return new Report(other);
        }

        Report withExtraRow(String row) {      // 不可变风格的"改一点":返回新对象
            List<String> next = new ArrayList<>(rows);
            next.add(row);
            return new Report(title, next);
        }

        List<String> rows() {
            return new ArrayList<>(rows);      // 出口也拷贝,防止外部改内部状态
        }
    }

    public static void main(String[] args) {
        Report origin = new Report("月度报表", new ArrayList<>(List.of("华东")));
        Report copy = Report.copyOf(origin).withExtraRow("华南");

        System.out.println(origin.rows());  // [华东]      ← 原件不受影响
        System.out.println(copy.rows());    // [华东, 华南]
    }
}

它比 clone() 好在四个地方:

  1. 不依赖 Cloneable/clone() 这套脆弱机制,不用处理 CloneNotSupportedException;
  2. final 字段可以正常参与(clone() 里没法给 final 字段重新赋值,想做深拷贝只能把 final 去掉);
  3. 可以校验——先过一遍构造器里的不变量检查再返回,clone() 是绕过构造器的;
  4. 对子类友好——clone() 在非 final 类上很难同时满足"每个子类都正确复制"。

配合"改一点就返回新对象"的 withXxx() 方法,这套写法就是今天 Java 里事实上的主流。

六、clone() 的七个坑(写之前先知道)

坑说明
浅拷贝陷阱默认只复制引用;集合/数组/可变对象必须手动深拷贝,且每一层都不能漏
绕过构造器clone() 不调用构造器:校验、注册、计数器、日志统统跳过,被克隆出的对象可能不满足类的不变量
final 字段冲突想深拷贝的可变字段不能是 final;用拷贝构造器则不受此限
受检异常CloneNotSupportedException 必须处理,可它实际上只表示"你没实现 Cloneable",属于可编程错误
Cloneable 语义混乱空标记接口 + protected 的 Object.clone():既不是接口契约,也不是继承契约
非 final 类很难写对子类若不重新实现,clone() 返回的是父类视角的副本,容易出微妙 bug
数组的 clone() 有"特殊性"数组的 clone() 是 public 的、协变的、也是浅拷贝;元素是可变对象时同样要逐元素复制

顺带一个常被忽略的点:HashMap、ArrayList 这些集合的"拷贝构造器"new ArrayList<>(list) 也是浅拷贝——元素本身不会复制。要深拷贝集合,得对每个元素再复制一遍。

七、原型管理器:用"样本"取代工厂

原型模式最实用的扩展是原型注册表(Prototype Registry / Prototype Manager):把一批预配置好的原型存进 Map,客户端按名字取副本。

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class PrototypeRegistryDemo {

    interface Shape extends Cloneable {
        Shape copy();          // 现代写法:自定义 copy(),避免暴露 Cloneable/clone()
        void draw();
        void scale(int factor);
    }

    static class Circle implements Shape {
        private int radius;
        private String color;

        Circle(int radius, String color) { this.radius = radius; this.color = color; }

        @Override public Shape copy() { return new Circle(radius, color); }
        @Override public void scale(int factor) { radius *= factor; }
        @Override public void draw() { System.out.println("圆形 r=" + radius + " color=" + color); }
    }

    static class Square implements Shape {
        private int side;
        private String color;

        Square(int side, String color) { this.side = side; this.color = color; }

        @Override public Shape copy() { return new Square(side, color); }
        @Override public void scale(int factor) { side *= factor; }
        @Override public void draw() { System.out.println("方形 a=" + side + " color=" + color); }
    }

    /** 原型管理器:保存"样本",create() 永远返回副本 */
    static class ShapeRegistry {
        private final Map<String, Shape> prototypes = new HashMap<>();

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

        Shape create(String key) {
            Shape prototype = prototypes.get(key);
            if (prototype == null) {
                throw new IllegalArgumentException("未注册的原型: " + key);
            }
            return prototype.copy();              // 关键:交出去的是副本,不是样本本身
        }
    }

    public static void main(String[] args) {
        ShapeRegistry registry = new ShapeRegistry();
        registry.register("small-circle", new Circle(10, "red"));
        registry.register("small-square", new Square(5, "blue"));

        Shape a = registry.create("small-circle");
        a.scale(3);                                // 只影响副本
        a.draw();                                  // 圆形 r=30 color=red

        registry.create("small-circle").draw();     // 圆形 r=10 color=red ← 样本没被改动
    }
}

34d417bb32cbeffd4ff9abd9e1e4bc27

它和工厂模式里那个"注册表式工厂"的区别值得玩味(参看工厂模式那篇第三节):

  • 注册表式工厂存的是"怎么造"——Supplier<Payment> 或构造器引用,每次调用现场 new 一个;
  • 原型注册表存的是"造好的样本"——一个现成的实例,每次调用复制一份。

前者适合创建逻辑可以做参数化,后者适合对象状态本身就是配置的场景(样式、关卡模板、装配好的规则集)。如果原型注册表里的 copy() 写成 prototype.getClass().newInstance(),它就退化成了反射式工厂——差别的本质就是"复制实例"还是"重新构造"。

八、复制过程的时序

proto-4-sequence

九、选型:什么时候用哪一种

proto-5-selection

做法适用代价
clone() + 手动深拷贝必须实现 Cloneable(第三方框架要求)、对象层次浅易漏层、final 字段受限、绕过构造器
拷贝构造器 / 拷贝工厂默认选择:语义显式、可校验、支持 final每个类要写一遍(可用 Lombok @Builder(toBuilder = true) 之类的工具减少样板)
序列化 / JSON 中转偶发使用、对象图深且复杂、不想逐层维护慢一到两个数量级;要求可序列化;transient 字段丢失
原型注册表需要"按名字取样本"、模板 + 微调样本必须是线程安全的只读模板,否则会被并发改坏

十、什么时候不要用原型

  • 对象不可变:直接共享引用,拷贝纯属浪费(String、Integer、record、枚举都是这一类)。
  • 创建逻辑简单:new Report(title, rows) 一行就够,绕到 clone() 只会更难读。
  • 需要"分步装配":用建造者模式——原型擅长"照抄一个成品",不擅长"逐步构造一个复杂对象"。
  • 需要统一的创建策略:用工厂模式。原型与工厂不冲突:工厂内部完全可以拿原型来生产(注册表式工厂 + 原型注册表是同一思路的两种形态)。
  • 拷贝后还要大量改字段:如果每次复制后要改八九个字段,说明这个"模板"并不合适,考虑建造者或不可变对象的 withXxx() 链。

十一、参考资料

  • GoF《设计模式:可复用面向对象软件的基础》——Prototype 章(意图、结构、参与者、协作)
  • Joshua Bloch《Effective Java》Item 13:Override clone judiciously(为什么推荐拷贝构造器/拷贝工厂)
  • JDK API 文档:java.lang.Object#clone()、java.lang.Cloneable(契约与"标记接口"说明)
  • 《Effective Java》Item 50:必要时做防御性拷贝(集合字段进出都要拷贝的原因)

评论