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