美文网首页
第十一条:覆盖equals时总要覆盖hashCode

第十一条:覆盖equals时总要覆盖hashCode

作者: Js_Gavin | 来源:发表于2021-02-02 19:08 被阅读0次

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

    1、在应用程序的执行期间,只要对象的equals方法比较操作所有的信息没有被修改,那么对同一个对象的多次调用,hashCode方法都必须返回同一值。在一个应用程序与另一个应用程序的执行过程中,执行hashCode方法所返回的值可以不一致。

    2、如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中的hashCode方法都必须产生同样的整数结果。

    3、如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象的hashCode方法,则不一定要求产生不同的结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表的性能。

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

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

    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中,第二个实例与第一个逻辑上相等,用于从Map中根据PhoneNumber去获取用户名字。由于PhoneNumber类没有覆盖hashCode方法,从而导致两个相等的实例具有不相等的散列码,违反了hashCode的约定。因此,put方法把电话对象放在一个散列桶中,get方法却在另一个散列桶中查找这个电话号码。即使这两个实例正好被放到同一个散列桶中,get方法也必然会返回null,因为HashMap有一项优化,可以将与每个项相关的散列码缓存起来,如果散列码不匹配,也就不再去检验对象的等同性。

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

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

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

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

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

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

    2.1、为该域计算int类型的散列码 c

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

    2.1.2、如果该域是一个对象引用,并且该类的equals方法通过递归的调用equals的方式来比较这个域,则同样为这个域递归的调用hashCode。如果需要更复杂的比较,则为这个域计算一个“范式”,然后针对这个范式调用hashCode。如果这个域的值为null,则返回0(或者其他某个常数,但通常是0)。

    2.1.3、如果该域是一个数组,则把每一个元素当作单独的域来处理,也就是说,递归的应用上述规则,对每个重要的元素计算一个散列码,然后后根据步骤2.2中的做法把这些散列码组合起来。如果数组域中没有重要的元素,也可以使用一个常量,但最好不要用0。如果数组域中的所有元素都很重要,可以使用Arrays.hashCode方法。

    2.2、按照下面的公式,把步骤2.1中计算得到的散列码 c 合并到result中:
    result = 31 * result + c;

    3、返回result。

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

    在散列码的计算过程中,可以把衍生域排除在外。换句话说,如果一个域的值可以根据参与计算的其他域的值计算出来,则可与把这样的域排除在外。必须排除equals比较中没有用到的任何值,否则很有可能违反hashCode约定的第二条。
    请看以下例子:

     Xiaowu x1 = new Xiaowu("xiaowuit.com", 13);
     Xiaowu x2 = new Xiaowu("xiaowuit.com", 12);
    
    System.out.println(x1.equals(x2));
    System.out.println(x1.hashCode() + " | " + x2.hashCode());
    

    输出结果为:

    true
    114409964 | 114409003
    

    Xiaowu对象中的equals方法没有用到第二个参数来进行比较,但是却在hashCode方法上使用了第二个参数来计算hashCode,就会违反hashCode约定的第二条。

    步骤2.2中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String散列函数省略了这个乘法部分,那么只是字母顺序不同的所有字符串将都会有相同的散列码。之所以选择31,是因为他是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。使用素数的好处并不很明显,但是习惯上都使用素数来计算散列结果。31有一个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:
    31 * 1 == (i << 5) - i
    现代的虚拟机可以自动完成这种优化。

    现在我们要把上述解决办法用到PhoneNumber类种:

    @Override
    public int hashCode(){
      int result = Short.hashCode(areaCode);
      result = 31 * result + Short.hashCode(prefix);
      result = 31 * result + Short.haschCode(lineNum);
      return result;
    }
    

    因为这个方法返回的结果是一个简单、确定的计算结果,它的输入只是PhoneNumber实例中的三个关键字,因此相等的PhoneNumber实例显然都会有相等的散列码。实际上,对于PhoneNumber的hashCode实现而言,上面这个方法是非常合理的,相当于Java平台类库中的实现,也相当快捷,恰当的把不相等的电话号码分散到不同的散列桶中。

    虽然本条目中前面给出的hashCode实现方法能够获得相当好的散列函数,但它们并不是最先进的。它们的质量堪比Java平台类库的值类型中提供的散列函数,这些方法对于绝大多数应用程序而言已经足够了。如果执意想让散列函数尽可能地不会造成冲突,请参阅 Guava 框架的的com.google.common.hash.Hashing [Guava] 方法。

    Objects类有一个静态方法,它带有任意数量的对象,并为它们返回一个散列码。这个方法名为hash,是让你只需要编写一行代码的hashCode方法,与根据本条目前面介绍过的解决方案编写出来的相比,它的质量是与之相当的。遗憾的是,运行速度更慢一些。因为它们会引发数组的创建,以便传入数目可变的参数,如果参数中有基本类型,还需要装箱和拆箱。建议只将这类散列函数用于不太注重性能的情况。下面就是用这个方法为PhoneNumber编写的散列函数:

    @Override
    public int hashCode(){
      return Objects.hash(lineNum, prefix, areaCode);
    }
    

    如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果你觉得这种类型的大多数对象会被用作散列键,就应该在创建实例的时候计算散列码。否则,可以选择“延迟初始化散列码”,即一直到hashCode被第一次调用的时候才初始化(见第83条)。虽然我们的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;
    }
    

    不要试图从散列码计算中排除掉一个对象的关键域来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。特别是在实践中,散列函数可能面临大量得实例,在你选择忽略得区域之中,这些实例任然区别很大。如果是这样,散列函数就会把所有这些实例映射到极少数得散列码上,原本应该以线性时间运行得程序,将会以平方级得时间运行。

    这不只是一个理论问题,在Java2发行版本之前,一个String散列函数最多只能使用16个字符,若长度少于16个字符就计算所有的字符,否则就从第一个字符开始,在整个 字符串中间隔均匀的选取样本进行计算,对于像URL这种层次层次名称的大型集合,该散列函数正好表现出了这里所提到的病态行为。

    不要对hashCode方法的返回值做出具体的规定,因此客户端无法理所当然的依赖它;这样可以为修改提供灵活性。Java类库中的许多类,比如String和Integer,都可以把它们的hashCode方法返回的确切值规定为该实例值的一个函数。一般来说,这并不是个好主意,因为这样做严格地限制了在未来地版本中改进散列函数的能力。如果没有规定散列函数的细节,那么当你发现了它内部缺陷时,或者发现了更好的散列函数时,就可以在后面的发行版本中修正它。

    总而言之,每当覆盖equals方法是都必须覆盖hashCode,否则程序将无法正确运行,hashCode方法必须遵守Object规定的通用约定,并且必须完成一定的工作,将不相等的散列码分配给不相等的实例。这个很容易实现,但是如果不想那么费力,也可以使用前文建议的解决方法,如第十条所述,AutoValue框架提供了很好的替代方法,可以不必手工编写equals和hashCode方法,并且现在的集成开发环境IDE也提供了类似的部分功能。

    相关文章

      网友评论

          本文标题:第十一条:覆盖equals时总要覆盖hashCode

          本文链接:https://www.haomeiwen.com/subject/rbwltltx.html