Kotlin 中的数据类和 Copy

1. 概述

data class 是 Kotlin 的一个特性,使编写处理仅包含数据的类(DTO、model、value object)时比 Java 简洁得多。本文帮助你理解 Kotlin 为 data class 自动生成什么,如何使用 copy() 创建具有修改几个字段的副本,以及常见的陷阱,如错误的比较、在 data class 中使用 var、或继承 data class。

2. 什么是数据类?

当你用 data 关键字声明一个类时,Kotlin 编译器会根据主构造函数(primary constructor)中声明的属性自动生成一些函数。

data class Customer(
    val id: Long,
    val name: String,
    val email: String
)

仅用这 4 行,Kotlin 就自动生成了:

  • 基于 id、name、email 的 equals() / hashCode()。
  • 形式为 Customer(id=1, name=..., email=...) 的 toString()。
  • copy() 用来创建可以修改某些字段的副本。
  • componentN() 以支持解构声明。

与 Java 相比,你需要编写包含构造函数、getter、equals、hashCode、toString 的类 —— 大约 40-50 行代码才能实现相同的功能。

2.1. 默认的 toString()

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    println(customer)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)

自动生成的 toString() 在记录日志或调试时非常方便,你不需要手动编写它。

3. 按值的 equals() 和 hashCode()

data class 和普通类之间最大的区别是 equals() 按值比较(结构相等),而不是按引用比较。

fun main() {
    val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
    val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

    println(c1 == c2)   // 按值比较
    println(c1 === c2)  // 按引用比较
}
true
false
  • == 调用 equals(),两个具有相同字段的对象相等,即使它们的引用不同。
  • === 检查两个变量是否指向内存中的同一个对象。

注意: 对于普通类(不是 data class),== 默认像 === 一样比较引用,除非你自己重写 equals()。

因为 hashCode() 也是根据属性生成的,你可以安全地使用 data class 作为 HashMap 的键或 HashSet 中的元素。

fun main() {
    val set = hashSetOf(
        Customer(1, "An Nguyen", "an@nonladev.com"),
        Customer(1, "An Nguyen", "an@nonladev.com")
    )
    println(set.size)
}
1

两个值完全相同的对象被视为重复,HashSet 只保留 1 个元素。

4. copy() 函数 —— 创建不可变副本

如果你对属性使用 val,Customer 是不可变的 —— 创建后无法直接修改字段。为了“修改”一些值,Kotlin 生成 copy() 函数,允许创建新对象,保持未传递的字段不变。

fun main() {
    val original = Customer(1, "An Nguyen", "an@nonladev.com")
    val updated = original.copy(email = "an.new@nonladev.com")

    println(original)
    println(updated)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
Customer(id=1, name=An Nguyen, email=an.new@nonladev.com)
  • original 没有被改变 —— 这就是不可变性的本质。
  • updated 是新对象,仅 email 不同,其他字段原样复制。

这是在处理 UI 中的状态(Compose、MVI)或在业务逻辑中更新实体时非常常见的模式,而无需直接变动对象。

4.1. 带有命名参数的 copy()

你可以一次传递多个参数,使用命名参数(named arguments)来避免混淆顺序:

val promoted = original.copy(name = "An Nguyen (VIP)", email = "vip@nonladev.com")

如果不使用命名参数而是按位置顺序,当类有多个相同类型 String 的字段时,代码容易产生混淆。

5. 浅拷贝 —— 包含可变对象时的陷阱

copy() 只创建浅拷贝:它复制字段的引用,而不深拷贝(deep copy)其中的复杂对象。

data class Order(
    val id: Long,
    val items: MutableList<String>
)

fun main() {
    val order1 = Order(1, mutableListOf("Book"))
    val order2 = order1.copy()

    order2.items.add("Pen")

    println(order1.items) // 被影响了!
    println(order2.items)
}
[Book, Pen]
[Book, Pen]
  • order2 = order1.copy() 只创建新的 Order 对象,但字段 items 仍然指向同一个 MutableList。
  • 修改 order2.items 会导致 order1.items 也随之改变 —— 这是在 data class 中使用可变集合时非常容易犯的错误。

解决方法: 使用不可变的 List(toList()),或在构造函数中自己复制集合。

data class Order(
    val id: Long,
    val items: List<String>
)

fun main() {
    val order1 = Order(1, listOf("Book"))
    val order2 = order1.copy(items = order1.items + "Pen")

    println(order1.items)
    println(order2.items)
}
[Book]
[Book, Pen]

使用不可变的 List<String> 和 + 运算符创建新列表而不是变动它,保持 data class 的不可变性精神。

注意: 如果你需要真正的深拷贝(嵌套 data class 包含其他 data class),你必须自己编写递归复制逻辑,Kotlin 不会自动生成深拷贝。

6. 解构声明

得益于自动生成的 componentN(),你可以将一个 data class “解构”成多个单独的变量:

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    val (id, name, email) = customer

    println("$id - $name - $email")
}
1 - An Nguyen - an@nonladev.com

