Java拾遗:001 - 重写 equals 和 hashCode 方法

重写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个建议》

转载于:https://my.oschina.net/zhanglikun/blog/1921429

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值