Effective Java: 第二章 Object类的方法

尽管Object是一个具体类,但设计它主要是为了扩展。它的所有非final方法(equalshashCodetoStringclonefinalize)都有明确的通用约定,因为它们被设计成可被覆盖(override)的。任何一个类,在覆盖这些方法的时候,都有责任遵守这些约定。如果不能做到这一点,其它依赖于这些约定的类,比如HashMapHashSet,就无法结合该类一起正常运作。

本章将讨论何时以及如何覆盖这些非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方法
  1. 使用 == 操作符检查 “参数是否为这个对象的引用”
  2. 使用instanceOf操作符检查"参数是否为正确的类型"
  3. 把参数转换成正确的类型
  4. 对于该类中的每个关键字段,检查参数中的对应字段是否相匹配。

根据以上的诀窍构建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的通用规定,而导致该类无法结合基于散列的集合一起正常运作,这类集合包括HashMapHashSet

以下是类的hashCode方法的一些规范:

  • 在应用程序执行期间,只要对象的equals方法比较操作所用到的字段信息没有被修改,那么对同一个对象多次调用hashCode方法,必须返回同一个值。同一个应用程序的一次执行和另一次执行过程中,调用hashCode方法所返回的值可以不一致。

  • 如果两个对象根据equals方法比较是相等的,那么调用这两个对象的hashCode方法必须产生相同的结果。

  • 如果两个对象根据equals方法比较是不相等的,那么调用这两个对象的hashCode方法,不要求必须产生不同的结果。

但是程序员应该知道,给不相等的对象产生不同的结果,能提高散列表的性能。不相等的对象的hashCode结果应该尽可能地不相等。

糟糕的hasCode方法

先看一个糟糕的hashCode方法的例子:

@Override public int hashCode(){ 
    return 43; 
 }

当然,这是一个合法的hashCode方法,但是它对所有的对象都返回同一个hashCode值。如果把这些对象存到散列表中,会使散列表退化成链表。它使得本该线性时间运行的程序变成了平方级时间运行,使性能大打折扣,甚至会导致程序无法正常工作。

具体实现

一个好的hashCode方法应该遵循上述第三条规范,尽可能为不相等的对象产生不相等的散列码。理想情况下,hashCode方法应该把集合中不相等的对象均匀分布到所有可能的值上。要达到这种理想情况是非常困难的,但我们可以实现相对接近这种理想情况。下面给出一个简单的实现方法:

  1. 声明一个int变量并命名为result,将它初始化为对象中的一个关键域的散列码。(关键域是指影响equals方法比较的域)

  2. 对剩下的每个关键域都执行以下操作:
    2.1 计算该域f的散列码c

    • 如果该域是基本数据类型,则通过Type.hashCode(f)方法计算
    • 如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals方法来比较这个域,则同样为这个域递归地调用hashCode。如果这个域的值为null,则返回0
    • 如果该域是一个数组,则把每个元素当作单独的域来处理。如果数组中没有重要的元素,可以使用一个常量,但最好不要用0。如果数组中的所有元素都很重要,可以使用Arrays.hashCode方法

    2.2 按照下面的公式,把2.1中计算得到的散列码合并到result中:

    result = 31 * result + c;
  1. 返回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 Hashcom.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接口中使用比较器构造方法。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值