当前位置: 首页 > news >正文

《Effective Java》第13条:谨慎的覆盖clone

说明:

关于本博客使用的书籍,源代码Gitee仓库 和 其他的相关问题,请查看本专栏置顶文章:《Effective Java》第0条:写在前面,用一年时间来深度解读《Effective Java》这本书

正文:

原文P46:Cloneable接口的目的是作为对象的一个 mixin 接口,表明这样的对象允许克隆。遗憾的是,它并没有成功地达到这个目的。它的主要缺陷在于缺少一个 clone方法,而Obiect的 clone 方法是受保护的。如果不借助于反射(reflection),就不能仅仅因为一个对象实现了 Cloneable,就调用 clone方法。即使是反射调用也可能会失败,因为不能保证该对象一定具有可访问的clone方法。

在 Java 中,"mixin 接口"(Mixin Interface)是一种设计模式,它指的是一种包含方法实现的接口,用于为实现类提供额外的功能("混入" 功能),而无需使用继承。但是 Cloneable接口 并不是这样的,它没有任何的实现方法。Obiect 的 clone 方法又是受保护的,反射也不能很好的调用clone方法,那么我们应该如何实现一个行为良好的clone方法呢,请继续往下看。

这里我们先解决一个疑问,既然 Cloneable 接口没有任何的实现方法,那么设计这个接口有什么用?

其实这个接口是一个标记接口,它的特殊之处在于:没有定义任何方法,仅作为一种 "标记" 来表明实现它的类具备了 "可克隆" 的能力

用途主要体现在以下两个方面:

  1. 标记可克隆性:一个类实现 Cloneable 接口,本质上是向 JVM 表明:" 这个类的实例允许通过 Object.clone() 方法进行克隆 "。如果一个类没有实现 Cloneable 却调用了 clone() 方法,JVM 会抛出 CloneNotSupportedException 异常。
  2. 约定克隆行为:虽然 Cloneable 本身没有方法,但它隐含了一个约定:实现类应当重写 Object 类中的 clone() 方法(该方法是 protected 的),并将其访问权限改为 public,以便外部调用。

原文P47:虽然规范中没有明确指出,事实上,实现 Cloneable 接口的类是为了提供一个功能适当的公有的 clone 方法。为了达到这个目的,类及其所有超类都必须遵守一个相当复杂的不可实施的,并且基本上没有文档说明的协议。由此得到一种语言之外的机制:它无须调用构造器就可以创建对象。

clone方法的通用约定是非常弱的,下面是Object规范中约定的内容:

clone方法创建并返回一个该对象的“拷贝”,这个“拷贝”的精确含义取决于具体的业务场景和该对象所属的类,一般含义是:

① x.clone() != x // 返回true

② x.clone().getClass() == x.getClass() // 返回true

③ x.clone().equals(x) // 返回true

但是上面的三点,都不是绝对的要求,还是要根据具体的业务场景来决定。

原文P47:如果类的clone方法返回的实例不是通过调用super.clone方法获得,而是通过调用构造器获得,编译器就不会发出警告,但是该类的子类调用了super.clone方法,得到的对象就会拥有错误的类,并阻止了clone方法的子类正常工作。如果final类覆盖了clone 方法,那么这个约定可以被安全地忽略,因为没有子类需要担心它。如果final类的clone方法没有调用super.clone方法,这个类就没有理由去实现Cloneable接口了,因为它不依赖于 Object 克隆实现的行为。

这段话不好理解,举例说明:

// BaseClassA 类
public class BaseClassA {private int value;public BaseClassA(int value) {this.value = value;}// 错误的克隆实现:用构造器而非 super.clone()@Overridepublic BaseClassA clone() {// 直接 new 一个新对象,此时编译器不会报错(语法完全合法),但会破坏克隆机制的继承链条。return new BaseClassA(this.value); }
}// ChildClassB类 继承了 BaseClassA类
public class ChildClassB extends BaseClassA{private String name;public ChildClassB(int value, String name) {super(value);this.name = name;}// 子类正确重写 clone(),调用 super.clone()@Overridepublic ChildClassB clone() {// 调用父类 A 的 clone()ChildClassB copy = (ChildClassB) super.clone();copy.name = this.name;return copy;}
}
  • 当 ChildClassB 调用 super.clone() 时,实际调用的是 BaseClassA 的 clone() 方法。但 BaseClassA 的 clone() 返回的是 new BaseClassA(...)(类型为 BaseClassA),而非 ChildClassB 类型的实例。此时将 BaseClassA 类型强制转换为 ChildClassB 类型,会直接抛出 ClassCastException。
  • 而如果父类被final修饰,就不会出现上述的问题,因为子类不能重写父类的clone方法。
  • 如果final类的clone方法没有调用super.clone(),那么就不应该去实现Cloneable接口,即该类不应该被标记为可克隆。

