Java性能优化(10):谨慎改写clone

释放双眼,带上耳机,听听看~!

Cloneable接口的目的是作为对象的一个mixin接口,表明这样的对象允许克隆。不幸的是,它并没有成功达到这个目的。其主要的缺陷在于它缺少一个clone方法,Object的clone方法是被保护的,如果不借助于反射机制,则不能仅仅因为一个对象实现了Cloneable,就可以调用clone方法。即使在反射调用也可能会失败,因为并不保证该对象一定具有可访问性的clone方法。尽管存在这样那样的不足,这项设施仍然被广泛地使用着,值得我们进一步了解它。接下来,将告诉你如何实现一个行为良好的clone方法,并讨论何时这样做是恰当的,同时也简单地讨论了其他可替换做法。
那么,既然Cloneable并没有包含任何方法,它的作用又是什么?它决定了Object的clone方法返回该对象的逐域拷贝,否则的话,它抛出一个CloneNotSupportedException异常。这是接口的一种极端非典型的用法,它不值得仿效。通常情况下,实现一个接口是为了表明一个类可以为客户做某些事情。然而,对于Cloneable接口,它改变了超类中一个受保护的方法的行为。

为了实现Cloneable接口,使它对一个类确实产生效果,它和所有的超类都必须遵守一个相当复杂的、不可实施的,并且基本没有文档说明的协议。由此得到一种语言本身之外的机制:无须调用构造函数就可以创建一个对象。

Clone方法的通用约定是非常弱的:下面是来自java.lang.Object规范中的约定内容:
创建好返回对象的一个拷贝,这里”拷贝”的精确含义取决于该对象的类。一般大含义是,对于任何对象x,表达式

将会是true,并且,表达式

将会是true,但是,这些都不是绝对的要求。虽然,通常情况下,表达式

将会是true,但是,这也不是一个绝对要求。拷贝一个对象往往会导致创建该类的一个新实例,但同时它也会要求拷贝内部的数据结构。这个过程中没有调用构造函数。

这个约定存在几个问题,规定”没有调用构造函数”太强了。一个行为良好的clone方法可以调用构造函数来创建对象,构造之后再复制内部数据。如果这个类是final的,则clone甚至可能会返回一个构造函数创建的对象。

然而,规定”x.clone().getClass()通常应该等同于x.getClass()”则弱爆了。在实践中,程序员会假设:如果他们扩展了一个类,并且从子类中调用了super.clone,则返回的对象将是该子类的实例。超类能够提供这种功能的唯一途径是,返回一个通过调用super.clone而得到的对象。如果一个clone方法返回一个由构造函数创建的对象,它将具有错误的类。因此,**如果你改写了一个非final类的clone方法,则应该返回一个通过调用super.clone而得到的对象。**如果一个类的所有超类都遵守这条规则,那么一直调用super.clone,最终会调用到Object的clone方法,从而创建出正确类的实例。这种机制大致上类似于自动的构造函数链,只不过它不是强制要求的。

Cloneable接口并没有清楚地指明,一个类在实现这个接口时应该承担哪些责任。规范仅仅指出了:实现这个接口会以哪些方式影响到Object的clone实现的行为。**实际上,对于实现了Cloneable的类,我们总是期望它也提供了一个功能适当的公有clone方法。**但通常情况下,除非该类的所有超类都提供了一个行为良好的clone实现,不管是公有的,还是受保护的,否则,这是不可能的。

假设你希望在一个类中实现Cloneable,并且它的超类都提供了良好的clone方法。你从super.clone()中得到的对象可能会接近于最终要返回的对象,也可能相差甚远,这要取决于这个类的本质。从每一个超类的角度来看,这个对象是原始对象功能完整的克隆。在这个类中声明的域等同于被克隆对象中相应的域值。如果每个域包含一个原语类型的值,或者包含一个指向非可变对象的引用,那么被返回的对象可能正是你所需要的对象,在这种情况下不需要再做进一步处理。例如,PhoneNumber类正是这样的情形。在这种情况下,你所需要的是Object中受保护的clone方法提供一条公有的访问途径。

然后,如果对象中包含的域引用了可变的对象,那么使用这样的clone实现可能会导致灾难性后果。例如stack类

假设你希望把这个类做成可克隆的。如果它的clone方法仅仅返回super.clone(),那么这样的Stack实例,在其size域中具有正确的值,但是它的elements域将引用到与原始Stack实例中相同的数组上。修改原始的实例会破坏被克隆对象中的数组,反之亦然。很快你会发现,这个程序将产生毫无意义的结果,或者抛出NullPointerException异常。

如果调用Stack类唯一的构造函数,那么这种情况永远都不会发生。**实际上,clone方法是另一个构造函数;你必须确保它不会伤害到原始的对象,并且正确地建立起被克隆对象中的约束关系。**为了使Stack类中的clone方法正常的工作,它必须拷贝栈的内部信息。最容易的做法是,对于elements数组递归地调用clone:

注意,如果elements域是final的,则这种方案并不能正常工作,因为clone方法是被禁止给elements域赋一个新值的。这是一个基本问题:clone结构与指向可变对象的final域的正常用法是不兼容的。除非在原始对象和克隆对象之间可以安全地共享此可变对象。为了使一个类成为可克隆的,可能有必要从某些城市中去掉final修饰符。

