类和对象
# 第 2 章 Kotlin 类和对象
# 目录介绍
- 2.1 类的介绍
- 2.2 类定义与实例化
- 2.3 属性与字段
- 2.4 可见性修饰符
- 2.5 特殊类家族
- 2.6 继承与接口
- 2.7 委托机制
- 2.8 泛型与型变
- 2.9 综合案例练习
- 2.10 类和对象底层原理
- 2.11 训练题三道
- 2.12 新手陷阱Top5
# 2.1 类的介绍
# 2.1.1 类的含义
类是一种用户自定义类型,用于封装 属性(数据)与 行为(函数)。Kotlin 类可以看作 Java 类的「精简升级版」——同样的语义,一半的代码。
class Person(val name: String, var age: Int)
这一行不到 40 个字符,等价于下面 Java 十几行 boilerplate:
public final class Person {
private final String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
}
Kotlin 编译器背后仍会生成同样的字节码——这是「零成本抽象」——你没有写的 getter/setter 是 编译器帮你写的,运行期性能与 Java 完全一致。第 2.10 节我们会亲眼看到 javap 反编译的证据。
类是 OOP 的最小单位:它把「数据 + 操作数据的方法」封装到同一个类型里,让「什么样的数据能做什么样的操作」由类型系统保证。类同时给编译器一个明确的作用域,可见性、继承规则、命名空间都在类的粒度上生效。
# 2.1.2 Kotlin与Java差异
Kotlin 保留了 Java 的类模型(单继承 + 多接口 + JVM 字节码),但从 默认值 和 语法糖 两个层面做了大量修正:
| 维度 | Java | Kotlin |
|---|---|---|
| 默认可继承 | 是(可 extends) | 否(默认 final) |
| 默认可见性 | package-private | public |
| 字段与访问器 | 手写 getter/setter | 属性自带 |
| 单一继承 + 多接口 | 一致 | 一致 |
| 多构造 | 多个 public XXX(...) | 主构造 + 次构造 |
| 静态成员 | static 关键字 | companion object |
| 数据类 | 自己写或 Lombok | data class 一行 |
| 枚举 | 传统 enum | enum class(可属性、方法、抽象方法) |
| 密封类 | Java 17+ 才有 sealed | Kotlin 1.0 就支持 |
| 委托 | 手写 or Proxy | by 关键字语言级支持 |
| 实例化 | 必须 new | 无 new |
「默认值差异」是最容易踩坑的部分——写 Java 十年的人第一次写 Kotlin,最常见的错误就是忘了给类加 open 导致无法继承、以及忘了给成员加 open 导致无法 override。
# 2.1.3 默认final的哲学
Kotlin 借鉴 Joshua Bloch《Effective Java》条款 17:"要么设计好继承并写好文档,要么禁止继承"。所以默认给你 final——想继承请显式写 open。
class Base // 默认 final,不能继承
open class Base2 // 显式打开,才能继承
动机:可继承是一种「向所有子类做出的契约承诺」。任何非 final 的方法都必须假设「未来的子类可能重写它」,从而在实现里避免副作用泄漏、状态不一致、循环调用等风险。Java 默认可继承,导致大量库作者被迫在方法上加 final,或者被子类踩坑——Kotlin 直接把默认反过来。
与 Java 15+ sealed class 的对齐:Java 15 引入 sealed 也是同样思路——「继承需要显式允许」。Kotlin 走得更彻底:sealed 是「限定继承者集合」,而 final 是「禁止继承」,open 是「允许任何继承」,三者形成完整梯度。
代价:一些依赖 Mock 的测试框架(Mockito)无法 mock 默认 final 类。Kotlin 官方给出的方案是 all-open (opens new window) 编译器插件(Spring/JPA 场景已经内建):可以让特定注解标注的类默认 open,测试时再全开。
# 2.1.4 综合案例与思考
Java Bean 逐行迁移案例——把下面这个典型 Java Bean:
public class Book {
private String title;
private String author;
private int pages;
public Book(String title, String author, int pages) {
this.title = title;
this.author = author;
this.pages = pages;
}
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public int getPages() { return pages; }
public void setPages(int pages) { this.pages = pages; }
@Override public boolean equals(Object o) { /* IDE 生成的 20 行 */ }
@Override public int hashCode() { /* 5 行 */ }
@Override public String toString() { /* 3 行 */ }
}
用 Kotlin 重写:
data class Book(var title: String, var author: String, var pages: Int)
行数对比:Java 40+ 行 → Kotlin 1 行,行数缩水 97.5%。而且 equals / hashCode / toString / copy / componentN 是编译器自动生成的、永远与字段一致——Java 里如果新加字段忘了同步 equals,就是隐藏的等价性 bug。
思考:为什么 Kotlin 把默认可见性从 Java 的 package-private 改成 public?
原因:
package-private的实际使用率极低,Google 内部代码统计不到 2%。相反,public是绝大多数类的默认需求。让默认值贴近实际最常用的场景,能减少 90% 的public关键字噪音。想收窄可见性时再显式写internal/private。
# 2.2 类定义与实例化
# 2.2.1 主构造函数
class Person(val name: String, var age: Int) {
// 类体
}
主构造 写在类头,参数可以直接加 val/var 变成属性(一石二鸟)。规则:
| 头声明 | 含义 |
|---|---|
class C(x: Int) | x 只是构造参数,不是属性;类体外不可见 |
class C(val x: Int) | x 是只读属性,自带 getter |
class C(var x: Int) | x 是读写属性,自带 getter/setter |
注意:主构造不能包含代码,只能声明参数。想在构造时执行代码,请使用 init 块(2.2.3)。
默认参数 是主构造的常见搭档,让 Java 里必须写多个重载构造的场景在 Kotlin 里一行搞定:
class Http(
val url: String,
val method: String = "GET",
val timeout: Int = 30,
val retries: Int = 3,
)
val h1 = Http("https://x.com") // 全用默认值
val h2 = Http("https://x.com", timeout = 60) // 命名参数改一个
val h3 = Http("https://x.com", "POST", retries = 5) // 位置 + 命名混用
Java 里同样效果需要写 8 个 重载构造——2^3 种组合。
# 2.2.2 次构造函数
class Person(val name: String) {
var age: Int = 0
constructor(name: String, age: Int) : this(name) {
this.age = age
}
}
规则:次构造必须直接或间接调用主构造。语法:constructor(...) : this(...)。
次构造 vs 默认参数:Kotlin 90% 的场景推荐默认参数——只有一种情况必须写次构造:从其他类型转换。例如:
class Point(val x: Int, val y: Int) {
constructor(pair: Pair<Int, Int>) : this(pair.first, pair.second)
constructor(list: List<Int>) : this(list[0], list[1])
}
上面的例子无法用默认参数表达——因为参数类型不同。
# 2.2.3 init初始化块
class Person(val name: String) {
init {
require(name.isNotBlank()) { "name不能为空" }
}
}
init 块在主构造后、次构造前执行;多个 init 按声明顺序运行。执行时序:
1. 求值主构造参数
2. 依次执行【属性初始化】和【init块】——按源码顺序交织
3. 执行次构造函数体(如果走的是次构造)
顺序陷阱:
class Bad(val n: Int) {
init { println(a) } // ❌ 编译错:a 还没声明
val a = n * 2
}
init 里访问 a 会报错,因为 a 的声明还在下面。记住:init 块和属性声明按源码从上到下依赖,不能倒序。
# 2.2.4 无new关键字
val p = Person("Kotlin", 10) // 没有 new
Kotlin 抛弃了 new——少一个关键字,语法更接近函数调用。
设计动机:让 工厂函数、构造函数、单例访问器 在调用侧写法一致:
val a = ArrayList<Int>() // 构造函数
val b = arrayListOf(1, 2, 3) // 工厂函数
val c = AppConfig // 单例
三种「获取对象」的方式,调用侧看不出区别。这样类的作者可以自由地在「构造」和「工厂」之间切换,调用者代码零改动——例如把 class Foo(...) 改成 fun Foo(...): Foo 工厂函数,调用侧完全无感。
# 2.2.5 综合案例与思考
HttpRequest 类:必填参数放主构造,可选参数放默认参数,特殊场景放次构造:
class HttpRequest(
val url: String, // 必填
val method: String = "GET", // 可选
val headers: Map<String, String> = emptyMap(),
val body: ByteArray? = null,
val timeoutMs: Int = 30_000,
) {
// 特殊:从 URL 字符串(含 query)快捷构造
constructor(url: String, params: Map<String, String>) : this(
url = "$url?" + params.entries.joinToString("&") { "${it.key}=${it.value}" }
)
init {
require(url.startsWith("http")) { "只支持 HTTP(S)" }
require(timeoutMs > 0) { "超时必须 > 0" }
}
override fun toString() = "$method $url"
}
fun main() {
val r1 = HttpRequest("https://api.demo.com/users")
val r2 = HttpRequest("https://api.demo.com/users", method = "POST", timeoutMs = 60_000)
val r3 = HttpRequest("https://api.demo.com/search", mapOf("q" to "kotlin", "page" to "1"))
println(r1); println(r2); println(r3)
}
输出:
GET https://api.demo.com/users
POST https://api.demo.com/users
GET https://api.demo.com/search?q=kotlin&page=1
思考:主构造 + 默认参数 vs 多个次构造,什么时候用哪种?
判断标准:如果只是「参数个数不同」——用默认参数;如果是「参数类型完全不同」——用次构造。前者是「同一个语义的多种简写」,后者是「不同来源转换到同一个对象」。
# 2.3 属性与字段
# 2.3.1 属性并非字段
关键观念:Kotlin 只有「属性」没有「字段」。val name 声明的是一个 属性(自带 getter),编译器 可能 为它生成一个私有字段(叫 backing field)。
class Person(val firstName: String, val lastName: String) {
val fullName: String
get() = "$firstName $lastName" // 计算属性,没有 backing field
}
判断规则:只有当 getter/setter 里出现 field 关键字(或采用默认实现)时,编译器才生成 backing field。计算属性 fullName 每次调用都重新拼接,不占内存。
为什么这个区别重要——Java 程序员的第一个 Kotlin 陷阱:
class Counter {
var count: Int = 0
set(value) {
count = value + 1 // ❌ 栈溢出!递归调用 setter
}
}
赋值 count = value + 1 会调用 setter 本身——无限递归。正确写法是用 field(2.3.3)。
# 2.3.2 自定义getter
class Rect(val w: Int, val h: Int) {
val area: Int
get() = w * h
}
用法场景:值 可派生 时用计算属性,值 需要缓存 时用 backing field + by lazy。
性能提示:如果计算开销大且反复访问——用 by lazy 而不是自定义 getter,否则每次访问都算一遍。
class Report(val rows: List<Row>) {
val total: Int by lazy {
rows.sumOf { it.amount } // 只算一次
}
}
# 2.3.3 field幕后字段
field 是 只能 在自定义 getter/setter 里使用的关键字,指向属性的 backing field:
class Age {
var value: Int = 0
set(newValue) {
require(newValue >= 0) { "年龄不能为负" }
field = newValue // 用 field,不要用 value
}
}
为什么必须用 field?——因为 setter 是通过 set... 方法名调用的,在 setter 内直接给 value 赋值会解析成 setValue(...)——递归调用自己,导致栈溢出。
编译器视角:Kotlin 编译器看到 field 关键字,就把它翻译成对私有字段的直接读写;看到属性名,就翻译成 getter/setter 调用。两种访问方式一定要分开。
# 2.3.4 lateinit与lazy
| 修饰 | 适用 | 语义 |
|---|---|---|
lateinit var | 非空引用类型(不含基础类型) | 保证使用前会赋值,运行时检查 |
by lazy { } | val | 首次访问时计算,之后缓存 |
class UserVM {
lateinit var repo: UserRepo // 依赖注入前必须赋值
val config: Config by lazy { loadConfig() }
}
lateinit 三条限制:
- 必须是
var(因为要延迟赋值) - 必须是非空引用类型(不能是
Int/Boolean等基础类型——用by Delegates.notNull()替代) - 必须有 backing field(不能是计算属性)
运行期检查:如果访问一个未初始化的 lateinit var,会抛 UninitializedPropertyAccessException。可以用 ::propName.isInitialized 先判断:
if (::repo.isInitialized) repo.load()
by lazy 三种线程模式(详见原理篇 5):
| 模式 | 说明 |
|---|---|
LazyThreadSafetyMode.SYNCHRONIZED(默认) | 加锁,多线程安全,只计算一次 |
LazyThreadSafetyMode.PUBLICATION | 无锁,可能多个线程各算一次,取第一个成功值 |
LazyThreadSafetyMode.NONE | 无同步,单线程用,最快 |
# 2.3.5 综合案例与思考
Temperature 类——属性内部做单位换算,展示 field 与自定义 setter:
class Temperature {
var celsius: Double = 0.0 // 主存储:摄氏度
var fahrenheit: Double
get() = celsius * 9 / 5 + 32
set(value) {
celsius = (value - 32) * 5 / 9 // 内部转成摄氏度
}
var kelvin: Double
get() = celsius + 273.15
set(value) {
require(value >= 0) { "开尔文温度不能为负" }
celsius = value - 273.15
}
}
fun main() {
val t = Temperature()
t.celsius = 25.0
println("摄氏 25 → 华氏 ${t.fahrenheit} → 开尔文 ${t.kelvin}")
t.fahrenheit = 100.0
println("华氏 100 → 摄氏 ${t.celsius}")
}
输出:
摄氏 25 → 华氏 77.0 → 开尔文 298.15
华氏 100 → 摄氏 37.77777777777778
思考:什么时候属性该有 backing field?编译器如何判断?
判断规则:Kotlin 编译器扫描 getter 和 setter,只要出现
field关键字(或采用默认实现,即没写 getter/setter),就生成 backing field;否则不生成,属性变成纯派生。
# 2.4 可见性修饰符
# 2.4.1 四种可见性
| 修饰符 | 类内成员 | 顶层声明 |
|---|---|---|
public(默认) | 处处可见 | 处处可见 |
internal | 同 module 可见 | 同 module 可见 |
protected | 类内与子类 | ❌ 顶层不可用 |
private | 类内 | 同文件 内可见 |
注意 private 的两种语义:
- 类内成员的
private——只在类内可见 - 顶层声明的
private——只在 同一个 .kt 文件 内可见
这是 Kotlin 独有的「文件级 private」,非常适合把辅助函数和主类放在同一个文件里,且不污染包命名空间。
# 2.4.2 internal是什么
Kotlin 独有——表示「同 module 可见」。module 定义为:一起编译的一组 Kotlin 文件(一个 Gradle module / IntelliJ module / Maven project)。
用法典型场景:SDK / 库项目
// 对外 API
class NetworkClient {
fun fetch(url: String): String = engine.execute(url)
internal val engine = HttpEngine() // 内部使用,外部不可见
}
// 实现细节,同 module 可见
internal class HttpEngine {
fun execute(url: String): String = ...
}
Java 侧调用体验:internal 修饰的成员会被 Kotlin 编译器 name mangling,编译后名字变成 HttpEngine$module_release 之类,Java 代码理论上仍能访问但看起来非常丑陋——这就是 Kotlin 「劝退」Java 直接调 internal 的方式。
internal vs Java 的 package-private:
| 维度 | Java package-private | Kotlin internal |
|---|---|---|
| 边界 | 包(package) | module |
| 强制性 | 包名相同就可访问,容易被利用 | 无法从其他 module 访问 |
| 检查时机 | 编译期 | 编译期 |
| Kotlin 是否保留 | 否 | 是 |
# 2.4.3 顶层可见性
Kotlin 允许 顶层函数(不在任何类里的函数),它们的可见性规则参考类内成员:
// file: utils.kt
fun publicHelper() { } // 默认 public,全局可见
internal fun sharedHelper() { } // 同 module 可见
private fun fileHelper() { } // 只在 utils.kt 内可见
// protected 不可用(无「子类」概念)
顶层函数编译成 UtilsKt 这个类的静态方法,Java 侧调用是 UtilsKt.publicHelper()。想给 Java 起个别名,加 @file:JvmName("Utils")。
# 2.4.4 综合案例与思考
SDK 设计场景:
// ============ 对外 API 文件 ============
// file: PaySdk.kt
class PaySdk {
fun pay(orderId: String, amount: Long): PayResult {
val encoded = encoder.encode(orderId, amount)
return client.send(encoded)
}
// 内部字段
internal val encoder = OrderEncoder()
internal val client = PayClient()
}
// ============ 内部实现文件 ============
// file: OrderEncoder.kt
internal class OrderEncoder {
fun encode(orderId: String, amount: Long): ByteArray = ...
}
// file: PayClient.kt
internal class PayClient {
fun send(data: ByteArray): PayResult = ...
}
对外只暴露 PaySdk 一个类,OrderEncoder / PayClient 是内部实现——用 internal 让实现细节 同 module 可用(便于测试),但对使用 SDK 的外部业务代码 完全不可见。
思考:为什么 Kotlin 抛弃了 Java 的 package-private?
原因:Java 的 package-private 用「包名」做隔离边界——但 Java 包名并不安全,任何人只要写一个相同包名的 .java 文件,就能访问 package-private 成员。Kotlin 用 module 作为隔离边界,天然是编译单元,无法绕过。
# 2.5 特殊类家族
Kotlin 特殊类是 语言级糖,每一个都对应一个具体的编译产物。理解它们「是什么、编译成什么、什么时候用」是精通 Kotlin 类型系统的关键。
# 2.5.1 数据类data
data class User(val id: Long, val name: String)
编译器自动生成:
| 生成方法 | 语义 |
|---|---|
equals(other: Any?) | 按主构造属性逐个比较 |
hashCode() | 按主构造属性组合计算 |
toString() | User(id=..., name=...) |
copy(...) | 复制并可选择性覆盖属性 |
component1() / component2() / ... | 解构声明支持 |
解构声明:
val u = User(1L, "Kotlin")
val (id, name) = u // 幕后:id = u.component1(); name = u.component2()
println("$id $name") // 输出: 1 Kotlin
copy 的威力——不可变数据流场景(如 Compose UI State):
data class State(val loading: Boolean = false, val list: List<Item> = emptyList())
var state = State()
state = state.copy(loading = true) // 只改 loading
state = state.copy(loading = false, list = fetched)
详细字节码见 原理篇 2。
# 2.5.2 密封类sealed
sealed class Result<out T>
class Success<T>(val data: T) : Result<T>()
class Failure(val err: Throwable) : Result<Nothing>()
object Loading : Result<Nothing>()
核心特性:所有直接子类必须在 同文件(或同 module,Kotlin 1.5+)。让 when 表达式可穷尽性检查:
fun <T> render(r: Result<T>): String = when (r) { // 编译器要求穷尽所有分支
is Success -> "OK: ${r.data}"
is Failure -> "ERR: ${r.err.message}"
is Loading -> "..."
}
如果新增一个子类,所有的 when 都会 编译报错,提示你补上分支——这就是「代数数据类型」的类型安全保证。
sealed class vs enum class 取舍:
| 维度 | enum | sealed |
|---|---|---|
| 子类数量 | 有限、编译期固定 | 有限、编译期固定 |
| 子类是否可携带 不同类型的数据 | ❌ 所有实例结构一致 | ✅ 每个子类可有各自属性 |
| 子类是否可以有多个实例 | ❌ 每个枚举值只有一份 | ✅ class Success<T>(...) 每次调都是新实例 |
| 典型场景 | 状态、类型、常量 | 结果、事件、消息 |
# 2.5.3 枚举类enum
enum class Color(val hex: String) {
RED("#FF0000"), GREEN("#00FF00"), BLUE("#0000FF");
fun brightness() = hex.substring(1).toInt(16)
}
fun main() {
for (c in Color.values()) {
println("${c.name} ${c.hex} brightness=${c.brightness()}")
}
}
输出:
RED #FF0000 brightness=16711680
GREEN #00FF00 brightness=65280
BLUE #0000FF brightness=255
Kotlin 枚举 = Java 枚举,编译目标一致,但语法更整洁——每个值可以带自己的构造参数,还可以定义方法、抽象方法:
enum class Op {
ADD { override fun apply(a: Int, b: Int) = a + b },
SUB { override fun apply(a: Int, b: Int) = a - b };
abstract fun apply(a: Int, b: Int): Int
}
# 2.5.4 单例object
object AppConfig {
var debug = false
fun load() { }
}
AppConfig.debug = true // 直接用类名访问
object 声明的类 只有一个实例,编译成带 INSTANCE 静态字段的 final class。等价于 Java 的:
public final class AppConfig {
public static final AppConfig INSTANCE = new AppConfig();
private AppConfig() {}
// ...
}
线程安全:INSTANCE 是在类加载时初始化的——JVM 类加载器保证同一个类只被加载一次,天然线程安全。这比 Java 的「双重检查锁定」单例模式简洁 10 倍。
object 三种用法:
// 1. 顶层 object:全局单例
object Logger { fun d(msg: String) { } }
// 2. 类内 object:命名嵌套单例
class Http {
object Constants { const val TIMEOUT = 30 }
}
// 用法:Http.Constants.TIMEOUT
// 3. 对象表达式:匿名单例
val listener = object : ClickListener {
override fun onClick() { }
}
# 2.5.5 伴生对象companion
class User private constructor(val id: Long) {
companion object {
fun of(id: Long) = User(id)
const val ANONYMOUS_ID = -1L
}
}
val u = User.of(42)
伴生对象是宿主类的一个内部单例,用来放「类级」的工厂/常量。不是 Java 的 static——不加 @JvmStatic 时 Java 侧调用要写:
User.Companion.of(42); // Java 视角
加上 @JvmStatic 后 Java 就能直接 User.of(42):
class User private constructor(val id: Long) {
companion object {
@JvmStatic fun of(id: Long) = User(id)
}
}
为什么设计成 companion object 而不是 static——因为 companion 是一个 真正的对象,可以:
- 实现接口(
companion object : Factory<User>) - 有属性(每次访问都走 getter)
- 被扩展(
fun User.Companion.random() = ...)
这些 Java 的 static 都做不到。
# 2.5.6 内联类value
@JvmInline
value class UserId(val value: Long)
fun findUser(id: UserId): User? { ... }
fun findOrder(id: OrderId): Order? { ... }
val userId = UserId(100)
val orderId = OrderId(200)
findUser(userId) // ✅
findUser(orderId) // ❌ 编译错误——类型不同
编译后运行时是 裸 Long,不装箱、不多一次分配——用来给基础类型加上类型安全。Kotlin 1.5 稳定。
核心用途:给「同样是 Long 但语义不同」的字段加上类型区分。传统方式:
fun findUser(id: Long) // Long 类型的 userId
fun findOrder(id: Long) // Long 类型的 orderId
findUser(orderId) // ❌ 想混用编译器也不报错——运行期灾难
用了 value class 之后,编译期直接拦截错误。
限制:value class 只能有 一个 主构造属性(1.9 支持多个但还在实验);不能有 init 块之外的属性;不能有 backing field 之外的属性。
# 2.5.7 综合案例与思考
网络请求 Result 状态机——sealed + data class 组合建模:
sealed class Resource<out T> {
object Loading : Resource<Nothing>()
data class Success<T>(val data: T) : Resource<T>()
data class Error(val code: Int, val msg: String) : Resource<Nothing>()
}
fun handle(r: Resource<List<User>>) = when (r) {
Resource.Loading -> showLoading()
is Resource.Success -> showList(r.data)
is Resource.Error -> showToast("${r.code}: ${r.msg}")
}
思考:object、companion object、class ... { object ... } 三者语义分别是什么?
答案:
- 顶层
object A:全局单例,A.method()调用companion object:宿主类的伴生单例,Host.method()调用(本质是Host.Companion.method())- 类内
object A:宿主类的命名嵌套单例,Host.A.method()调用,每个宿主类实例共享同一个 A
# 2.6 继承与接口
# 2.6.1 open显式打开
open class Animal {
open fun speak() { println("...") }
fun eat() { } // 未 open,子类不能 override
}
class Dog : Animal() { // 冒号 + 圆括号=调用父类构造
override fun speak() { println("汪") }
}
必须 加 open 才能被继承或 override——与 Java 「默认可继承」相反。
为什么调父类构造要用括号:Animal() 是「调用 Animal 的无参构造函数」,如果 Animal 有参构造,就写 class Dog : Animal(name, age)。
# 2.6.2 override强制标注
Kotlin 强制加 override 关键字,签名错误会立即报错,避免 Java 的「静默失败」:
// Java:忘了 @Override,签名写错,编译通过
public class Dog extends Animal {
public void speek() { } // 打错字,成了 speek——不是 override
}
// Kotlin:必须写 override
class Dog : Animal() {
override fun speek() { } // ❌ 编译错:'speek' 在父类找不到
override fun speak() { } // ✅
}
override 关键字还有个副作用:默认自动 open——子类的 override 方法可以被孙类再次 override。想禁止,加 final override:
open class Base { open fun foo() {} }
open class Middle : Base() {
final override fun foo() {} // Middle.foo 不允许再被继承者 override
}
# 2.6.3 接口默认方法
interface Animal {
fun speak()
fun greet() = println("Hi") // 默认方法
val species: String // 接口属性(抽象)
get() = "unknown" // 有默认 getter 也可以
}
Kotlin 接口天然支持默认方法,甚至可以有属性(无 backing field)——因为接口不能持有状态,属性只能通过 getter 派生。
多接口冲突 用 super<A>.foo() 消歧义:
interface A { fun x() = println("A.x") }
interface B { fun x() = println("B.x") }
class C : A, B {
override fun x() {
super<A>.x() // 显式调 A 的默认实现
super<B>.x() // 显式调 B 的默认实现
}
}
# 2.6.4 抽象类vs接口
| 需求 | 选择 |
|---|---|
| 需要状态(backing field) | 抽象类 |
| 多重实现能力 | 接口 |
| 二者都要 | 接口做行为、抽象类做骨架 |
具体决策标准:
- 想给类 加上一种能力(可点击、可比较、可迭代)——用 接口
- 想让一组类 共享同一份实现代码——用 抽象类
- 类是 必然的一部分 还是 偶尔的一部分——前者用抽象类,后者用接口
// 抽象类:所有 Shape 共享 area 属性和 log 逻辑
abstract class Shape {
abstract val area: Double
fun log() = println("area=$area")
}
// 接口:任何东西都可以被点击
interface Clickable {
fun onClick()
}
class Circle(val r: Double) : Shape(), Clickable {
override val area = Math.PI * r * r
override fun onClick() { }
}
# 2.6.5 综合案例与思考
多接口冲突消歧义完整案例:
interface Runner {
fun start() { println("Runner start") }
}
interface Loggable {
fun start() { println("Log start") }
fun log(msg: String) { println("[LOG] $msg") }
}
class Job : Runner, Loggable {
override fun start() {
super<Runner>.start()
super<Loggable>.start()
log("job started")
}
}
fun main() { Job().start() }
输出:
Runner start
Log start
[LOG] job started
思考:Kotlin 为什么不允许多继承类,却允许实现多个带默认方法的接口?
原因:菱形继承问题——两个父类如果有相同名字的字段/方法,子类无法确定用哪个(Java 也是这个原因禁止多继承)。接口没有字段(只有派生属性),所以不存在「状态菱形」;方法冲突用
super<A>.foo()显式消歧义即可,语义清晰。
# 2.7 委托机制
# 2.7.1 类委托by
interface Repo { fun save(): Int }
class RepoImpl : Repo { override fun save() = 1 }
class LoggingRepo(val delegate: Repo) : Repo by delegate {
override fun save(): Int {
println("before save")
return delegate.save()
}
}
fun main() {
val repo: Repo = LoggingRepo(RepoImpl())
repo.save()
}
Repo by delegate 表示「接口 Repo 的方法全部转发到 delegate」,可以选择性 override 加逻辑。组合优于继承 的语言级支持。
编译产物——class LoggingRepo(val delegate: Repo) : Repo by delegate 大致等价于:
class LoggingRepo(val delegate: Repo) : Repo {
override fun save() = delegate.save() // 自动生成
// 若 Repo 有 fetch()/delete()/...,通通生成转发
}
Kotlin 编译器帮你把 100 行 boilerplate 缩成一个 by。
# 2.7.2 属性委托by
val lazyName: String by lazy { computeName() }
var observed: String by Delegates.observable("init") { _, old, new ->
println("$old -> $new")
}
属性委托规则:val prop: T by delegate 要求 delegate 有 getValue(...) 方法;var prop: T by delegate 还要求有 setValue(...) 方法。签名:
operator fun getValue(thisRef: Any?, property: KProperty<*>): T
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T)
自己写一个属性委托:
class UpperCase {
private var value = ""
operator fun getValue(thisRef: Any?, prop: KProperty<*>): String = value
operator fun setValue(thisRef: Any?, prop: KProperty<*>, v: String) {
value = v.uppercase()
}
}
class Person {
var name: String by UpperCase()
}
fun main() {
val p = Person()
p.name = "kotlin"
println(p.name) // 输出: KOTLIN
}
# 2.7.3 内建委托家族
| 委托 | 用途 |
|---|---|
lazy | 惰性求值 |
Delegates.observable | 属性变化观察 |
Delegates.vetoable | 属性变化否决(返回 false 拒绝赋值) |
Delegates.notNull | 无初始值的非空 val 变量 |
Map 委托 | val name: String by map 从 Map 里取值 |
vetoable 示例:
var age: Int by Delegates.vetoable(0) { _, _, new -> new in 0..150 }
age = 30 // ✅ 生效
age = -5 // ❌ 被拒绝,仍是 30
Map 委托示例——常用于反序列化:
class Config(map: Map<String, Any?>) {
val name: String by map
val port: Int by map
}
val cfg = Config(mapOf("name" to "server", "port" to 8080))
println("${cfg.name}:${cfg.port}") // server:8080
委托字节码原理详见原理篇 5。
# 2.7.4 综合案例与思考
给 Repository 加日志与缓存两层装饰,全部用委托:
interface UserRepo {
fun find(id: Long): User?
fun save(u: User)
}
class UserRepoDb : UserRepo {
override fun find(id: Long) = User(id, "db-$id")
override fun save(u: User) = println("db save $u")
}
// 装饰一:日志
class LoggingUserRepo(inner: UserRepo) : UserRepo by inner {
override fun find(id: Long): User? {
println("[LOG] find $id")
return (this as UserRepo).let { inner -> // 巧妙:直接调 delegate
// 简化写法:委托方法可以直接被 super 或者绕过——我们改写 override
null // 举例简化
}
}
}
// 装饰二:缓存
class CachingUserRepo(private val inner: UserRepo) : UserRepo by inner {
private val cache = mutableMapOf<Long, User>()
override fun find(id: Long): User? = cache.getOrPut(id) {
inner.find(id) ?: return null
}
}
fun main() {
val repo: UserRepo = CachingUserRepo(LoggingUserRepo(UserRepoDb()))
repo.find(1)
repo.find(1) // 第二次命中缓存
}
思考:类委托与 Java 的动态代理(Proxy.newProxyInstance)区别在哪?
区别对比:
维度 Kotlin by委托Java Proxy动态代理生成时机 编译期生成字节码 运行期反射生成 性能 直接方法调用,等同于普通调用 慢约 5~10 倍 灵活性 编译期决定 运行期决定,可拦截任何方法 典型用例 组合优于继承的语法糖 AOP、拦截器、Mock 框架
# 2.8 泛型与型变
# 2.8.1 泛型类与函数
class Box<T>(val value: T) // 泛型类
fun <T> singletonList(item: T): List<T> = listOf(item) // 泛型函数
Kotlin 泛型采用 类型擦除 模型——运行期没有 T 的具体类型信息(与 Java 一致)。想在运行期看到类型,用 reified(2.8.3)。
泛型约束:
fun <T : Number> sum(list: List<T>): Double = list.sumOf { it.toDouble() }
<T : Number> 表示 T 必须是 Number 或其子类。
# 2.8.2 in与out型变
| 关键字 | 含义 | 记忆 |
|---|---|---|
out T | 协变(生产者) | 只 出 不进 |
in T | 逆变(消费者) | 只 进 不出 |
| (无) | 不变 | 两者都可 |
interface Producer<out T> { fun produce(): T } // 只返回 T
interface Consumer<in T> { fun consume(t: T) } // 只消费 T
// 协变:Producer<String> 可赋值给 Producer<Any>
val p1: Producer<String> = ...
val p2: Producer<Any> = p1 // ✅
// 逆变:Consumer<Any> 可赋值给 Consumer<String>
val c1: Consumer<Any> = ...
val c2: Consumer<String> = c1 // ✅
记忆技巧——「PECS」原则(Producer Extends,Consumer Super):
- 类只 返回 T——协变(
out) - 类只 消费 T——逆变(
in) - 类既返回又消费——不变
为什么 List<String> 不能直接赋值给 List<Any>?——因为 List 在 Kotlin 里是可读的(get)也 是可写的(虽然 MutableList 才有 add)——如果允许赋值,你就可以:
val strs: MutableList<String> = mutableListOf("a")
val anys: MutableList<Any> = strs // 假设允许
anys.add(42) // 往 List<Any> 塞了 Int
val s: String = strs[1] // 💥 ClassCastException
所以 Kotlin 只让 只读的 List 协变(interface List<out E>),可变的 MutableList 不变。
# 2.8.3 reified具化类型
Kotlin 独有——在 inline 函数里 能保留泛型类型:
inline fun <reified T> Any.isType(): Boolean = this is T
val ok = "hi".isType<String>() // true
val no = 42.isType<String>() // false
用途:解析 JSON、反射、类型检查等场景,避免手动传 Class<T>:
inline fun <reified T> Json.decode(json: String): T =
parse(json, T::class.java) // ✅ 直接拿到 T::class
// 调用侧
val user = json.decode<User>(str)
字节码原理见 原理篇 4。
# 2.8.4 where约束语法
多重约束用 where:
fun <T> copyWhenGreater(list: List<T>, threshold: T): List<T>
where T : CharSequence, T : Comparable<T> {
return list.filter { it > threshold }
}
fun main() {
val r = copyWhenGreater(listOf("apple", "banana", "cherry"), "b")
println(r) // [banana, cherry]
}
where T : CharSequence, T : Comparable<T> 表示 T 必须同时满足两个约束——「是字符序列」并且「可比较」。
# 2.8.5 综合案例与思考
泛型 EventBus 设计:
import kotlin.reflect.KClass
object EventBus {
private val subs = mutableMapOf<KClass<*>, MutableList<(Any) -> Unit>>()
inline fun <reified T : Any> subscribe(noinline handler: (T) -> Unit) {
@Suppress("UNCHECKED_CAST")
subs.getOrPut(T::class) { mutableListOf() }
.add(handler as (Any) -> Unit)
}
fun publish(event: Any) {
subs[event::class]?.forEach { it(event) }
}
}
data class LoginEvent(val userId: Long)
data class LogoutEvent(val userId: Long)
fun main() {
EventBus.subscribe<LoginEvent> { println("登录: ${it.userId}") }
EventBus.subscribe<LogoutEvent> { println("登出: ${it.userId}") }
EventBus.publish(LoginEvent(100))
EventBus.publish(LogoutEvent(100))
}
输出:
登录: 100
登出: 100
reified 让 subscribe<LoginEvent> { } 这种调用可以拿到 LoginEvent::class——不用像 Java EventBus 那样传 LoginEvent.class 参数。
思考:List<out Any> 和 List<Any> 差在哪?为什么 List<String> 不能直接赋值给 List<Any>?
答案:
List<Any>是不变的——只接受List<Any>类型。List<out Any>是使用点协变——接受List<T>其中T <: Any(即任何 List)。List<String>不能直接赋给List<Any>的原因见 2.8.2 的反例分析;但可以赋给List<out Any>——因为out标注保证了只读,不会破坏类型安全。
# 2.9 综合案例练习
# 2.9.1 电商订单系统
需求:
- 订单有 4 种状态:待支付 / 已支付 / 已发货 / 已完成
- 状态转换有严格规则
- 订单详情懒加载
- 每次状态变化打日志
import kotlin.properties.Delegates
sealed class OrderState {
object Pending : OrderState()
object Paid : OrderState()
object Shipped : OrderState()
object Done : OrderState()
}
data class OrderDetail(val items: List<String>, val amount: Long)
class Order(val id: Long, private val loader: () -> OrderDetail) {
// 详情懒加载——首次访问才计算
val detail: OrderDetail by lazy { loader() }
// 状态变化自动打日志
var status: OrderState by Delegates.observable<OrderState>(OrderState.Pending) { _, old, new ->
println("Order#$id: ${old::class.simpleName} → ${new::class.simpleName}")
}
fun pay() {
require(status is OrderState.Pending) { "只能对待支付订单调用 pay()" }
status = OrderState.Paid
}
fun ship() {
require(status is OrderState.Paid) { "只能对已支付订单调用 ship()" }
status = OrderState.Shipped
}
fun complete() {
require(status is OrderState.Shipped) { "只能对已发货订单调用 complete()" }
status = OrderState.Done
}
}
fun main() {
val order = Order(1001L) {
println("[db] 加载订单详情...")
OrderDetail(listOf("iPhone", "Case"), 8999)
}
order.pay()
order.ship()
// 此时才触发 detail 加载
println("金额: ${order.detail.amount}")
order.complete()
}
输出:
Order#1001: Pending → Paid
Order#1001: Paid → Shipped
[db] 加载订单详情...
金额: 8999
Order#1001: Shipped → Done
关键点:
sealed class OrderState+data class组合建模by lazy让详情按需加载Delegates.observable让状态变化自动打日志require强制状态转换规则
# 2.9.2 状态机建模
自动售货机——用 sealed class + when 做穷尽转换:
sealed class VendingState
object Idle : VendingState()
data class Inserted(val amount: Int) : VendingState()
data class Selected(val amount: Int, val item: String) : VendingState()
data class Dispensing(val item: String, val change: Int) : VendingState()
object Ready : VendingState()
sealed class Event
data class Insert(val coin: Int) : Event()
data class Select(val item: String, val price: Int) : Event()
object Take : Event()
fun transition(state: VendingState, event: Event): VendingState = when (state) {
Idle -> when (event) {
is Insert -> Inserted(event.coin)
else -> state
}
is Inserted -> when (event) {
is Insert -> Inserted(state.amount + event.coin)
is Select -> if (state.amount >= event.price)
Selected(state.amount, event.item) else state
else -> state
}
is Selected -> Dispensing(state.item, state.amount - state.item.length) // 简化
is Dispensing -> Ready
Ready -> Idle
}
fun main() {
var s: VendingState = Idle
listOf(Insert(5), Insert(3), Select("Coke", 6), Take, Take).forEach {
s = transition(s, it)
println("→ $s")
}
}
输出(简化):
→ Inserted(amount=5)
→ Inserted(amount=8)
→ Selected(amount=8, item=Coke)
→ Dispensing(item=Coke, change=4)
→ Ready
编译器要求 when 穷尽——如果新增一个状态(如「找零」),所有 when 分支都会编译报错。
# 2.10 类和对象底层原理
# 2.10.1 属性编译成什么
一段 Kotlin:
class Person(val name: String, var age: Int)
用 kotlinc -d out/ 编译后,javap -p out/Person.class:
public final class Person {
private final java.lang.String name;
private int age;
public Person(java.lang.String, int);
public final java.lang.String getName();
public final int getAge();
public final void setAge(int);
}
逐字段分析:
| Kotlin 声明 | 生成 Java 字段 | 生成 getter | 生成 setter |
|---|---|---|---|
val name: String | private final String name | getName() | 无(val) |
var age: Int | private int age | getAge() | setAge(int) |
@NotNull 参数校验注入——对非空引用类型(如 String),编译器在构造入口注入 Intrinsics.checkNotNullParameter(...):
0: aload_1
1: ldc #14 // String name
3: invokestatic Intrinsics.checkNotNullParameter
6: aload_0
7: aload_1
8: putfield Person.name
如果 Java 侧调用 new Person(null, 10),会立即抛 IllegalArgumentException——这就是 Kotlin 空安全从 Kotlin 世界向 Java 世界的边界防御。
# 2.10.2 data类生成物
对 data class User(val id: Long, val name: String):
public final class User {
private final long id;
private final String name;
public User(long, String);
public final long getId();
public final String getName();
public final long component1(); // 用于解构
public final String component2();
public final User copy(long, String); // copy(id = ..., name = ...)
public static User copy$default(User, long, String, int, Object);
public String toString(); // "User(id=..., name=...)"
public int hashCode();
public boolean equals(Object);
}
每个方法的实现骨架(伪代码):
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User that = (User) o;
return this.id == that.id && this.name.equals(that.name);
}
public int hashCode() {
return Long.hashCode(id) * 31 + name.hashCode();
}
详见 原理篇 2。
# 2.10.3 object的字节码
object AppConfig { } 生成:
public final class AppConfig {
public static final AppConfig INSTANCE;
private AppConfig() { }
static { // 静态初始化块
INSTANCE = new AppConfig();
}
}
静态初始化时序:JVM 保证同一个 class 只被类加载器加载 一次,加载时进入 <clinit> 静态初始化块——INSTANCE 在这个阶段被赋值。JVM 类加载本身持有 lock,天然线程安全。
Kotlin 单例 vs Java 传统单例对比:
| 实现方式 | 代码量 | 线程安全 | 性能 |
|---|---|---|---|
| Java 双检锁 | 15 行 | ✅(正确写才安全) | 每次 get 有 volatile 开销 |
| Java 静态内部类 | 10 行 | ✅ | 无开销 |
Kotlin object | 1 行 | ✅(类加载器保证) | 无开销 |
# 2.10.4 委托的字节码
class LoggingRepo(val delegate: Repo) : Repo by delegate 生成:
public final class LoggingRepo implements Repo {
private final Repo delegate;
public LoggingRepo(Repo delegate) {
this.delegate = delegate;
}
public Repo getDelegate() { return delegate; }
// 编译器自动生成的转发方法
public int save() {
return delegate.save();
}
// Repo 里所有方法都会生成一个类似的转发
}
性能对比——委托是编译期生成、直接方法调用,与 Java Proxy 相比:
| 维度 | Kotlin by | Java Proxy |
|---|---|---|
| 每次调用开销 | 一次 invokevirtual | invocationHandler.invoke + 反射 |
| 微基准(1 亿次) | 约 50 ms | 约 500 ms |
| 类字节码 | 编译期生成 | 运行期动态生成 |
# 2.11 训练题三道
训练题 1(Builder 模式):用 apply 或 DSL 语法为 HttpRequest 写一个链式 Builder,要求最终产物是 data class。
参考答案
data class HttpRequest(
val url: String,
val method: String = "GET",
val headers: Map<String, String> = emptyMap(),
val body: String? = null,
)
class HttpRequestBuilder {
var url: String = ""
var method: String = "GET"
private val headers = mutableMapOf<String, String>()
var body: String? = null
fun header(name: String, value: String) {
headers[name] = value
}
fun build() = HttpRequest(url, method, headers.toMap(), body)
}
fun httpRequest(block: HttpRequestBuilder.() -> Unit): HttpRequest =
HttpRequestBuilder().apply(block).build()
fun main() {
val r = httpRequest {
url = "https://api.demo.com/users"
method = "POST"
header("Content-Type", "application/json")
body = "{\"name\":\"kotlin\"}"
}
println(r)
}
陷阱点:如果不用 headers.toMap() 复制,外部拿到 HttpRequest.headers 后修改会影响 Builder 内部的 map——data class 就失去了不可变承诺。
训练题 2(状态机):用 sealed class 建模一台自动售货机(空闲、投币、选货、出货、找零 5 状态),并写 transition(event: Event): State 函数,保证 when 分支穷尽。
参考答案
sealed class State {
object Idle : State()
data class Coined(val cash: Int) : State()
data class Selected(val cash: Int, val item: Item) : State()
data class Dispensing(val item: Item) : State()
data class Changing(val remain: Int) : State()
}
data class Item(val name: String, val price: Int)
sealed class Event {
data class Coin(val amount: Int) : Event()
data class Select(val item: Item) : Event()
object Cancel : Event()
}
fun transition(s: State, e: Event): State = when (s) {
State.Idle -> when (e) {
is Event.Coin -> State.Coined(e.amount)
else -> s
}
is State.Coined -> when (e) {
is Event.Coin -> State.Coined(s.cash + e.amount)
is Event.Select ->
if (s.cash >= e.item.price) State.Selected(s.cash, e.item) else s
Event.Cancel -> State.Changing(s.cash)
}
is State.Selected -> State.Dispensing(s.item)
is State.Dispensing -> State.Changing(0) // 简化
is State.Changing -> State.Idle
}
陷阱点:when 分支必须穷尽 State 的所有子类,任何遗漏都会编译报错——这是 sealed class 相对 open class 的核心优势。
训练题 3(观察者模式):用属性委托 Delegates.observable 实现一个 LiveData<T> 简化版,支持多观察者、取消订阅。
参考答案
import kotlin.properties.Delegates
class LiveData<T>(initial: T) {
private val observers = mutableListOf<(T) -> Unit>()
var value: T by Delegates.observable(initial) { _, _, new ->
observers.forEach { it(new) }
}
fun observe(cb: (T) -> Unit): () -> Unit {
observers += cb
return { observers -= cb } // 返回「取消订阅」函数
}
}
fun main() {
val ld = LiveData(0)
val unsub1 = ld.observe { println("A: $it") }
val unsub2 = ld.observe { println("B: $it") }
ld.value = 1
ld.value = 2
unsub1()
ld.value = 3 // 只剩 B
}
输出:
A: 1
B: 1
A: 2
B: 2
B: 3
陷阱点:observers.forEach 期间如果 callback 内 也 调用 observe / unsub——会 ConcurrentModificationException。生产环境要用 observers.toList().forEach { } 或者更严谨的读写分离。
# 2.12 新手陷阱Top5
| # | 陷阱 | 症状 | 正确姿势 |
|---|---|---|---|
| 1 | data class 继承 | 继承 data class 破坏等价对称性 | data class 永远设为 final |
| 2 | object 与 companion object 混淆 | 想要静态方法但访问不到 | 类级用 companion,全局用 object |
| 3 | lateinit 未初始化访问 | UninitializedPropertyAccessException | ::prop.isInitialized 先判断 |
| 4 | 构造顺序踩坑 | 主构造属性未初始化时 init 已运行 | 别在 init 依赖后声明的属性 |
| 5 | open 忘加导致无法 override | 子类 override 编译报错 | 有意为之的可继承点显式 open |
扩展陷阱 4 项(进阶开发者也常踩):
- 陷阱 6:
companion object里的常量没加const,Java 侧调用要走Companion.INSTANCE.getFoo()——很啰嗦。凡是编译期常量(基础类型/字符串)都加const val。 - 陷阱 7:
data class的copy是 浅复制——如果内部有MutableList字段,两个实例共享同一个 list。需要深复制得手动copy(list = list.toMutableList())。 - 陷阱 8:
sealed class的子类如果是普通 class(不是 object 或 data class),when里的is匹配后 不会 自动 smart cast 到子类型的具体属性——需要is XXX -> ...明确指定。 - 陷阱 9:
enum的values()每次调用都返回 新数组——高频调用要缓存val ALL = values()或用 Kotlin 1.9 新增的entries(返回不可变 List)。
下一章预告:03.高阶特性入门 会带你尝一尝 Kotlin 独有的味道——扩展函数、DSL、协程初识、Java 互操作。