为什么需要重写 equals 方法?
我们要理解 Object 类中 equals 方法的默认行为。
Object 类中的 equals 方法实现如下:
public boolean equals(Object obj) {
return (this == obj);
}
它的作用是比较两个对象的内存地址(引用)是否相同,也就是说,只有当两个变量指向同一个对象时,它们才被认为是“相等”的。
在大多数业务场景中,我们更关心的是(状态)是否相同,而不是它们是否是同一个对象。
经典例子:String 类
String 类是重写 equals 方法的最典型例子,我们希望:
String s1 = new String("hello");
String s2 = new String("hello");
// 虽然 s1 和 s2 是两个不同的对象(内存地址不同)
// 但它们的内容都是 "hello",所以我们希望它们是相等的
System.out.println(s1.equals(s2)); // 输出 true
// 如果使用默认的 equals,结果会是 false
System.out.println(s1 == s2); // 输出 false
当我们创建的类需要基于其成员变量的值来判断逻辑相等性时,就必须重写 equals 方法。
如何正确重写 equals 方法?(黄金法则)
为了确保重写的 equals 方法既健壮又符合规范,我们需要遵循以下几个步骤,这是一个被广泛认可的“黄金法则”。
假设我们要重写 Person 类的 equals 方法,Person 类有两个字段:name (String) 和 age (int)。
public class Person {
private String name;
private int age;
// 构造函数、getters 和 setters...
}
步骤 1:检查“引用相等”(自反性)
检查传入的参数是否就是当前对象本身,如果是,直接返回 true,这是为了性能和自反性(x.equals(x) 必须为 true)。
@Override
public boolean equals(Object obj) {
// 1. 检查是否是同一个对象
if (this == obj) {
return true;
}
// ... 后续步骤
}
步骤 2:检查“参数是否为 null”和“类型是否匹配”
检查传入的参数是否为 null,如果不是 null,再检查传入的对象是否是当前类的实例,如果不是,直接返回 false,这是为了处理 ClassCastException 并满足“非空性”和“对称性”。
注意:在 Java 5 之后,推荐使用 instanceof 操作符,因为它可以正确处理泛型,并且避免了显式的类型转换。
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
// 2. 检查 obj 是否为 null 或类型是否匹配
if (obj == null || getClass() != obj.getClass()) {
return false;
}
// ... 后续步骤
}
步骤 3:进行类型转换
在通过了类型检查后,我们可以安全地将 Object 类型的参数转换为我们自己类的类型。
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
// 3. 将 obj 转换为当前类类型
Person other = (Person) obj;
// ... 后续步骤
}
步骤 4:比较“关键字段”
这是最核心的一步,比较当前对象的成员变量和传入对象的成员变量是否相等。
- 对于基本数据类型(如
int,double,boolean),直接使用 比较。 - 对于引用数据类型(如
String,List, 自定义对象),必须调用其equals方法进行比较。 - 对于数组,可以使用
Arrays.equals()方法。
最佳实践:将 null 安全的比较放在最后,这样可以避免 NullPointerException。
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
Person other = (Person) obj;
// 4. 比较关键字段
// 先比较可能为 null 的字段 name
if (age != other.age) {
return false;
}
// 使用 Objects.equals 来安全地比较可能为 null 的对象
// return Objects.equals(this.name, other.name); // 推荐方式
if (name == null) {
return other.name == null;
} else {
return name.equals(other.name);
}
}
Objects.equals() 是一个非常有用的工具方法,它内部已经处理了 null 的情况,可以简化代码,推荐使用它。
equals 方法的约定(非常重要)
Java 官方文档(Object 类的 equals 方法文档)规定,一个合格的 equals 方法必须满足以下五个特性:
-
自反性:对于任何非
null的引用值x,x.equals(x)必须返回true。- 我们的实现通过
if (this == obj)满足了这一点。
- 我们的实现通过
-
对称性:对于任何非
null的引用值x和y,y.equals(x)和x.equals(y)必须返回相同的布尔值。- 我们的实现通过
getClass() != obj.getClass()保证了这一点。x是Person,y是Student,它们永远不相等,满足对称性。 - 反例:
B类的equals方法可以和A类的对象比较,A.equals(B)和B.equals(A)的结果可能不同,破坏了对称性。
- 我们的实现通过
-
传递性:对于任何非
null的引用值x,y,z,x.equals(y)返回true,y.equals(z)返回true,x.equals(z)也必须返回true。我们的实现通过比较相同的关键字来满足这一点。
-
一致性:对于任何非
null的引用值x和y,只要x和y所包含的信息没有被修改,多次调用x.equals(y)必须一致地返回true或false。我们的实现不依赖任何外部状态(如系统时间),只依赖对象自身的字段,因此满足一致性。
-
非空性:对于任何非
null的引用值x,x.equals(null)必须返回false。- 我们的实现通过
if (obj == null ...)满足了这一点。
- 我们的实现通过
完整的 Person 类示例
import java.util.Objects;
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// Getters and Setters...
/**
* 重写 equals 方法
*/
@Override
public boolean equals(Object o) {
// 1. 自反性检查
if (this == o) return true;
// 2. null 和类型检查
if (o == null || getClass() != o.getClass()) return false;
// 3. 类型转换
Person person = (Person) o;
// 4. 比较关键字段
// 先比较基本类型,再比较引用类型
// 使用 Objects.equals 安全地处理可能为 null 的引用类型
return age == person.age &&
Objects.equals(name, person.name);
}
// 注意:重写 equals 后,强烈建议也重写 hashCode()!
@Override
public int hashCode() {
return Objects.hash(name, age);
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
equals 和 hashCode 的“契约”
这是一个极其重要的知识点:如果你重写了 equals 方法,那么你必须同时重写 hashCode 方法。
为什么?
hashCode 方法返回一个对象的哈希码(一个整数),它被广泛用于 HashMap, HashSet, Hashtable 等哈希集合中。
契约规定:
如果两个对象根据
equals()方法是相等的,那么调用这两个对象的hashCode()方法必须产生相同的整数结果。
违反契约的后果:
假设 Person 类只重写了 equals,而没有重写 hashCode。
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
// p1.equals(p2) 返回 true
System.out.println(p1.equals(p2)); // true
// 因为它们没有重写 hashCode,所以使用 Object 的 hashCode,
// 返回的是对象的内存地址,p1.hashCode() != p2.hashCode()
// System.out.println(p1.hashCode() == p2.hashCode()); // false
// 这会导致在 HashSet 或 HashMap 中的问题
HashSet<Person> set = new HashSet<>();
set.add(p1);
System.out.println("Set contains p2? " + set.contains(p2)); // 输出 false!
解释:
set.add(p1)时,HashSet会计算p1的哈希码,找到对应的“桶”,然后调用p1.equals()来检查桶中是否已存在相同的元素。set.contains(p2)时,HashSet会计算p2的哈希码,由于p2.hashCode()不等于p1.hashCode(),HashSet会在一个完全不同的“桶”里查找,自然找不到p2,即使它们在逻辑上是相等的。
重写 equals 而不重写 hashCode 会让你的对象在哈希集合中行为异常。
如何重写 hashCode?
最简单、最推荐的方式是使用 Objects.hash() 方法,它接收可变参数,并为你生成一个高质量的哈希码。
@Override
public int hashCode() {
return Objects.hash(name, age);
}
这个方法内部会处理 null 值,并且将多个字段的哈希码组合起来,能很好地满足哈希分布的要求。
- 何时重写:当类的逻辑相等性需要基于其成员变量的值来判断时(如
String,Integer,Date以及自定义的实体类)。 - 重写步骤:
- 检查引用相等 (
this == obj)。 - 检查参数是否为
null或类型不匹配 (obj == null || getClass() != obj.getClass())。 - 进行类型转换。
- 比较关键字段,对
null安全的比较要特别小心(推荐使用Objects.equals())。
- 检查引用相等 (
- 必须重写
hashCode:一旦重写了equals,就必须重写hashCode,以维护“相等对象必须有相同哈希码”的契约。 - IDE 辅助:现代 IDE(如 IntelliJ IDEA 和 Eclipse)可以一键生成
equals和hashCode方法,并且生成的代码是规范和高效的,强烈建议使用它们,但理解其背后的原理仍然至关重要。
