11、数据类与密封类:数据类的定义与使用、copy函数、解构声明、密封类的概念与使用

说实话,数据类和密封类这两个东西,是我在Kotlin项目里用得最频繁的特性之一。尤其是数据类,几乎每个实体类我都会考虑用它。今天我们就来好好聊聊它们。

数据类:不只是POJO那么简单

先说说数据类。在Java时代,我们写一个数据模型类,得手动生成getter、setter、toString、equals、hashCode……那叫一个繁琐。Kotlin的数据类,说白了就是帮我们把这一堆样板代码全干了。

定义一个数据类,只需要在class前面加个data关键字:

data class User(
    val id: Long,
    val name: String,
    val email: String,
    val age: Int = 0
)

就这么一行,编译器自动帮我们生成了:

  • equals() / hashCode() —— 基于主构造函数的所有属性
  • toString() —— 格式是"User(id=1, name=张三, ...)"
  • componentN() —— 按声明顺序生成,用于解构
  • copy() —— 这个后面重点讲
小提示:数据类的主构造函数至少得有一个参数。而且所有参数最好都用val或var声明,否则自动生成的方法不会包含它们。

copy函数:不可变对象的“修改”利器

我个人习惯把数据类的属性都声明为val,也就是不可变的。这样在多线程环境下特别安全。但问题来了——我想改某个字段怎么办?

这时候copy()函数就派上用场了。它不会修改原对象,而是创建一个新的对象,只替换你指定的字段:

val user1 = User(1, "张三", "zhangsan@example.com", 25)
val user2 = user1.copy(age = 26, name = "张三丰")

println(user1) // User(id=1, name=张三, email=zhangsan@example.com, age=25)
println(user2) // User(id=1, name=张三丰, email=zhangsan@example.com, age=26)

嗯,这里要注意:copy()是浅拷贝。如果属性里包含引用类型(比如List、自定义对象),拷贝的是引用,不是深层复制。我在项目中遇到过这个问题——一个数据类里有个MutableList,用copy之后两个对象共享同一个列表,改一个另一个也跟着变。后来我改成用不可变列表List才解决。

避坑指南:我曾经在团队代码里看到有人直接用copy()修改集合字段,结果引发了诡异的并发问题。记住:数据类的属性尽量用不可变类型,配合copy()使用才是最佳实践。

解构声明:把对象拆开用

解构声明是Kotlin一个很酷的特性。它允许你把一个对象拆成多个变量:

val (id, name, email, age) = user1
println("$id - $name - $email - $age")

为什么能这么写?因为数据类自动生成了component1()component2()……这些函数。解构声明其实就是按顺序调用它们。

你想想看,这个特性在遍历集合时特别爽:

val users = listOf(
    User(1, "张三", "a@b.com", 20),
    User(2, "李四", "c@d.com", 22)
)

for ((id, name) in users) {
    println("用户$id: $name")
}

只取前两个字段,后面的忽略掉。简洁又直观。

注意:解构声明的变量顺序必须和主构造函数的属性声明顺序一致。如果你只想要第1个和第3个属性,可以用下划线占位:val (id, _, email) = user

密封类:受限的类层次结构

密封类这个概念,我第一次接触时觉得有点抽象。说白了,它就是一种受限的继承结构——一个密封类的子类只能是有限的几种,而且必须在同一个文件里定义。

为什么需要它?举个例子。假设我们要表示网络请求的状态:

sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val message: String, val code: Int = -1) : NetworkResult<Nothing>()
    object Loading : NetworkResult<Nothing>()
}

这里NetworkResult只有三种可能:成功、失败、加载中。用密封类的好处是,当你用when表达式处理时,编译器能帮你检查是否覆盖了所有分支:

fun handleResult(result: NetworkResult<String>) {
    when (result) {
        is NetworkResult.Success -> println("数据: ${result.data}")
        is NetworkResult.Error -> println("错误: ${result.message}")
        is NetworkResult.Loading -> println("加载中...")
        // 不需要else分支,因为所有情况都覆盖了
    }
}

如果以后新增了一个子类,编译器会直接报错,提醒你更新when分支。这比用枚举或者抽象类安全多了。

我的经验:我在项目里用密封类封装过UI状态、网络请求结果、导航事件等场景。每次都能在编译期发现遗漏的分支处理,省了不少调试时间。

知识体系总览

下面这张图帮你梳理本章的核心内容:

数据类 & 密封类 数据类 (data class) 自动生成方法 copy() 函数 解构声明 密封类 (sealed class) 有限子类 when 穷举检查 同一文件定义 应用场景:UI状态管理 · 网络请求封装 · 导航事件 · 数据模型

数据类 vs 普通类:一张表说清楚

特性 数据类 普通类
自动生成toString/equals/hashCode ✅ 是 ❌ 需要手动实现
自动生成copy() ✅ 是 ❌ 需要手动实现
解构声明支持 ✅ 自动支持 ❌ 需手动实现componentN()
继承其他类 ❌ 不能继承(但可实现接口) ✅ 可以
主构造函数参数要求 至少一个参数 无要求

密封类与枚举类的区别

很多人会问:密封类和枚举类有什么区别?我简单说说:

  • 枚举类:每个枚举常量是单例,不能携带不同的数据。适合表示状态本身。
  • 密封类:每个子类可以有不同的属性和行为。适合表示带数据的状态。

举个例子,如果只是表示“成功/失败”两种状态,用枚举就够了。但如果成功时要携带数据,失败时要携带错误信息,那就得用密封类。

核心要点:数据类帮你省掉样板代码,密封类帮你构建安全的类型层次。两者结合使用,能写出既简洁又健壮的Kotlin代码。

好了,这一章的内容就到这里。数据类和密封类在实际项目中用得非常多,建议你多写几个例子感受一下。尤其是copy()和解构声明,用习惯了真的回不去。


公众号:蓝海资料掘金营,微信deep3321