原文P48:如果每个域包含一个基本类型的值或者包含一个指向不可变对象的引用,那么被返回的对象则可能正是你所需要的对象,在这种情况下不需要再做进一步处理。例如,第11条中的PhoneNumber类正是如此:

// PhoneNumber 类 必须要实现Cloneable接口,否则会抛出CloneNotSupportedException异常
public class PhoneNumber implements Cloneable {private final short areaCode, prefix, lineNum;public PhoneNumber(short areaCode, short prefix, short lineNum) {this.areaCode = areaCode;this.prefix = prefix;this.lineNum = lineNum;}@Overridepublic PhoneNumber clone(){try {return (PhoneNumber)super.clone();} catch (CloneNotSupportedException e) {throw new RuntimeException(e);}}@Overridepublic String toString() {return this.areaCode + "-" + this.prefix + "-" + this.lineNum;}
}// Main类
PhoneNumber phoneNumber = new PhoneNumber((short) 123,(short) 456,(short) 789);
// phoneNumber2 是由 phoneNumber 克隆而来,直接调用的Object的clone方法
PhoneNumber phoneNumber2 = phoneNumber.clone();
PhoneNumber phoneNumber3 = phoneNumber;System.out.println(phoneNumber2.toString());        // 输出:123-456-789
System.out.println(phoneNumber2 == phoneNumber);    // 输出:false
System.out.println(phoneNumber3 == phoneNumber);    // 输出:true

由上面的代码可以看出来,clone来的对象 phoneNumber2 和 phoneNumber内存地址并不相同,所以两者指向的内存地址不是同一块,即有两个PhoneNumber对象。

原文P48:注意,不可变的类永远都不应该提供 clone 方法,因为它只会激发不必要的克隆。

因为“不可变”所以内存中只需要存在一份就可以,clone会在内存中多克隆一份出来,破坏单一性。

原文P48:如果对象中包含的域引用了可变的对象,使用上述这种简单的clone实现可能会导致灾难性的后果。例如第7条中的stack类:

// Stack类,实现了 Cloneable接口
public class Stack implements Cloneable {// 用来存放元素的数组private Object[] elements;private int size = 0;public class Stack implements Cloneable {// 用来存放元素的数组private Object[] elements;private int size = 0;private static final int DEFAULT_INITIAL_CAPACITY = 16;public Stack() {elements = new Object[DEFAULT_INITIAL_CAPACITY];}// 放public void push(Object e) {ensureCapacity();elements[size++] = e;}// 取public Object pop() {if(size == 0) throw new EmptyStackException();Object result = elements[--size];elements[size] = null;return result;}// 判断是否需要扩容数组private void ensureCapacity() {if (elements.length == size) {elements = Arrays.copyOf(elements, 2 * size + 1);}}// 返回栈的长度public int getSize() {return size;}@Overridepublic String toString() {return Arrays.toString(elements);}// 错误的克隆实现方式@Overridepublic Stack clone() {try {return (Stack)super.clone();} catch (CloneNotSupportedException e) {throw new RuntimeException(e);}}
}// Main类
Stack stack = new Stack();
stack.push(1);
stack.push(2);
Stack stack2 = stack.clone();
System.out.println("stack 栈数组:" + stack.toString());        // 输出:stack 栈数组:[1, 2, null, null, null, ...(null省略)]
System.out.println("stack 栈长度:" + stack.getSize());         // 输出:stack 栈长度:2
System.out.println("stack2 栈数组:" + stack2.toString());      // 输出:stack2 栈数组:[1, 2, null, null, null, ...(null省略)]
System.out.println("stack2 栈数组:" + stack2.getSize());       // 输出:stack2 栈长度:2// 此时,向 stack栈中添加元素,注意没有向stack2栈中添加
stack.push(3);
stack.push(4);System.out.println("stack 栈数组:" + stack.toString());        // 输出:stack 栈数组:[1, 2, 3, 4, null, ...(null省略)]
System.out.println("stack 栈长度:" + stack.getSize());         // 输出:stack 栈长度:4
System.out.println("stack2 栈数组:" + stack2.toString());      // 输出:stack2 栈数组:[1, 2, 3, 4, null, ...(null省略)]
System.out.println("stack2 栈数组:" + stack2.getSize());       // 输出:stack2 栈长度:2// 此时,向 stack2栈中添加元素,注意没有向stack栈中添加
stack2.push(5);
stack2.push(6);
stack2.push(7);System.out.println("stack 栈数组:" + stack.toString());        // 输出:stack 栈数组:[1, 2, 5, 6, 7, ...(null省略)]
System.out.println("stack 栈长度:" + stack.getSize());         // 输出:stack 栈长度:4
System.out.println("stack2 栈数组:" + stack2.toString());      // 输出:stack2 栈数组:[1, 2, 5, 6, 7, ...(null省略)]
System.out.println("stack2 栈数组:" + stack2.getSize());       // 输出:stack2 栈长度:5

