设计模式预计阅读 15 分钟5758 字2661 次浏览

设计模式修炼之路 ⑤ 原型模式——clone() 的浅与深

clone() 与 Cloneable、浅拷贝踩坑、逐字段克隆与序列化两种深拷贝

目录

上一篇《设计模式修炼之路-建造者模式》里,我们用链式 Builder 优雅地造出了披萨和套餐。至此,"造对象"的姿势已经见过三种:单例管"造几个",工厂管"从哪造",建造者管"怎么装"。创建型模式还剩最后一个问题没人管——能不能干脆别造了,直接抄一份?

造一只"羊"如果很便宜,new 一下无所谓;可要是这只羊的构造函数要查三次数据库、算两轮报表、读一堆配置,而你需要的不过是一只和手里这只几乎一模一样的羊呢?这时候最经济的办法,就是原型模式的主张:找个现成的,复制一份。说好的克隆羊多利,这就来。

一、什么是原型模式?

原型模式(Prototype Pattern)属于创建型模式,GoF 23 种之一。先看定义:

用原型实例指定创建对象的种类,并通过拷贝这些原型来创建新的对象。

翻译成人话:不走 new 的路子,拿一个现成对象当"母版",复制出新的来。

克隆羊多利就是现成的类比:多利不是公羊母羊"构造"出来的,而是从一只白面母羊的体细胞里取出细胞核、移植培育而成——遗传信息和"母版"完全一致。母版羊就是原型(Prototype),多利就是那次 clone() 的返回值。

Java 里实现克隆是一套"三件套":

  1. Object.clone():所有类的祖宗 Object 自带一个 protected 的 native 方法 clone(),负责把对象逐字段复制一份;
  2. Cloneable 接口:一个方法都没有的"标记接口"(marker interface),唯一作用是给 clone() 发授权——类不实现它,super.clone() 在运行时直接抛 CloneNotSupportedException;
  3. 重写 clone():把访问权限改成 public,让外部调得到。

角色也只有两个,是五个创建型模式里最简的:

  • Prototype(抽象原型):声明克隆能力。在 Java 里就是 Cloneable 接口加各自重写的 clone() 方法;
  • ConcretePrototype(具体原型):实现克隆逻辑的具体类。

进阶玩法还有"原型注册表"(Prototype Registry):把常用母版缓存进一个 Map,按 key 取出来克隆——游戏里的怪物模板、编辑器里的样式库都是这个思路,本篇不展开。

而原型模式真正的灵魂、也是面试的重灾区,是浅拷贝与深拷贝的区别。不急,第四节用实验现场复现这个坑。

二、原型模式的优缺点

优点

  1. 性能好。内存逐字段复制比走一遍构造函数快得多,尤其构造里有查库、读文件这类 IO 时,差距肉眼可见。
  2. 躲开构造器的副作用。不走 new,构造函数里的日志、计数、资源加载统统不执行——当然这也是把双刃剑,后面说。
  3. 运行时动态创建。多态场景下你手里只握着一个对象引用,不知道(也不需要知道)它的具体类,照样能克隆出同类型的新对象;new 必须写死类名,做不到这一点。

缺点

  1. 浅拷贝是隐形炸弹。引用类型字段被两只对象共享,改一处、动全身,bug 隐蔽到怀疑人生。
  2. 深拷贝成本不低。要么每个引用字段递归 clone(类多了写得吐血),要么序列化(要求整条引用链都 Serializable,还慢)。
  3. Cloneable 机制天生别扭。克隆不走构造函数,字段校验全部失效;《Effective Java》第 13 条专门吐槽它,标题就叫"谨慎地覆盖 clone"。

三、原型模式的应用场景

  • JDK 里:ArrayList、HashMap 都实现了 Cloneable,不过它们的 clone() 都是浅拷贝——集合壳是新的,里面的元素还是共享的。
  • Spring 里:BeanUtils.copyProperties(source, target),反射逐字段拷贝,DTO 转换神器,本质就是原型思想(也是浅拷贝)。
  • 游戏/模板场景:怪物按模板批量克隆、关卡按母版复制,配原型注册表食用更佳。
  • 规避重复初始化:对象创建要查库、要 RPC、要解析模板,而新对象和现成对象只差个别字段——克隆一份改两个字段,比重新走一遍初始化省得多。

