15.Kotlin 类:类的形态(二):数据类 (Data Class)

73 阅读8分钟

希望帮你在Kotlin进阶路上少走弯路,在技术上稳步提升。当然,由于个人知识储备有限,笔记中难免存在疏漏或表述不当的地方,也非常欢迎大家提出宝贵意见,一起交流进步。 —— Android_小雨

整体目录:Kotlin 进阶不迷路:41 个核心知识点,构建完整知识体系

一、前言

在 Java 开发时代,创建一个简单的 POJO(Plain Old Java Object)或 JavaBean 往往令人感到繁琐。你需要定义私有字段,然后生成 Getter/Setter,重写 equals()hashCode()toString()。虽然 IDE 可以自动生成这些代码,但它们依然让类文件变得臃肿,且一旦修改字段,维护成本极高。

1.1 数据类的核心定位

Kotlin 引入 数据类 (Data Class) 的核心目的非常纯粹:专注数据存储。它是对“值对象 (Value Object)”概念的完美语言级支持。在架构设计中,它通常扮演 DTO (Data Transfer Object)、VO (View Object) 或 Entity 的角色,只负责承载数据,不负责复杂的业务逻辑。

1.2 Kotlin 数据类的优势

使用 data 关键字修饰类后,Kotlin 编译器会在后台默默为你承担所有繁重的工作。它自动根据主构造函数中的属性生成通用的实用方法,确保代码的简洁性(减少样板代码)与安全性(标准化的比较逻辑)。

1.3 本文核心内容预告

本文将从以下四个维度带你彻底掌握数据类:

  1. 定义规则:语法硬性约束及其背后的原因。
  2. 自动魔法:解密编译器生成的隐藏方法(copycomponentN 等)。
  3. 实战用法:解构声明与进阶技巧。
  4. 避坑指南:属性声明位置引发的逻辑陷阱与继承限制。

二、数据类基础:定义与语法要求

2.1 基本语法

定义一个数据类非常简单,只需在 class 关键字前加上 data 修饰符:

// 推荐:属性定义为 val (只读),保证线程安全和数据不可变性
data class User(val name: String, val age: Int)

2.2 必须满足的规则 (编译器硬性约束)

为了让编译器能够正确生成代码,数据类必须遵循以下严格规则,违反任何一条都会导致编译报错:

  1. 主构造函数至少包含 1 个参数
    • 原理详解:如果没有参数,生成 equals/toString/componentN 就没有数据依据,也就失去了作为“数据容器”的意义。
  2. 主构造函数的所有参数必须标记为 valvar
    • 原理详解:普通类构造函数的参数如果不加 val/var,只是构造函数的入参,不会自动成为类的成员属性。数据类生成的 toString 等方法需要访问类的属性,因此必须强制将其声明为属性。
  3. 数据类不能是 abstract (抽象)、open (开放)、sealed (密封) 或 inner (内部) 类
    • 原理详解
      • 不能 open:数据类生成的 equals 方法会严格检查类的类型。如果允许继承,子类可能会破坏父类的等值性判断逻辑(例如子类增加了字段,但父类的 equals 无法感知)。
      • 不能 inner:内部类持有外部类的引用,这会使自动生成的 toStringequals 逻辑变得异常复杂且容易造成内存泄漏。

2.3 简单示例

// 1. 网络请求响应实体 (DTO)
data class ApiResponse(
    val code: Int,
    val message: String,
    val data: String? // 允许为空
)

// 2. 商品模型 (带默认值,极大简化调用)
// 提示:给所有参数提供默认值,编译器会自动生成一个无参构造函数,利于 Gson/Jackson 反射
data class Product(
    val id: Long,
    val name: String,
    val price: Double = 0.0
)

三、数据类的 "自动魔法":默认生成的方法

一旦使用了 data 关键字,Kotlin 编译器会自动根据主构造函数中声明的属性生成以下 5 类方法。

3.1 equals() & hashCode():内容相等的基石

  • equals()
    • 机制:编译器会生成代码,依次比较主构造函数中所有属性的值。
    • 区别:普通类的 == 比较的是引用(内存地址),数据类的 == 比较的是内容
  • hashCode()
    • 机制:基于主构造函数中的属性计算哈希值。
    • 价值:保证了将数据类对象放入 HashMapHashSet 时,能根据内容正确去重或查找。

3.2 toString():可视化的调试信息

  • 机制:生成格式为 ClassName(field1=value1, field2=value2) 的字符串。
  • 对比:普通类的 toString() 打印的是 ClassName@Hashcode(如 User@4554617c),对调试毫无帮助。数据类则清晰展示所有数据,极大方便了日志排查。

3.3 componentN():解构声明的幕后推手

  • 机制:编译器为每个属性按声明顺序生成对应的 component1(), component2()... 方法。
  • 对应关系:主构造函数的第 1 个参数对应 component1(),第 2 个对应 component2(),以此类推。
  • 用途:这是 Kotlin 实现“解构赋值”的底层原理(详见第四章)。

3.4 copy():不可变性的最佳拍档

  • 机制:生成一个 copy 方法,允许你创建一个新对象,同时指定修改某些属性,而保持其他属性不变。
  • 核心细节(面试考点 - 浅拷贝)
    • copy 执行的是 浅拷贝 (Shallow Copy)
    • 如果是基本数据类型(Int, String 等),值被复制,互不影响。
    • 如果是引用类型(如 MutableList, User),复制的是引用地址。修改新对象中 List 的内容,旧对象的 List 内容也会变!

