文章目录
尽管Object
是一个具体类,但设计它主要是为了扩展。它的所有非final方法(equals
、hashCode
、toString
、clone
和finalize
)都有明确的通用约定,因为它们被设计成可被覆盖(override)的。任何一个类,在覆盖这些方法的时候,都有责任遵守这些约定。如果不能做到这一点,其它依赖于这些约定的类,比如HashMap
和HashSet
,就无法结合该类一起正常运作。
本章将讨论何时以及如何覆盖这些非final的Object方法。本章不讨论finalize方法,因为在第一章的 Item8 里已经讨论过。而
Comparable.compareTo
虽然不是Object方法,但也将对它进行讨论,因为它具有类似的特征。
Item1: 覆盖equals时请遵守通用约定
无需覆盖的情况
覆盖equals方法看似简单,但是很多覆盖方式会导致错误,并且后果非常严重。最容易避免这类问题的方法就是不覆盖equals方法,在这种情况下,类的每个实例都只与它自身相等。
如果满足以下任何一个条件,就无需覆盖:
- 类的每个实例本质上都是唯一的
- 类没必要提供逻辑相等的测试功能
- 父类已经覆盖了equals方法,父类的行为也适用于该子类
- 类是私有或包级私有的,可以确定它的equals方法永远不会被调用
覆盖的规范
- 自反性(reflexive):对于任何非null的引用值x, x.equals(x)必须返回true。
- 对称性(symmetric):对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
- 传递性(transitive):对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。
- 一致性(consistent):对于任何非null的引用值x和y,只要在equals比较操作中的对象的信息没有被修改,多次调用x.equals(y)就会一致地返回相同的结果。
- 对于任何非null的引用值x,x.equals(null)必须返回false。
实现高质量的equals方法
- 使用 == 操作符检查 “参数是否为这个对象的引用”
- 使用instanceOf操作符检查"参数是否为正确的类型"
- 把参数转换成正确的类型
- 对于该类中的每个关键字段,检查参数中的对应字段是否相匹配。
根据以上的诀窍构建equals方法的具体例子如下:
public final class PhoneNumber{
private final short areaCode, prefix, lineNumber;
public PhoneNumber(int areaCode, int prefix, int lineNumber){
this.areaCode = areaCode;
this.prefix = prefix;
this.lineNumber = lineNumber;
}
@Override public boolean equals(Object o){
if(o == this){
return true;
}
if(!o instanceof PhoneNumber){
return false;
}
PhoneNumber pn = (PhoneNumber)o;
return pn.lineNumber == lineNumber && pn.prefix == prefix && pn.areaCode == areaCode;.
}
}
下面是最后的一些忠告:
- 覆盖equals方法时总要覆盖hashCode方法
- 不要企图让equals方法过于智能
- 不要将equals方法声明中的Object对象替换为其它类型
比如:
public boolean equals(MyClass o){
//
}
这样写并没有覆盖(override)equals方法,因为Object类的equals方法需要的参数类型就是Object,如果改成其它类型,则相当于重载(overload)了该方法,而不是覆盖。
IDE也有工具可以自动生成equals方法和hashCode方法,通常IDE自动生成的比程序员手工实现的可靠性高一些,因为IDE不会犯粗心的错误。
总而言之,不要轻易覆盖equals方法,除非迫不得已。在许多情况下,从Object处继承的实现正是你想要的。如果要覆盖,一定要比较这个类的所有关键字段,并检查是否遵守了前面提到的规范。
Item2: 覆盖equals方法时总要覆盖hashCode方法
在每个覆盖了equals方法的类中,都必须覆盖hashCode方法。 如果不这么做,就会违反hashcode的通用规定,而导致该类无法结合基于散列的集合一起正常运作,这类集合包括HashMap
和HashSet
。
以下是类的hashCode方法的一些规范:
-
在应用程序执行期间,只要对象的equals方法比较操作所用到的字段信息没有被修改,那么对同一个对象多次调用hashCode方法,必须返回同一个值。同一个应用程序的一次执行和另一次执行过程中,调用hashCode方法所返回的值可以不一致。
-
如果两个对象根据equals方法比较是相等的,那么调用这两个对象的hashCode方法必须产生相同的结果。
-
如果两个对象根据equals方法比较是不相等的,那么调用这两个对象的hashCode方法,不要求必须产生不同的结果。
但是程序员应该知道,给不相等的对象产生不同的结果,能提高散列表的性能。不相等的对象的hashCode结果应该尽可能地不相等。
糟糕的hasCode方法
先看一个糟糕的hashCode方法的例子:
@Override public int hashCode(){
return 43;
}
当然,这是一个合法的hashCode方法,但是它对所有的对象都返回同一个hashCode值。如果把这些对象存到散列表中,会使散列表退化成链表。它使得本该线性时间运行的程序变成了平方级时间运行,使性能大打折扣,甚至会导致程序无法正常工作。
具体实现
一个好的hashCode方法应该遵循上述第三条规范,尽可能为不相等的对象产生不相等的散列码。理想情况下,hashCode方法应该把集合中不相等的对象均匀分布到所有可能的值上。要达到这种理想情况是非常困难的,但我们可以实现相对接近这种理想情况。下面给出一个简单的实现方法:
-
声明一个int变量并命名为result,将它初始化为对象中的一个关键域的散列码。(关键域是指影响equals方法比较的域)
-
对剩下的每个关键域都执行以下操作:
2.1 计算该域f
的散列码c
- 如果该域是基本数据类型,则通过
Type.hashCode(f)
方法计算 - 如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals方法来比较这个域,则同样为这个域递归地调用hashCode。如果这个域的值为null,则返回0
- 如果该域是一个数组,则把每个元素当作单独的域来处理。如果数组中没有重要的元素,可以使用一个常量,但最好不要用0。如果数组中的所有元素都很重要,可以使用
Arrays.hashCode
方法
2.2 按照下面的公式,把2.1中计算得到的散列码合并到result中:
- 如果该域是基本数据类型,则通过
result = 31 * result + c;
- 返回result
在散列码的计算过程中,可以把衍生域排除在外。也就是说,如果一个域的值可以根据其他域的值计算出来,就可以把这样的域排除在外。另外,必须排除在equals比较计算中没有用到的任何域,否则可能违反规范的第二条。
现在我们把上述方法用到Item1中的PhoneNumber类中:
@Override public int hashCode(){
int result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNumber);
return result;
}
更多的优化
Guava Hash库
虽然前面给出的hashCode实现方法能够获得相当好的散列函数,但它不是最先进的。它的质量堪比Java平台库类的值类型中提供的hashCode方法,这些方法对于绝大多数应用程序而言已经足够。如果执意让hashCode方法尽可能不造成冲突,可以参考 Guava Hash 库 com.google.common.hash.Hashing
Object.hash()方法
Objects
类有一个静态方法 Objects.hash(Object... values)
,它可以接收任意数量的参数并返回一个散列码,用一行代码就可以实现与前面介绍的方法同样的效果。但其运行速度会更慢一些,因为它会引发数组的创建,如果参数中有基本数据类型,还会引发装箱和拆箱。用它来实现前面PhoneNumber类的hashCode方法,如下:
@Override public int hashCode(){
return Objects.hash(lineNumber, prefix, areaCode);
}
缓存hashCode值
如果一个类是不可变的,并且计算hash值的开销也比较大,就可以考虑把hash值缓存在对象内部,而不是每次请求的时候都重新去计算。还是以前面的PhoneNumber类作为例子
prviatre int hashCode;
@Override public int hashCode(){
int result = hashCode;
if(result == 0){
result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNumber);
}
return result;
}
注意事项
- 不要试图从散列码计算中排除掉一个对象的关键域来提高性能。
- 不要对hashCode方法的返回值做出具体的规定,因此客户端无法理所当然地依赖它;这样可以为修改提供灵活性。
Item3: 始终要覆盖toString方法
虽然Object类提供了toString方法的实现,但它的返回字符串通常不是用户期望看到。它包含类的名称,以及一个@
符号,接着是散列码的无符号十六进制表示法。例如一个PhoneNumber对象的toString结果可能是PhoneNumber@163b91
toString的通用约定指出,它返回的应该是一个简洁的但内容信息丰富,并且易于阅读的表达形式。提供良好的toString方法实现不仅使类用起来更加舒适,也更加便于调试。
在实际应用中,toString方法应该返回对象中包含的所有值得关注的信息。
在静态工具类中编写toString方法是没有意义的。也不要在多少枚举类中编写toString方法,因为Java已经为枚举提供了完好的实现方法。
总而言之,要在每一个可实例化的类中覆盖Object的toString方法,除非已经在父类中这么做了。
Item4: 谨慎地覆盖clone方法
基本实现
一个类若想覆盖clone方法,需要先实现Cloneable
接口。如果它的超类提供了行为良好的clone方法。首先,调用super.clone方法,将得到一个原始对象功能完整的克隆。如果该类的每个域都是基本数据类型的值,或者是指向不可变对象的引用,这种情况就无需再作进一步处理。
以前没的PhoneNumber类为例,它的clone方法应该是这样的:
@Overrideprotected PhoneNumber clone() throws CloneNotSupportedException {
try {
return (PhoneNumber)super.clone();
}catch (CloneNotSupportedException e){
throw new AssertionError();
}
}
虽然Object的clone方法返回的是Object,但这个clone方法返回的却是PhoneNumber。这么做是合法的,也是符合期望的,因为Java支持协变返回类型。换句话说,覆盖方法的返回类型可以是被覆盖方法的返回类型的子类。
深度拷贝
如果对象中的域引用了可变的对象,那么上述拷贝方法可能会引发严重的后果。
比如下面这个Stack类:
class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_SIZE = 16;
public Stack(){
this.elements = new Object[DEFAULT_SIZE];
}
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);
}
}
}
如果它的clone方法仅仅返回super.clone(),这样得到的Stack实例,其size域具有正确的值,但它的elements域将引用原始Stack实例相同的数组。对克隆获得的对象的elements域的修改将影响原始对象的值,反之亦然。这当然不是我们想要的结果。
实际上,clone方法就是另一个构造器,必须确保它不会影响到原始对象,并确保被创建后的对象也不会受到原始对象的影响。
为了是Stack类中的clone方法正常工作,它必须拷贝栈的内部信息。最容易的做法就是在elements数组中递归地调用clone方法。如下:
@Overrideprotected
Stack clone() throws CloneNotSupportedException {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
return result;
}catch (CloneNotSupportedException e){
throw new AssertionError();
}
}
要注意的是如果elements域是final的,上述方案就不能正常工作,因为final域是无法赋新值的。这就造成了一个问题,Cloneable架构与可变引用的final域的正常用法是不兼容的,除非把final修饰符去掉。
简而言之,所有实现了Cloneable接口的类都应该覆盖clone方法,并且是公有方法,它的返回类型为类本身。该方法应该先调用super.clone,然后修正任何需要修正的域。一般情况下,这意味着要拷贝任何包含深层结构的可变对象,并用指向新对象的引用替代原来指向这些对象的引用。
拷贝构造器
对象拷贝的更好的办法是提供一个拷贝构造器或者拷贝工厂。
拷贝构造器只是一个构造器,它唯一的参数类型是包含该构造器的类,如下:
public Yum(Yum yum){
...
}
拷贝工厂是类似于拷贝构造器的静态工厂:
public static Yum newInstance(Yum yum){
...
}
拷贝构造器的做法,及其静态工厂方法的变形,都被Cloneable/clone方法更具有优势:它们不依赖某一种有风险的、语言之外的对象创建机制;它们不要求遵守尚未制定好文档的规范;也不会与final域的正常使用发生冲突;他们不会抛出不必要的异常,也不需要进行类型转换。
既然所有的问题都与Cloneable接口有关,新的接口就不应该扩展这个接口,新的可扩展的类也不应该实现这个接口。
总之,复制功能最好由构造器或者工厂提供。这条规则的绝对例外是数组,最好利用clone方法复制数组。
Item5: 考虑实现Comparable接口
类实现了Comparable接口,就表明它的实例具有内在的排序关系。对实现了Comparable接口的对象数组进行排序非常简单:
Arrays.sort(a);
每当实现一个对排序敏感的类时,都应该让这个类实现Comparable接口,以便其实例可以轻松地被分类、搜索,以及用在基于比较的集合中。当在compareTo方法中实现比较域值时,应该避免使用 > 和 < 操作符,而应该使用在装箱基本类型的类中的静态comparet方法,或者在Comparator接口中使用比较器构造方法。