设计模式修炼之路 ⑤ 原型模式——clone() 的浅与深
clone() 与 Cloneable、浅拷贝踩坑、逐字段克隆与序列化两种深拷贝
目录
上一篇《设计模式修炼之路-建造者模式》里,我们用链式 Builder 优雅地造出了披萨和套餐。至此,"造对象"的姿势已经见过三种:单例管"造几个",工厂管"从哪造",建造者管"怎么装"。创建型模式还剩最后一个问题没人管——能不能干脆别造了,直接抄一份?
造一只"羊"如果很便宜,new 一下无所谓;可要是这只羊的构造函数要查三次数据库、算两轮报表、读一堆配置,而你需要的不过是一只和手里这只几乎一模一样的羊呢?这时候最经济的办法,就是原型模式的主张:找个现成的,复制一份。说好的克隆羊多利,这就来。
一、什么是原型模式?
原型模式(Prototype Pattern)属于创建型模式,GoF 23 种之一。先看定义:
用原型实例指定创建对象的种类,并通过拷贝这些原型来创建新的对象。
翻译成人话:不走 new 的路子,拿一个现成对象当"母版",复制出新的来。
克隆羊多利就是现成的类比:多利不是公羊母羊"构造"出来的,而是从一只白面母羊的体细胞里取出细胞核、移植培育而成——遗传信息和"母版"完全一致。母版羊就是原型(Prototype),多利就是那次 clone() 的返回值。
Java 里实现克隆是一套"三件套":
- Object.clone():所有类的祖宗 Object 自带一个 protected 的 native 方法 clone(),负责把对象逐字段复制一份;
- Cloneable 接口:一个方法都没有的"标记接口"(marker interface),唯一作用是给 clone() 发授权——类不实现它,super.clone() 在运行时直接抛 CloneNotSupportedException;
- 重写 clone():把访问权限改成 public,让外部调得到。
角色也只有两个,是五个创建型模式里最简的:
- Prototype(抽象原型):声明克隆能力。在 Java 里就是 Cloneable 接口加各自重写的 clone() 方法;
- ConcretePrototype(具体原型):实现克隆逻辑的具体类。
进阶玩法还有"原型注册表"(Prototype Registry):把常用母版缓存进一个 Map,按 key 取出来克隆——游戏里的怪物模板、编辑器里的样式库都是这个思路,本篇不展开。
而原型模式真正的灵魂、也是面试的重灾区,是浅拷贝与深拷贝的区别。不急,第四节用实验现场复现这个坑。
二、原型模式的优缺点
优点
- 性能好。内存逐字段复制比走一遍构造函数快得多,尤其构造里有查库、读文件这类 IO 时,差距肉眼可见。
- 躲开构造器的副作用。不走 new,构造函数里的日志、计数、资源加载统统不执行——当然这也是把双刃剑,后面说。
- 运行时动态创建。多态场景下你手里只握着一个对象引用,不知道(也不需要知道)它的具体类,照样能克隆出同类型的新对象;
new必须写死类名,做不到这一点。
缺点
- 浅拷贝是隐形炸弹。引用类型字段被两只对象共享,改一处、动全身,bug 隐蔽到怀疑人生。
- 深拷贝成本不低。要么每个引用字段递归 clone(类多了写得吐血),要么序列化(要求整条引用链都 Serializable,还慢)。
- 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)——当你手里攥着三相插头、墙上却是两孔插座时,第一个想到的就是它。