重写equals与hashCode详解
本文讲解了重写equals()方法以实现对象内容的逻辑相等性,并强调必须同时重写hashCode()。文章提供了遵循五个协定(自反、对称、传递、一致、非空)的最佳实践模板,确保与哈希集合兼容。
为什么需要重写 equals()?
在 Java 中,所有类都隐式或显式地继承自 java.lang.Object 类。Object 类提供了一个基本的 equals() 方法,其默认实现是:
public boolean equals(Object obj) {
return (this == obj);
}
这表示,默认的 equals() 方法比较的是两个对象的内存地址,即判断它们是否为同一个对象的引用。这被称为“引用相等性”(Reference Equality)。
然而,在很多业务场景下,我们关心的是两个对象的内容或状态是否相同,而不是它们是否指向同一块内存。例如,我们认为 new String("hello") 和另一个 new String("hello") 是相等的。这种基于内容的比较被称为“逻辑相等性”(Logical Equality)。
因此,当你希望自定义类的相等性判断标准时,就需要重写 equals() 方法。
equals() 方法的重写协定(The Contract)
Java 官方文档为 equals() 方法规定了五个必须遵守的通用协定。如果违反这些协定,可能会导致程序的行为变得不可预测,尤其是在使用集合类(如 HashMap, HashSet)时。
这五个协定是:
自反性 (Reflexive)
- 规则:对于任何非
null的引用值x,x.equals(x)必须返回true。 - 解释:一个对象必须等于它自己。
- 规则:对于任何非
对称性 (Symmetric)
- 规则:对于任何非
null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)才必须返回true。 - 解释:如果
x等于y,那么y也必须等于x。
- 规则:对于任何非
传递性 (Transitive)
- 规则:对于任何非
null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)返回true,那么x.equals(z)必须返回true。 - 解释:如果
x等于y,y等于z,那么x必须等于z。
- 规则:对于任何非
一致性 (Consistent)
- 规则:对于任何非
null的引用值x和y,只要equals比较操作中使用的信息没有被修改,那么多次调用x.equals(y)的结果应该保持一致(要么一直返回true,要么一直返回false)。 - 解释:只要对象的状态不变,
equals()的结果就不应该改变。
- 规则:对于任何非
非空性 (Non-nullity)
- 规则:对于任何非
null的引用值x,x.equals(null)必须返回false。 - 解释:任何对象都不等于
null。
- 规则:对于任何非
hashCode() 的重要规则
这是一条与 equals() 紧密相关的、至关重要的规则:
如果你重写了 equals() 方法,那么你必须重写 hashCode() 方法。
为什么?
hashCode() 用于为对象生成一个整数“哈希码”,这个值主要用于基于哈希的集合,如 HashMap, HashSet, Hashtable。这些集合使用哈希码来确定对象的存储位置,以实现快速查找。
hashCode() 的协定要求:
- 如果两个对象根据
equals()方法是相等的,那么它们的hashCode()方法必须返回相同(相等)的整数。 - 如果两个对象根据
equals()方法是不相等的,它们的hashCode()方法不要求必须返回不同的整数。但是,为不相等的对象生成不同的哈希码可以提高哈希表的性能。
违反此规则的后果:
如果你只重写了 equals() 而没有重写 hashCode(),那么当你把对象放入 HashSet 或作为键放入 HashMap 时,就会出现问题。
例如,你创建了两个内容相同的 Person 对象 p1 和 p2。
p1.equals(p2)返回true。- 但由于没有重写
hashCode(),它们会使用Object类的默认实现,该实现通常基于内存地址,因此p1.hashCode()和p2.hashCode()很可能不同。
当你执行 set.add(p1),然后 set.contains(p2) 时,HashSet 会先计算 p2 的哈希码来确定查找的“桶”(bucket),由于哈希码不同,它很可能找不到 p1 所在的桶,从而直接返回 false,尽管 p1 和 p2 在逻辑上是相等的。
重写 equals() 的最佳实践步骤
这是一个经过验证的、可以安全实现 equals() 方法的模板。
import java.util.Objects;
public class Person {
private String name;
private int age;
// constructor, getters, setters...
// 最佳实践步骤
@Override
public boolean equals(Object obj) {
// 1. 使用 == 检查参数是否为这个对象的引用(性能优化)
if (this == obj) {
return true;
}
// 2. 检查参数是否为 null,以及类型是否匹配
// 使用 getClass() 而不是 instanceof 可以确保比较的对象是完全相同的类,
// 避免了子类可能带来的对称性问题。
if (obj == null || getClass() != obj.getClass()) {
return false;
}
// 3. 将参数转换为正确的类型
Person person = (Person) obj;
// 4. 对所有“关键”字段进行比较
// - 基本类型使用 ==
// - 对象类型使用 Objects.equals()(它能优雅地处理 null)
return age == person.age &&
Objects.equals(name, person.name);
}
}
步骤分解:
@Override注解:强烈建议使用。它可以帮助编译器检查你是否正确地重写了方法(例如,参数类型是否为Object)。- 引用检查
(this == obj):这是一个简单的性能优化。如果两个引用指向同一个对象,它们必然是相等的。 null和类型检查:obj == null:符合非空性协定。getClass() != obj.getClass():确保比较的两个对象属于同一个类。使用instanceof也可以,但在处理继承时可能破坏对称性,因此getClass()通常是更安全的选择。
- 类型转换:在通过了类型检查后,可以安全地将
Object类型的参数强制转换为你的类类型。 - 字段比较:这是核心逻辑。
- 对于
int,double,boolean等基本数据类型,直接使用==比较。 - 对于对象类型(如
String,Date或其他自定义类),必须使用Objects.equals(a, b)。这个静态方法是 Java 7 引入的,它会自动处理其中一个或两个对象为null的情况,非常安全和方便。
- 对于
配套的 hashCode() 实现
与上面的 Person.equals() 方法配套的 hashCode() 实现如下:
@Override
public int hashCode() {
// 使用 Objects.hash() 工具方法来为所有关键字段生成一个哈希码
// 它能处理 null 值,并且实现方式高效、标准。
return Objects.hash(name, age);
}
Objects.hash(Object... values) 是 Java 7 引入的便捷方法,它会根据你传入的字段计算出一个合适的哈希码。你应该将 equals() 方法中用到的所有字段都传给它。
现代 Java 的简化方式:Record (Java 14+)
如果你使用的是 Java 14 或更高版本,并且你的类主要用作不可变的数据载体,那么可以使用 Record 来极大简化这个过程。
// 使用 record,编译器会自动为你生成正确的 equals(), hashCode(), 和 toString() 方法
public record PersonRecord(String name, int age) {}
这短短一行代码等同于前面手动编写的、包含构造函数、getter、equals() 和 hashCode() 的整个 Person 类。编译器生成的实现完全符合所有协定,是现代 Java 的首选方案。
总结 Checklist
在重写 equals() 时,请检查以下几点:
- 是否遵守了五个协定? (自反、对称、传递、一致、非空)
- 是否也重写了
hashCode()? -
equals方法的参数类型是否为Object? - 是否使用了
@Override注解? - 是否正确处理了
null? - 是否正确比较了基本类型和对象类型? (推荐使用
Objects.equals()) - 是否可以考虑使用
Record来代替?