11、数据类与密封类:data class的自动生成方法、copy()、解构声明、sealed class的使用场景

说实话,Kotlin 里我最常用的两个特性,就是数据类和密封类。一个帮我省掉了大量模板代码,另一个帮我写出更安全的逻辑分支。今天咱们就好好聊聊它们。

一、data class:你只管写属性,剩下的交给编译器

先说说数据类。它的核心价值就一句话:自动生成那些你懒得写、但又必须写的方法

你想想看,在 Java 里定义一个纯数据载体类,你得写多少东西?getter、setter、equals()、hashCode()、toString()…… 每次写都像在重复造轮子。Kotlin 的 data class 直接把这些全包了。

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

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

  • equals() / hashCode() —— 基于所有主构造属性
  • toString() —— 格式是 User(id=1, name=Alice, email=...)
  • componentN() —— 用于解构声明
  • copy() —— 基于所有属性的浅拷贝

注意:只有主构造函数里声明的属性才会参与这些自动生成。类体里的属性不会被考虑。

二、copy():不可变对象的“修改”利器

我个人习惯把 data class 的属性都声明为 val,也就是不可变的。这样对象一旦创建就不会变,线程安全、逻辑清晰。但问题来了——我想改其中一个字段怎么办?

这时候 copy() 就派上用场了。它返回一个新对象,只修改你指定的字段,其他保持不变。

val alice = User(1, "Alice", "alice@example.com")
val bob = alice.copy(name = "Bob", email = "bob@example.com")

println(alice)  // User(id=1, name=Alice, email=alice@example.com)
println(bob)    // User(id=1, name=Bob, email=bob@example.com)

我在项目中遇到过这样一个场景:用户修改个人资料时,后端只返回了部分字段。我用 copy() 把旧对象和新字段合并,几行代码就搞定了,不用手写合并逻辑。

小技巧copy() 是浅拷贝。如果属性是可变对象(比如 ArrayList),拷贝后两个对象会共享同一个引用。这点要小心。

三、解构声明:把对象拆开用

data class 自动生成了 component1()component2() 等方法,这就是解构声明的基础。

val user = User(1, "Alice", "alice@example.com")
val (id, name, email) = user

println("ID: $id, Name: $name, Email: $email")

解构的顺序就是主构造参数的声明顺序。你还可以用下划线 _ 跳过不需要的字段:

val (_, name, _) = user  // 只取 name

我记得有一次写 RecyclerView 的 Adapter,在 onBindViewHolder 里直接解构 data class,代码瞬间清爽了很多。你想想看,以前在 Java 里要写 user.getId()user.getName()…… 现在一行搞定。

避坑指南:我曾经在解构时搞错了字段顺序,导致数据错乱。后来我养成了一个习惯——解构时尽量用命名清晰的变量,或者干脆用 _ 跳过不用的字段,避免混淆。

四、sealed class:让分支逻辑更安全

密封类,说白了就是受限的继承体系。它告诉编译器:“我这个类的子类就这么多,没有别的了。”

为什么需要它?我们来看一个常见的场景:网络请求结果。

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

当你用 when 处理这个密封类时,编译器会强制你覆盖所有子类。少写一个分支?编译不通过。

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

我个人觉得,这是密封类最大的价值——把运行时错误变成编译时错误。你想想看,如果以后新增了一个 Result.Cancelled 子类,所有 when 表达式都会报编译错误,逼着你去处理新情况。这比运行时崩溃强太多了。

五、sealed class 的实际应用场景

除了网络请求结果,密封类还有很多用武之地:

  • UI 状态管理:比如页面有加载中、空数据、错误、正常显示等状态
  • 事件总线:定义有限的事件类型,避免用字符串或整数做区分
  • 状态机:订单状态、登录状态等有限状态集合
  • 多类型列表:RecyclerView 里不同类型的 item

我在项目中遇到过这样一个需求:一个页面有 5 种不同的 UI 状态。如果用枚举 + 数据类组合,代码会变得很别扭。用密封类就自然多了——每个状态可以携带自己的数据,而且类型安全。

核心区别:枚举的每个实例是单例,不能携带数据。密封类的子类可以是 data class,可以携带任意数据。所以密封类 = 枚举的受限性 + 数据类的灵活性。

六、知识体系总览

下面这张图帮你理清今天讲的内容:

数据类与密封类知识体系 data class 数据类 自动生成:equals() / hashCode() toString() / componentN() copy() 方法 基于所有属性的浅拷贝 解构声明 val (a, b, c) = obj ⚠️ 注意:只针对主构造属性 类体中的属性不参与自动生成 sealed class 密封类 受限的继承体系 子类必须在同一文件或嵌套内部 when 表达式穷举检查 编译器强制覆盖所有子类 常见应用场景 网络请求 / UI状态 / 事件总线 ⚠️ 与枚举的区别 密封类子类可携带数据 互补

七、总结

数据类和密封类,一个帮你省代码,一个帮你保安全。两者结合,能写出既简洁又健壮的 Kotlin 代码。

数据类的核心是自动生成——你只管定义属性,编译器帮你搞定 equals、hashCode、toString、copy 和解构。密封类的核心是受限继承——编译器知道所有子类,强制你穷举处理,把运行时错误扼杀在编译期。

嗯,这两个特性我每天都在用。希望你也用得上。

最后一个小建议:如果你的类只是用来装数据,就用 data class。如果你的逻辑分支是有限的、明确的,就用 sealed class。别滥用,也别不用。


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