Kotlin 数据类与密封类:从原理到实战

1. 引言

Kotlin 作为现代 JVM 语言,在类型系统设计上提供了许多实用特性。其中,数据类(Data Class)密封类(Sealed Class)是日常开发中使用频率极高的两个特性。数据类帮助我们以极简的代码表达"纯数据"模型,而密封类则为受限的类层次结构提供了优雅的建模方式。本文将通过丰富的代码实例,深入讲解这两类特性的原理、用法与最佳实践。

2. 数据类(Data Class)

2.1 什么是数据类

数据类是 Kotlin 中专门用于承载数据的类。使用 data 关键字修饰的类,编译器会自动生成 equals()hashCode()toString()copy() 以及 componentN() 等常用方法,极大减少了样板代码。

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

fun main() {
    val user = User(1L, "Alice", "alice@example.com")
    println(user) // User(id=1, name=Alice, email=alice@example.com)
}

2.2 自动生成的方法

编译器为数据类自动生成的方法包括:

  • equals() / hashCode():基于主构造函数中声明的所有属性进行相等性比较。
  • toString():输出格式为 类名(属性1=值1, 属性2=值2)
  • copy():基于现有实例创建副本,可选择性修改部分属性。
  • componentN():支持解构声明。
data class Point(val x: Int, val y: Int)

fun main() {
    val p1 = Point(3, 4)
    val p2 = p1.copy(y = 10) // 只修改 y
    println(p2) // Point(x=3, y=10)

    // 解构声明
    val (x, y) = p1
    println("x=$x, y=$y") // x=3, y=4
}

2.3 数据类的约束条件

使用数据类时需要注意以下约束:

  • 主构造函数必须至少有一个参数。
  • 主构造函数的所有参数必须标记为 valvar
  • 数据类不能是抽象类、开放类、密封类或内部类。
// 正确示例
data class Product(val sku: String, val price: Double)

// 错误示例:主构造函数没有参数
// data class Empty

// 错误示例:参数未标记 val/var
// data class Bad(val a: Int, b: String)

2.4 类体中的属性

在数据类类体中声明的属性不会参与 equals()hashCode()toString() 的生成。

data class Person(val name: String) {
    var age: Int = 0 // 不参与 equals/hashCode/toString
}

fun main() {
    val p1 = Person("Bob")
    val p2 = Person("Bob")
    p1.age = 30
    p2.age = 40
    println(p1 == p2) // true,因为 age 不参与比较
    println(p1) // Person(name=Bob)
}

3. 密封类(Sealed Class)

3.1 什么是密封类

密封类用于表达受限的类层次结构,即一个值只能是有限几种类型之一。使用 sealed 关键字修饰的类,其所有直接子类必须声明在同一个文件(Kotlin 1.5 之前)或同一个包及编译单元(Kotlin 1.5+)中。

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

fun <T> handle(result: Result<T>) {
    when (result) {
        is Result.Success -> println("成功: ${result.data}")
        is Result.Error -> println("失败: ${result.message}")
        Result.Loading -> println("加载中...")
    }
}

3.2 密封类与 when 表达式

密封类最大的优势在于与 when 表达式配合时,编译器可以穷尽所有分支,无需 else 分支。当新增子类时,编译器会提示所有 when 表达式需要补充新分支,提升代码安全性。

sealed class UiState {
    object Idle : UiState()
    object Loading : UiState()
    data class Success(val data: List<String>) : UiState()
    data class Error(val throwable: Throwable) : UiState()
}

fun render(state: UiState) {
    // 无需 else 分支,编译器保证穷尽
    when (state) {
        UiState.Idle -> println("空闲状态")
        UiState.Loading -> println("加载中")
        is UiState.Success -> println("数据: ${state.data}")
        is UiState.Error -> println("错误: ${state.throwable.message}")
    }
}

3.3 密封类的子类类型

密封类的子类可以是数据类、普通类或单例对象,这为建模提供了极大的灵活性。

