原型模式详解:浅克隆、深克隆与Java clone实践

发布时间:2026/10/29 6:29:40
原型模式详解:浅克隆、深克隆与Java clone实践

简介:面向软件设计模式学习者的原型模式演示课件,聚焦创建型模式中通过复制现有对象生成新实例的思路,帮助读者理解如何避开复杂初始化与重复构造带来的性能开销,并适用于课程学习、知识梳理和面试复习。压缩包仅含1个PPTX文件,大小约5.93MB,页面围绕概述、使用场景与优缺点、浅克隆和深克隆、代码示例四个部分组织。课件结合Java的clone()方法、Cloneable标识接口及CloneNotSupportedException异常,讲解客户类、抽象原型类与具体原型类的职责划分,并给出ConcretePrototype与Client的示例代码,便于对照理解浅拷贝与深拷贝的差异、复制深度和对象状态一致性等问题。目前已有192人学习或浏览,适合需要系统掌握原型模式实现细节与适用边界的读者参考。

1. 从一台复印机说起:原型模式到底复制了什么

手边有一份签好字、盖好章的合同,现在要一百份。绝大多数人的做法是拿去复印,而不是照着原稿再抄一百遍——每抄一遍都可能抄错一个字,还慢。原型模式(Prototype Pattern)在软件设计模式里扮演的就是这台复印机:拿一个已经建好的对象当原件,复制它得到副本,而不重新走一遍创建流程。它属于创建型模式,关心的是「对象怎么来」,答案是从另一个对象那儿复制过来。这思路听着朴素,却能解决一类很实际的问题:当创建一个对象的代价远高于复制一个对象时,复印就比重造划算。也正因为朴素,它最容易被想当然地理解,然后在不该复印的地方复印,翻车翻得莫名其妙。所以值得花点时间搞懂:它到底复制了什么,又漏掉了什么。

2. 为什么要有原型模式:一次 new 到底贵在哪

2.1 一行 new 的背后,可能藏着几十行准备工作

写代码时创建一个对象,看起来只有一行new,干净利落。但构造函数里可以塞进任何东西:读配置文件、连一次数据库、算一个复杂的指标、加载一张大图、发起一次远程查询。这一行只是入口,真正的活儿都藏在构造函数和它调用的那一串方法里。

举个虚构的例子。某报表对象在构造时需要做三件事:拉取当前用户的权限清单、拼接报表模板、预计算一遍汇总值。单次构造耗时两百毫秒。听起来不多,可如果一天要生成一万份,光花在「造对象」上的时间就是半个多小时,而且每份报表之间有九成内容是重复的——同样的权限、同样的模板、同样的汇总口径。

这种时候,「再 new 一个」就是在做无用功。原型模式的价值在这儿才显出来:对象已经有一个了,剩下的事只是把它复制一份,再改掉那不到一成的差异。

2.2 创建成本高的三种典型情形

判断一个场景该不该考虑原型模式,先看创建成本高在哪。常见的三种情形,基本能覆盖绝大多数情况。

第一种是构造过程要碰外部资源。比如构造时要读磁盘上的模板文件、要连一次数据库校验权限、要请求一个远程接口拿配置。外部资源的特点是慢且不稳定,每次构造都碰一遍,等于把不稳定性乘以对象数量。

第二种是构造过程要经过复杂计算或多次试错。有些对象的字段不是直接给的,而是算出来的:先跑一遍解析,再跑一遍校验,失败了还得回退重试。这类构造逻辑往往写得又长又绕,复制一个现成对象,等于把这段逻辑整体跳过去。

第三种是需要大量相似对象,只有少数字段不同。批量生成一千条消息、一千个任务卡片、一千份带不同收件人的通知,它们的模板、样式、关联数据完全一样,只有称呼和编号不同。用new的方式造一千次,就是重复一千次一模一样的初始化。

提示:这三种情形可以叠加。真正适合原型模式的场景,往往是「外部资源 + 大量相似对象」同时成立。

2.3 原型模式换掉的是哪一步

把创建对象的过程想象成做一锅汤。正常流程是备料、下锅、调味、炖煮,一锅下来两小时。如果你要的是同一锅汤的第二份,重新走一遍全流程显然不划算,直接盛一碗出来就行——前提是你接受它和第一锅是同一个味道。

