Effective Java 3rd 条目11 当你覆写equals时每次都覆写hashCode

在每个覆写equals的类中,你必须覆写hashCode。如果你不这么做,你的类将违反hashCode的通用协定,这将阻止它在数据集(比如HashMap和HashSet)中正常运行。以下是这个协定,改编自Object的文档:

  • 在应用执行期间,当在一个对象上反复调用hashCode方法时,它必须一致返回同样的值,假设equals比较中使用的信息没有改变。当一个应用的执行到另外一个应用, 这个值没必要保持一致。

  • 根据equals(Object)方法,如果两个对象是相等,那么在这两个对象上调用hashCode必须产生同一个整数结果。

  • 根据equals(Object)方法,如果两个对象是不相等的,那么没有要求在每个对象调用hashCode必须产生不同的结果。然而,程序员应该明白,为不相等的对象产生不同的结果会可能改善哈希表的性能。

当没有覆写hashCode的时候,你违反的关键条款是第二个:相等的对象必须有相同的哈希码。根据一个类的equals方法,两个不同的对象可能在逻辑上是相等的,但是对于Object的hashCode方法,它们仅仅是没有太多相同的两个对象。所以,Object的hashCode方法返回看上去像随机的两个数字,而不是协定要求的两个相等的数字。

比如,假设你尝试使用条目10的PhoneNumber类作为hashMap的键:

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

这时候,你可能期望m.get(new PhoneNumber(707, 867, 5309))返回”Jenny”,但是,相反它返回了空。注意到两个PhoneNumber实例相关:一个用作插入到HashMap,而第二个,相等的实例用作(意图)获取。PhoneNumber类的未能覆写hashCode,导致两个相等的实例有不相等的哈希值,这违反了hashCode的协定。所以,get方法可能在一个哈希桶查找电话号码,这个哈希桶不同于用put方法存储的哈希桶。即使两个实例恰巧哈希在同一个桶中,get方法也将几乎一定返回空,因为HashMap有一个优化:缓存了每个键值对相关的哈希值,而且如果哈希值不匹配,没必要去麻烦检查对象的相等性。

解决这个问题就像为PhoneNumber编写一个合适的hashCode方法这么简单。那么hashCode方法看上去像什么呢?很容易编写一个糟糕的方法。这个方法,比如,总是合法的但是永不应该使用:

// 可能是最糟糕的hashCode合法的实现 - 永远不要使用! 
@Override public int hashCode() { 
    return 42; 
}

它是合法的,因为它保证了相等的对象有相同的哈希码。它也是糟透的,因为它保证了每个对象有相同的哈希码。所以,每个对象哈希到相同的桶,而且哈希表退化到链表。应该运行在线性时间的代码,相反运行在二次时间。对于大哈希表,这是工作与不工作之间的区别。

一个好的哈希函数倾向于为不相等的实例产生不相等的哈希码。这正是hashCode协定第三部分的含义。理想上,一个哈希函数应该把不同实例的任何合理数据集均匀分布到所有整数值上。实现这个理想可能很难。所幸,实现一个合理的近似是不那么难。下面是一个简单的办法:

  • 1 声明一个整数变量名字为result,为你的对象的第一个重要域,初始化它为哈希码c,就像在步骤2.a中计算一样。(回想条目10:一个重要域是一个影响equals对比的域)

  • 2 为你的对象的剩余的每个重要域f,做如下事情:

    • a 为域计算一个整数哈希码c

    i. 如果域是一个原始类型,那么计算Type.hashCode(f),这里Type是相应于f类型的原始装箱类型。

    ii. 如果域是一个对象引用,而且这个类的equals方法通过递归调用equals比较域,那么在域上递归调用hashCode。如果需要一个更加复杂的比较,为这个域以规范形式计算一个“规范形式”。如果这个域的值是空,那么使用0(或者某个另外一个常量,但是0是惯例的)。

    iii. 如果域是一个队列,那么对待它好像每个重要元素是一个单独的域。就是说,通过递归地应用这些规则为每个重要元素计算一个哈希码,然后结合步骤2.b的值。如果队列没有重要元素,使用一个常量,最好不是0。如果所有的元素是重要的,使用Arrays.hashCode。

    • b 结合步骤2.a计算的哈希码c到如下的result:
  result = 31 * result + c; 
  1. 返回result。

如果你完成了编写hashCode方法,问问自己相等的实例是否有相等的哈希码。编写单元测试检测你的直觉(除非你使用AutoValue产生你的equals和hashCode方法,这种情况下,你可以安全地省略这些测试)。如果相等的实例有不相等的哈希码,思考为什么然后解决这个问题。

哈希计算时你可能排除派生域(derived field)。用另外的话说,你可能忽略任何域,它的值可以在计算中包含的域中计算出。你必须排除equals比较中没有使用的域,否则你有违反hashCode协定第二个条款的危险。

步骤2.b的乘法使得结果依赖于域的顺序,如果类有多个相似的域,会导致一个更加好的哈希函数。比如,如果String的哈希函数中相乘被忽略了,那么所有的重组字会有相同的哈希码。选择值31,因为它是一个奇质数。如果它是偶数,而且相乘溢出,那么信息会丢失,因为用2相乘是和移位相同的。使用质数的优点在于不更加清楚,但是符合惯例。31的一个好性质是,相乘可以被移位替代,而且对于某些架构来说相减有更好的性能:31 * i == (i << 5) - i。现代的虚拟机自动进行这样的优化。

让我们使用前面的方法到PhoneNumber类:

// 典型的hashCode方法 
@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的一个哈希函数:

// 只有一行hashCode方法 - 一般性能 
@Override public int hashCode() { 
    return Objects.hash(lineNum, prefix, areaCode); 
}

如果一个类是不可变的,而且计算哈希码的代价是明显的,那么你应该考虑在对象中缓存哈希码,而不是每次被请求时重新计算。如果你相信这种类型的大多数对象是用作哈希键,那么当实例生成时,你应该计算这个哈希码。或者,你可以选择当hashCode第一次调用时延迟初始化哈希码。面对延迟初始化的域,需要一些考虑,保证类保持线程安全。我们的PhoneNumber类没有这个处理,仅仅向你展示它是怎么完成的,如是而已。注意hashCode域的初始值(这个情形中为0)不应该是一个普通创建的实例的哈希码。

// 用延迟初始化缓存哈希码的hashCode方法 
private int hashCode; 
// 自动初始化为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的哈希函数,从第一个字符开始,使用最多十六个字符等间隔地贯穿这个字符串。对于有层次的名字的大数集,比如URL,这个函数恰好显示出前面描述的非常规行为。

不要为hashCode返回值提供一个详细的文档,因此客户端不能合理地依赖于它;这给你改变它的灵活性。Java库里面的许多类,比如String和Integer,它们的hashCode方法返回相同的值,指定为实例值的函数。这不是一个好主意,但是时我们被迫忍受的一个错误:这阻止了在未来发布中改进哈希函数的可能。如果你让细节未详细说明,在哈希函数发现了一个缺陷或者找到了一个更好的哈希函数,你可以在后续的发布中改变他。

总之,每次你覆写equals时,你应该覆写hashCode,否则你的程序不会正常工作。你的hashCode方法必须遵从Object里面的通用规范,而且必须做赋值不相等的哈希码到不相等的实例的合理工作。这是容易完成的,如果稍显乏味,使用上面的方法。就像在条目10提到的,AutoValue框架提供了手动编写equals和hashCode方法的好的替代方案,而且IDE也提供了一些这样的功能。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值