递归调用clone往往还不够。假如,假设你正在为一个散列表编写clone方法,它的内部数据是由一个散列桶数组组成,每一个散列桶都指向”键-值”对链表的第一个条目,如果桶是空的,则为null。出于性能的考虑,该类实现了它自己的单向链表,而没有使用Java内部的java.util.LinkedList。该类如下:

假设你仅仅递归地克隆这个散列桶数组,就像我们为Stack类所做的那样

虽然被克隆的对象有它自己的桶数组,但是,这个数组引用的链表与原始对象是一样的,从而很容易引起克隆对象和原始对象之中的不确定行为。为了修正这个问题,你必须为每一个组成桶的链表单独地拷贝。下面是一种常见的做法:

私有类HashTable.Entry被加强了,它支持了一个”深度拷贝”方法。HashTable上的clone方法分配了一个适当大小的、新的buckets数组,并且遍历原始的bucket数组,对每一个非空散列桶进行深度拷贝。Entry类中的深度拷贝方法递归调用它自己,以便拷贝整个链表。虽然这项技术手法很漂亮,并且,如果散列桶不是很长的haunted,也会工作得很好,但是,这样克隆一个链表并不是一个好的方案,因给针对链表中的每个元素,它都要消耗一段栈空间。如果链表比较长的话,这将很容易导致栈溢出。为了避免发生这种情况,你可以在deepCopy中迭代代替递归:

克隆复杂对象的最后一个办法是,先调用super.clone,然后把结果对象中的所有域都设置到它们的空白状态,然后调用高层的方法来重新产生对象的状态。在我们的HashTable例子中,buckets域将被初始化为一个新的散列桶数组,然后,对于正在被克隆的散列表中的每一个键值对,都调用put(key,value)方法。这种做法往往会产生一个简单的、合理的、优美的clone方法,但是它允许起来没有”直接操作对象和其克隆的内部状态的clone方法”快。

如同构造函数一样,clone方法不应该在构造过程中,调用新对象中任何非final方法。如果clone调用了一个被该写过的方法,那么在该方法所在的子类都有机会修正新对象中的状态之前,该方法就会被执行,这样很有可能会导致克隆对象和原始对象之间的不一致。因此,上一段落中讨论的put(key,value)方法应该要么是final,要么是私有的。

Object的clone方法被声明为可抛出CloneNotSupportedException异常,但是改写版本的Clone方法可能会忽略这个声明。final类的clone方法应该省略这个声明,因为不会抛出被检查异常的方法会比抛出异常的方法用起来更舒服。如果一个可扩展的类改写了clone方法,那么改写版本的clone方法应该包含”抛出CloneNotSupportedException异常”,这样做可以使子类通过提供下面的clone方法,选择温和地放弃克隆能力:

遵守前面的建议并不是必需的,因为如果一个子类不希望它的实例被克隆,并且它改写的clone方法没有被声明为可抛出CloneNotSupportedException异常,那么它的clone方法总是可以抛出一个未被检查的异常。然而,通常的实践表明,在这样情况下,CloneNotSupportedException是正确的异常。

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

**最好的做法是,提供某些其他的途径来代替对象拷贝,或者干脆不提供这样的能力。**例如,对于非可变类,支持对象拷贝并没有太大的意义,因为拷贝的对象与原始对象并没有实质的不同。

另一个实现对象拷贝的好办法是提供一个拷贝构造函数。所谓拷贝构造函数,它也是一个构造函数,其唯一的参数的类型是包含该构造函数的类,例如,

另一种方法是它的一个微小的变形:提供一个静态工厂来代替够着函数:

拷贝构造函数的做法,以及它的静态工厂方法变形,比Cloneable/clone方法具有更多的优势:它们不依赖于某一种很有风险的、语言之外的对象创建机制;它们不要求遵守尚未良好文档化的规范;它们不会与final域的正常使用发生冲突;它们不会要求可不捕获不必要的被检查异常;它们为客户提供了一个静态类型化的对象。虽然你不可能把一个拷贝构造函数或者静态工厂放到一个接口中,但是由于Cloneable接口缺少一个公有的clone方法,所以它也没有提供一个接口该有的功能。因此使用拷贝构造函数来代替clone方法,并没有放弃接口的功能特性。

跟进一步,一个拷贝构造函数可以带一个参数,它的类型可以是该类实现的一个适当的接口。按照惯例,所哟通用集合实现都提供了一个拷贝构造函数,它的参数类型是Collection或者map。基于接口的拷贝构造函数允许客户来选择拷贝动作的具体实现,而不是强迫客户接受原始的实现。假设你有一个LinkeList,并且希望把它拷贝成一个ArrayList。clone方法并没有提供这样的功能,但是用拷贝构造函数很容易实现:new ArrayList。

既然Cloneable具有以上那么多问题,所以,可以安全地说,其他接口不应该扩展这个接口,并且为了继承而设计的类也不应该实现这个接口。由于它具有这么多缺点,有些资深程序员从来不去改写clone方法,也从来不去调用它,除非是为了低开销地拷贝一个数组。对于一个专门为了继承而设计的类,如果你未能提供一个行为良好的protected类型的clone方法,那么它的子类要想实现Cloneable接口是不可能的。

给TA打赏
共{{data.count}}人
人已打赏
安全技术

详解Node.js API系列 Crypto加密模块(2) Hmac

2021-12-21 16:36:11

安全技术

从零搭建自己的SpringBoot后台框架(二十三)

2022-1-12 12:36:11

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索