对象共有的方法(第十一条:当你重写EQUALS方法的时不要忘了重写HASHCODE方法)

你必须在重写equals方法中的每个类里面去重写hashCode方法。如果你没有做,你的类会违背hashCode的基本准则,这样就会导致这个类在像HashMap还有HashSet的集合中不能正确地工作。这儿有从Object声明里面所引用的约定:

在程序运行期,如果一个对象的hashCode方法被重复调用,只要用于equals比较的任何信息没有被修改,那么它必须一直返回相同的值。如果从一个程序的执行到另一个程序的执行中,这个结果是不必一直保持一致的。

如果根据equals(Object)方法调用得出两个对象是相等的,那么调用这两个对象上的hashCode方法必须产生相同的integer结果。

如果根据equals(Object)方法调用得出两个对象是不相等的,那么调用这两个对象上的hashCode方法不必产生不一样的integer结果。然而,开发人员应该意识到,对于不相等的对象产生不同的结果可能会提高哈希表的性能。

当你没有重写hashCode时,主要违背的点是第二点:相等的对象必须有相等的hash code。调用两个不一样的实例的equals方法也许会得出逻辑相等,但是对于Object的hashCode方法而言,这两个对象一样的地方就很少了。因此,尽管依据约定它们需要返回相等的哈希结果,但是事实似乎却返回了两个任意的结果。比如,假设你尝试使用Item 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",但是,它却返回的是null。注意这里涉及的是两个PhoneNumber的实例:一个被用来插入到HashMap里面,第二个相等的对象被用来查找。由于PhoneNumber类没有重写hashCode引起了两个相等的实例产生了不相等的哈希结果,因此它违背了hashCode约定。因此,虽然调用put方法将一个实例存在了一个哈希桶里面,但是调用get方法却可能在一个不同的哈希桶里面去查找电话号码。尽管两个实例碰巧会哈希到相同的桶里面,get方法也一定会返回null,因为HashMap有个优化机制,它会将与每个记录相关的哈希码缓存下来,如果哈希码不匹配的话,它就不会再去检查对象的相等了。只要给PhoneNumber写一个合适的hashCode方法就可以解决这个问题。那么一个hashCode方法长什么样字呢?写一个不好的是没有价值的。比如这个,他总是合法的,但永远也不应该被使用:

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

他合法是因为它能保证相等的对象有相等的哈希码。它糟糕透了是因为它确保让每个对象都有相同的哈希码。因此,每个对象都哈希到同一个桶内,哈希表也因此退化成了一个链表。程序本该以线性的时间去跑,而事实上它却以2次方的时间去跑。对于大一点的哈希表,这就可能是能不能工作的问题了。一个好的哈希函数趋于在不相等的对象产生不相等的哈希码。这正是hashCode约定的第三部分的意义。理想情况下,一个哈希函数应该将遍布于所有整数上不相等的实例均匀分布在任意合适的集合上。尽管达到这一理想情况很困难,但是庆幸的是,实现跟它接近却不会太难。这有一个简单的方法:

1.      声明一个叫做result的int变量,然后将它初始化为对象中第一个有意义的字段的哈希码c,就像2.a中所说的。(从Item 10中回想一下,有意义的字段指的是影响equals方法比较的字段。)

2.      对于对象中所有剩下的有意义的字段f,按照下面来做:

a.  为这个字段计算int的哈希码c:

I.              如果字段是一个基本类型,计算Type .hashCode(f),Type是f的基本类型对应的装箱类型。

II.            如果字段是一个对象引用,并且这个类的equals方法是通过递归调用equals方法来比较相等的,那么递归调用这个字段上的hashCode方法。如果面临更加复杂的情况,那么计算这个字段的“标准形式(canonical representation)”,然后在这个标准形式上调用hashCode方法。如果值是null,使用0(或者其他常量,但是一般都用0)。

III.          如果对象是一个数组,将它的每个元素当成一个有意义的字段去对待。也就是说,依据规则递归的调用每个有意义的元素的哈希值,并且每次在步骤2.b是,将值组装一哈。如果数组没有有意义的元素,使用常量,一般都不用0。如果所有的元素都是有意义的,使用Arrays.hashCode。

