Effective Java 第三版——11. 重写equals方法时同时也要重写hashcode方法

Tips
《Effective Java, Third Edition》一书英文版已经出版,这本书的第二版想必不少人都读过,号称Java四大名著之一,不过第二版2009年出版,到如今已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深入的变化。
在这里第一时间翻译成中文版。供你们学习分享之用。程序员

Effective Java, Third Edition

11. 重写equals方法时同时也要重写hashcode方法

在每一个类中,在重写 equals 方法的时侯,必定要重写 hashcode 方法。若是不这样作,你的类违反了hashCode的通用约定,这会阻止它在HashMap和HashSet这样的集合中正常工做。根据 Object 规范,如下时具体约定。数组

  1. 当在一个应用程序执行过程当中,若是在equals方法比较中没有修改任何信息,在一个对象上重复调用hashCode方法时,它必须始终返回相同的值。从一个应用程序到另外一个应用程序的每一次执行返回的值能够是不一致的。
  2. 若是两个对象根据equals(Object)方法比较是相等的,那么在两个对象上调用hashCode就必须产生的结果是相同的整数。
  3. 若是两个对象根据equals(Object)方法比较并不相等,则不要求在每一个对象上调用hashCode都必须产生不一样的结果。 可是,程序员应该意识到,为不相等的对象生成不一样的结果可能会提升散列表(hash tables)的性能。

当没法重写hashCode时,所违反第二个关键条款是:相等的对象必须具备相等的哈希码( hash codes)。根据类的equals方法,两个不一样的实例可能在逻辑上是相同的,可是对于Object 类的hashCode方法,它们只是两个没有什么共同之处的对象。所以, Object 类的hashCode方法返回两个看似随机的数字,而不是按约定要求的两个相等的数字。缓存

举例说明,假设你使用条目 10中的PhoneNumber类的实例作为HashMap的键(key):安全

Map<PhoneNumber, String> m = new HashMap<>();

m.put(new PhoneNumber(707, 867, 5309), "Jenny");

你可能指望m.get(new PhoneNumber(707, 867, 5309))方法返回Jenny字符串,但实际上,返回了 null。注意,这里涉及到两个PhoneNumber实例:一个实例插入到 HashMap 中,另外一个做为判断相等的实例用来检索。PhoneNumber类没有重写 hashCode 方法致使两个相等的实例返回了不一样的哈希码,违反了 hashCode 约定。put 方法把PhoneNumber实例保存在了一个哈希桶( hash bucket)中,但get方法倒是从不一样的哈希桶中去查找,即便刚好两个实例放在同一个哈希桶中,get 方法几乎确定也会返回 null。由于HashMap 作了优化,缓存了与每一项(entry)相关的哈希码,若是哈希码不匹配,则不会检查对象是否相等了。框架

解决这个问题很简单,只须要为PhoneNumber类重写一个合适的 hashCode 方法。hashCode方法是什么样的?写一个不规范的方法的是很简单的。如下示例,虽然永远是合法的,但绝对不能这样使用:ide

// The worst possible legal hashCode implementation - never use!

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

这是合法的,由于它确保了相等的对象具备相同的哈希码。这很糟糕,由于它确保了每一个对象都有相同的哈希码。所以,每一个对象哈希到同一个桶中,哈希表退化为链表。应该在线性时间内运行的程序,运行时间变成了平方级别。对于数据很大的哈希表而言,会影响到可以正常工做。函数

一个好的 hash 方法趋向于为不相等的实例生成不相等的哈希码。这也正是 hashCode 约定中第三条的表达。理想状况下,hash 方法为集合中不相等的实例均匀地分配int 范围内的哈希码。实现这种理想状况多是困难的。 幸运的是,要得到一个合理的近似的方式并不难。 如下是一个简单的配方:性能

  1. 声明一个 int 类型的变量result,并将其初始化为对象中第一个重要属性c的哈希码,以下面步骤2.a中所计算的那样。(回顾条目10,重要的属性是影响比较相等的领域。)
  2. 对于对象中剩余的重要属性f,请执行如下操做:单元测试

    a. 比较属性f与属性c的 int 类型的哈希码:
    -- i. 若是这个属性是基本类型的,使用 Type.hashCode(f)方法计算,其中Type类是对应属性 f 基本类型的包装类。
    -- ii 若是该属性是一个对象引用,而且该类的equals方法经过递归调用equals来比较该属性,并递归地调用hashCode方法。 若是须要更复杂的比较,则计算此字段的“范式(“canonical representation)”,并在范式上调用hashCode。 若是该字段的值为空,则使用0(也可使用其余常数,但一般来使用0表示)。
    -- iii 若是属性f是一个数组,把它看做每一个重要的元素都是一个独立的属性。 也就是说,经过递归地应用这些规则计算每一个重要元素的哈希码,而且将每一个步骤2.b的值合并。 若是数组没有重要的元素,则使用一个常量,最好不要为0。若是全部元素都很重要,则使用Arrays.hashCode方法。学习

    b. 将步骤2.a中属性c计算出的哈希码合并为以下结果:result = 31 * result + c;

  3. 返回 result 值。