sealed class Shape {
    data class Circle(val radius: Double) : Shape()
    data class Rectangle(val width: Double, val height: Double) : Shape()
    object Unknown : Shape()
}

fun area(shape: Shape): Double = when (shape) {
    is Shape.Circle -> Math.PI * shape.radius * shape.radius
    is Shape.Rectangle -> shape.width * shape.height
    Shape.Unknown -> 0.0
}

4. 数据类与密封类的组合实战

4.1 网络请求状态管理

在实际项目中,数据类和密封类经常组合使用,用于管理网络请求的多种状态。

sealed class NetworkResult<out T> {
    data class Success<T>(val data: T, val timestamp: Long) : NetworkResult<T>()
    data class Failure(val code: Int, val message: String) : NetworkResult<Nothing>()
    object Loading : NetworkResult<Nothing>()
}

data class Article(val id: Long, val title: String, val content: String)

fun fetchArticle(id: Long): NetworkResult<Article> {
    // 模拟网络请求
    return if (id > 0) {
        NetworkResult.Success(Article(id, "Kotlin 数据类", "正文内容"), System.currentTimeMillis())
    } else {
        NetworkResult.Failure(400, "无效的 ID")
    }
}

fun display(result: NetworkResult<Article>) {
    when (result) {
        is NetworkResult.Success -> println("文章: ${result.data.title}")
        is NetworkResult.Failure -> println("错误码: ${result.code}")
        NetworkResult.Loading -> println("加载中...")
    }
}

4.2 表达式求值器

下面通过一个简单的表达式求值器,展示数据类与密封类在递归数据结构中的应用。

sealed class Expr {
    data class Num(val value: Int) : Expr()
    data class Add(val left: Expr, val right: Expr) : Expr()
    data class Mul(val left: Expr, val right: Expr) : Expr()
}

fun eval(expr: Expr): Int = when (expr) {
    is Expr.Num -> expr.value
    is Expr.Add -> eval(expr.left) + eval(expr.right)
    is Expr.Mul -> eval(expr.left) * eval(expr.right)
}

fun main() {
    // 表达式: (1 + 2) * 3
    val expr = Expr.Mul(Expr.Add(Expr.Num(1), Expr.Num(2)), Expr.Num(3))
    println(eval(expr)) // 9
}

5. 最佳实践与注意事项

5.1 数据类使用建议

  • 数据类适合承载不可变数据,建议属性使用 val 声明。
  • 避免在数据类中定义大量业务逻辑,保持类的纯粹性。
  • 使用 copy() 方法进行不可变更新,而不是直接修改属性。
data class Config(val host: String, val port: Int, val debug: Boolean)

fun main() {
    val default = Config("localhost", 8080, false)
    val debugConfig = default.copy(debug = true)
    println(debugConfig) // Config(host=localhost, port=8080, debug=true)
}

5.2 密封类使用建议

  • 当类型层次结构固定且有限时,优先使用密封类而非抽象类。
  • 利用编译器穷尽检查,避免遗漏分支。
  • 密封类常与 when 表达式、状态机模式配合使用。

5.3 常见陷阱

数据类的 equals()hashCode() 只基于主构造函数属性,如果类体中包含参与业务相等的属性,需要手动重写这两个方法。

data class Account(val id: String) {
    var balance: Double = 0.0

    // 手动重写以包含 balance
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Account) return false
        return id == other.id && balance == other.balance
    }

    override fun hashCode(): Int {
        var result = id.hashCode()
        result = 31 * result + balance.hashCode()
        return result
    }
}

6. 总结

数据类和密封类是 Kotlin 类型系统中两个极具表现力的特性。数据类通过自动生成样板方法,让开发者专注于数据本身;密封类则通过受限的类层次结构,配合 when 表达式实现类型安全的穷尽分支处理。将两者结合使用,可以构建出既简洁又健壮的领域模型。建议读者在实际项目中多加练习,逐步体会这两个特性的设计哲学。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值