原型模式做的就是「盛一碗」这个动作。它在结构上把创建拆成两半:一半是「原型对象」,负责被复制;另一半是「发起创建的一方」,负责说一句「给我来一份」。发起方完全不需要知道原件是怎么做出来的,它只管要。

专利申请里的「克隆」这个词其实更贴切:从同一个母本分裂出一个新个体。新个体和母本拥有同样的基因,但它们是两个独立的生命体。原型模式要的正是这个效果——类型一致、内容一致、身份不同。

2.4 优点和缺点得放在一张桌上谈

只讲优点容易让人误以为这是个万能钥匙,实际上它的代价相当具体。

优点有三条。第一,简化创建过程。创建新对象时,复用已有实例的数据和状态,省掉重复初始化,效率提升在对象数量大时尤其明显。第二,运行时可动态改变具体实现类型。客户端拿到的是一个抽象原型,具体复制出来的是什么类型,在运行时才定,扩展新类型不需要改客户端代码。第三,简化创建结构。有些系统为了组合出不同的对象状态,会堆出一层层工厂类;用原型模式复制现成实例,这层结构可以省掉。

缺点也有两条,而且都不轻。第一,每个类都得配一个克隆方法,这个方法还要对类的全部字段通盘考虑——对一个全新设计的类不算难,对一个已经跑了几年、字段层层嵌套、还有循环引用的老类来说,改造起来相当麻烦,动到源代码就意味着违背开闭原则。第二,深克隆的实现代码复杂,序列化方案还会带来额外开销。

一句话概括:原型模式不是免费的,它把复杂度从「创建」搬到了「复制」。创建那头的账省了,复制这头的账就得有人认。

3. 三个角色一台戏:谁被复制、谁发起复制

3.1 抽象原型类:先把「能被复制」这件事规定下来

原型模式的结构很轻,只有三个角色,其中第一个是抽象原型类。它的职责单一到有点抽象:声明一个克隆方法,让所有具体原型类都具备「可以被复制」的能力。

在 Java 里,这个角色通常用一个抽象类或接口实现,里面放一个clone()方法。之所以要抽出这一层,是为了让客户端面向抽象编程——客户端手里拿的是抽象原型的引用,调用clone()时并不关心背后是哪个具体类。这样一来,新增一种原型类型,客户端代码一行都不用改。

这个角色容易被忽略,因为它几乎没有业务逻辑。但没有它,客户端就得知道自己复制的是哪个具体类,扩展性立刻打折。

3.2 具体原型类:真正决定复制出什么

具体原型类是被复制的对象,它继承或实现抽象原型类,并实现自己的克隆逻辑。复制出来的东西长什么样、有哪些字段、引用怎么处理,全在这一层决定。

关键在于,克隆方法写在哪一层,就决定了复制到哪一层。如果具体原型类只是简单调一下父类的克隆方法,那得到的是浅克隆;如果它要连同内部的引用对象一起复制,就得在这一层补上额外的逻辑。同一个抽象原型下,不同的具体原型类完全可以采用不同的复制策略。

这也是原型模式灵活的地方:复制规则由被复制的类自己说了算,而不是由调用方猜。

3.3 客户类:只提需求,不碰细节

客户类是提出创建请求的一方。它的代码通常短得惊人——拿到一个原型对象,调一次克隆方法,完事。

