Effective Java 第三版读书笔记——条款11:重写 equals 方法的同时也要重写 hashCode 方法
在每一个重写 equals
方法的类中,都要重写 hashCode
方法。如果不这样做,你的类会违反 hashCode 的通用约定,这会阻止它在 HashMap 和 HashSet 这样的集合中正常工作。下面是根据 Object 源码改编的约定:
- 在一个应用程序执行过程中,如果在
equals
方法比较中没有修改任何信息,在一个对象上重复调用hashCode
方法必须始终返回相同的值。从一个应用程序到另一个应用程序时返回的值可以是不一致的。- 如果两个对象根据
equals(Object)
方法比较是相等的,那么在这两个对象上调用hashCode
就必须产生相同的整数结果。- 如果两个对象根据
equals(Object)
方法比较并不相等,不要求在每个对象上调用hashCode
都必须产生不同的结果。 但是,程序员应该意识到,为不相等的对象生成不同的结果可能会提高散列表(hash tables)的性能。
没有重写 hashCode
会违反上述约定的第二条。两个逻辑上相同的对象调用 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 方法导致两个相等的实例返回了不同的哈希码。
解决这个问题很简单,只需要为 PhoneNumber
类重写一个合适的 hashCode 方法。一个好的 hash 方法趋向于为不相等的实例生成不相等的哈希码。理想情况下,hash 方法为集合中不相等的实例均匀地分配 int 范围内的哈希码。实现这种理想情况可能很困难。 幸运的是,要获得一个合理的近似方式并不难。 以下是一个简单的秘诀:
-
声明一个 int 类型的变量 result,并将其初始化为对象中第一个重要属性
c
的哈希码,如下面步骤 2.a 中所计算的那样。 -
对于对象中剩余的重要属性
f
,执行以下操作:a. 为属性
f
与计算一个 int 类型的哈希码c
:- i. 如果这个属性是基本类型,使用
Type.hashCode(f)
方法计算,其中Type
类是对应属性f
的包装类。 - ii. 如果该属性是一个对象引用,并且该类的 equals 方法通过递归调用 equals 来比较该属性,那么递归地调用 hashCode 方法。 如果需要更复杂的比较,则计算此字段的“范式(canonical representation)”,并在范式上调用 hashCode 。 如果该字段的值为空,则使用 0(也可以使用其他常数,但通常使用 0 表示)。
- iii. 如果属性
f
是一个数组,把数组中每个重要的元素都看作是一个独立的属性。如果数组没有重要的元素,则使用一个常量,最好不要为0。如果所有元素都很重要,则使用Arrays.hashCode
方法。
b. 将步骤 2.a 中计算出的哈希码
c
合并为如下结果:result = 31 * result + c;
- i. 如果这个属性是基本类型,使用
-
返回 result 值。
让我们把上述办法应用到 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
实例具有相同的哈希码。实际上,这个方法是一个非常好的 hashCode 实现,与 Java 平台类库中的实现一样。它很简单,速度相当快,并且合理地将不相同的电话号码分散到不同的哈希桶中。
如果一个类是不可变的,并且计算哈希码的代价很大,那么可以考虑在对象中缓存哈希码,而不是在每次请求时重新计算哈希码。如果你认为这种类型的大多数对象将被用作哈希键,那么应该在创建实例时计算哈希码。否则,可以选择在首次调用 hashCode 时延迟初始化(lazily initialize)哈希码。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;
}
不要试图从哈希码计算中排除重要的属性来提高性能。 由此产生的哈希函数可能运行得更快,但其差劲的质量可能会降低哈希表的性能,甚至使哈希表无法使用。如果发生这种情况,哈希函数将把所有这些实例映射到少许哈希码上,而应该以线性时间运行的程序将会运行平方级的时间。