第 8 章 控制流与 when:卫语句、上下文相关解析与穷尽性
本章目标
- 理解 Kotlin "一切皆表达式"的设计及其字节码后果;
- 掌握
when的 8 种形态,尤其是 📌 卫语句(2.2 稳定) 和 数据流驱动穷尽性检查(2.3 稳定);- 精确知道
when编译成什么——tableswitch/lookupswitch/instanceof链 / if-else 链,以及各自的性能含义;- 发现一个库作者必须知道的枚举
when二进制兼容陷阱;- 掌握 📌 上下文相关解析(2.2 预览) 带来的代码简化。
8.1 一切皆表达式
在 Kotlin 里,几乎所有构造都返回值:
val max = if (a > b) a else b // if 是表达式
val desc = when { // when 是表达式
x < 0 -> "负"
x == 0 -> "零"
else -> "正"
}
val result = try { // try 是表达式
parse(s)
} catch (e: NumberFormatException) {
-1
}
val label = run { // 代码块也能是表达式(作用域函数)
val tmp = compute()
"value=$tmp"
}
唯一的例外:for / while / do-while 循环是语句(返回 Unit),因为它们没有有意义的"值"。
🔬 设计动因:
- 消除可变变量:
val x = if (...) a else b优于var x; if (...) x = a else x = b。前者是不可变的,后者可被后续代码修改。 - 消除"忘记赋值"的 bug:Java 里
int x; if (c) x = 1; ... use x会编译错误,但更隐蔽的是int x = 0; if (c) x = 1;——默认值 0 可能是错的。表达式形式强制覆盖所有分支。 - 让穷尽性检查成为可能:因为
when必须有值,编译器就能强制它覆盖所有情况(8.4)。
🔬 字节码层面:表达式形式和语句形式生成的字节码几乎一样——都是条件跳转 + 赋值。差别只在编译器的类型检查阶段。所以没有性能代价,纯粹是可读性与安全性收益。
// 表达式形式
val max = if (a > b) a else b
iload_0
iload_1
if_icmple L1
iload_0
goto L2
L1:
iload_1
L2:
istore_2 // max
8.2 when 的八种形态
// ① 无主题 when(等价于 if-else 链,但可读性更好)
val sign = when {
x < 0 -> -1
x == 0 -> 0
else -> 1
}
// ② 常量匹配
val name = when (code) {
200 -> "OK"
404 -> "Not Found"
500 -> "Server Error"
else -> "Unknown"
}
// ③ 多值合并(逗号分隔,共享分支体)
val isWeekend = when (day) {
Day.SAT, Day.SUN -> true
else -> false
}
// ④ 区间与集合包含
val grade = when (score) {
in 90..100 -> "A"
in 80..89 -> "B"
in validScores -> "valid" // 集合的 contains
else -> "F"
}
// ⑤ 类型检查 + 智能转换
val len = when (obj) {
is String -> obj.length // ✅ obj 已智能转换为 String
is List<*> -> obj.size
is IntArray -> obj.size
else -> -1
}
// ⑥ 带主题绑定(subject binding)
val result = when (val r = callService()) {
is Success -> r.data // ✅ r 在此可用且已智能转换
is Failure -> throw r.cause
}
// ⑦ 卫语句(📌 Kotlin 2.2 稳定)
when (val e = event) {
is Click if e.button == LEFT -> openMenu()
is Click if e.count == 2 -> openFile()
is Click -> select()
else -> {}
}
// ⑧ 调用任意布尔表达式
val action = when (val s = readLine()) {
s.startsWith("GET") -> handleGet(s)
s.contains("?") -> handleQuery(s)
null, "" -> default()
else -> handleOther(s)
}
⚠️ 形态 ⑧ 的隐蔽语义:when (x) { expr -> ... } 里的 expr 是与 x 做相等比较(x == expr),不是"求值 expr 的布尔结果"。所以:
when (s) {
s.startsWith("GET") -> ... // 实际是 s == s.startsWith("GET"),即 String == Boolean → 永远 false!
}
🚀 正确写法:要么用无主题 when {},要么用卫语句:
when {
s.startsWith("GET") -> handleGet()
else -> ...
}
// 或
when (s) {
is String if s.startsWith("GET") -> ...
}
这是 when 最常见的误用,编译器通常会给 incompatible types 错误,但如果类型碰巧兼容(如 when (b: Boolean)),就会静默失效。
8.3 卫语句(Guard Conditions):📌 Kotlin 2.1 预览 → 2.2 稳定
语法
when (subject) {
is Type if <布尔表达式> -> body
else -> ...
}
if 后面是卫语句,只有"类型匹配 且 卫语句为真"才进入该分支。
为什么需要它
旧写法(2.1 之前):
sealed interface Response
data class Success(val code: Int, val body: String) : Response
data class Error(val code: Int, val message: String) : Response
fun handle(r: Response) = when (r) {
is Success -> when { // ← 嵌套 when,丑
r.code == 200 -> render(r.body)
r.code == 204 -> noContent()
else -> unexpected(r.code)
}
is Error -> if (r.code >= 500) alert(r) else log(r)
}
新写法(卫语句):
fun handle(r: Response) = when (r) {
is Success if r.code == 200 -> render(r.body)
is Success if r.code == 204 -> noContent()
is Success -> unexpected(r.code)
is Error if r.code >= 500 -> alert(r)
is Error -> log(r)
}
扁平、线性、每个条件一目了然。
卫语句的四个规则
- 顺序敏感,首个匹配生效:
when (x) {
is Int if x > 0 -> "正"
is Int -> "非正" // 只有 x <= 0 才到这里
else -> "其他"
}
⚠️ 如果把 is Int -> 放在前面,后面的卫语句分支永远不可达(编译器会给 unreachable code 警告)。
- 卫语句里可以用智能转换后的变量:
when (val s = input) {
is String if s.length > 10 -> s.take(10) // ✅ s 是 String
is List<*> if s.size > 5 -> s.subList(0, 5) // ✅ s 是 List<*>
else -> null
}
- 卫语句会破坏穷尽性推断:
sealed interface E { data object A : E; data object B : E }
fun f(e: E) = when (e) {
is E.A -> 1
is E.B -> 2
// ✅ 穷尽,无需 else
}
fun g(e: E) = when (e) {
is E.A if someCondition() -> 1
is E.B -> 2
// ❌ 编译错误:'when' expression must be exhaustive
// 因为编译器无法知道 someCondition() 一定为真
}
🚀 这是有意的设计:卫语句引入了"运行时才知道的条件",编译器无法静态证明覆盖完整,所以强制你加 else。这避免了一整类 bug。
- 卫语句可以有副作用,但强烈不建议:
when (x) {
is Int if logAndCheck(x) -> ... // ⚠️ 卫语句会被求值一次;如果分支不匹配,副作用已经发生
}
🔬 求值顺序:类型检查 → 卫语句 → (若都为真)分支体。不匹配的分支,其卫语句仍会被求值(直到某个分支匹配为止)。所以卫语句必须是纯函数。
开启方式
📌 Kotlin 2.2.0 起稳定,无需任何编译器开关。2.1.x 需要:
compilerOptions { freeCompilerArgs.add("-Xguard-conditions") }
8.4 穷尽性检查:Kotlin 最重要的安全网之一
基本规则
| 场景 | when 作为表达式 | when 作为语句 |
|---|---|---|
主题是 sealed class/sealed interface | 必须穷尽(编译错误) | 📌 1.7 起也必须穷尽(先是警告,后升级为错误) |
主题是 enum class | 必须穷尽 | 📌 同上 |
主题是 Boolean | 必须覆盖 true/false | 同 |
主题是 Nothing | 无需分支 | |
| 其他类型 | 必须有 else | 无要求 |
为什么这条规则价值巨大
sealed interface PaymentResult {
data class Paid(val amount: Long) : PaymentResult
data class Declined(val reason: String) : PaymentResult
}
fun render(r: PaymentResult) = when (r) {
is Paid -> showPaid(r.amount)
is Declined -> showDeclined(r.reason)
}
// 三个月后,有人加了新的子类型:
data class Refunded(val amount: Long) : PaymentResult
加上 Refunded 的瞬间,全项目所有对 PaymentResult 的 when 都编译失败。 编译器帮你列出了每一处需要处理新情况的代码。
对比 Java:
switch (result) {
case Paid p -> ...;
case Declined d -> ...;
// 加了 Refunded 之后,这里静默地什么都不做 💥
}
Java 21 的 sealed + pattern matching switch 也有穷尽性检查,但要求 switch 是表达式或用于赋值,且生态中 sealed 的使用远不如 Kotlin 普及。
🚀 这就是"用 sealed 建模领域状态"的核心价值:把"新增一种情况时容易漏改"这个人为错误,变成编译错误。
📌 数据流驱动的穷尽性检查(2.2.20 引入,2.3.0 稳定)
传统的穷尽性检查只看类型。新版本还能利用数据流信息:
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Square(val s: Double) : Shape
// 传统写法
fun area1(sh: Shape) = when (sh) {
is Circle -> PI * sh.r * sh.r
is Square -> sh.s * sh.s
}
// 📌 数据流驱动:编译器能推断 else 分支里剩下的类型
fun area2(sh: Shape) = when {
sh !is Circle -> when (sh) { // 这里 sh 已知是 Square
is Square -> sh.s * sh.s
}
else -> PI * sh.r * sh.r // ✅ sh 被智能转换为 Circle,无需 is 检查
}
// 更常见的形态
fun describe(x: Any?): String = when {
x is Int -> "int $x"
x is String -> "str $x"
x == null -> "null"
else -> "other: ${x::class.simpleName}" // 编译器知道这里 x 非空且不是 Int/String
}
🔬 实现原理:K2 前端的 FIR 里,每个 when 分支的条件会更新数据流信息(KtFakeDataFlowInfo),后续分支的类型检查基于"已排除的类型"进行。这是 K2 才能做到的(旧前端的数据流分析与类型检查耦合不紧)。
📌 同批稳定的还有:
- 嵌套类型别名(nested type aliases)(2.3.0 稳定);
- 表达式体中的
return语句(2.2.20 引入,📌 2.3.0 默认启用):
// 需要显式声明返回类型
fun findUser(id: String): User? =
users.firstOrNull { it.id == id } ?: return null // ✅ 现在可以在表达式体里 return
8.5 when 编译成什么?—— 决定性能的真相
这是本章最有价值的部分。when 的性能取决于它被编译成什么结构。
情况 ①:Int 常量,值域紧凑 → tableswitch(O(1) 跳转表)
fun label(n: Int) = when (n) {
1 -> "one"; 2 -> "two"; 3 -> "three"; else -> "many"
}
public static final String label(int);
0: iload_0
1: tableswitch {
1: 28 // → "one"
2: 30 // → "two"
3: 32 // → "three"
default: 34 // → "many"
}
28: ldc "one"
30: ldc "two"
32: ldc "three"
34: ldc "many"
🔬 tableswitch 是一个跳转地址数组,JVM 用 n - low 做索引,O(1) 常数时间,与分支数量无关。
情况 ②:Int 常量,值域稀疏 → lookupswitch(二分查找,O(log n))
fun label(n: Int) = when (n) {
1 -> "a"; 100 -> "b"; 10000 -> "c"; else -> "d"
}
1: lookupswitch {
1: 32
100: 34
10000: 36
default: 38
}
🔬 lookupswitch 是一个有序的 (值, 跳转) 对数组,JVM 用二分查找,O(log n)。
📌 编译器的选择启发式(javac 与 kotlinc 类似):如果 (max - min) / 分支数 < 某个阈值(通常约 3–4),用 tableswitch;否则 lookupswitch。因为 tableswitch 需要分配 (max-min+1) 个槽位,稀疏值域会浪费大量常量池空间。
⚠️ 极端例子:
when (n) {
0 -> ...
1_000_000_000 -> ...
else -> ...
}
如果用 tableswitch,需要 10 亿个槽位 → 编译器一定选 lookupswitch。
情况 ③:String → hashCode + lookupswitch + equals 二次确认
fun parse(s: String) = when (s) {
"GET" -> Method.GET
"POST" -> Method.POST
else -> Method.UNKNOWN
}
0: aload_0
1: astore_1
2: iconst_m1
3: istore_2
4: aload_1
5: invokevirtual #10 // String.hashCode:()I
8: lookupswitch {
70453: 30 // "GET" 的 hashCode
2461856: 44 // "POST" 的 hashCode
default: 58
}
30: aload_1
31: ldc #14 // "GET"
33: invokevirtual #16 // String.equals:(Ljava/lang/Object;)Z ← 二次确认!
36: ifne 40
39: goto 58
40: iconst_0
41: istore_2
...
🔬 为什么要二次 equals 确认? 因为 hash 冲突:两个不同的字符串可能有相同的 hashCode。跳转表只能按 hash 分派,所以必须再比一次内容。
⚠️ 性能含义:String 的 when 比 Int 的 when 慢得多——一次 hashCode()(虽然 String 缓存了 hash,首次仍需计算)+ 一次跳转 + 一次或多次 equals(逐字符比较)。
🚀 热路径优化:如果 String 分支很多且很热,考虑:
- 先映射成
enum,再对 enum 做when(enum 走 ordinal 跳转,快); - 用
Map<String, Handler>(HashMap 查找,也是 hash + equals,但可复用); - 如果字符串短且首字符区分度高,先
when (s[0])再嵌套。
情况 ④:enum → 合成映射表 + 跳转
enum class Day { MON, TUE, WED, THU, FRI, SAT, SUN }
fun isWorkday(d: Day) = when (d) {
Day.SAT, Day.SUN -> false
else -> true
}
🔬 Kotlin 生成一个合成的映射类:
// 合成类:book.ch08.WhenMappings(或类似名称)
static final class WhenMappings {
static final int[] $EnumSwitchMapping$0;
static {
$EnumSwitchMapping$0 = new int[Day.values().length];
$EnumSwitchMapping$0[Day.MON.ordinal()] = 1;
$EnumSwitchMapping$0[Day.TUE.ordinal()] = 2;
...
}
}
// when 的字节码
getstatic WhenMappings.$EnumSwitchMapping$0
aload_0
invokevirtual Day.ordinal:()I
iaload // mapping[ordinal]
tableswitch { ... }
⚠️ 为什么需要这个间接映射?—— 一个库作者必须知道的陷阱
因为 ordinal() 是不稳定的。
如果直接用 ordinal() 做 tableswitch:
Day 枚举顺序:MON=0, TUE=1, WED=2, ...
已编译的调用方:tableswitch case 0 → "工作日", case 5 → "周末"
现在库作者在中间插入一个新值:
enum class Day { MON, TUE, WED, THU, FRI, SAT, SUN, HOLIDAY } // 加在末尾 → 安全
enum class Day { SUN, MON, TUE, ... } // 改顺序 → 💥 灾难
如果直接依赖 ordinal,重排枚举会让所有已编译的调用方产生错误行为(不报错,只是逻辑错乱)。
映射表的作用:在运行时(WhenMappings 的 <clinit>)根据当前加载的枚举类重新计算 ordinal → case 索引的映射。所以只要调用方重新编译过(映射表是调用方的类),顺序变化就是安全的。
📌 但这里有个残留风险:如果调用方没有重新编译,它带着旧的 WhenMappings 类运行,映射仍然是旧的 → 依然错乱。
🚀 库设计准则:
- 永远在枚举末尾追加新值,不要插入或重排;
- 不要把
ordinal()持久化(数据库、序列化)——用name或显式code字段; - 如果枚举会跨版本演化,给它一个显式的稳定 id:
enum class Day(val code: Int) {
MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(7);
companion object {
private val BY_CODE = entries.associateBy { it.code }
fun fromCode(c: Int) = BY_CODE[c] ?: error("unknown code $c")
}
}
📌 entries(Kotlin 1.9+) 是 values() 的现代替代:返回不可变的 EnumEntries<T>(缓存的 List),而 values() 每次调用都克隆数组(防御性复制)。热路径上应该用 entries:
Day.values() // 每次都 new Day[7] + arraycopy ← 有分配!
Day.entries // 返回缓存的不可变 List ← 零分配 ✅
🧪 实验 8-1:验证 values() 的分配
@Benchmark fun valuesCall() = Day.values().size // 分配 1 个数组
@Benchmark fun entriesCall() = Day.entries.size // 零分配
用 -prof gc 观察 gc.alloc.rate.norm:前者约 40 B/op,后者 0 B/op。
情况 ⑤:类型检查 → instanceof 链
when (x) {
is String -> ...
is Int -> ...
is List<*> -> ...
else -> ...
}
aload_0
dup
astore_1
instanceof #10 // String
ifeq L1
...
L1:
aload_1
instanceof #14 // Integer
ifeq L2
...
🔬 这是线性扫描,O(n)。分支越多越慢,且顺序影响性能(先检查的类型命中率应该更高)。
📌 JIT 的优化:C2 会用类型 Profile 记录每个 instanceof 的实际命中分布,然后:
- 重排检查顺序(把热的放前面);
- 如果只有一个类型出现,直接内联该分支(单态优化);
- 如果分支可预测,用条件移动指令替代跳转。
所以解释执行时 instanceof 链很慢,但充分预热后 JIT 能大幅优化。这也是"不要在预热期做性能判断"的又一例证。
情况 ⑥:混合/复杂条件 → if-else 链
无主题的 when {}、含区间的 in、含卫语句的分支,都退化成朴素的 if-else:
条件1 → ifeq next1 → body1 → goto end
next1:
条件2 → ifeq next2 → body2 → goto end
...
性能对照总结
when 形态 | 编译结构 | 复杂度 | 相对速度 |
|---|---|---|---|
| Int,值域紧凑 | tableswitch | O(1) | ★★★★★ |
| Int,值域稀疏 | lookupswitch | O(log n) | ★★★★ |
| enum | 映射表 + tableswitch | O(1) + 1 次数组访问 | ★★★★ |
| String | hashCode + lookupswitch + equals | O(log n) + 字符串比较 | ★★★ |
sealed 类型(is 检查) | instanceof 链 | O(n) | ★★(JIT 预热后改善) |
| 无主题 / 卫语句 / 区间 | if-else 链 | O(n) | ★★ |
in range(多个区间) | 逐个比较 | O(n) | ★★ |
🚀 实践建议:
- 不要因为性能而回避
when——绝大多数场景差异在纳秒级,可读性收益远大于此; - 在真正的热路径(每秒百万次的分派逻辑,如协议解析、路由),优先用 Int/enum 主题;
- String 分派极热时,先转 enum;
- sealed 类型的
when分支数通常 < 10,instanceof链完全够用。
8.6 循环与迭代
for 的三种底层机制
for (i in 1..10) { } // ① 降解为原生 int 循环(零分配)
for (x in list) { } // ② 迭代器协议:调用 iterator()
for (i in list.indices) { } // ③ IntRange → 原生循环
for ((i, x) in list.withIndex()) {// ④ 分配 IndexedValue 对象(除非被优化)
🔬 迭代器协议:for (x in c) 编译为:
val it = c.iterator()
while (it.hasNext()) {
val x = it.next()
// body
}
字节码里是 invokeinterface Iterator.hasNext/next。
⚠️ 分配问题:
ArrayList的iterator()分配一个Itr对象(📌 JDK 27 紧凑头下 24 字节);- JIT 的逃逸分析通常能消除它(标量替换),但不保证——如果循环体很大或迭代器逃逸,就会真的分配。
🚀 IntArray 等原生数组的 for 是零分配的:
val arr = IntArray(100)
for (x in arr) { } // 编译为 index-based 循环,不创建 Iterator ✅
🧪 实验 8-2:验证数组不分配迭代器
javap -c -p out/LoopKt.class | grep -A20 "arrayLoop"
iconst_0
istore_1
aload_0
arraylength
istore_2
loop:
iload_1
iload_2
if_icmpge end
aload_0
iload_1
iaload // ← 直接数组访问,无 Iterator
...
withIndex() 的代价
for ((i, x) in list.withIndex()) { }
🔬 withIndex() 返回 Iterable<IndexedValue<T>>,其迭代器每次 next() 分配一个 IndexedValue 对象(含 index 和 value 两个字段)。解构 val (i, x) 调用 component1()/component2()。
⚠️ 在热路径上这会产生大量短命对象。JIT 通常能标量替换掉,但更安全的写法:
for (i in list.indices) {
val x = list[i] // 零分配 ✅
}
while / do-while
while (cond) { } // 先判断
do { } while (cond) // 后判断,至少执行一次
与 Java 完全一致。
📌 非局部 break / continue(Kotlin 2.2 稳定)
在内联 Lambda 里,break/continue 可以作用于外层的循环:
// Kotlin 2.2+ 稳定
val result = mutableListOf<Int>()
for (i in 1..10) {
listOf(1, 2, 3).forEach { j ->
if (i * j > 12) break // ✅ 跳出外层 for!(需要标签在早期版本)
result.add(i * j)
}
}
📌 2.2 之前必须写标签:
loop@ for (i in 1..10) {
listOf(1, 2, 3).forEach { j ->
if (i * j > 12) break@loop
result.add(i * j)
}
}
🔬 为什么这只有 inline 才行:非局部控制流要求 lambda 体在同一个方法帧内。forEach 是 inline 的,所以 lambda 被复制到循环体里,break 就是普通的字节码 goto。如果 forEach 不是 inline,lambda 是独立对象,break 无法跨越方法边界。
⚠️ 这就是 crossinline 存在的原因(第 9 章):它禁止非局部返回,让 lambda 可以被传给非内联的执行点。
标签
outer@ for (i in 1..3) {
inner@ for (j in 1..3) {
if (i * j > 4) break@outer
continue@inner
}
}
// 标签也可用于 lambda 返回
fun findFirstEven(list: List<Int>): Int? {
list.forEach label@{
if (it % 2 == 0) return@label // 从 lambda 返回(继续下一个元素)
if (it > 100) return null // 从外层函数返回(非局部返回)
}
return null
}
🔬 return@label vs return 的区别是 Kotlin 新手最容易搞混的:
return@forEach→ 局部返回,从 lambda 返回,循环继续;return(在 inline lambda 内)→ 非局部返回,从外层函数返回,循环终止。
fun f(list: List<Int>): String {
list.forEach {
if (it == 2) return "found 2" // 非局部返回,f 立刻返回
if (it == 3) return@forEach // 局部返回,继续下一个元素
}
return "done"
}
8.7 📌 上下文相关解析(Context-Sensitive Resolution,2.2 预览)
一个让代码显著变简洁的特性:
enum class Problem { CONNECTION, AUTHENTICATION, DATABASE, UNKNOWN }
// 旧写法
fun message(p: Problem) = when (p) {
Problem.CONNECTION -> "connection"
Problem.AUTHENTICATION -> "authentication"
Problem.DATABASE -> "database"
Problem.UNKNOWN -> "unknown"
}
// 📌 上下文相关解析(2.2 预览,需开启)
fun message(p: Problem) = when (p) {
CONNECTION -> "connection" // ✅ 编译器从 when 主题的类型推断前缀
AUTHENTICATION -> "authentication"
DATABASE -> "database"
UNKNOWN -> "unknown"
}
同样适用于 sealed 类型的子类、buildList { } 里的方法等。
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Square(val s: Double) : Shape
fun area(sh: Shape) = when (sh) {
is Circle -> PI * sh.r * sh.r
Circle -> ... // 也可以(作为类型时)
else -> 0.0
}
开启方式(📌 2.2–2.3 为预览):
compilerOptions { freeCompilerArgs.add("-Xcontext-sensitive-resolution") }
⚠️ 谨慎评估:
- 收益:减少重复前缀,
when更紧凑; - 代价:降低"一眼看出这是哪个类型的成员"的明确性;在大型
when中可能引起歧义(如果多个类型有同名成员)。
🚀 建议:在分支很多、前缀很长的 when 中启用(收益大);在短小的 when 中保持全名(可读性优先)。团队应统一约定。
8.8 智能转换与控制流的深度交互
fun process(input: Any?): String {
// ① 类型检查后智能转换
if (input is String) return input.uppercase()
// ② null 检查后智能转换
if (input == null) return "null"
// ③ 此时 input 是 Any(非空)
// ④ 但编译器不记得它不是 String —— 数据流信息会累积
return when (input) {
is Int -> "int ${input + 1}" // input 是 Int
is List<*> -> "list ${input.size}" // input 是 List<*>
else -> input.toString() // input 是 Any(非空,非 String/Int/List)
}
}
🔬 数据流分析的累积性:每个 if/when 分支都会收窄类型信息,后续代码基于收窄后的类型。这是 K2 前端(FIR)的核心能力之一。
⚠️ 智能转换失效的常见场景(复习第 6 章):
class Holder { var value: Any? = null }
fun f(h: Holder) {
if (h.value is String) {
// println(h.value.length) // ❌ var 属性,不能智能转换
val v = h.value // ✅ 固定到局部 val
if (v is String) println(v.length)
}
}
🚀 最优雅的解法:when + 主题绑定
fun f(h: Holder) = when (val v = h.value) { // 一次固定,全分支可用
is String -> v.length
is Int -> v
null -> 0
else -> -1
}
8.9 控制流的性能:分支预测的视角
🔬 CPU 层面的真相:现代 CPU 有分支预测器,能预测 90%+ 的分支。预测命中时,分支几乎零成本;预测失败时,流水线冲刷(pipeline flush)代价约 15–20 个周期。
这解释了三个现象:
现象 1:排序后的数组遍历更快
val data = IntArray(32768) { Random().nextInt(200) - 100 }
fun sumPositive(): Long {
var s = 0L
for (v in data) if (v >= 0) s += v // 未排序:分支不可预测 → 慢
return s
}
// data.sort() 之后:分支完全可预测(先全 false 后全 true)→ 快 2–5 倍
这是著名的 StackOverflow 问题("Why is processing a sorted array faster")。在 JVM 上,C2 有时能自动优化(如用条件移动指令替代分支),但不总是。
现象 2:tableswitch 比 if-else 链快的真正原因
不只是 O(1) vs O(n),更重要的是间接跳转对分支预测器的压力更小(现代 CPU 有专门的间接分支预测器 BTB),而长的 if-else 链会产生大量难以预测的条件分支。
现象 3:异常处理很慢
// ❌ 用异常做控制流
try { s.toInt() } catch (e: NumberFormatException) { 0 }
// ✅ 用可空返回
s.toIntOrNull() ?: 0
🔬 为什么慢:
- 抛异常需要构造异常对象 + 填充栈轨迹(
fillInStackTrace是 native 方法,需要遍历整个调用栈,成本 O(栈深度)); - JVM 需要展开栈帧(unwinding),逐个查找匹配的 catch;
- 异常表查找 + 去优化(JIT 编译的方法在首次抛异常时通常会去优化)。
🚀 优化手段:
- 用
toIntOrNull()、getOrNull()、firstOrNull()、as?这些"可空返回"API; - 如果必须用异常且不需要栈轨迹,重写
fillInStackTrace():
class BusinessException(msg: String) : RuntimeException(msg) {
override fun fillInStackTrace(): Throwable = this // 跳过栈轨迹填充,快 10–100 倍
}
⚠️ 但这会丢失调试信息,仅适用于"预期内的、高频的业务异常"。
📌 协程的取消异常(CancellationException)就属于这类高频异常,kotlinx.coroutines 对它做了专门优化(第 42 章)。
8.10 本章小结
- Kotlin 的
if/when/try都是表达式。这在字节码层面零成本,但带来三大收益:消除可变变量、强制覆盖所有分支、让穷尽性检查成为可能。 when有 8 种形态,最常见的误用是"在带主题的when里写布尔表达式"(实际是相等比较,会静默失效)。- 📌 卫语句(2.2 稳定) 让复杂条件扁平化,但会破坏穷尽性推断(强制加
else),且卫语句必须无副作用(不匹配的分支也会被求值)。 - 📌 数据流穷尽性检查(2.3 稳定) 是 K2 的能力:编译器能基于"已排除的类型"推断后续分支,配合智能转换大幅简化代码。
when的编译结构决定性能:Int 紧凑值域 →tableswitch(O(1));稀疏 →lookupswitch(O(log n));String → hash + 跳转 +equals二次确认;sealed/类型 →instanceof链(O(n));无主题/卫语句 → if-else 链。- enum 的
when通过合成映射表(WhenMappings)间接跳转,这是为了隔离ordinal()的不稳定性。库作者必须只在枚举末尾追加值,且永不持久化ordinal()。 entries(1.9+)优于values():后者每次调用都克隆数组,热路径上有分配。- 📌 非局部
break/continue(2.2 稳定) 只对inlinelambda 有效,因为它依赖"lambda 体在同一栈帧内"。 - 不要用异常做控制流:
fillInStackTrace的成本是 O(栈深度),用toIntOrNull()这类可空 API 替代。
8.11 练习题
8-1. 下面的代码有什么问题?如何修复?
sealed interface Msg
data class Text(val s: String) : Msg
data class Image(val url: String, val size: Long) : Msg
fun render(m: Msg, verbose: Boolean) = when (m) {
is Text if verbose -> m.s
is Image if verbose -> "${m.url} (${m.size})"
is Text -> m.s.take(10)
is Image -> m.url
}
答案要点
代码是正确的(穷尽性满足:Text 和 Image 都被无条件分支覆盖)。但有两个改进点:
-
顺序依赖脆弱:如果以后有人把
is Text -> m.s.take(10)移到is Text if verbose之前,卫语句分支就永远不可达(编译器会给 unreachable 警告,但容易被忽略)。建议把"带卫语句的分支"和"兜底分支"用注释分隔。 -
可读性:更清晰的写法是把
verbose提到外层:
fun render(m: Msg, verbose: Boolean) = when (m) {
is Text -> if (verbose) m.s else m.s.take(10)
is Image -> if (verbose) "${m.url} (${m.size})" else m.url
}
判断准则:卫语句适合"同一个类型的不同子情况需要完全不同的处理逻辑";如果只是同一逻辑的参数差异,用嵌套 if 更清晰。
8-2. 下面两个函数功能相同,哪个更快?为什么?(假设 n 均匀分布在 0..1000)
fun a(n: Int) = when (n) {
in 0..10 -> 1
in 11..100 -> 2
in 101..1000 -> 3
else -> 4
}
fun b(n: Int) = when {
n <= 10 -> 1
n <= 100 -> 2
n <= 1000 -> 3
else -> 4
}
答案要点
b 更快(略快)。
- a 使用
in IntRange,编译为Range.contains(n),即n >= first && n <= last——两次比较。三个分支最多 6 次比较。 - b 使用
n <= X——一次比较。三个分支最多 3 次比较。
实际差异在纳秒级(JIT 可能把两者都优化成相似的机器码,特别是如果它识别出 contains 的模式并内联)。但在极端热路径上,b 少一半的比较指令。
⚠️ 更重要的洞察:如果 n 的分布高度倾斜(例如 90% 落在第一个区间),分支顺序比分支形式影响大得多。应该把最热的分支放最前面。
🚀 可读性 vs 性能:a 的可读性明显更好(in 0..10 一目了然)。除非 profile 证明这里是瓶颈,否则选 a。
8-3.(🧪 动手)验证 values() 和 entries 的分配差异。
答案要点
enum class Color { RED, GREEN, BLUE }
@Benchmark @OperationsPerInvocation(1000)
fun valuesBench(): Int { var s = 0; repeat(1000) { s += Color.values().size }; return s }
@Benchmark @OperationsPerInvocation(1000)
fun entriesBench(): Int { var s = 0; repeat(1000) { s += Color.entries.size }; return s }
./gradlew :bench:jmh --args='-prof gc .*ColorBench.*'
预期:
valuesBench:gc.alloc.rate.norm ≈ 24000 B/op(1000 次 × 24 字节数组)entriesBench:gc.alloc.rate.norm ≈ 0 B/op
字节码验证:
javap -c -p Color.class | grep -A10 "public static final Color\[\] values"
# getstatic $VALUES → clone() ← 每次都克隆
8-4. 为什么 sealed 类型的 when 穷尽性检查对大型重构如此重要?给出一个具体的重构场景。
答案要点
场景:一个支付系统,最初只有两种结果:
sealed interface PaymentResult {
data class Paid(val amount: Long) : PaymentResult
data class Declined(val reason: String) : PaymentResult
}
代码库里有 47 处对 PaymentResult 的 when(渲染、日志、指标、重试、对账、通知、审计……)。
需求变更:新增"部分退款"。
data class PartiallyRefunded(val paid: Long, val refunded: Long) : PaymentResult
加上这一行的瞬间,47 处 when 全部编译失败。 编译器给出精确的文件与行号清单,你逐个处理,不可能遗漏。
对比 Java:switch 语句(非表达式)没有穷尽性检查,加了新子类后所有 switch 静默地什么都不做。你要靠全局搜索 + 人工审查 + 测试覆盖率来保证,漏掉的会在生产环境以"钱算错了"的形式暴露。
这就是"让编译器替你记住所有需要改的地方"的价值——它把一类运行时事故转成了编译期错误。
延伸:这也是为什么"用 sealed 建模领域状态"是 Kotlin 领域驱动设计的核心实践。设计时的关键判断:凡是"未来可能新增种类"的枚举式类型,都应该用 sealed interface 而非 enum(enum 新增值不会触发 when 编译错误——如果 when 有 else 分支的话)。
8-5. 解释为什么卫语句会破坏穷尽性推断。这合理吗?
答案要点
原因:穷尽性检查是编译期的静态分析。编译器能证明"is Text + is Image 覆盖了 sealed interface 的所有子类",因为这是类型系统的事实。
但 is Text if verbose 的成立还依赖 verbose 这个运行时值。编译器无法证明 verbose 一定为 true(它可能来自参数、配置、随机数、网络)。所以从编译器的视角,is Text if verbose 这个分支可能不被执行,Text 类型就没有被完全覆盖 → 需要 else。
是否合理:完全合理,而且是保守设计的典范。如果编译器"聪明地"认为卫语句覆盖了该类型,那么当卫语句在运行时为 false 时,when 表达式会没有值可返回 → 运行时抛 NoWhenBranchMatchedException。把一个运行时异常变成编译期错误,正是类型系统的价值。
补充:Kotlin 对没有卫语句的非穷尽 when(用作语句且主题是普通类型)会在运行时抛 kotlin.NoWhenBranchMatchedException。这是运行时的最后防线。
8.12 面试题精选
Q1:Kotlin 的 when 和 Java 的 switch 有什么区别?
答题框架:
- 表达式 vs 语句:
when天然是表达式(有返回值);Java 的switch从 14 起支持表达式形式(->语法)。 - 匹配能力:
when支持类型检查(is)、区间(in)、集合包含、任意布尔表达式(无主题形式)、卫语句(📌 2.2+);Java 的switch从 21 起支持 record/sealed 模式匹配与卫语句(case X x when cond),但只支持有限类型(byte/short/char/int/Integer/String/enum/record/sealed/数组)。 - 穷尽性:
when对 sealed/enum 强制穷尽;Java 21 的 pattern switch 对 sealed 也强制,但普通switch不强制。 - 主题绑定:
when (val x = expr)一次求值 + 智能转换;Java 需要额外变量。 - 性能:Kotlin
when的编译结构与 Javaswitch类似(tableswitch/lookupswitch),String/类型的处理有差异。 - 加分:提到
when编译成instanceof链时对 sealed 类型的 O(n) 复杂度;提到 enumwhen的WhenMappings映射表。
Q2:sealed class 和 enum class 怎么选?
答题框架:
- enum:实例数量固定且已知,每个实例是单例,可以有各自的行为(抽象方法或 lambda),可以携带字段。适合:状态机状态、星期、方向、错误码。
- sealed:每个子类型可以携带不同的数据结构(不同的构造参数),子类可以有多个实例。适合:领域事件、结果类型(
Result)、AST 节点、协议消息。 - 关键差异(二进制/源码兼容):
- enum 新增枚举项:
when若有else则静默通过(危险); - sealed 新增子类:
when必然编译失败(安全)。 - 所以**"未来会扩展的类别"应该用 sealed**,"真正封闭的枚举"才用 enum。
- enum 新增枚举项:
- 加分:enum 的
ordinal()不稳定不能持久化;entriesvsvalues()的分配差异;sealed 的子类必须在同包(1.5 前)/同编译单元(现在允许同模块)。
Q3:什么是智能转换?when 如何利用它?
见第 6 章 Q4。补充 when 特有场景:主题绑定 when (val x = f())、卫语句中使用智能转换后的变量、数据流穷尽性推断(📌 2.3 稳定)。
下一章预告:第 9 章《函数是一等公民》——Kotlin 最具威力的部分。我们会彻底讲清:Lambda 在 JVM 上到底是什么(invokedynamic + LambdaMetafactory + 运行时生成的隐藏类);inline / noinline / crossinline 三个修饰符的精确语义;契约(contract) 如何让编译器相信你写的断言;以及 tailrec 是怎么把递归变成循环的。