从上面的例子可以看出来,当向 stack 对象的elements数组中添加 3、4元素的时候,stack2的elements数组也跟着变了,但size的长度却没变。

这是因为,Stack类中的 elements 元素是一个数组类型(引用类型),而不是简单的基本类型(size是基本类型)。stack2 对象的 elements域引用了与stack实例相同的数组。

当再往stack2对象的elements数组中添加 5、6元素的时候,stack的elements数组也跟着变了,但size的长度却没变,且3、4被覆盖了,这完全不是我们想要的结果。

那对于这种带有引用对象的类,应该怎么克隆呢?其实正确的方法就是,因类而异,不同的类,根据它的具体使用场景,搭配不同的克隆方法。

原文P49:实际上,clone 方法就是另一个构造器;必须确保它不会伤害到原始的对象,并确保正确地创建被克隆对象中的约束条件

对于上述的Stack类,正确的克隆方式必须要拷贝栈的内部信息,最简单的做法是,在 elements 数组中递归地调用clone:

// StackA类
public class StackA implements Cloneable {// 用来存放元素的数组private Object[] elements;private int size = 0;private static final int DEFAULT_INITIAL_CAPACITY = 16;public StackA() {elements = new Object[DEFAULT_INITIAL_CAPACITY];}// ...省略其他方法// 正确的克隆实现方式@Overridepublic StackA clone() {try {StackA result = (StackA) super.clone();result.elements = elements.clone();return result;} catch (CloneNotSupportedException e) {throw new RuntimeException(e);}}
}StackA stackA = new StackA();
stackA.push(1);
stackA.push(2);
StackA stackA2 = stackA.clone();
System.out.println("stackA 栈数组:" + stackA.toString());        // 输出:stackA 栈数组:[1, 2, null, null, null, ...(null省略)]
System.out.println("stackA 栈长度:" + stackA.getSize());         // 输出:stackA 栈长度:2
System.out.println("stackA2 栈数组:" + stackA2.toString());      // 输出:stackA2 栈数组:[1, 2, null, null, null, ...(null省略)]
System.out.println("stackA2 栈数组:" + stackA2.getSize());       // 输出:stackA2 栈长度:2// 此时,向 stack栈中添加元素,注意没有向stack2栈中添加
stackA.push(3);
stackA.push(4);System.out.println("stackA 栈数组:" + stackA.toString());        // 输出:stackA 栈数组:[1, 2, 3, 4, null, ...(null省略)]
System.out.println("stackA 栈长度:" + stackA.getSize());         // 输出:stackA 栈长度:4
System.out.println("stackA2 栈数组:" + stackA2.toString());      // 输出:stackA2 栈数组:[1, 2, null, null, null, ...(null省略)]
System.out.println("stackA2 栈数组:" + stackA2.getSize());       // 输出:stackA2 栈长度:2// 此时,向 stack2栈中添加元素,注意没有向stack栈中添加
stackA2.push(5);
stackA2.push(6);
stackA2.push(7);System.out.println("stackA 栈数组:" + stackA.toString());        // 输出:stackA 栈数组:[1, 2, 3, 4, null, ...(null省略)]
System.out.println("stackA 栈长度:" + stackA.getSize());         // 输出:stackA 栈长度:4
System.out.println("stackA2 栈数组:" + stackA2.toString());      // 输出:stackA2 栈数组:[1, 2, 5, 6, 7, ...(null省略)]
System.out.println("stackA2 栈数组:" + stackA2.getSize());       // 输出:stackA2 栈长度:5