什么时候该用:对象创建昂贵(初始化涉及 IO 或大量计算)+ 需要大量相似对象;或者运行时才知道具体类型、还必须复制它。反过来,简单的 POJO 直接 new,别拿克隆装门面。

四、原型模式的实现方式

示例代码在 design-pattern 项目的 prototype 包里:

design-pattern/src/main/java
└── prototype                     // 原型模式
    ├── Sheep.java                // 浅拷贝羊:Cloneable + clone()
    ├── DeepSheep.java            // 深拷贝羊:递归克隆 + 序列化两种方式
    └── PrototypeTest.java        // 测试入口

1. 浅拷贝:第一次克隆

先造一只最普通的克隆羊,实现 Cloneable,重写 clone()。注意 friend 字段是引用类型,专门用它来观察拷贝的"深浅":

public class Sheep implements Cloneable {

    private String name;
    private int age;
    private Sheep friend; // 引用类型字段,用来观察浅拷贝的引用共享

    public Sheep(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Sheep getFriend() {
        return friend;
    }

    public void setFriend(Sheep friend) {
        this.friend = friend;
    }

    @Override
    public Sheep clone() {
        try {
            return (Sheep) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(e); // 实现了 Cloneable 就不会走到这里
        }
    }

    @Override
    public String toString() {
        return "羊[" + name + ", " + age + "岁]";
    }
}

测试代码:给多利安排一只 2 岁的朋友汤姆,然后克隆:

private static void shallowCopy() {
    Sheep friend = new Sheep("汤姆", 2);
    Sheep dolly = new Sheep("多利", 1);
    dolly.setFriend(friend);

    Sheep dolly2 = dolly.clone();
    System.out.println("克隆出来的羊:" + dolly2);
    System.out.println("两只羊是同一个对象吗:" + (dolly == dolly2));
    System.out.println("两只羊的朋友是同一个对象吗:" + (dolly.getFriend() == dolly2.getFriend()));
}

运行结果:

克隆出来的羊:羊[多利, 1岁]
两只羊是同一个对象吗:false
两只羊的朋友是同一个对象吗:true

前两行看着皆大欢喜:克隆成功,而且 dolly == dolly2 是 false,确实是新对象。但第三行翻车了——两只羊的 friend 存的是同一个地址,它们牵着同一只汤姆。

这就是 super.clone() 的行为:逐字段复制,复制的是字段本身。对 int age 这种基本类型,复制值,相安无事;对 String name 这种引用,虽然也只复制了引用,但 String 不可变,共享无害;唯独 friend 这种可变引用类型,复制的只是"门牌号"——两只羊一个壳两个门牌,推开是同一间房。

这就是浅拷贝:壳是新的,瓤是共享的。此时对 dolly2.getFriend() 做任何修改,多利都同步感知,坑就这么埋下了。

2. 深拷贝方式一:逐字段递归克隆

深拷贝的思路很直白:引用指向的对象也复制一份。第一种做法是在 clone() 里对引用字段再调一次 clone(),一路递归下去:

public class DeepSheep implements Cloneable, Serializable {

    private String name;
    private int age;
    private DeepSheep friend;

