Cloneable接口用作mixin接口(item20) ,用于表示实现这个接口的类允许克隆。不幸的是,它没有达到这个目的。它的主要缺陷是它缺少克隆方法,并且Object的克隆方法是protected的。除非使用反射,否则仅仅实现Cloneable是不能在对象上调用克隆的。即使是反射调用也有可能失败,因为无法保证对象具有可访问的克隆方法。尽管有这个缺陷和许多其他问题,该设施还是应用相当广泛,因此理解它是值得的。该条目告知你如何实现一个良好的克隆方法,讨论什么时候适合这么做,并提供替代方案。
那么Cloneable并没有包含任何方法,它做了什么?它确定了Object的protected clone实现的行为:如果一个类实现了Cloneable,对象的clone方法就会返回这个对象逐个字段的拷贝对象,否则它将会抛出CloneNotSupportedException。这是一种高度非典型的接口使用而不是一种模拟。通常,实现一个接口说明类可以为其客户端做些什么。在这种情况下,它会修改超类的protected方法的行为。
虽然规范没有说明,实际上,一个类实现了Cloneable是期望其提供一个功能正常的public clone方法。为了实现这个目标,该类及其所有超类必须遵守一个复杂,不可执行的,精简的协议。由此产生的机制是脆弱的,危险的,非语言的:它不调用构造方法来创建对象。
clone方法的一般契约是薄弱的。如下是从Object规范拷贝过来的:
创建并返回这个对象的拷贝。“拷贝”的确切含义可能决定于对象的类。一般意图是,对于任意对象x,表达式
x.clone() != x
的结果是true,表达式
x.clone().getClass() == x.getClass()
的结果是true,但是这些并非是绝对要求。尽管通常情况下
x.clone().equals(x)
的结果是true,但这也不是绝对要求。按照惯例,该方法返回的对象应该通过调用super.clone来获取。如果一个类以及所有它的超类(Object除外)遵守这个约定,那么情况就是如此:
x.clone().getClass() == x.getClass().
按照惯例,返回的对象应该独立于被拷贝的对象。为了实现独立性,在返回对象之前可能有必要修改super.clone的一个或多个字段。
这个机制与构造方法链有点类似,除了它没有强制执行:如果一个类的clone方法没有调用super.clone但是调用构造方法返回了一个实例,编译器不会报错,但是如果该类的子类调用super.clone,生成的对象将成为错误的类,阻止克隆方法的子类正常工作。如果覆写clone的类是final的,这个约定就可以安全地忽略,因为没有子类可以担心。但是如果一个final类有一个不调用super.clone的clone方法,这个类就没有实现Cloneable的理由,因为它不依赖于Object的clone的实现。
&emsp假定你想要在类中实现Cloneable,并且它的超类提供了良好的clone方法。首先调用super.clone。你将获得拥有原始的完整功能的拷贝对象。任何在你的类中声明的字段将具有原始值相同的值。如果每个字段包含了原始值或对不可变对象的引用,返回的对象可能正是你需要的,在这种情况下,不需要进一步处理。例如,在item11 中的PhoneNumber类就是这样的请情况,但是请注意,不可变类应该永不提供clone方法因为它仅仅会浪费拷贝。有了这个警告,再看一下PhoneNumber的clone方法长什么样:
为了让该方法起作用,PhoneNumber的类声明将不得不修改来表明它实现了Cloneable。虽然Object的clone方法返回Object,但这个clone方法返回PhoneNumber。这是合法的并且希望这么做,因为Java支持协变返回类型。换句话说,一个覆写方法的返回类型可以可以是覆写方法返回类型的子类。这消除了客户端的转换。我们必须在返回之前将super.clone的结果转换为PhoneNumber,但是这样的转换必然转换成功。
super.clone的调用被try-catch块中包含。这是因为Object声明它的clone方法会抛出CloneNotSupportedException,这是一个检查异常。因为PhoneNumber实现了Cloneable,我们知道调用super.clone将会成功。对此样板文件的需求表明应该取消选中CloneNotSupportedException(item71)
如果一个对象包含引用可变对象的的字段,之前介绍的简单克隆实现可能是灾难性的。比如,考虑itm7的Stack类:
假定你想要使这个类可克隆。如果clone方法仅仅只是返回super.clone(),生成的Stack实例的正确的值只有size字段,它的elements字段将仍旧引用原始Stack实例的相同数组。修改原始文件将破坏克隆的不变量,反之亦然。你将很快发现你的程序产生无意义的结果或抛出NullPointerException。
由于在Stack类中调用了唯一的构造方法,这个情况永远不会发生。实际上,clone方法用作构造方法;你必须确保它对原始对象没有损害并且它正确地在克隆上建立不变量。为了使Stack上的clone方法工作正常,它必须拷贝堆栈的内部。做这件事情最简单的方法就是在elements数组上递归地调用克隆方法:
注意到我们不必将elements.clone的结果强制转换为Object[]。调用数组的克隆方法将会返回一个数组,其运行时和编译时的类型一致,这是拷贝数组的首选习惯用法。事实上,数组是克隆共组的唯一用途。
另请注意,如果elements字段是final的,早期解决方案将不起作用,因为向字段分配新值的clone方法是被禁止的。这是一个基本问题:与序列化一样,Cloneable体系结构与引用可变对象的final字段的正常使用是不兼容的,除非可变对象可以在对象和它的拷贝中被安全共享。为了使类可以被Cloneable,可能有必要在部分字段中删除final修饰符。
仅仅递归调用clone并不总是够的。例如,假设你正在为hash table编写clone方法,该hash table内部有桶数组组成,每个桶都引用了键值对链表的第一个条目。为了提高性能,该类实现了它自己轻量级的单链表而不是在内部使用java.util.LinkedList:
public class HashTable implements Cloneable {
private Entry[] buckets = ...;
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;
}
}
... // Remainder omitted
}
假设你只想递归克隆桶,就像我们为Stack所作的:
虽然克隆具有它自己的存储区数组,但是这个数组引用了原先相同的链表,这很容易在原始和克隆部分引起不确定的行为。为了解决这个问题,你必须拷贝整个桶的链表。如下是一种常见的方法:
// Recursive clone method for class with complex mutable state
public class HashTable implements Cloneable { private Entry[] buckets = ...;
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;
}
// Recursively copy the linked list headed by this Entry
Entry deepCopy() { return new Entry(key, value, next == null ? null : next.deepCopy()); }
}
@Override
public HashTable clone() {
try {
HashTable result = (HashTable) super.clone();
result.buckets = new Entry[buckets.length];
for (int i = 0; i < buckets.length; i++) if (buckets[i] != null) result.buckets[i] = buckets[i].deepCopy();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
} ... // Remainder omitted
}
私有类HashTable.Entry已经被增强以支持“深拷贝”方法。在HashTable上的克隆方法分配了一个合适大小的新的桶数组,并且在原始桶数组上迭代,深拷贝每个非空桶。Entry的深拷贝方法递归地调用了它自己来拷贝整个链表从头到尾。虽然这个技术很可爱且只要这个桶不是很长,也能正常工作,但是它不是一个好方法来克隆链表,因为它为列表中的每个元素消耗了一个堆栈帧。如果列表很长,就很容易导致堆栈溢出。为了阻止此类事情的发生,你可以用迭代来替代deepCopy的递归:
克隆复杂可变对象的一个最后的方法是调用super.clone,将结果对象的所有字段设置为其初始状态,然后调用更高级别的方法来重新生成原始对象的状态。在例子HashTable这个例子中,桶字段将初始化为一个新的桶数组,并调用put(key,value)(未展示)方法来克隆字段。这种方法通常会产生一种简单,相当优雅的克隆方法,该克隆方法运行效率并不如直接操作克隆内部的克隆方法。虽然这个方法很简洁,但是它与整个Cloneable架构是对立的,因为它盲目地覆写构成基本架构的字段对象接着字段对象的拷贝。
就像构造方法,一个克隆方法必须永不在构造中的克隆上调用一个可覆写的方法 (item19)。 如果克隆调用了一个子类覆写的方法,这个方法将在子类在克隆中有机会修复其状态之前执行,很可能导致克隆和原始体的腐败。因此,前面讨论的put(key,value)方法应该是final或者是private的(如果它是private,它可能是nonfinal public方法的“辅助方法”。)
对象的克隆方法显式声明抛出CloneNotSupportedException,但是覆写方法不需要。public克隆方法应该忽略这个throw语句,因为不抛出检查异常更容易使用 (item71).
在设计继承类时,你有两个选择(item19) ,但是无论你选择了哪一项,这个类都不该实现Cloneable。你可以选择实现声明抛出CloneNotSupportedException的正常运行的受保护的克隆方法来模拟Object的行为。这使得子类可以自由实现Cloneable,就像它们直接继承Object一样。或者,你可以选择不实现可用的克隆方法,通过提供下列简单克隆实现来阻止子类实现它:
还有一个细节需要注意。如果你编写一个实现了Cloneable的线程安全类,记住它的克隆方法必须正确同步,就像其他任何方法一样(item78) .
对象的克隆方法不是synchromized的,所以即使它的实现是令人满意的,你也可能需要编写一个synchronized克隆方法来返回super.clone().
回顾一下,所有实现Cloneable的类应该覆写返回类型为它自己的public克隆方法。这个方法应该首先调用super.clone,然后修复所有需要修复的字段。通常,这意味着拷贝任何可变对象包括了对象内部“深层结构”,并在克隆副本中由替克隆的引用替换原对象的引用。虽然这些内部拷贝经常可以通过调用递归克隆来拷贝,但是这经常不是一个最好的方法。如果类只包含原始字段或不可变对象的引用,那么很可能不需要修复字段。但是这条规则有例外。比如,表示序列号或其他唯一ID的字段需要修复,即使它是原始变量或不可变的。
所有这些复杂性真的有必要吗?很少。如果你继承一个早已实现Cloneable的类,除了实现一个良好的克隆方法以外,别无它法。否则,通常你最好提供另一种替代对象拷贝的方法。更好的的方法来拷贝对象是提供一个拷贝构造方法或拷贝工厂。拷贝构造方法只是一个构造方法,它接受一个参数,它的类型是包含构造方法的类,例如:
拷贝工厂是与拷贝构造方法类似的静态工厂(item1):
拷贝构造方法和它的静态工厂方法变体与Cloneable/clone相比有更多优点:它们不依赖于一个容易发生语外对象创造机制(risk-prone extralinguistic object creation mechanism);它们不需要遵守精简文件的惯例;它们与正确使用final字段不冲突;它们不抛出没有必要的检查异常;它们不需要强制转换。
此外,一个拷贝构造方法或工厂可以采用类型是类实现的接口的参数。例如,按照惯例,所有通用集合实现提供了一个参数类型是Collection或Map的构造方法。基于接口的拷贝构造方法和工厂,更确切地称为转换构造方法和转换工厂,允许客户端选择拷贝的实现类型而不是强制客户端接受原始实现类型。例如,假设你有一个HashSet,s,并且你想要拷贝它为一个TreeSet。克隆方法不能提供该功能,但是转换构造方法很容易做到:new TreeSet<>(s)。
鉴于与Cloneable有关联的各种问题,新街口不应该扩展它,新扩展类不该实现它。虽然实现Cloneable对final类危害比较小,但是它应该被视为一种性能优化,保留用户正忙其合理性的极少数情况 (item67) 。通常,拷贝功能最好由构造方法和构造工厂提供。这个规则的一个值得注意的例外是数组,最好使用clone方法进行复制。
本文写于2019.3.6,历时11天