b.  将2.a计算出来的哈希码依照下面的方式合并起来:

result = 31 *result + c;

3.      返回result。

当你写完了hashCode方法后,问问自己相等的实例是不是有相等的哈希码。写单元测试去验证你的直觉(除非你使用AutoValue来生成equals和hashCode方法,这种情况下你可以安全的省去这些测试)。如果想的的实例有不相等的哈希码,找到原因并且改正它。

在哈希值的计算中,你可以排除衍生字段。换句话来说,你可以忽略任何可以从当前的这些字段推算出来的字段。你必须排除任何没有用在equals比较的字段,否则你就可能违背hashCode约定的第二点。

步骤2.b中的乘积使得哈希结果依赖于字段的顺序,如果类有多个相似的字段,那么这个乘积会帮助产生一个更好的哈希函数。比如,如果这个乘积被删除了,那么打乱顺序的字符组成的串会有相同的哈希值。选择31是因为它是一个奇数并且是一个质数。如果选择偶数并且乘积溢出的话,那么信息就会丢失,因为2的乘积就等于去移位。使用质数的有点久不是很明确了,但是以前就这么做的。31的好处是,有时候为了追求性能,它可以被移位还有相减代替:31 * i == (i << 5) – i。现代虚拟机都是自动做这种优化的。

让我们把上面的过程用于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类中有一个静态方法,它接收任意数量的对象作为参数,然后为他们返回一个哈希结果。这个方法叫做哈希,它可以让你的hashCode方法只写一行,然而质量却跟你用上面方法写出的不相上下。不幸的是,他们要创建数组来将你传入的参数放进去,如果参数是基本类型的时候还涉及装箱拆箱,所以它跑起来还是挺慢的。这种哈希函数只有在性能要求不是很高的情况下才被推荐使用。这儿有一个用这种方法给PhoneNumber写成的哈希函数:

// One-line hashCode method - mediocre performance
    @Override public int hashCode() {
        return Objects.hash(lineNum, prefix, areaCode);
    }

如果一个类是不可变的,并且计算哈希结果的代价很大,在每次需要它的时候,你可能要考虑把哈希结果缓存下来,而不是每次都去重新计算它们。如果你认为这种类型的大部分对象都会被用作哈希的键,那么你应该在实例被创建的时候就去计算哈希值。负责,你可能要选取懒初始化,在hashCode方法第一次被调用的时候生成哈希结果。在使用懒初始化这个字段的时候,要考虑类是不是线程安全的(Item 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;
    }

不要为了提升性能去尝试排除有意义的字段。这样做尽管会让哈希函数更快,它会导致将哈希表的性能降级到不可用。特殊情况下,哈希函数要面临一大堆实例,而这些实例主要不一样的地方恰恰是你忽略的。如果这种情况发生,哈希函数就会给所有的实例产生一点点哈希结果,程序本应该以线性的时间去跑却以2次方的时间去跑。这不仅仅是个理论问题。在Java2以前,String的哈希函数使用从第一个字符起的至多16个字符,尽管它可能包含空格。对于很多又层级关系的名字,比如URLs,这个哈希函数确切的表现出了上面描述的问题。

不要给hashCode返回值提供一个详细的说明,这样客户端就不能依赖这个说明了;这样你就有了更改它的灵活性了。在Java类库中,很多类比如String和Integer,声明了hashCode方法是根据实例的值计算出那个值的。这不仅不是一个好主意,而且是一个错误。因为我们不得不依据这个:它阻碍了在将来的release中改善哈希函数的能力。如果你不说明实现细节,在这种情况下如果发现了一个瑕疵,或者找到了一个更好的哈希函数,你可以在后面的release中改变它。

总之,只要你重写equals方法,你就必须重写hashCode方法,否则,程序可能会出错。你的hashCode方法必须遵守Object里面声明的基本约定,而且必须尽量给不同的实例生成不同的哈希值。这实现起来很容易,只是可能有点麻烦,使用上面提供的方法。正如Item 10所声明的,AutoValue框架是手动写equals和hashCode方法的一个好的替代办法,一些IDEs可能也会提供类似的功能。


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值