    // 深拷贝方式一:重写 clone(),对引用字段递归克隆
    @Override
    public DeepSheep clone() {
        try {
            DeepSheep cloned = (DeepSheep) super.clone();
            if (friend != null) {
                cloned.friend = friend.clone();
            }
            return cloned;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(e);
        }
    }
}

getter/setter、toString 与浅拷贝版相同,略。测试代码和方式完全一样,改调 DeepSheep:

private static void deepCopyByClone() {
    DeepSheep friend = new DeepSheep("汤姆", 2);
    DeepSheep dolly = new DeepSheep("多利", 1);
    dolly.setFriend(friend);

    DeepSheep dolly2 = dolly.clone();
    System.out.println("逐字段克隆出来的羊:" + dolly2);
    System.out.println("两只羊的朋友是同一个对象吗:" + (dolly.getFriend() == dolly2.getFriend()));
}

运行结果:

逐字段克隆出来的羊:羊[多利, 1岁]
两只羊的朋友是同一个对象吗:false

这次是 false 了——朋友也各是各的,两只羊彻底独立。代价也明摆着:每个引用类型字段都要手工递归 clone,字段一多、嵌套一深,写得怀疑人生;而且克隆链上任何一环没重写好 clone(),整条链当场塌方。

3. 深拷贝方式二:序列化 + 反序列化

第二种做法更"偷懒":把对象连同它引用的所有对象整棵序列化成字节数组,再反序列化回来——对象图整体搬家,深拷贝一步到位:

// 深拷贝方式二:序列化 + 反序列化,整棵对象图一起复制
public DeepSheep deepClone() {
    try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
         ObjectOutputStream oos = new ObjectOutputStream(bos)) {
        oos.writeObject(this);
        try (ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
             ObjectInputStream ois = new ObjectInputStream(bis)) {
            return (DeepSheep) ois.readObject();
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

注意开头 DeepSheep 的声明里除了 Cloneable 还实现了 Serializable,并声明了 serialVersionUID——序列化这条路要求整条引用链上的类都可序列化,少一个都走不通。测试代码:

private static void deepCopyBySerialization() {
    DeepSheep friend = new DeepSheep("汤姆", 2);
    DeepSheep dolly = new DeepSheep("多利", 1);
    dolly.setFriend(friend);

    DeepSheep dolly2 = dolly.deepClone();
    System.out.println("序列化复制出来的羊:" + dolly2);
    System.out.println("两只羊的朋友是同一个对象吗:" + (dolly.getFriend() == dolly2.getFriend()));
}

运行结果:

序列化复制出来的羊:羊[多利, 1岁]
两只羊的朋友是同一个对象吗:false

同样是 false,同样是深拷贝。两种方式怎么选:递归 clone 快但要手写,序列化省心但慢(一来一回两次 IO 级操作);字段多、嵌套深、性能不敏感,选序列化。工程里常见的"对象转 JSON 再转回来"就是它的近亲。

顺带交代《Effective Java》的态度:它更推荐拷贝构造器(new Sheep(other))或拷贝工厂——走正常构造流程,类型明确、校验有效,不踩 Cloneable 的各种坑。原型模式的思想(复制代替创建)比它的 Java 原生实现更长寿。

五、复制对象的其他姿势

实际开发里"复制一个对象"远不止 clone() 一条路,顺手盘一盘:

姿势 深浅 特点
Object.clone() 浅 JDK 原生,需实现 Cloneable,Effective Java 不待见
拷贝构造器/拷贝工厂 自定义 `new Sheep(other)`,类型明确,Effective Java 力荐
BeanUtils.copyProperties 浅 Spring 反射工具,字段名相同就拷,DTO 转换神器
序列化/JSON 往返 深 对象图整体复制,省心但慢,要求可序列化

选型一句话:字段全是基本类型和 String(不可变),浅拷贝随便用;一旦有可变引用类型,老老实实深拷贝,别让"共享的汤姆"混进队伍。

写在最后

回顾这一篇:原型模式不走 new,拿现成对象当母版复制,适合"创建昂贵 + 大量相似对象"的场景;Java 三件套是 Cloneable 接口、Object.clone()、重写 clone();super.clone() 是浅拷贝,"两只羊牵同一只朋友"的坑现场复现过;深拷贝两条路,递归 clone 逐字段复制、序列化整图搬家,各有取舍。而它"复制代替创建"的思想早已渗透到拷贝构造器、BeanUtils、JSON 往返这些日常工具里。

至此,创建型模式五篇全部完结:单例管"造几个",工厂管"从哪造",建造者管"怎么装",原型管"抄一份"——四种姿势殊途同归,都是把对象的创建过程从业务代码里解耦出来。

下一篇进入结构型模式,开篇选手:适配器模式(Adapter)——当你手里攥着三相插头、墙上却是两孔插座时,第一个想到的就是它。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论