这样,克隆的对象和被克隆的对象就不会存在相互干扰的情况了。

原文P49:还要注意如果elements域是final的,上述方案就不能正常工作,因为clone 方法是被禁止给 final域赋新值的。这是个根本的问题:就像序列化一样,Cloneable架构与引用可变对象的 final域的正常用法是不相兼容的,除非在原始对象和克隆对象之间可以安全地共享此可变对象。为了使类成为可克隆的,可能有必要从某些域中去掉final修饰符。

但仅仅是这样就够了么?其实还不够,如课本P50页举的例子,这里不再赘述课本上的例子,请同学们自己去看。这里只简单的提示几点:

  1. 有时候递归的调用clone(如上例)还不够。
  2. 如本例中,HashTable 类中包含 Entry[] 数组,Entry类中又包含Entry属性。如果仅仅克隆 HashTable 类中包含的 Entry[] 数组,那么Entry类中包含的Entry属性就不会被克隆。
  3. 所以书上采用了一种深克隆的方式,单独地拷贝组成每个桶的链表

原文P51:克隆复杂对象的最后一种办法是,先调用super.clone方法,然后把结果对象中的所有域都设置成它们的初始状态,然后调用高层的方法来重新产生对象的状态。在我们的HashTable例子中,buckets域将被初始化为一个新的散列桶数组,然后,对于正在被克隆的散列表中的每一个键-值映射,都调用put(key,value)方法(上面没有给出其代码)。这种做法往往会产生一个简单、合理目相当优美的clone 方法,但是它运行起来通常没有“直接操作对象及其克隆对象的内部状态的clone方法”快。虽然这种方法干脆利落,但它与整个cloneable架构是对立的,因为它完全抛弃了Cloneable架构基础的逐域对象复制的机制。

写代码看一下:

// HashTable 类 ,调用put(key,value)方法进行克隆
public class HashTable implements Cloneable{private static final int NUMBER = 16;private Entry[] buckets = new Entry[NUMBER];private static class Entry{final Object key;Object value;Entry next;Entry(Object key, Object value, Entry next) {this.key = key;this.value = value;this.next = next;}}public void put(Object key, Object value) {// 计算key的散列码,并与 NUMBER 取模int local = key.hashCode() % NUMBER;// 链表采用前插法Entry top = buckets[local];buckets[local] = new Entry(key, value, top);}@Overridepublic HashTable clone() {try {HashTable result = (HashTable) super.clone();result.buckets = new Entry[NUMBER];for (Entry bucket : this.buckets) {result.put(bucket.key, bucket.value);}return result;} catch (CloneNotSupportedException e) {throw new RuntimeException(e);}}
}

但是这样做也是有问题的,且看书上是怎么说的:

原文P51:像构造器一样,clone方法也不应该在构造的过程中,调用可以覆盖的方法(详见第19条)。如果clone调用了一个在子类中被覆盖的方法,那么在该方法所在的子类有机会修正它在克隆对象中的状态之前,该方法就会先被执行,这样很有可能会导致克隆对象和原始对象之间的不一致。因此,上一段中讨论到的put(key,value)方法要么应是final的,要么应是私有的。(如果是私有的,它应该算是非final公有方法的“辅助方法”。)

那么,如果要设计一个基类供其他类继承的话,有两种设计方式但是无论选择其中的哪一种方法,这个类都不应该实现 Cloneable接口:

第一种:原文P52:你可以选择模拟 0bject的行为:实现一个功能适当的受保护的 clone 方法,它应该被声明抛出 CloneNotSupportedException 异常。这样可以使子类具有实现或不实现Cloneable接口的自由,就仿佛它们直接扩展了 Object 一样。

第二种:原文P52:也可以选择不去实现一个有效的clone方法,并防止子类去实现它,只需要提供下列退化了的clone实现即可:

@Override
protected final Object clone() throws CloneNotSupportedException {throw new CloneNotSupportedException;
}

原文P52:还有一点值得注意。如果你编写线程安全的类准备实现Cloneable接口,要记住它的 clone方法必须得到严格的同步,就像任何其他方法一样。0bject的 clone方法没有同步,即使很满意可能也必须编写同步的clone方法来调用 super.clone(),即实现synchronized clone()方法。

原文P52:简而言之,所有实现了 cloneable接口的类都应该覆盖clone 方法,并且是公有的方法,它的返回类型为类本身。该方法应该先调用super.clone方法,然后修正任何需要修正的域。一般情况下,这意味着要拷贝任何包含内部“深层结构”的可变对象,并用指向新对象的引用代替原来指向这些对象的引用。虽然,这些内部拷贝操作往往可以通过递归地调用clone来完成,但这通常并不是最佳方法。如果该类只包含基本类型的域,或者指向不可变对象的引用,那么多半的情况是没有域需要修正。这条规则也有例外。例如代表序列号或其他唯一ID值的域,不管这些域是基本类型还是不可变的,它们也都需要被修正。

从以上所有的介绍可以看出,似乎覆盖clone方法并不是一个完美的选择,这也是作者要我们“谨慎覆盖clone方法”的原因。那么有没有一种简单且优势明显的做法来代替clone方法呢。答案是肯定的。

原文P52:对象拷贝的更好的办法是提供一个拷贝构造器(copyconstructor)或拷贝工厂(copy factory)。拷贝构造器只是一个构造器,它唯一的参数类型是包含该构造器的类。

原文P52:拷贝构造器的做法,及其静态工厂方法的变形,都比cloneable/clone方法具有更多的优势:它们不依赖于某一种很有风险的、语言之外的对象创建机制;它们不要求遵守尚未制定好文档的规范;它们不会与final域的正常使用发生冲突;它们不会抛出不必要的受检异常;它们不需要进行类型转换。 甚至,拷贝构造器或者拷贝工厂可以带一个参数,参数类型是该类所实现的接口。

jdk源码中,关于集合的部分有很多这样的实现,我们来看一个:

// HashSet类 其中的一个构造方法,可以看到,参数类型是基类 Collection
public HashSet(Collection<? extends E> c) {map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));addAll(c);
}// TreeSet类 其中的一个构造方法,可以看到,参数类型是基类 Collection
public TreeSet(Collection<? extends E> c) {this();addAll(c);
}