当你写完hashCode方法后,问本身是否相等的实例有相同的哈希码。 编写单元测试来验证你的直觉(除非你使用AutoValue框架来生成你的equals和hashCode方法,在这种状况下,你能够放心地忽略这些测试)。 若是相同的实例有不相等的哈希码,找出缘由并解决问题。

能够从哈希码计算中排除派生属性(derived fields)。换句话说,若是一个属性的值能够根据参与计算的其余属性值计算出来,那么能够忽略这样的属性。您必须排除在equals比较中没有使用的任何属性,不然可能会违反hashCode约定的第二条。

步骤2.b中的乘法计算结果取决于属性的顺序,若是类中具备多个类似属性,则产生更好的散列函数。 例如,若是乘法计算从一个String散列函数中被省略,则全部的字符将具备相同的散列码。 之因此选择31,由于它是一个奇数的素数。 若是它是偶数,而且乘法溢出,信息将会丢失,由于乘以2至关于移位。 使用素数的好处不太明显,但习惯上都是这么作的。 31的一个很好的特性,是在一些体系结构中乘法能够被替换为移位和减法以得到更好的性能:31 * i ==(i << 5) - i。 现代JVM能够自动进行这种优化。

让咱们把上述办法应用到PhoneNumber类中:

// Typical hashCode method

@Override public int hashCode() {

    int result = Short.hashCode(areaCode);

    result = 31 * result + Short.hashCode(prefix);

    result = 31 * result + Short.hashCode(lineNum);

    return result;

}

由于这个方法返回一个简单的肯定性计算的结果,它的惟一的输入是PhoneNumber实例中的三个重要的属性,因此显然相等的PhoneNumber实例具备相同的哈希码。 实际上,这个方法是PhoneNumber的一个很是好的hashCode实现,与Java平台类库中的实现同样。 它很简单,速度至关快,而且合理地将不相同的电话号码分散到不一样的哈希桶中。

虽然在这个项目的方法产生至关好的哈希函数,但并非最早进的。 它们的质量与Java平台类库的值类型中找到的哈希函数至关,对于大多数用途来讲都是足够的。 若是真的须要哈希函数而不太可能产生碰撞,请参阅Guava框架的的com.google.common.hash.Hashing [Guava]方法。

Objects类有一个静态方法,它接受任意数量的对象并为它们返回一个哈希码。 这个名为hash的方法可让你编写一行hashCode方法,其质量与根据这个项目中的上面编写的方法至关。 不幸的是,它们的运行速度更慢,由于它们须要建立数组以传递可变数量的参数,以及若是任何参数是基本类型,则进行装箱和取消装箱。 这种哈希函数的风格建议仅在性能不重要的状况下使用。 如下是使用这种技术编写的PhoneNumber的哈希函数:

// One-line hashCode method - mediocre performance

@Override public int hashCode() {

   return Objects.hash(lineNum, prefix, areaCode);

}

若是一个类是不可变的,而且计算哈希码的代价很大,那么能够考虑在对象中缓存哈希码,而不是在每次请求时从新计算哈希码。 若是你认为这种类型的大多数对象将被用做哈希键,那么应该在建立实例时计算哈希码。 不然,能够选择在首次调用hashCode时延迟初始化(lazily initialize)哈希码。 须要注意确保类在存在延迟初始化属性的状况下保持线程安全(项目83)。 PhoneNumber类不适合这种状况,但只是为了展现它是如何完成的。 请注意,属性hashCode的初始值(在本例中为0)不该该是一般建立的实例的哈希码:

// hashCode method with lazily initialized cached hash code

private int hashCode; // Automatically initialized to 0

@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(lineNum);

        hashCode = result;

    }

    return result;

}

不要试图从哈希码计算中排除重要的属性来提升性能。 由此产生的哈希函数可能运行得更快,但其质量较差可能会下降哈希表的性能,使其没法使用。 具体来讲,哈希函数可能会遇到大量不一样的实例,这些实例主要在你忽略的区域中有所不一样。 若是发生这种状况,哈希函数将把全部这些实例映射到少量哈希码上,而应该以线性时间运行的程序将会运行平方级的时间。

这不只仅是一个理论问题。 在Java 2以前,String 类哈希函数在整个字符串中最多使用16个字符,从第一个字符开始,在整个字符串中均匀地选取。 对于大量的带有层次名称的集合(如URL),此功能正好显示了前面描述的病态行为。

不要为hashCode返回的值提供详细的规范,所以客户端不能合理地依赖它; 你能够改变它的灵活性。 Java类库中的许多类(例如String和Integer)都将hashCode方法返回的确切值指定为实例值的函数。 这不是一个好主意,而是一个咱们不得不忍受的错误:它妨碍了在将来版本中改进哈希函数的能力。 若是未指定细节并在散列函数中发现缺陷,或者发现了更好的哈希函数,则能够在后续版本中对其进行更改。

总之,每次重写equals方法时都必须重写hashCode方法,不然程序将没法正常运行。你的hashCode方法必须听从Object类指定的常规约定,而且必须执行合理的工做,将不相等的哈希码分配给不相等的实例。若是使用第51页的配方,这很容易实现。如条目 10所述,AutoValue框架为手动编写equals和hashCode方法提供了一个很好的选择,IDE也提供了一些这样的功能。

相关文章
相关标签/搜索