Effective-Java-11覆盖equals时总要覆盖hashCode

在每个覆盖了 equals 方法的类中,都必须覆盖 hashCode 方法。如果不这样做,就会违反 hashCode 的通用约定,从而导致该类无法结合所有基于散列的集合一起正常运作,这类集合包括 HashMap 和 HashSet。下面是约定的内容,摘自 Object 规范:

  • 在应用程序的执行期间,只要对象的 equals 方法的比较操作所用到的信息没有被修改,那么对同一个对象的多次调用,hashCode 方法都必须始终返回同一个值。在一个应用程序与另一个程序的执行过程中,执行 hashCode 方法所返回的值可以不一致。
  • 如果两个对象根据 equals(Object) 方法比较相等,调用这两个对象中的 hashCode 方法都必须产生同样的整数结果。
  • 如果两个对象根据 equals(Object) 方法比较不相等,调用这两个对象中的 hashCode 方法,则不一定要求 hashCode 方法必须产生不同的结果 (哈希碰撞)。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(hash table)的性能。

因没有覆盖 hashCode 而违反的关键约定是第二条:相等的对象必须具有相等的散列码 (hash code)。根据类的 equals 方法,两个截然不同的实例在逻辑上有可能是相等的,但是根据 Object 类的 hashCode 方法,它们仅仅是两个没有任何共同之处的对象。因此,对象的 hashCode 方法返回两个看起来是随机的整数,而不是根据第二个约定所要求的那样,返回两个相等的整数。

假设在 HashMap 中用第10条中出现过的 PhoneNumber 类的实例作为键:

1
2
HashMap<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");

你可能期望 m.get(new PhoneNumber(707, 867, 5309)) 会返回 “Jenny”,但它实际上返回的是 null。注意,这里涉及两个 PhoneNumber 实例:第一个被插入 HashMap 中,第二个实例与第一个相等,用于从 Map 中根据 PhoneNumber 去获取用户名字。由于 PhoneNumber 类没有覆盖 hashCode 方法,从而导致两个相等的实例具有不相等的散列码,违反了 hashCode 的约定。因此,put 方法把电话号码对象存放在一个散列桶 (hash bucket)中,get 方法却在另一个散列桶中查找这个电话号码。即使这两个实例正好被放到同一个散列桶中,get 方法也必定会返回 null,因为 HashMap 有一项优化,可以将与每个项相关联的散列码缓存起来,如果散列码不匹配,也就不再去检验对象的等同性。

修正这个问题非常简单,只需为PhoneNumber类提供一个适当的 hashCode 方法即可。那么,hashCode 方法应该是什么样的呢?编写一个合法但并不好用的 hashCode 方法没有任何价值。例如,下面这个方法总是合法的,但是它永远都不应该被正式使用

1
2
3
4
5
// The worst possible legal hashCode implementation -never use!
@Override
public int hashCode() {
return 42;
}

上面这个 hashCode 方法是合法的,因为它确保了相等的对象总是具有同样的散列码。但它也极为恶劣,因为它使得每个对象都具有同样的散列码。因此,每个对象都被映射到同一个散列桶中,使散列表退化为链表 (linked list)。它使得本该线性时间运行的程序变成了以平方级时间在运行。对于规模很大的散列表而言,这会关系到散列表能否正常工作。

一个好的散列函数通常倾向于 “为不相等的对象产生不相等的散列码”。这正是hashCode约定中第三条的含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能的int值上。要想完全达到这种理想的情形是非常困难的。幸运的是,相对接近这种理想情形则并不太困难。下面给出一种简单的解决办法:

  1. 声明一个 int 变量并命名为 result,将它初始化为对象中第一个关键域的散列码 c,如步骤 2.1 中计算所示 (如第10条所述,关键域是指影响 equals 比较的域)。

  2. 对象中剩下的每一个关键域 f 都完成以下步骤:

    1. 为该域计算 int 类型的散列码 c :

      1. 若该域是基本类型,则计算 Type.hashCode(f)。这里的 Type 是装箱基本类型的类,与 f 的类型相对应。

      2. 若该域是一个对象引用,并且该类的 equals 方法通过递归地调用 equals 的方式来比较这个域,则同样为这个域递归地调用 hashCode。

        若需要更复杂的比较,则为这个域计算一个 “范式” (canonical representation),然后针对这个范式调用 hashCode。

        若这个域的值为 null,则返回 0 (或者其他某个常数,但通常是0)。

      3. 如果该域是一个数组,则要把每一个元素当作单独的域来处理。

        递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤 2.2 中的做法把这些散列值组合起来。

        若数组域中没有重要的元素,可以使用一个常量,但最好不要用 0。

        若数组域中的所有元素都很重要,可以使用 Arrays.hashCode 方法。

    2. 按照下面的公式,把步骤 2.1 中计算得到的散列码 c 合并到 result 中:

      1
      result = 31 * result + c;
  3. 返回 result 。

    (Ryuu:附上 java 源码的实现,你会发现这就是作者提供的解决方案。这并不奇怪,本书作者 Joshua Bloch,曾任职 Sun 公司 进行 Java 的开发)

    1
    2
    3
    4
    // java.util.Objects
    public static int hash(Object... values) {
    return Arrays.hashCode(values);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // java.util.Arrays
    public static int hashCode(Object a[]) {
    if (a == null)
    return 0;

    int result = 1;

    for (Object element : a)
    result = 31 * result + (element == null ? 0 : element.hashCode());

    return result;
    }

    写完了 hashCode 方法之后,问问自己相等的实例是否都具有相等的散列码。要编写单元测试来验证你的推断 (除非利用 AutoValue 生成 equals 和 hashCode 方法,这样你就可以放心地省略这些测试)。如果相等的实例有着不相等的散列码,则要找出原因,并修正错误。