1. 概述

本文将深入探讨 Kotlin 数据类(data class)中 equals()hashCode() 方法的自动生成机制,并讲解如何覆盖或自定义其行为。

你可能已经知道,数据类是 Kotlin 中用于封装数据的核心特性之一。它会自动为类生成一些标准方法,包括 equals()hashCode()toString() 以及 copy() 等。但背后的实现原理和可定制性,往往在实际开发中被忽视,甚至导致踩坑 ❌。

掌握这些机制,不仅能帮你写出更可靠的业务逻辑,还能避免在集合操作(如 HashMapHashSet)中因哈希不一致引发的诡异问题 ✅。


2. Kotlin 中的 equals() 与 hashCode()

在 Java 和 Kotlin 中,equals()hashCode() 是对象比较和哈希集合存储的基础。它们必须遵循一个关键契约:

如果两个对象通过 equals() 判定相等,则它们的 hashCode() 必须返回相同值。

Kotlin 的数据类会在编译时自动为所有主构造函数中的属性生成符合规范的 equals()hashCode() 实现。这是 Kotlin “简洁但不简单” 设计哲学的典型体现。

来看一个标准的数据类定义:

data class Person(val name: String, val age: Int, val address: String)

这个类虽然只写了三行代码,但编译后生成的字节码对应到 Java 类时,会包含完整的 equals()hashCode() 实现。

我们可以通过反编译查看其 Java 形态(IntelliJ IDEA 内置反编译器即可完成,无需额外工具):

public class Person {
    // 构造函数、字段、getter 省略

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age &&
                Objects.equals(name, person.name) &&
                Objects.equals(address, person.address);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age, address);
    }
}

✅ 关键点总结:

  • equals() 使用了经典的四步判断:引用相等 → null 检查 → 类型检查 → 字段逐一对比。
  • hashCode() 基于所有主构造函数中的属性计算哈希值,确保“相等对象拥有相同哈希码”。

⚠️ 注意:只有出现在主构造函数参数列表中的属性才会参与 equals()hashCode() 的生成。


3. 覆盖 equals() 与 hashCode()

如果你希望改变默认的比较逻辑(比如只根据 name 判断是否相等),可以直接重写这两个方法。

例如,定义一个仅通过姓名判断唯一性的 PersonWithUniqueName

data class PersonWithUniqueName(val name: String, val age: Int, val address: String) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || javaClass != other.javaClass) return false
        val otherPerson: PersonWithUniqueName = other as PersonWithUniqueName
        return otherPerson.name == name
    }

    override fun hashCode(): Int {
        return Objects.hash(name)
    }
}

✅ 效果说明:

  • 即使 ageaddress 不同,只要 name 相同,两个对象就被视为相等。
  • 编译后的 Java 代码中,hashCode() 也只会基于 name 计算。

这种做法灵活且明确,适合需要精确控制语义的场景。

❌ 踩坑提醒:
手动重写时务必保证 equals()hashCode() 的一致性!否则放入 HashMapHashSet 会导致查找失败或内存泄漏。


4. 不重写方法的情况下排除字段

有时候我们想让某个字段不参与 equals()hashCode(),但又不想手动重写方法。Kotlin 提供了一种巧妙的方式:将字段移出主构造函数,放到类体中

示例:我们希望 address 不参与比较,但仍保留在对象中:

data class PersonWithUniqueNameAndAge(val name: String, val age: Int) {
    lateinit var address: String
}

📌 核心技巧:

  • address 不再是主构造函数参数,因此不会被编译器纳入 equals()hashCode() 的生成范围。
  • 使用 lateinit var 表示该字段可变且延迟初始化。

反编译后的 Java 代码验证这一点:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    PersonWithUniqueNameAndAge that = (PersonWithUniqueNameAndAge) o;
    return age == that.age && Objects.equals(name, that.name);
}

@Override
public int hashCode() {
    return Objects.hash(name, age);
}

✅ 优点:无需手动维护 equals()hashCode(),减少出错概率。
⚠️ 缺点:引入了可变状态(var),破坏了数据类应有的不可变性(immutability),在并发或多线程环境下容易出问题。

📌 建议使用场景:

  • 临时缓存字段
  • 序列化/反序列化所需的辅助字段
  • 明确需要后期赋值且不影响业务逻辑的场景

否则,还是推荐通过重写方法来控制行为,保持数据类的纯净性和可预测性。


5. 测试验证

理论讲完,必须靠测试来验证行为是否符合预期。以下是针对不同情况的单元测试用例。

5.1 默认实现测试

class PersonUnitTest {
    @Test
    fun `equal when fields are equal`() {
        val person1 = Person("John", 18, "Address")
        val person2 = Person("John", 18, "Address")
        assertEquals(person1, person2)
    }

    @Test
    fun `not equal when fields are different`() {
        val person1 = Person("John", 18, "Address")
        val person2 = Person("John", 18, "Another Address")
        assertNotEquals(person1, person2)
    }
}

✅ 验证结果:两个测试均通过,说明默认实现严格按照所有字段进行比较。


5.2 自定义实现测试

测试仅按名称比较的类:

class PersonWithUniqueNameTest {
    @Test
    fun `equal when name field is equal`() {
        val person1 = PersonWithUniqueName("John", 18, "Address")
        val person2 = PersonWithUniqueName("John", 19, "Another Address")
        assertEquals(person1, person2)
    }
}

✅ 结果:即使年龄和地址不同,只要名字相同就判定为相等。

测试类体中字段被排除的情况:

class PersonWithUniqueNameAndAgeTest {
    @Test
    fun `equal when name and age fields are equal`() {
        val person1 = PersonWithUniqueNameAndAge("John", 18)
        person1.address = "Address"
        val person2 = PersonWithUniqueNameAndAge("John", 18)
        person2.address = "Another Address"

        assertEquals(person1, person2)
    }

    @Test
    fun `not equal when name and age fields are not equal`() {
        val person1 = PersonWithUniqueNameAndAge("John", 18)
        person1.address = "Address"
        val person2 = PersonWithUniqueNameAndAge("John", 19)
        person2.address = "Address"

        assertNotEquals(person1, person2)
    }
}

✅ 结果:

  • 第一个测试通过:尽管 address 不同,但未参与比较。
  • 第二个测试通过:age 不同导致整体不等。

📌 关键细节:由于 address 不在主构造函数中,我们必须显式赋值,这也是代码可读性下降的一个信号 —— 容易让人误以为它是核心属性。


6. 总结

本文系统解析了 Kotlin 数据类中 equals()hashCode() 的生成规则及定制方式,重点包括:

默认行为:主构造函数中的所有属性自动参与 equals()hashCode() 生成。
手动重写:适用于需要特定比较逻辑的场景,灵活性高,但需注意契约一致性。
字段排除技巧:通过将字段移入类体并声明为 lateinit var,可避免其参与比较,但牺牲了不可变性,慎用。
测试保障:任何自定义行为都应有对应测试覆盖,防止未来重构引入 bug。

📌 最佳实践建议:

  • 若需修改比较逻辑,优先考虑重写方法而非移动字段。
  • 尽量保持数据类的不可变性,避免使用 var
  • 在使用 HashMapSet 等集合前,确认对象的 equals()hashCode() 行为符合预期。

理解这些底层机制,才能真正驾驭 Kotlin 数据类,而不是被它的“语法糖”牵着走 🍬。


原始标题:equals() and hashCode() Generator in Kotlin