Kotlin与Java虚拟机八:控制流与 when:卫语句、上下文相关解析与穷尽性

0 阅读9分钟

第 8 章 控制流与 when:卫语句、上下文相关解析与穷尽性

本章目标

  1. 理解 Kotlin "一切皆表达式"的设计及其字节码后果;
  2. 掌握 when 的 8 种形态,尤其是 📌 卫语句(2.2 稳定) 和 数据流驱动穷尽性检查(2.3 稳定);
  3. 精确知道 when 编译成什么——tableswitch / lookupswitch / instanceof 链 / if-else 链,以及各自的性能含义;
  4. 发现一个库作者必须知道的枚举 when 二进制兼容陷阱;
  5. 掌握 📌 上下文相关解析(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),因为它们没有有意义的"值"。

🔬 设计动因:

  1. 消除可变变量:val x = if (...) a else b 优于 var x; if (...) x = a else x = b。前者是不可变的,后者可被后续代码修改。
  2. 消除"忘记赋值"的 bug:Java 里 int x; if (c) x = 1; ... use x 会编译错误,但更隐蔽的是 int x = 0; if (c) x = 1;——默认值 0 可能是错的。表达式形式强制覆盖所有分支。
  3. 让穷尽性检查成为可能:因为 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)
}

扁平、线性、每个条件一目了然。

卫语句的四个规则

  1. 顺序敏感,首个匹配生效:
when (x) {
    is Int if x > 0 -> "正"
    is Int          -> "非正"     // 只有 x <= 0 才到这里
    else            -> "其他"
}

⚠️ 如果把 is Int -> 放在前面,后面的卫语句分支永远不可达(编译器会给 unreachable code 警告)。

  1. 卫语句里可以用智能转换后的变量:
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
}
  1. 卫语句会破坏穷尽性推断:
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。

  1. 卫语句可以有副作用,但强烈不建议:
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 分支很多且很热,考虑:

  1. 先映射成 enum,再对 enum 做 when(enum 走 ordinal 跳转,快);
  2. 用 Map<String, Handler>(HashMap 查找,也是 hash + equals,但可复用);
  3. 如果字符串短且首字符区分度高,先 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 类运行,映射仍然是旧的 → 依然错乱。

🚀 库设计准则:

  1. 永远在枚举末尾追加新值,不要插入或重排;
  2. 不要把 ordinal() 持久化(数据库、序列化)——用 name 或显式 code 字段;
  3. 如果枚举会跨版本演化,给它一个显式的稳定 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,值域紧凑tableswitchO(1)★★★★★
Int,值域稀疏lookupswitchO(log n)★★★★
enum映射表 + tableswitchO(1) + 1 次数组访问★★★★
StringhashCode + lookupswitch + equalsO(log n) + 字符串比较★★★
sealed 类型(is 检查)instanceof 链O(n)★★(JIT 预热后改善)
无主题 / 卫语句 / 区间if-else 链O(n)★★
in range(多个区间)逐个比较O(n)★★

🚀 实践建议:

  1. 不要因为性能而回避 when——绝大多数场景差异在纳秒级,可读性收益远大于此;
  2. 在真正的热路径(每秒百万次的分派逻辑,如协议解析、路由),优先用 Int/enum 主题;
  3. String 分派极热时,先转 enum;
  4. 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

🔬 为什么慢:

  1. 抛异常需要构造异常对象 + 填充栈轨迹(fillInStackTrace 是 native 方法,需要遍历整个调用栈,成本 O(栈深度));
  2. JVM 需要展开栈帧(unwinding),逐个查找匹配的 catch;
  3. 异常表查找 + 去优化(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 本章小结

  1. Kotlin 的 if/when/try 都是表达式。这在字节码层面零成本,但带来三大收益:消除可变变量、强制覆盖所有分支、让穷尽性检查成为可能。
  2. when 有 8 种形态,最常见的误用是"在带主题的 when 里写布尔表达式"(实际是相等比较,会静默失效)。
  3. 📌 卫语句(2.2 稳定) 让复杂条件扁平化,但会破坏穷尽性推断(强制加 else),且卫语句必须无副作用(不匹配的分支也会被求值)。
  4. 📌 数据流穷尽性检查(2.3 稳定) 是 K2 的能力:编译器能基于"已排除的类型"推断后续分支,配合智能转换大幅简化代码。
  5. when 的编译结构决定性能:Int 紧凑值域 → tableswitch(O(1));稀疏 → lookupswitch(O(log n));String → hash + 跳转 + equals 二次确认;sealed/类型 → instanceof 链(O(n));无主题/卫语句 → if-else 链。
  6. enum 的 when 通过合成映射表(WhenMappings)间接跳转,这是为了隔离 ordinal() 的不稳定性。库作者必须只在枚举末尾追加值,且永不持久化 ordinal()。
  7. entries(1.9+)优于 values():后者每次调用都克隆数组,热路径上有分配。
  8. 📌 非局部 break/continue(2.2 稳定) 只对 inline lambda 有效,因为它依赖"lambda 体在同一栈帧内"。
  9. 不要用异常做控制流: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 都被无条件分支覆盖)。但有两个改进点:

  1. 顺序依赖脆弱:如果以后有人把 is Text -> m.s.take(10) 移到 is Text if verbose 之前,卫语句分支就永远不可达(编译器会给 unreachable 警告,但容易被忽略)。建议把"带卫语句的分支"和"兜底分支"用注释分隔。

  2. 可读性:更清晰的写法是把 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 有什么区别?

答题框架:

  1. 表达式 vs 语句:when 天然是表达式(有返回值);Java 的 switch 从 14 起支持表达式形式(-> 语法)。
  2. 匹配能力:when 支持类型检查(is)、区间(in)、集合包含、任意布尔表达式(无主题形式)、卫语句(📌 2.2+);Java 的 switch 从 21 起支持 record/sealed 模式匹配与卫语句(case X x when cond),但只支持有限类型(byte/short/char/int/Integer/String/enum/record/sealed/数组)。
  3. 穷尽性:when 对 sealed/enum 强制穷尽;Java 21 的 pattern switch 对 sealed 也强制,但普通 switch 不强制。
  4. 主题绑定:when (val x = expr) 一次求值 + 智能转换;Java 需要额外变量。
  5. 性能:Kotlin when 的编译结构与 Java switch 类似(tableswitch/lookupswitch),String/类型的处理有差异。
  6. 加分:提到 when 编译成 instanceof 链时对 sealed 类型的 O(n) 复杂度;提到 enum when 的 WhenMappings 映射表。

Q2:sealed class 和 enum class 怎么选?

答题框架:

  • enum:实例数量固定且已知,每个实例是单例,可以有各自的行为(抽象方法或 lambda),可以携带字段。适合:状态机状态、星期、方向、错误码。
  • sealed:每个子类型可以携带不同的数据结构(不同的构造参数),子类可以有多个实例。适合:领域事件、结果类型(Result)、AST 节点、协议消息。
  • 关键差异(二进制/源码兼容):
    • enum 新增枚举项:when 若有 else 则静默通过(危险);
    • sealed 新增子类:when 必然编译失败(安全)。
    • 所以**"未来会扩展的类别"应该用 sealed**,"真正封闭的枚举"才用 enum。
  • 加分:enum 的 ordinal() 不稳定不能持久化;entries vs values() 的分配差异;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 是怎么把递归变成循环的。