public class Client { public static void main(String[] args) throws CloneNotSupportedException { // 先准备好一个原型实例,具体怎么造出来的,客户端并不关心 Prototype prototypeA = new ConcretePrototypeA(); // 需要几个就复制几个,每次都是一个独立的新对象 Prototype copyA1 = prototypeA.clone(); Prototype copyA2 = prototypeA.clone(); System.out.println("原对象类型:" + prototypeA.getClass()); System.out.println("副本1类型:" + copyA1.getClass()); System.out.println("副本2类型:" + copyA2.getClass()); } }

这段代码里没有任何一行涉及「对象内部有哪些字段」「初始化要做什么」。客户端只知道两件事:手里有个原型,原型能克隆。这就是原型模式宣称的「无须知道创建的细节」。

参数和返回值上有个细节要留意:clone()声明抛出CloneNotSupportedException,调用方必须处理,通常直接在方法签名上往外抛。返回类型在具体原型类里被收窄成了自己的类型(协变返回),这样调用方拿到的就是具体类型而不是笼统的Object。

3.4 Java 的 Cloneable 与 clone():一个没有方法的接口

Java 这套克隆机制初看有点玄学:Cloneable接口里一个方法都没有,可你不实现它,调clone()就抛异常。

原因在于分工。clone()方法本身是Object类提供的,所有 Java 类都继承自Object,所以人人都有这个方法。而Cloneable是一个标记接口,它的唯一作用是告诉运行时:「这个类允许被克隆」。没打这个标记就调用,运行时会抛CloneNotSupportedException。所以判断一个类支不支持复制,看它有没有实现Cloneable就够了,不需要看方法签名。

// 抽象原型:把"能被复制"这件事固定下来 abstract class Prototype implements Cloneable { @Override protected Prototype clone() throws CloneNotSupportedException { // super.clone() 最终走到 Object 的本地方法,按位复制当前对象 return (Prototype) super.clone(); } } // 具体原型:返回类型收窄成自己,调用方不用再强转 public class ConcretePrototypeA extends Prototype { @Override public ConcretePrototypeA clone() throws CloneNotSupportedException { return (ConcretePrototypeA) super.clone(); } }

这段代码有两处值得说。一是super.clone(),它最终调用的是Object的本地实现,做的是内存层面的按位复制,不走构造函数——这一点很多人第一次听到会愣一下,克隆出来的对象确实没有经过构造流程。二是返回类型,父类返回Prototype,子类收窄成ConcretePrototypeA,这叫协变返回类型,好处是调用方拿到就是具体类型。

Object.clone()还有三条约定,写在注释和文档里,值得记一下:对任意对象 x,x.clone() != x成立,克隆对象和原对象不是同一个;x.clone().getClass() == x.getClass()成立,类型一致;如果equals()定义得当,x.clone().equals(x)应该成立。前两条是硬性的,第三条是建议。

4. 浅克隆与深克隆:复制到第几层才算干净

4.1 值类型和引用类型:复制的时候各发生了什么

一个类里通常有两类字段。一类是值类型,比如数字、布尔、字符,它们的数据直接存在对象身上;另一类是引用类型,比如另一个对象、数组、集合,对象身上存的只是一个地址,真正的数据在别处。

复制对象时,这两类字段的命运完全不同。值类型字段复制的是数据本身,复制完两份互不相干;引用类型字段复制的是那个地址,复制完两个对象指向同一块内存。前者安全,后者埋雷。

这个区别就是浅克隆和深克隆的分水岭。判断标准只有一条:对象内部的引用类型成员,有没有被一起复制。

4.2 浅克隆:外壳是新的,里子还是旧的

浅克隆的做法最省事:只复制对象本身和其中的值类型字段,引用类型字段原样复制地址。结果就是两个对象共享同一批子对象。

拿邮件来举例。一封邮件有发件人、收件人、标题、正文、日期,还有一个附件对象。浅克隆之后,你会得到一封新的邮件,标题正文都是独立的,但两个邮件对象的attachment指向同一个附件实例。

public class Email implements Cloneable { private String sender; private String receiver; private String subject; private String body; private String date; private Attachment attachment; // 引用类型字段,坑就在这里 // 构造方法省略 @Override public Email clone() throws CloneNotSupportedException { // super.clone() 按位复制:字符串等不可变字段拿到新副本, // 但 attachment 只复制了地址,两个 Email 共用同一个附件对象 return (Email) super.clone(); } }

后果很直接:如果你改了副本的附件内容,原件的附件也跟着变。因为从头到尾只有一个附件对象,两份邮件只是各自拿着它的地址。这不是 bug,是浅克隆的定义使然。但如果你以为复制出来的东西彼此独立,那这就是个标准的踩坑现场。

4.3 深克隆:顺着引用一路复制到底

深克隆把这件事做彻底:不光复制对象本身,还把它引用的对象也复制一份,引用的对象再引用的对象继续复制,一直到最底层。复制完成后,原对象和副本不共享任何可变状态,改哪个都不影响另一个。

判断深克隆做得够不够彻底,有个简单的检验方法:改副本里的每一个字段,看原对象有没有任何变化。全都没变,才算真深。

代价也随之而来。对象图越复杂,复制成本越高;遇到循环引用(A 引用 B,B 又引用 A),天真的递归实现会直接栈溢出;遇到不支持序列化的间接对象,序列化方案会当场报错。所以深克隆不是「越深越好」,而是「按需而定」——你需要副本在多深的层次上独立,就复制到哪一层。

4.4 两种深克隆实现路径:递归 clone 与序列化

第一种是手工递归。每个引用类型字段所在的类都实现Cloneable,在自己的clone()里对引用字段再调一次clone()。控制精细,但代码量大,字段一多就容易漏,加一个新字段忘了同步,就是一个隐藏的共享状态。

第二种是序列化。把整个对象写进字节流,再从字节流读回来,读回来的自然是一棵全新的对象树。

public class Email implements Serializable { private static final long serialVersionUID = 1L; private String sender; private String receiver; private String subject; private String body; private String date; private Attachment attachment; public Email deepClone() throws IOException, ClassNotFoundException { // 序列化:把整个对象图写进内存字节数组 ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(this); // 反序列化:从字节数组重建一棵全新的对象树 ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bis); return (Email) ois.readObject(); } }

这段代码的关键参数是ByteArrayOutputStream和它的toByteArray():先把对象写进内存里的字节数组,再从数组读回来,全程不碰磁盘,比文件流快得多。serialVersionUID建议显式声明,否则类结构一改,反序列化可能直接失败。

前提条件不能忘:Email和它引用的Attachment都必须实现Serializable,否则writeObject会抛NotSerializableException。这也是序列化方案的软肋——只要对象图里有一个环节不支持序列化,整条路就走不通。而递归 clone 方案虽然啰嗦,却没有这个限制。

5. 常见误区与易混概念:原型模式不是工厂的替代品

5.1 误区一:原型模式等于拷贝构造函数

易混点在于,两者都在「根据一个已有对象造一个新对象」,写法上也像。很多人第一次接触原型模式,脑子里浮现的就是 C++ 那种拷贝构造函数。

之所以会混,是因为它们的目标确实重叠:都想要一份现有对象的副本。区别在于关注点和用法不同。拷贝构造函数是语言层面的机制,在构造新对象时由编译器自动调用,属于「创建」的一部分;原型模式是设计层面的模式,核心是一个统一的克隆接口,让客户端在不了解具体类型的情况下发起复制。

区分方法看调用方:如果客户端写的是new Email(other),那是拷贝构造的思路,客户端必须知道具体类型;如果客户端写的是prototype.clone(),手里只有一个抽象引用,那是原型模式。前者的复制规则固定在类型里,后者的复制规则藏在多态背后。

5.2 误区二:浅克隆和深克隆只是性能差异

易混点在于,两个词都带「克隆」,看起来只是复制得深一点浅一点,快慢不同而已。

之所以会混,是因为浅克隆确实更快,很容易被归纳成「性能取舍」。但真正的差别在语义:浅克隆之后,副本和原件共享内部的可变对象,改一个另一个跟着变;深克隆之后,两者完全独立。这不是快慢问题,是「副本到底算不算独立个体」的问题。

区分方法只有一条:问自己副本需不需要独立修改内部引用对象。需要,就必须深克隆;不需要,浅克隆足够且更省。用性能去决定这个选择,早晚会出事——省下的那点开销,往往不够填一次数据串改的坑。

5.3 误区三:clone() 出来的对象一定和原对象相等

易混点在于Object.clone()的第三条约定:如果equals()定义恰当,x.clone().equals(x)应该成立。于是有人以为克隆天然保证相等。

之所以会混,是因为这条约定带了前提——「定义恰当」。默认的equals()比较的是引用地址,两个不同对象永远不等。如果你的类没重写equals(),克隆出来的副本和原件在equals()眼里就是两个陌生人。

区分方法:先看类有没有重写equals()和hashCode()。重写了,克隆后内容一致通常能判等;没重写,就只能逐个字段比对。别拿equals()的结果去验证克隆是否成功,那是两码事。

5.4 误区四:原型模式能替代工厂模式

易混点在于,两者都解决了「客户端不想关心对象怎么造」的问题,都属于创建型模式,于是常被拿来二选一。

之所以会混,是因为它们的目标确实相近:把创建细节从客户端手里拿走。但路径完全不同。工厂模式是「从无到有」,客户端给参数,工厂按参数造一个新对象;原型模式是「从有到有」,客户端手里得先有一个原型实例,然后复制它。

区分方法看起点:系统启动时一个对象都没有,那只能用工厂;已经有一个配置好的实例,需要大量同类副本,那原型更顺手。还有一条实际的分界线——工厂模式适合「创建逻辑复杂但结果种类有限」,原型模式适合「结果状态多变但复制成本低」。真要做复杂系统,两者常常一起用:工厂负责造出第一个原型,原型负责复制后面的千千万万个。

5.5 误区五:实现了 Cloneable 就自动「会克隆」

易混点在于,Cloneable这个接口名听起来像是「具备克隆能力」,实现它就等于打开了克隆开关。

之所以会混,是因为 Java 里大多数接口实现之后确实就有了对应能力。但Cloneable是个标记接口,里面空无一物,它只是给运行时看的一个信号,不提供任何复制逻辑。

区分方法:实现Cloneable只是拿到了「允许调用clone()而不抛异常」的资格,真正的复制行为还得自己写。而且父类如果没做好深克隆,子类继承下来照样是浅的。一个类实现Cloneable却只调super.clone(),那它的克隆深度完全取决于父类——这种继承链上的复制深度,是最容易被忽略的地方。

6. 什么时候用、什么时候别用:一封邮件的复制

把前面的东西收束到一个具体场景上,边界会清楚很多。

某系统要做邮件复制:一封邮件已经写好了发件人、收件人、标题、正文、日期和附件,用户想基于它再发一封,只改收件人和正文。这时该用浅克隆还是深克隆?判断标准只有一条——副本和原件要不要共享附件。

如果只是转发同一份附件、谁都不改附件内容,浅克隆就够,代码短、开销小。如果副本要替换或修改自己的附件,那就必须深克隆,否则改副本的附件会连带改掉原件的。这个判断跟性能无关,跟「谁拥有这个附件」有关。

判断维度适合浅克隆适合深克隆
引用字段是否需要独立修改不需要,共享即可需要,必须隔离
引用对象是否不可变是,共享无风险不必,隔离更稳
对象图复杂度简单,字段少复杂,但要求副本完全独立
复制频率高,开销敏感低,正确性优先

再看什么时候压根不该用原型模式。如果对象创建本身就很简单,一次new只做几个赋值,那配一个克隆方法纯属给自己加负担,代码量翻倍还多一处要维护。如果对象的内部状态变化极其频繁,每次复制都要跟着改一堆引用,那不如老老实实重新构造,或者干脆换一种状态管理方式。还有一种情况:类里有不支持序列化的间接对象,或者引用结构带环,又急需深克隆,这时候先别硬上,改造成本可能远超收益。

下面这段是邮件深克隆的最小骨架,附件类必须一起实现序列化接口,否则整条链断在第一步。

// 附件类:必须可序列化,否则深克隆会抛 NotSerializableException public class Attachment implements Serializable { private static final long serialVersionUID = 1L; private String fileName; public void download() { System.out.println("下载附件:" + fileName); } } // 客户类:复制一封邮件,然后只改副本的收件人和正文 public class Client { public static void main(String[] args) throws Exception { Attachment attachment = new Attachment(); Email original = new Email("用户甲", "用户乙", "设计模式分享", "原型模式", "2025", attachment); // 深克隆:副本拥有自己独立的附件对象 Email copy = original.deepClone(); copy.setReceiver("用户丙"); copy.setBody("原型模式 - 副本"); // 改副本的正文不会影响原件,两者已完全隔离 System.out.println(original.display()); System.out.println(copy.display()); } }

这段代码的重点不在语法,而在顺序:先克隆,再改副本。如果反过来先改原件再克隆,那复制出来的就是改过的版本,跟需求正好相反。这种低级错误在实际项目里并不少见,尤其是克隆和修改分散在不同方法里的时候。

我自己的血泪经验是:只要一个类开始出现「复制一份再改改」的需求,我会先强制自己回答三个问题——副本要不要独立改引用字段、对象图里有没有不支持序列化的环节、这个类以后会不会加新字段。前两个决定用浅还是用深,第三个决定这个克隆方法能不能扛住时间。很多克隆相关的 bug 不是写错在当下,是半年后加了个新字段、忘了同步进深克隆逻辑,然后某天线上数据莫名其妙串了,查半天查不出来。

从那以后我每次动克隆方法,都会顺手写一条断言:改副本、看原件。看着有点笨,但它救过我不少次。

希望这些能帮你在下次遇到「复制一个对象」的需求时,少走一点弯路。这套 PPT 里把角色结构、浅深克隆对比和完整的邮件复制代码示例都整理在了一起,需要的话可以拿去对着看,比临时翻文档省事。

本文还有配套的精品资源,点击获取