总结:

  • 复制(克隆)功能最好由构造器或者工厂提供,尽量不要扩展Cloneable接口,只有极少数的情况下才用。
  • 但这条规则最绝对的例外是数组,最好利用clone方法复制数组。
http://www.dtcms.com/a/523514.html

相关文章:

  • 第一章、React + TypeScript + Webpack项目构建
  • 前端:金丝雀部署(Canary Deployment)/ A、B部署 / 灰度部署
  • Spark微博舆情分析系统 情感分析 爬虫 Hadoop和Hive 贴吧数据 双平台 讲解视频 大数据 Hadoop ✅
  • 宁波公司网站建设价格dw建设手机网站
  • 长沙做网站价格有哪些网站可以做青旅义工
  • 怎么查看网站打开速度企业网站用vps还是虚拟主机
  • Vue3 模板引用——ref
  • XGBoost完整学习指南:从数据清洗到模型调参
  • 【深度学习新浪潮】AI Agent工具流产品:从原理到落地,打造智能协作新范式
  • 页面滚动加载更多
  • 除了provide和inject,Vue3还有哪些用于组件通信的方式?
  • React 表单与事件
  • AI代码开发宝库系列:FAISS向量数据库
  • 前端埋点学习
  • Spring AI与DeepSeek实战:打造企业级知识库+系统API调用
  • 秦皇岛市建设局网站关于装配式专家做运动特卖的网站
  • j2ee 建设简单网站设计婚纱网站
  • C++类和对象(中):const 成员函数与取地址运算符重载
  • 数据结构 散列表—— 冲突解决方法
  • 箭头函数和普通函数有什么区别
  • Spring Boot 缓存知识体系大纲
  • 破局政务数字化核心难题:金仓数据库以国产化方案引领电子证照系统升级之路
  • XML:从基础到 Schema 约束的全方位解析
  • 技术引领场景革新|合合信息PRCV论坛聚焦多模态文本智能前沿实践
  • 海南网站建设网络货运平台有哪些
  • 系统架构设计师备考第53天——业务逻辑层设计
  • 科技创新与数字化制造转型在“十五五”规划中的意义
  • 网站开发最新技术wordpress4.7.4密码
  • HarmonyOS方舟编译器与运行时优化
  • HarmonyOS AI能力集成与端侧推理实战