Kotlin 数据类与密封类详解
在 Java 里,写一个"纯数据"的类要手写一堆 getter/setter/equals/hashCode/toString,写一个"有限取值"的类型又得靠枚举或抽象类加 if-else。Kotlin 为这两类高频场景提供了专门语法:数据类(data class)专治"持有数据"的样板代码,密封类(sealed class)专治"受限分类"的穷尽匹配。本文把两者的语法、约束、典型用法与组合讲透。
参考文档:Kotlin 官方文档 · Data classes。
1、数据类与密封类各解决什么
先建立全局认知,二者服务于不同场景:
// 数据类(data class)—— 专治"纯数据载体"的样板代码
data class User(val name: String, val age: Int)
// 密封类(sealed class)—— 专治"有限的分类/状态"的穷尽匹配
sealed class UiState {
object Loading : UiState()
data class Success(val data: String) : UiState()
data class Error(val msg: String) : UiState()
}- 数据类:自动生成 equals/hashCode/toString/copy/componentN,省去手写样板
- 密封类:限定子类范围,配合 when 实现"穷尽检查",新增分支编译期即报错
- 二者常组合:密封类的子类,本身用数据类来表达——一个管"有哪些情况",一个管"每种情况带什么数据"
2、数据类:自动生成的样板代码
给类加上 data 修饰符,编译器就会根据主构造函数的属性,自动生成一系列实用方法。一行声明,顶 Java 几十行模板:
data class User(val name: String, val age: Int)
fun main() {
val u1 = User("Alice", 30)
val u2 = User("Alice", 30)
println(u1) // 自动生成的 toString:User(name=Alice, age=30)
println(u1 == u2) // 自动生成的 equals:true(按属性比较)
println(u1.hashCode()) // 自动生成,相等对象哈希也相等
}自动生成的东西有五个:equals() / hashCode() / toString() / copy() / componentN()。前三个好理解(基于属性的比较、哈希、字符串),后两个是数据类的"杀手锏"——copy 用于"改一个字段其余照抄",componentN 用于解构,下一节细讲。本质属性相同的两个数据类实例,equals 返回 true,这让它天然适合做 Map 的 key、集合去重。
3、数据类的约束条件
享受自动生成的前提,是满足一组硬约束,否则编译报错:
// 约束1:主构造函数至少要有 1 个参数
// data class Empty() // 错误:无参数
// 约束2:所有主构造参数必须用 val 或 var 声明(不能是裸参数)
// data class Bad(name: String) // 错误:name 未声明为属性
data class Good(val name: String) // 正确
// 约束3:不能是 abstract / open / sealed / inner
// abstract data class X(val v: Int) // 错误
// 补充:数据类可继承其他类、可实现接口(Kotlin 1.1+)这些约束的出发点是"语义清晰":一个"纯数据"类,当然每个属性都应是可见的(val/var)、不可被继承篡改语义(非 open/sealed)。如果需求超出这些约束(比如要可继承、要可变状态机),那它就不该用 data class,而该用普通 class 或 sealed class。
4、copy() 与解构声明
copy() 是数据类最实用的能力之一:基于现有实例"复制一份,只改指定字段",其余属性原样保留。它做的是浅拷贝,配合 val 属性,是"不可变数据 + 局部修改"范式的核心:
data class User(val name: String, val age: Int)
val jack = User("Jack", 1)
val olderJack = jack.copy(age = 2) // 只改 age,name 仍是 Jack
println(olderJack) // User(name=Jack, age=2)解构声明依赖自动生成的 componentN():把对象的属性"拆"成独立变量,语法干净利落:
val jane = User("Jane", 35)
val (name, age) = jane // 等价于 jane.component1() / jane.component2()
println("$name, $age years") // Jane, 35 years
// 解构在 for 循环里尤其好用(遍历 Map)
for ((key, value) in someMap) { /* ... */ }Kotlin 还内置了标准数据类 Pair(两元组)和 Triple(三元组),适合临时凑两个/三个值一起返回,省得专门定义类:
fun divide(a: Int, b: Int): Pair<Int, Int> = Pair(a / b, a % b)
val (quotient, remainder) = divide(17, 5) // (3, 2)5、密封类:受限的类层次
密封类用 sealed 修饰,表示一个"受限的类层次"——它的所有子类都必须在可知的范围内(同一文件,Kotlin 1.5+ 放宽到同一包/模块)。这让编译器掌握了"完整子类清单",从而做穷尽检查。它常用来表达"有限的几种情况":表达式、状态、结果、事件。
sealed class Expr
// 子类可以是数据类、object、普通类,都继承自密封类
data class Const(val number: Double) : Expr()
data class Sum(val e1: Expr, val e2: Expr) : Expr()
object NotANumber : Expr()注意几个特性:密封类自身是隐式抽象的(不能直接 new 它),但它不必写 abstract;子类必须继承自它(写在同文件里);子类的形态很自由——可以是 data class(带数据)、object(单例形态)、甚至普通 class。这让它既能表达"有几种情况",又能为每种情况挂上不同数据。
6、密封类配合 when 的穷尽检查
密封类最大的价值,在于和 when 配合时的"穷尽检查(exhaustive)":因为编译器知道全部子类,when 不写 else 也算完整;而且将来新增一个子类,所有没覆盖它的 when 会立即编译报错,强迫你逐一补全——这是用类型系统保证"不漏分支":
fun eval(expr: Expr): Double = when (expr) {
is Const -> expr.number // 用 is 判断类型,智能转换后直接访问
is Sum -> eval(expr.e1) + eval(expr.e2)
NotANumber -> Double.NaN // object 子类可直接匹配(无需 is)
// 无需 else —— 编译器确认已覆盖所有子类
}
// 假如以后新增:data class Mul(val e1: Expr, val e2: Expr) : Expr()
// 上面这个 when 会立刻编译报错,提醒你补上 Mul 分支这比"枚举 + if/else"或"抽象类 + 忘了写分支"安全得多:前者只能携带固定数据、后者靠人肉保证不漏。密封类把"分支完整性"从运行期隐患变成了编译期错误,是 Kotlin 类型安全的招牌特性之一。
7、密封类的约束与 sealed interface
密封类有几条约束需要记牢。第一,子类的位置:早期版本要求所有子类与密封类在同一文件;Kotlin 1.5 起放宽到"同一包/模块"(且可跨文件),但仍必须在编译期可知。第二,构造函数可见性:密封类本身不能直接实例化,其构造默认是 protected(1.1+),不能声明为 public。
// 同一文件内,子类可放在不同位置(1.5+ 甚至可跨文件、跨模块边界受限)
sealed class Shape {
data class Circle(val radius: Double) : Shape()
data class Square(val side: Double) : Shape()
}
// Kotlin 1.5+ 支持 sealed interface(枚举多层、多实现更灵活)
sealed interface Error
object NetworkError : Error
data class HttpError(val code: Int) : Errorsealed interface(1.5+)补上了"接口侧的穷尽":一个类可以实现多个 sealed interface,而密封类只能单继承,所以需要"多维度受限分类"时,sealed interface 更合适。
8、实战场景与最佳实践
- 数据类:DTO/模型/网络响应/数据库实体——凡是"主要是数据"的类,默认用 data class
- 不可变优先:属性用 val,需要修改时用 copy 生成新实例,避免共享可变状态
- 密封类:UI 状态(Loading/Success/Error)、操作结果(Result)、意图/事件、表达式 AST、状态机
- 经典组合:用 sealed class 定义"有哪几种情况",每种情况的子类用 data class 携带数据
- 配合 when 一定要用"穷尽"写法(不写 else),让"新增子类"自动触发编译期补全
- 一个判断口诀:"有限分类、要穷尽"用 sealed;"主要是数据、要比较/拷贝"用 data
9、总结
Kotlin 用 data class 与 sealed class 两把"专用刀",分别砍掉了"数据类的样板代码"和"受限分类的漏分支":数据类自动生成 equals/hashCode/toString/copy/componentN,配上 copy 与解构,让"不可变数据 + 局部修改"写得极简;密封类限定子类范围,配合 when 实现"穷尽检查",把分支完整性从运行期隐患变成编译期保证。
关键要点:
- data class 自动生成五件套;约束:主构造至少一参、参数必 val/var、非 abstract/open/sealed/inner
- copy() 浅拷贝、可改字段;componentN() 支持解构声明;标准库有 Pair/Triple
- sealed class 限定子类范围(同文件 / 1.5+ 同模块),自身隐式 abstract、构造 protected
- when 配密封类可穷尽、无需 else,新增子类编译期报错;1.5+ 还有 sealed interface
对于写出地道 Kotlin 的开发者而言,把"持有数据用 data、受限分类用 sealed、二者常组合"内化为肌肉记忆,能同时收获"代码更短、bug 更少、重构更安全"三重好处。下次遇到一堆字段要建模、或一段分情况逻辑要落地,先想想该用 data class 还是 sealed class——一旦用顺手,你会很难再回到 Java 那套手写样板与人肉穷举的日子。
学习资料:

