data class 与 sealed class 的编译原理
从"样板代码地狱"到"编译器代劳"
在前一篇文章中,我们看到 Kotlin 编译器如何将简洁的类声明转换为完整的 JVM 字节码——自动生成构造函数、getter/setter、backing field。但这只是开始。当你需要一个纯粹用来"承载数据"的类时,Java 要求你手动编写 equals()、hashCode()、toString()、clone() 方法——每一个方法都有严格的契约要求,而手写这些方法几乎必然会在某一天引入 bug。
Kotlin 的 data class 用一个关键字彻底消灭了这类样板代码。而 sealed class / sealed interface 则解决了另一个根本性问题——在有限的类型层级中,如何让编译器帮你保证"没有遗漏任何一种情况"。
本文将深入这两个机制的编译产物,让你清楚地理解:编译器到底为你生成了什么代码,这些代码遵循什么契约,以及在哪些场景下你会踩到陷阱。
data class 的魔法:编译器自动生成了什么
一个关键字触发的代码生成
当你在类声明前加上 data 关键字时:
data class User(val name: String, val age: Int)
编译器会基于主构造函数中声明的属性,自动生成以下五个成员:
equals()—— 结构化相等判断hashCode()—— 与equals()配套的哈希值计算toString()—— 格式化的属性输出copy()—— 带命名参数的"修改式复制"componentN()—— 支持解构声明的取值函数
这不是简单的语法糖——编译器为每个方法都生成了遵循 JVM 契约的完整实现。让我们逐一拆解。
equals() 与 hashCode():基于主构造函数参数的生成规则
equals() 的生成逻辑
编译器生成的 equals() 严格遵循 Object.equals() 的通用契约(自反性、对称性、传递性、一致性),其检查步骤如下:
// data class User(val name: String, val age: Int) 编译后的 equals()
public boolean equals(Object other) {
// ① 引用相等——同一个对象,直接返回 true
if (this == other) return true;
// ② 类型检查——不是同一个类,直接返回 false
if (!(other instanceof User)) return false;
User otherUser = (User) other;
// ③ 逐一比较主构造函数中的每个属性
if (!Intrinsics.areEqual(this.name, otherUser.name)) return false;
if (this.age != otherUser.age) return false;
return true;
}
几个关键细节值得注意:
- 只比较主构造函数中的属性。定义在类体内的属性不参与
equals()比较 - 使用
Intrinsics.areEqual()而非直接==,因为前者能安全处理null值(等效于a?.equals(b) ?: (b === null)) - 基本类型(如
Int)直接用!=比较,避免装箱开销
这就像身份证上的信息——两张身份证"相等"只看证件号码和关键字段,不看证件的物理位置或持有者手上的其他物品。主构造函数参数就是"证件上的字段",类体内的属性就是"持有者手上的其他物品"。
hashCode() 的生成逻辑
hashCode() 的实现遵循经典的"31 乘子累积"算法——这与 java.lang.String.hashCode() 使用的策略完全一致:
// 编译后的 hashCode()
public int hashCode() {
int result = this.name != null ? this.name.hashCode() : 0;
result = 31 * result + Integer.hashCode(this.age);
return result;
}
为什么选择 31 作为乘子?
这不是随机选择,而是经过精心考量的工程决策:
| 考量维度 | 31 的优势 |
|---|---|
| 分布性 | 31 是一个奇素数,能让哈希值在哈希表中分布更均匀,减少碰撞 |
| 性能 | JVM 的 JIT 编译器可以将 31 * x 优化为 (x << 5) - x——用一次位移和一次减法替代乘法指令 |
| 历史验证 | 这是 Joshua Bloch 在 《Effective Java》中推荐的标准做法,已被 Java 生态验证超过 20 年 |
数组属性的陷阱
如果你的 data class 包含数组属性,编译器生成的 equals() 和 hashCode() 会使用引用比较,而不是内容比较:
data class Matrix(val values: IntArray)
val a = Matrix(intArrayOf(1, 2, 3))
val b = Matrix(intArrayOf(1, 2, 3))
println(a == b) // false!——因为 IntArray.equals() 是引用比较
println(a.hashCode() == b.hashCode()) // 大概率 false
这是因为 JVM 中数组的 equals() 继承自 Object,只进行引用比较。如果你需要内容比较,必须手动覆写 equals() 和 hashCode(),在其中使用 contentEquals() 和 contentHashCode()。
toString():格式化输出的实现
编译器生成的 toString() 以"类名(属性名=值, ...)"的格式输出所有主构造函数属性:
// 编译后的 toString()
public String toString() {
return "User(name=" + this.name + ", age=" + this.age + ")";
}
这看似简单,但有一个实际的工程价值——日志可读性。与 Java 中默认的 User@1a2b3c 相比,User(name=Alice, age=30) 在调试日志中一眼就能看出对象状态。
同样,类体内声明的属性不出现在 toString() 输出中:
data class User(val name: String, val age: Int) {
var loginCount: Int = 0 // 不会出现在 toString() 中
}
println(User("Alice", 30)) // 输出:User(name=Alice, age=30)
// loginCount 被"隐藏"了
copy():浅拷贝的陷阱
生成机制
copy() 方法允许你创建一个对象的副本,同时有选择地修改某些属性。编译器为它生成的实现本质上就是"调用构造函数":
// 编译后的 copy()(简化后)
public final User copy(String name, int age) {
Intrinsics.checkNotNullParameter(name, "name");
return new User(name, age); // 直接调用构造函数
}
// 带默认值的合成方法——和构造函数默认参数一样的位掩码机制
public static User copy$default(User instance, String name, int age, int mask, Object marker) {
if ((mask & 0x1) != 0) name = instance.name; // 未指定则沿用原值
if ((mask & 0x2) != 0) age = instance.age; // 未指定则沿用原值
return instance.copy(name, age);
}
使用时非常直观:
val alice = User("Alice", 30)
val olderAlice = alice.copy(age = 31)
// 等价于 new User("Alice", 31)
浅拷贝的致命陷阱
copy() 执行的是浅拷贝(Shallow Copy)——它复制的是属性的"值",而对于引用类型,"值"就是引用本身(即内存地址),而非引用指向的对象。
把
copy()想象成复印一张名片。名片上写着"保险箱密码:A123"。复印后,你手里有两张名片,但它们指向的是同一个保险箱。一个人根据密码打开保险箱拿走东西,另一个人再去开,发现东西已经不见了。
用代码来演示这个陷阱:
data class Team(val name: String, val members: MutableList<String>)
val teamA = Team("Alpha", mutableListOf("Alice", "Bob"))
val teamB = teamA.copy(name = "Beta")
// teamB 和 teamA 共享同一个 MutableList 实例!
teamB.members.add("Charlie")
println(teamA.members) // [Alice, Bob, Charlie] ← 原对象被"意外"修改了!
println(teamB.members) // [Alice, Bob, Charlie]
从字节码角度看,原因很清晰——copy() 就是 new Team("Beta", this.members),它把原来的 MutableList 引用直接传给了新对象。
如何避免浅拷贝问题
最佳实践:使用不可变集合
// ✅ 推荐:用 List 替代 MutableList
data class Team(val name: String, val members: List<String>)
val teamA = Team("Alpha", listOf("Alice", "Bob"))
val teamB = teamA.copy(name = "Beta")
// teamB.members.add("Charlie") // ❌ 编译错误——List 没有 add 方法
如果必须使用可变对象:手动深拷贝
data class Team(val name: String, val members: MutableList<String>) {
// 手动实现的深拷贝方法
fun deepCopy(): Team = Team(name, members.toMutableList())
}
val teamA = Team("Alpha", mutableListOf("Alice", "Bob"))
val teamB = teamA.deepCopy()
teamB.members.add("Charlie")
println(teamA.members) // [Alice, Bob] ← 原对象安全
println(teamB.members) // [Alice, Bob, Charlie]
componentN() 函数:解构声明的底层机制
生成规则
编译器为主构造函数中的每个属性按声明顺序生成 component1()、component2() ... componentN() 函数:
// data class User(val name: String, val age: Int) 编译后
public final String component1() { return this.name; }
public final int component2() { return this.age; }
这些函数是 Kotlin **解构声明(Destructuring Declaration)**的底层支撑:
val user = User("Alice", 30)
// 解构声明
val (name, age) = user
// 编译后等价于:
val name = user.component1() // "Alice"
val age = user.component2() // 30
位置陷阱:解构靠顺序,不靠名字
解构声明是基于位置的,不是基于名字的。这意味着变量名和属性名不匹配时,编译器不会报错,但你会得到错误的值:
data class User(val name: String, val email: String)
val user = User("Alice", "alice@example.com")
// ⚠️ 变量名写反了,但编译器不会警告!
val (email, name) = user
println(email) // "Alice" ← 这其实是 name!
println(name) // "alice@example.com" ← 这其实是 email!
编译器只看位置:email 被绑定到 component1()(即 name 属性),name 被绑定到 component2()(即 email 属性)。
这就像邮局的信箱——取信的人只看信箱编号(第 1 格、第 2 格),不看信箱上贴的名字。你把自己的名字贴在第 2 格上,取出的依然是第 2 格里的信。
componentN() 在 for 循环和 map 中的应用
解构声明不仅限于局部变量,它在集合操作中特别强大:
// 遍历 Map 时解构 Map.Entry
val scores = mapOf("Alice" to 95, "Bob" to 87)
for ((name, score) in scores) {
println("$name: $score")
}
// 在 lambda 中解构
val users = listOf(User("Alice", "alice@mail.com"), User("Bob", "bob@mail.com"))
users.forEach { (name, email) ->
println("$name 的邮箱是 $email")
}
这些语法糖的底层都是 componentN() 函数调用。
data class 的限制与约束
data class 有一系列编译器强制的限制,每一条都有其设计理由:
| 限制 | 技术原因 |
|---|---|
| 主构造函数至少一个参数 | 没有属性就没有可生成 equals/hashCode 的依据 |
所有主构造函数参数必须是 val 或 var |
只有属性才能参与 componentN() 和 copy() |
不能是 abstract、open、sealed、inner |
保证编译器生成代码的正确性(下面详述) |
为什么 data class 不能被继承
这是理解 data class 设计最核心的问题。考虑如果 data class 允许被继承会发生什么:
// ⚠️ 假设 data class 允许 open(实际不允许)
open data class Person(val name: String)
data class Student(val name: String, val grade: Int) : Person(name)
val p: Person = Student("Alice", 5)
val q: Person = Person("Alice")
// p.equals(q) 返回什么?
这是经典的对称性破坏问题:
Person.equals()只比较name,它认为p == qStudent.equals()比较name和grade,它认为p != q- 这违反了
equals()契约的对称性:如果a == b,则b == a必须成立
Kotlin 设计团队从根源上切断了这个问题——data class 隐式 final,不能被继承。编译器生成的 equals() 使用 instanceof 进行精确类型检查,在继承关系下无法保证正确性,因此直接禁止继承。
但 data class 可以继承自其他类或实现接口:
// ✅ data class 可以继承自抽象类
abstract class Identifiable(val id: String)
data class Product(val name: String, val price: Double) : Identifiable("prod-001")
// ✅ data class 可以实现接口
interface Printable { fun prettyPrint(): String }
data class Report(val title: String) : Printable {
override fun prettyPrint() = "📄 $title"
}
sealed class / sealed interface 的底层原理
问题的起源:开放继承的"安全缺口"
在 Java 中处理一组有限的类型时,你通常依赖 if-else 或 switch 链来分发逻辑。但 Java 编译器无法帮你检查——如果有人新增了一种子类型,而你忘了在 switch 中加上对应的分支呢?程序会默默走进 default 分支(如果有的话),或者直接跳过。
Kotlin 的 sealed class 从根本上解决了这个问题:它让编译器知道一个类型层级中的所有可能的子类型,从而在 when 表达式中进行穷尽性检查(Exhaustive Check)。
把
sealed class想象成一个"固定菜单"的餐厅。菜单上一共就这么几道菜,点单系统(编译器)能确保你处理了菜单上的每一道菜。如果厨房新增了一道菜(新增子类),点单系统会立刻提醒你:"这道新菜你还没配置出餐流程。"
编译时类型枚举:编译器如何保证所有子类已知
sealed class 的核心约束是:所有直接子类必须与密封类定义在同一个编译模块中。从 Kotlin 1.1 开始,子类可以在同一模块的不同文件中;在此之前,子类必须嵌套在密封类内部。
// 定义在 Response.kt 中
sealed class Response {
data class Success(val data: String) : Response()
data class Error(val code: Int, val message: String) : Response()
data object Loading : Response()
}
在字节码层面,sealed class 被编译为一个抽象类,其构造函数的可见性被设为 private(或 protected):
// 编译后的 Response 类
public abstract class Response {
// 私有构造函数——模块外部无法继承
private Response() {}
// 编译器为 Kotlin 子类生成的合成构造函数
// 使用 DefaultConstructorMarker 参数来限制只有 Kotlin 编译器能调用
public Response(DefaultConstructorMarker marker) {
this();
}
}
// Success 是 Response 的子类
public static final class Success extends Response {
private final String data;
public Success(String data) {
super((DefaultConstructorMarker) null);
this.data = data;
}
// ... data class 生成的 equals/hashCode/toString/copy/componentN
}
// Loading 是 Response 的子类(data object = 单例)
public static final class Loading extends Response {
public static final Loading INSTANCE;
private Loading() {
super((DefaultConstructorMarker) null);
}
static {
Loading var0 = new Loading();
INSTANCE = var0;
}
}
关键观察:
Response的构造函数是private的——这从字节码层面阻止了外部模块创建新的子类- 编译器通过合成构造函数(带
DefaultConstructorMarker参数)让模块内的子类能够调用父类构造函数 DefaultConstructorMarker是 Kotlin 编译器内部类,Java 代码虽然理论上能调用,但这是一个"君子协定"——正常的开发者不会去绕过它
Kotlin 元数据与 JVM 17+ 的 PermittedSubclasses
编译器将子类列表存储在两个地方:
| 存储位置 | 用途 | JVM 版本要求 |
|---|---|---|
@Metadata 注解 |
Kotlin 编译器读取,用于穷尽性检查和反射 | 任意 |
PermittedSubclasses 属性 |
JVM 原生密封类支持 | Java 17+ |
在运行时,你可以通过 kotlin-reflect 库访问子类列表:
import kotlin.reflect.full.sealedSubclasses
// 获取 Response 的所有直接子类
val subclasses = Response::class.sealedSubclasses
// [class Response.Success, class Response.Error, class Response.Loading]
when 表达式的穷尽检查
sealed class 最强大的应用在于与 when 表达式的配合。编译器利用自己维护的子类列表,在编译时检查你是否处理了所有情况:
fun handleResponse(response: Response): String = when (response) {
is Response.Success -> "数据: ${response.data}"
is Response.Error -> "错误 ${response.code}: ${response.message}"
is Response.Loading -> "加载中..."
// 不需要 else 分支——编译器知道所有情况都已覆盖
}
如果你注释掉其中一个分支:
fun handleResponse(response: Response): String = when (response) {
is Response.Success -> "数据: ${response.data}"
is Response.Error -> "错误 ${response.code}: ${response.message}"
// ❌ 编译错误:'when' expression must be exhaustive,
// add necessary 'is Loading' branch or 'else' branch instead
}
穷尽检查的字节码实现
在字节码层面,when 表达式被编译为一系列 instanceof 检查:
// 编译后的 when 表达式(等效 Java)
public static String handleResponse(Response response) {
if (response instanceof Response.Success) {
return "数据: " + ((Response.Success) response).getData();
} else if (response instanceof Response.Error) {
Response.Error error = (Response.Error) response;
return "错误 " + error.getCode() + ": " + error.getMessage();
} else if (response instanceof Response.Loading) {
return "加载中...";
} else {
// 编译器在穷尽 when 中插入的"兜底"异常
throw new NoWhenBranchMatchedException();
}
}
注意最后的 NoWhenBranchMatchedException——即使编译器已经确认所有分支都已覆盖,它在字节码中仍然会插入这个兜底异常。这是一种防御性编程:如果在运行时(比如由于二进制兼容性问题)出现了一个编译时未知的子类,程序会抛出明确的异常而不是默默失败。
sealed class vs enum class:设计决策
sealed class 和 enum class 都能表示"有限的类型集合",但它们的设计目标截然不同:
| 维度 | enum class | sealed class |
|---|---|---|
| 实例模型 | 每个枚举常量是单例 | 每个子类可以有多个实例 |
| 数据负载 | 所有常量共享相同的属性结构 | 每个子类可以有不同的属性 |
| 继承层级 | 扁平,不能嵌套继承 | 可以构建多层级的类型树 |
| 内置能力 | name、ordinal、values()、entries |
无内置,需自行实现 |
| 序列化 | 天然支持(通过名称) | 需要额外处理 |
| 适用场景 | 固定的、无状态的标签/分类 | 携带不同状态数据的类型层级 |
快速决策流程
你的类型变体之间——
│
├─ 只是不同的"标签"?(如 MONDAY、TUESDAY)
│ └─ 用 enum class
│
├─ 需要携带不同的数据?(如 Success 携带数据,Error 携带异常)
│ └─ 用 sealed class
│
└─ 需要携带不同数据,且某些变体需要实现多个接口?
└─ 用 sealed interface
用 enum class 的经典场景:
enum class HttpMethod { GET, POST, PUT, DELETE, PATCH }
enum class LogLevel(val priority: Int) {
DEBUG(1), INFO(2), WARN(3), ERROR(4);
fun shouldLog(minLevel: LogLevel): Boolean = this.priority >= minLevel.priority
}
用 sealed class 的经典场景:
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
data object Loading : NetworkResult<Nothing>()
}
关键区别:Error 携带的数据结构(code + message)与 Success 携带的数据结构(data)完全不同——这是 enum class 无法表达的。
sealed interface:跨越单继承限制
Kotlin 1.5 引入了 sealed interface,它解决了 sealed class 的一个根本限制——JVM 单继承约束。
// 问题:一个类只能继承自一个 sealed class
sealed class Error
sealed class Recoverable
// 如果想让某个错误同时属于 Error 和 Recoverable?
// 用 sealed class 做不到——因为 Kotlin/JVM 不支持多继承
sealed interface 的出现让多重密封分类成为可能:
// 定义两个独立的密封维度
sealed interface Error
sealed interface Recoverable
// 一个类型可以同时实现多个密封接口
class NetworkError(val code: Int) : Error, Recoverable
class DatabaseError(val query: String) : Error
class AuthError(val reason: String) : Error, Recoverable
// 两个维度都可以独立进行穷尽检查
fun handleError(error: Error) = when (error) {
is NetworkError -> retry(error.code)
is DatabaseError -> logAndFail(error.query)
is AuthError -> refreshToken(error.reason)
}
fun attemptRecovery(issue: Recoverable) = when (issue) {
is NetworkError -> retryRequest()
is AuthError -> reAuthenticate()
}
在字节码层面,sealed interface 和普通 interface 的编译产物几乎没有区别——密封性完全由编译器的 @Metadata 注解和模块边界来保证,JVM 本身并不感知 interface 的"密封"属性(除非目标平台是 Java 17+,此时会生成 PermittedSubclasses 属性)。
| 特性 | sealed class | sealed interface |
|---|---|---|
| 可以持有状态(backing field) | ✅ | ❌ |
| 可以有构造函数 | ✅ | ❌ |
| 支持多重实现 | ❌(单继承) | ✅ |
用于穷尽 when 检查 |
✅ | ✅ |
| 字节码中的表现 | abstract class |
interface |
设计建议: 优先使用 sealed interface,它提供更好的灵活性;只有在需要共享状态或构造逻辑时,才使用 sealed class。
实战模式:用 sealed class 构建类型安全的 UI 状态
在 Android 开发中,sealed class 最经典的应用场景就是建模 UI 状态(UiState)。传统做法是用多个布尔标志和可空字段来表示加载中、成功、失败等状态——这不仅难以维护,还容易出现"不可能的状态"(比如 isLoading = true 同时 data != null)。
Result 模式
// 泛型密封类——适用于所有的"操作结果"
sealed class Result<out T> {
// 成功:携带数据
data class Success<T>(val data: T) : Result<T>()
// 失败:携带异常信息
data class Failure(val exception: Throwable) : Result<Nothing>()
}
// 使用示例
fun <T> Result<T>.getOrNull(): T? = when (this) {
is Result.Success -> data
is Result.Failure -> null
}
fun <T> Result<T>.getOrThrow(): T = when (this) {
is Result.Success -> data
is Result.Failure -> throw exception
}
UiState 模式
// UI 状态建模
sealed class UiState<out T> {
// 初始状态——还没有发起请求
data object Idle : UiState<Nothing>()
// 加载中——可以携带上一次的数据(实现"静默刷新")
data class Loading<T>(val previousData: T? = null) : UiState<T>()
// 成功——携带数据
data class Success<T>(val data: T) : UiState<T>()
// 失败——携带错误信息和可选的重试回调
data class Error(
val message: String,
val retryAction: (() -> Unit)? = null
) : UiState<Nothing>()
}
在 Compose 或传统 View 体系中使用:
@Composable
fun <T> UiStateHandler(
state: UiState<T>,
onSuccess: @Composable (T) -> Unit
) {
when (state) {
is UiState.Idle -> { /* 什么都不显示 */ }
is UiState.Loading -> {
CircularProgressIndicator()
// 如果有之前的数据,可以同时显示
state.previousData?.let { onSuccess(it) }
}
is UiState.Success -> onSuccess(state.data)
is UiState.Error -> {
ErrorView(
message = state.message,
onRetry = state.retryAction
)
}
}
// 编译器保证:不可能遗漏任何状态
}
这种模式的优势:
- 状态互斥:不可能同时处于 Loading 和 Error 状态——类型系统保证了这一点
- 数据与状态绑定:
data只在Success中存在,不需要在使用时做空检查 - 扩展安全:新增一种状态(如
Empty)时,编译器会在所有when表达式中报错,逼你处理这种新情况
嵌套密封层级
对于更复杂的业务场景,可以构建多层级的密封类型树:
sealed class PaymentState {
data object Idle : PaymentState()
sealed class Processing : PaymentState() {
data class Authorizing(val transactionId: String) : Processing()
data class WaitingFor3DS(val redirectUrl: String) : Processing()
}
sealed class Completed : PaymentState() {
data class Success(val receipt: Receipt) : Completed()
data class Refunded(val refundId: String, val amount: Double) : Completed()
}
data class Failed(val error: PaymentError) : PaymentState()
}
// 粗粒度处理:只关心大状态
fun getStatusLabel(state: PaymentState): String = when (state) {
is PaymentState.Idle -> "待支付"
is PaymentState.Processing -> "处理中"
is PaymentState.Completed -> "已完成"
is PaymentState.Failed -> "支付失败"
}
// 细粒度处理:处理详细子状态
fun getDetailedLabel(state: PaymentState): String = when (state) {
is PaymentState.Idle -> "等待用户操作"
is PaymentState.Processing.Authorizing -> "授权中: ${state.transactionId}"
is PaymentState.Processing.WaitingFor3DS -> "等待 3DS 验证"
is PaymentState.Completed.Success -> "支付成功"
is PaymentState.Completed.Refunded -> "已退款 ¥${state.amount}"
is PaymentState.Failed -> "失败: ${state.error}"
}
字节码全景:data class 和 sealed class 的完整编译产物
让我们用一段综合性的代码来串联本文所有核心知识点,并列出它们的字节码产物:
// ① sealed class:定义有限类型层级
sealed class Shape {
abstract fun area(): Double
}
// ② data class 继承自 sealed class
data class Circle(val radius: Double) : Shape() {
override fun area(): Double = Math.PI * radius * radius
}
data class Rectangle(val width: Double, val height: Double) : Shape() {
override fun area(): Double = width * height
}
// ③ data object:无实例数据的密封子类
data object Unknown : Shape() {
override fun area(): Double = 0.0
}
// ④ 穷尽 when + 解构
fun describeShape(shape: Shape): String = when (shape) {
is Circle -> {
val (r) = shape // 解构:调用 component1()
"圆形,半径 $r,面积 ${shape.area()}"
}
is Rectangle -> {
val (w, h) = shape // 解构:调用 component1() + component2()
"矩形,$w × $h,面积 ${shape.area()}"
}
is Unknown -> "未知图形"
}
// ⑤ copy() 使用
fun growCircle(circle: Circle, factor: Double): Circle {
return circle.copy(radius = circle.radius * factor)
}
编译产物清单:
| Kotlin 构造 | 字节码产物 |
|---|---|
sealed class Shape |
public abstract class Shape,私有构造函数 + 合成构造函数 |
data class Circle |
public final class Circle extends Shape,带完整的 equals/hashCode/toString/copy/component1 |
data class Rectangle |
public final class Rectangle extends Shape,带 component1 + component2 |
data object Unknown |
public final class Unknown extends Shape,单例模式(INSTANCE 字段 + <clinit>) |
when(shape) |
instanceof 检查链 + NoWhenBranchMatchedException 兜底 |
val (r) = shape |
shape.component1() 方法调用 |
circle.copy(radius = ...) |
Circle.copy$default(circle, 0.0, 1, null) 位掩码合成方法调用 |
设计哲学总结
纵观 data class 和 sealed class 的整套设计,可以提炼出三条核心认知:
1. 编译器是最可靠的代码生成器
data class 的五个自动生成方法,每一个都严格遵循 JVM 契约。手写 equals() 时忘记对称性、手写 hashCode() 时忘记和 equals() 保持一致——这些人类常犯的错误,编译器永远不会犯。把样板代码交给编译器,你只需要关注业务逻辑。
2. 类型系统是零成本的安全网
sealed class 的穷尽检查在编译时完成,运行时是标准的 instanceof 检查链——没有任何额外的性能开销。你得到的是编译时的安全保证,付出的运行时成本为零。这就是"类型驱动开发"的核心理念:让类型系统帮你捕获错误,而不是靠测试或代码评审。
3. 不可变是"默认正确"的基石
data class 的 copy() 是浅拷贝,只有在所有属性都不可变时才是真正安全的。使用 val 而非 var,使用 List 而非 MutableList——这些"不可变优先"的实践不是教条,而是由编译器生成代码的行为所决定的工程必然。
理解了 data class 和 sealed class 在字节码层面的真实面貌,你就能在使用它们时做出精确的判断——什么时候用 data class,什么时候用普通 class;什么时候用 sealed class,什么时候用 enum;copy() 什么时候安全,什么时候有陷阱。这正是写出零缺陷代码的关键心智模型。