杰瑞科技汇

Java equals重写时需注意哪些关键点?

为什么需要重写 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 方法必须满足以下五个特性:

  1. 自反性:对于任何非 null 的引用值 xx.equals(x) 必须返回 true

    • 我们的实现通过 if (this == obj) 满足了这一点。
  2. 对称性:对于任何非 null 的引用值 xyy.equals(x)x.equals(y) 必须返回相同的布尔值。

    • 我们的实现通过 getClass() != obj.getClass() 保证了这一点。xPersonyStudent,它们永远不相等,满足对称性。
    • 反例B 类的 equals 方法可以和 A 类的对象比较,A.equals(B)B.equals(A) 的结果可能不同,破坏了对称性。
  3. 传递性:对于任何非 null 的引用值 x, y, zx.equals(y) 返回 truey.equals(z) 返回 truex.equals(z) 也必须返回 true

    我们的实现通过比较相同的关键字来满足这一点。

  4. 一致性:对于任何非 null 的引用值 xy,只要 xy 所包含的信息没有被修改,多次调用 x.equals(y) 必须一致地返回 truefalse

    我们的实现不依赖任何外部状态(如系统时间),只依赖对象自身的字段,因此满足一致性。

  5. 非空性:对于任何非 null 的引用值 xx.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 +
                '}';
    }
}

equalshashCode 的“契约”

这是一个极其重要的知识点:如果你重写了 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!

解释

  1. set.add(p1) 时,HashSet 会计算 p1 的哈希码,找到对应的“桶”,然后调用 p1.equals() 来检查桶中是否已存在相同的元素。
  2. 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 值,并且将多个字段的哈希码组合起来,能很好地满足哈希分布的要求。

  1. 何时重写:当类的逻辑相等性需要基于其成员变量的值来判断时(如 String, Integer, Date 以及自定义的实体类)。
  2. 重写步骤
    • 检查引用相等 (this == obj)。
    • 检查参数是否为 null 或类型不匹配 (obj == null || getClass() != obj.getClass())。
    • 进行类型转换。
    • 比较关键字段,对 null 安全的比较要特别小心(推荐使用 Objects.equals())。
  3. 必须重写 hashCode:一旦重写了 equals,就必须重写 hashCode,以维护“相等对象必须有相同哈希码”的契约。
  4. IDE 辅助:现代 IDE(如 IntelliJ IDEA 和 Eclipse)可以一键生成 equalshashCode 方法,并且生成的代码是规范和高效的,强烈建议使用它们,但理解其背后的原理仍然至关重要。
分享:
扫描分享到社交APP
上一篇
下一篇