变量的顺序与主构造函数中属性的顺序相对应,而不是按名称。改变属性声明的顺序会导致解构错误 —— 重构时需要小心。

注意: 只应该对字段少的 data class(2-3 个)进行解构。对于字段多的类,直接使用属性名会使代码更易读。

7. 数据类的限制

7.1. 无法继承

data class 不允许继承另一个 data class(不能用 open 结合 data 的方式自动继承字段):

// 如果尝试这样做会出现编译错误:
// data class VipCustomer(val tier: String) : Customer(...)

Kotlin 只允许 data class 实现接口,或继承一个没有主构造函数属性的普通类(non-data)。原因是:当有多级字段继承时,自动生成的 equals()/copy() 很难正确定义。

常见的解决方案: 使用组合而不是继承。

data class VipCustomer(
    val customer: Customer,
    val tier: String
)

7.2. 主构造函数必须至少有 1 个属性

// 错误:data class 必须在主构造函数中至少有 1 个参数
// data class Empty

7.3. 只有主构造函数中的属性被计算

data class Product(val name: String) {
    var discount: Int = 0
}

fun main() {
    val p1 = Product("Laptop").apply { discount = 10 }
    val p2 = Product("Laptop").apply { discount = 20 }

    println(p1 == p2) // true,discount 不参与 equals()
}
true

discount 在类体中声明,不在主构造函数中,所以不会被计算在 equals()、hashCode()、toString()、copy() 中。这容易引起混淆 —— 如果字段对于比较很重要,必须将其放在主构造函数中。

8. 使用 JUnit 5 和 AssertJ 进行测试

所需的依赖项(Gradle Kotlin DSL):

testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.assertj:assertj-core:3.26.0")

编写测试以检查 copy() 和 equals() 的行为:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class CustomerTest {

    @Test
    fun `copy should keep unchanged fields and update specified ones`() {
        val original = Customer(1, "An Nguyen", "an@nonladev.com")

        val updated = original.copy(email = "an.new@nonladev.com")

        assertThat(updated.id).isEqualTo(original.id)
        assertThat(updated.name).isEqualTo(original.name)
        assertThat(updated.email).isEqualTo("an.new@nonladev.com")
        assertThat(original.email).isEqualTo("an@nonladev.com")
    }

    @Test
    fun `two customers with same fields should be equal`() {
        val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
        val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

        assertThat(c1).isEqualTo(c2)
        assertThat(c1).isNotSameAs(c2)
    }
}
  • isEqualTo 检查值相等,符合 data class 的 equals() 本质。
  • isNotSameAs 确认 c1 和 c2 是内存中的两个不同对象,虽然值相等。
  • 第一个测试确认 copy() 正确工作:未指定的字段保持不变,指定的字段被更新,原始对象不变。

9. 何时不应使用数据类

  • JPA/Hibernate Entity: equals()/hashCode() 根据所有字段(包括持久化前生成的 id 等字段)容易在 HashSet 中使用或比较已保存/未保存的实体时出错。应该根据 id 为实体手动编写 equals()/hashCode()。
  • 具有复杂业务逻辑的类: data class 用于数据容器,当类需要许多行为或多级继承时不合适。
  • 需要优化内存且有许多嵌套可变字段的类: 如果浅拷贝带来难以控制的风险,考虑使用 builder 模式或普通类。

10. 结论

data class 帮助你在处理 Kotlin 中的 DTO/model 时消除样板代码:自动生成 equals()、hashCode()、toString()、copy() 并支持解构。copy() 函数是保持不可变性的强大工具,但你需要记住它只进行浅拷贝 —— 要小心包含集合或可变对象的字段。避免将 data class 用于 JPA 实体或具有复杂业务逻辑的类,并始终将重要字段放在主构造函数中,以确保 equals()/copy() 按预期工作。

kotlindata-classcopyimmutabilitykotlin-basics