美文网首页Java拾遗基础知识
Java拾遗:001 - 重写 equals 和 hashCod

Java拾遗:001 - 重写 equals 和 hashCod

作者: ed72fd6aaa3c | 来源:发表于2018-08-02 13:17 被阅读35次

    重写equals方法

    在Java中Object类是一个具体类,但它设计的主要目的是为了扩展,所以它的所有非final方法,都被设计成可覆盖(override)的。但任何一个子类在覆盖这些方法时都应遵守一些通用约定,否则就会在使用中引起各种问题。

    equals方法定义于Object类中,用于比较两个对象是否相等,说起比较相等我们也常用==符号来比较,但两者有什么区别呢?

    equals方法与==的区别

    一般来说==用于比较基本类型值是否相等,如:int、float等,或者用于比较对象引用地址是否相同(两个引用指向同一对象),而equals方法则由程序员自己来实现(JDK源码里的类是由JDK的开发者实现的,同样也是程序员自己实现的)来比较两个对象是否相等(强调一下,这里说的是相等而非相同)的。后者包含前者,即:使用==比较相同的对象equals方法一定返回true。

    什么时候需要重写equals方法?

    通常我们需要在代码中实现判断一个对象是否等于另外一个对象,或者需要将对象加入集合时,会需要使用equals方法来提供判断逻辑(集合中添加元素时会使用contains方法来判断添加对象是否已存在于集合中,内部调用的判断方法即为equals方法)。

    equals方法的等价关系

    重写equals方法看似很简单,但很许多方式会导致错误,并且造成严重后果,所以Java规范对对重写equals方法定义了一些约定(非强制,但应尽量遵守),即:equals方法需要实现等价关系(equivalence relation)。

    • 自反性(reflexive),对于任何非null的引用值x,x.equals(x)必须返回true。
    • 对称性(symmetric),对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
    • 传递性(transitive),对于任何非null的引用值x、y和z,如果x.equals(y)返回true且y.equals(z)也返回true,那么x.equals(z)也必须返回true。
    • 对于任何非null的引用值x,x.equals(null)必须返回false。
      你当然可以无视这些约定,但当你发现你的程序表现不正常或者未达到预期的时候,你可以会很难找到失败的根源(出自《Effective Java》)。

    重写equals方法的最佳实践

    如果说上面的条目还不是很具体的话,下面通过一些示例来阐述上面的条目。
    首先我们有一个Employee类和Manager类,包含几个域对象(属性)

    public class Employee {
    
        private String name;
        private Double salary;
        private Date joinDate;
    
        // getter / setter ...
    }
    
    public class Manager extends Employee {
    
        private Double bonus;
    
        // getter / setter ...
    }
    

    如果我们不重写equals方法

        @Test
        public void equals_1() {
    
            // 有两个Employee对象,我们假定如果姓名与薪资相等即认为两个对象相等
            Employee x = new Employee();
            x.setName("Jane");
            x.setSalary(3500.0);
            Employee y = new Employee();
            y.setName("Jane");
            y.setSalary(3500.0);
    
            // 此时我们没有重写equals方法,此时使用的equals方法由Object提供,只简单比较两个对象是否相同
            assertTrue(x.equals(x));
            assertFalse(x.equals(y));
        }
    

    会看到对象x.equals(x)返回true而x.equals(y)返回false,而根据假定条件应返回true,所以Object里的equals方法显然不够用,我们需要自定义equals方法。
    而在实现自定义equals方法时,第一条约定自反性,这一条很难无意识地违反这一条(如果违反了,你在向集合中添加元素时就会重复添加),但通常我们还是实现该约定,这通常是一种性能优化的方式(如果两个比较对象是同一个对象,就返回true,后面的比较逻辑就省略了)。

        @Override
        public boolean equals(Object obj) {
            // 这里使用==显示判断比较对象是否是同一对象
            if (this == obj) {
                return true;
            }
            // 对于任何非null的引用值x,x.equals(null)必须返回false
            if (obj == null) {
                return false;
            }
            // TODO 核心域比较
            return false;
        }
    

    注意@Override注解,重写方法时务必加上该注解,IDE会帮我们检查是否是重写父类方法,否则可能实现的是重载方法(改变了方法签名),导致后面运行出错而找不到问题的原因。

    上面实现了自反性,下面继续实现对称性

        @Override
        public boolean equals(Object obj) {
            // 这里使用==显示判断比较对象是否是同一对象
            if (this == obj) {
                return true;
            }
            // 对于任何非null的引用值x,x.equals(null)必须返回false
            if (obj == null) {
                return false;
            }
            // 通过 instanceof 判断比较对象类型是否合法
            if (!(obj instanceof Employee)) {
                return false;
            }
            // 对象类型强制转换,如果核心域比较相等,则返回true,否则返回false
            // 强制类型转换前,必须使用instanceof判断,避免代码抛出ClassCastException异常
            Employee other = (Employee) obj;
            return (this.name == other.name || (this.name != null && this.name.equals(other.name)))
                    && (this.salary == other.salary || (this.salary != null && this.salary.equals(other.salary)));
        }
    

    测试代码证明equals方法实现了对称性

        @Test
        public void equals_2() {
    
            Employee x = new Employee();
            x.setName("Jane");
            x.setSalary(3500.0);
            Manager y = new Manager();
            y.setName("Jane");
            y.setSalary(3500.0);
    
            assertTrue(x.equals(y));
            assertTrue(y.equals(x));
    
        }
    

    但在使用instanceof的时候需要注意,如果所有子类拥有统一的语义时使用instanceof 检查,如果要求比较目标类必须与当前类为同一类,可以使用this.getClass() == obj.getClass()来比较。

    使用JDK7提供的工具类优化代码

    我们在写equals方法时,经常需要判断属性值是否为空,非空时才比较目标对象的相同属性值是否相等,而在JDK8中提供了Objects的工具类,可以帮我们简化这部分代码

        @Override
        public boolean equals(Object obj) {
            // 这里使用==显示判断比较对象是否是同一对象
            if (this == obj) {
                return true;
            }
            // 对于任何非null的引用值x,x.equals(null)必须返回false
            if (obj == null) {
                return false;
            }
            // 通过 instanceof 判断比较对象类型是否合法
            if (!(obj instanceof Employee)) {
                return false;
            }
            // 对象类型强制转换,如果核心域比较相等,则返回true,否则返回false
            Employee other = (Employee) obj;
            // 如果两者相等,返回true(含两者皆空的情形),否则比较两者值是否相等
            return Objects.equals(this.name, other.name)
                    && Objects.equals(this.salary, other.salary);
        }
    

    另外该类还提供了深度比较的方法deepEquals,对于属性为引用类型比较使用。

    重写hashCode方法

    通常来说,覆写equals方法时必须要覆写hashCode方法,但这是为什么呢?

    HashCode(散列码)是什么?

    首先来说一下HashCode是什么,HashCode中文翻译为哈希码或散列码,由哈希算法,将对象映射为一个整型数值。在Java中一般用于HashMap、HashSet、HashTable集合类中。

    为什么重写equals方法同时需要重写hashCode方法?

    上面说到HashMap等哈希类型集合对类,由于HashMap的底层存储结构为数组结构,每个元素又是一个链表,而数组的下标即为HashCode,所以相同HashCode的对象会被存放在同个链表中。所以如果重写equals方法而不重写hashCode方法时,就会导致将两个相等的对象(equals判断相等)加入HashMap时,因为返回不同的HashCode而分在了不同的哈希桶中,造成重复添加元素(同一个哈希桶会通过equals方法判断是否重复)。

        @Test
        public void hashCode_1() {
    
            Employee x = new Employee();
            x.setName("Jane");
            x.setSalary(3500.0);
            Employee y = new Employee();
            y.setName("Jane");
            y.setSalary(3500.0);
    
            // HashSet底层由HashMap实现
            HashSet<Employee> sets = new HashSet<>();
            sets.add(x);
            sets.add(y);
            assertEquals(2, sets.size());
    
        }
    

    上述测试代码证明了这一点,预期添加两个相等对象,集合中应只有一个元素才对。

    怎样编写一个好的hashCode方法?

    相等的对象必须具有相等的HashCode,但反过来却不一定,因为存在哈希碰撞,通俗地说就是不同对象(也不相等),可能生成的HashCode是相同的,而发生哈希碰撞的几率则是由哈希算法决定的。一般来说发生哈希碰撞几率越大,性能就越差,所以一个好的hashCode方法因尽可能的减少哈希碰撞的几率。

    业界并没有最佳的哈希码生成算法(没有最好,只有最合适),这里参考《Core Java》和《Effective Java》给出一个参考实现

        @Override
        public int hashCode() {
            int r = 17;
            r = 31 * r + this.name.hashCode();
            r = 31 * r + this.salary.hashCode();
            return r;
        }
    

    使用JDK7中提供的工具类优化

    同样Objects类也提供了hashCode的工具方法,底层代码使用了Arrays类的hashCode生成方法

    @Override
    public int hashCode() {
        return Objects.hash(this.name, this.salary);
    }
    

    下面是Arrays类的hashCode方法代码

        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;
        }
    

    String类中的hashCode方法

        public int hashCode() {
            int h = hash;
            if (h == 0 && value.length > 0) {
                char val[] = value;
    
                for (int i = 0; i < value.length; i++) {
                    h = 31 * h + val[i];
                }
                hash = h;
            }
            return h;
        }
    

    JDK中在编写hashCode方法时,大量使用了31这个魔法数字,据《Effective Java》描述该数字有一个很好的特性:用移位和减法代替乘法,可以得到更好的性能31 * i == (i << 5) - i

    散列码的性能优化

    通常不建议会被修改的属性参与HashCode计算(实际难以避免),因为这会引起HashCode的变化,对于已加入HashMap的对象,不会重新分配存储位置,而导致一些问题。

    对于一些比较复杂的对象,其HashCode的计算是一件非常消耗资源的事,一个简单的办法就是对其HashCode进行缓存,比如在类中添加一个属性,记录该HashCode,HashCode可以在类初始化时生成,也可以在第一次调用hashCode方法时生成,这要视具体应用而定。但前提条件是参与计算HashCode的属性值不能修改。

    结语

    有很多约定不是强制的,但实际开发过程中却应尽量遵循,这些“最佳实践”会减少很多代码中潜在的Bug,或者提升代码性能。

    参考资料

    • 《Core Java》
    • 《Effective Java》
    • 《编写高质量代码:改善Java程序的151个建议》

    相关文章

      网友评论

        本文标题:Java拾遗:001 - 重写 equals 和 hashCod

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