3.5 示例验证

data class Book(val title: String, val price: Int)

fun main() {
    val b1 = Book("Kotlin Guide", 50)
    val b2 = Book("Kotlin Guide", 50)

    // 1. equals 验证:内容相同即为 true
    println(b1 == b2) // true

    // 2. toString 验证
    println(b1) // Book(title=Kotlin Guide, price=50)

    // 3. copy 验证
    val b3 = b1.copy(price = 60) // 只改价格,书名不变
    println(b3) // Book(title=Kotlin Guide, price=60)
}

四、数据类的进阶用法

4.1 自定义生成方法

编译器生成的方法并非不可更改。如果你觉得默认实现不符合需求,可以手动重写,编译器会使用你的实现,跳过自动生成。

  • 场景:为了安全,重写 toString 屏蔽敏感信息(如密码)。
data class Account(val id: Long, val password: String) {
    // 手动重写 toString,覆盖默认实现
    override fun toString(): String {
        return "Account(id=$id, password=******)"
    }
}

4.2 补充非主构造属性 (类体属性)

这是数据类逻辑中最重要的细节! 只有定义在主构造函数中的属性,才会参与 equals, hashCode, toStringcopy 的生成。定义在类体 {} 中的属性会被完全忽略。

data class Person(val name: String) {
    var age: Int = 0 // 定义在类体中
}

fun main() {
    val p1 = Person("Jack"); p1.age = 10
    val p2 = Person("Jack"); p2.age = 20

    // 输出 true!因为 equals 只比较 name,无视了 age 的差异。
    println(p1 == p2)

    // 输出 Person(name=Jack)。toString 中也没有 age。
    println(p1)
}

4.3 继承与接口实现

  • 继承:数据类可以继承普通类、抽象类或实现接口。
    • 注意:如果继承的父类重写了 equals 且为 final,数据类将不会重新生成 equals,这可能导致比较逻辑不一致。
  • 被继承:数据类本身是 final 的,不可被其他类继承

4.4 解构赋值实战

解构声明让代码极其优雅,特别是处理键值对或列表时。

data class Coordinate(val x: Int, val y: Int)

fun main() {
    val point = Coordinate(10, 20)

    // 本质是调用 point.component1() 和 point.component2()
    val (xAxis, yAxis) = point
    println("X: $xAxis, Y: $yAxis")

    // 在 Map 遍历中的应用 (Map.Entry 扩展了解构函数)
    val map = mapOf("key" to 1, "value" to 2)
    for ((k, v) in map) {
        println("$k -> $v")
    }
}

五、数据类的典型应用场景

5.1 网络请求/响应实体 (DTO)

Retrofit 等网络库解析 JSON 的首选载体。

data class UserDto(
    val id: String,
    val name: String,
    val avatarUrl: String?
)

5.2 数据库实体 (Entity)

Room 或其他 ORM 框架的表结构映射。

@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?
)

5.3 本地数据封装 (UI State)

在 MVVM/MVI 架构中,UI 状态通常是一个不可变的数据类。

data class UiState(
    val isLoading: Boolean = false,
    val data: List<String> = emptyList(),
    val error: String? = null
)
// 更新状态时使用 copy,触发流式响应
// _state.value = currentState.copy(isLoading = true)

5.4 函数返回多个值

当函数需要返回多个结果,但又不想使用 PairTriple 这种语义模糊(first, second)的类时,自定义数据类是最佳选择。

六、使用注意事项与避坑点

6.1 与普通类的核心区别

  • 普通类 (class):侧重于行为、逻辑封装和继承体系。equals 默认比较引用。
  • 数据类 (data class):侧重于数据存储。牺牲了被继承的能力,换取了自动化的数据处理方法。

6.2 主构造属性的重要性 (再次强调)

一定要把所有关键的、用于判断“对象相等性”的数据放在主构造函数中。如果放在类体里,它们就是“二等公民”,会被 equals()copy() 无视,导致业务逻辑出现严重 Bug。

6.3 数组类型的陷阱

如果在数据类中使用 Array 类型:

  • 问题Arrayequals 方法比较的是引用,而不是数组内容。
  • 后果:两个内容相同的数组,在数据类中会被判定为不相等。
  • 解决:重写 equalshashCode,使用 java.util.Arrays.equals 或 Kotlin 的 contentEquals或者,尽量使用 List 代替 Array

6.4 序列化框架的无参构造函数

许多 Java 序列化框架(如 Gson, Jackson)需要无参构造函数来通过反射实例化对象。

  • 技巧:给主构造函数的所有参数都提供默认值,Kotlin 编译器就会自动生成一个无参构造函数。

七、总结与最佳实践

7.1 核心知识点回顾

  • 关键字data class
  • 核心规则:主构造参数必须为 val/var
  • 自动生成equals(内容比较)、hashCodetoStringcopy(浅拷贝)、componentN(解构)。

7.2 最佳实践

  1. 优先使用 val:让数据类不可变(Immutable),配合 copy() 修改数据,线程更安全。
  2. 保持纯粹:尽量不要在数据类中编写复杂的业务逻辑代码。
  3. 命名规范:通常以 Dto, Entity, Model, Item, State 等后缀结尾,表明其身份。

7.3 选型建议

在定义类时,问自己一个问题:“这个类的主要目的是为了保存数据吗?” 如果是,请毫不犹豫地使用 Data Class。它是 Kotlin 赋予开发者的效率神器。