炉 II · 熔炼CH 04进度 04/26

智能转换与 contracts:编译器为什么不敢转

每个 Kotlin 开发都对着这句报错骂过街:Smart cast to 'String' is impossible, because 's' is a mutable property that could have been changed by this time。你明明上一行才判过空。但编译器不是在刁难你,它是在拒绝撒谎 —— 它无法证明那两行之间没人改过它。这一章讲清智能转换的规则边界(一条原理就够,不用背表),然后教你一件很多人不知道自己能做的事:contract 亲手教会编译器,让你自己写的 isNotNullOrBlank() 也能触发智能转换。

smart castcontractscallsInPlace数据流分析

一条原理,解释全部

智能转换的规则可以背,但没必要。只要记住编译器的原则:

◆ 锭 · 智能转换的唯一原理

只有当编译器能证明「检查那一刻的值」和「使用那一刻的值」是同一个值时,它才敢转。

所有「为什么这里不给我智能转换」的问题,都是同一个答案的变体:因为编译器无法证明这一点。不是它蠢,是你没给它证据。

拿这条原理去过一遍所有情况,你会发现每一条都自然成立:

场景能转吗编译器的理由
局部 val它能看见这个变量的全部一生,中间没人能动它
局部 var(且中间没被改)同上 —— 只要没被 lambda 捕获,它能追踪每一次赋值
局部 var,但被 lambda 捕获并可能修改lambda 什么时候执行?它管不着
类的 var 属性另一个线程可能在这两行之间改掉它
类的 val 属性(同模块、无自定义 getter)不可变、存储在字段里,读两次必然相同
带自定义 getter 的 valgetter 每次调用都可能返回不同的值 —— 它就是个函数
open val(可被子类覆盖)子类可能把它覆盖成一个自定义 getter,见上一条
其他模块的 val 属性那个模块可能单独重新编译,把 val 改成带 getter 的实现,而你的代码不会重编。编译器不敢赌
委托属性 by它背后是 getValue() 调用,同「自定义 getter」

看出来了吗 —— 能转的都是「编译器能证明它是一块死存储」的,不能转的都是「它其实是个函数调用」的。Kotlin 的属性可以是字段,也可以是伪装成字段的函数(第 2 章的推论二),而智能转换只对前者有效。

三种解法,按优先级排

撞上「不能智能转换」时,别急着 !!。按这个顺序试:

① 先把它捞成局部 val(最常用)

class VM {
    var user: User? = null

    fun render() {
        val u = user ?: return       // 捞出来,钉死
        println(u.name)              // 之后全程智能转换,且线程安全
        println(u.age)               // 就算别人改了 this.user,u 还是原来那个
    }
}

这不只是「绕开编译器」—— 它真的更正确。捞成局部变量之后,你的函数在整个执行期间看到的是同一个快照,而不是一个可能中途变脸的属性。编译器的报错其实是在提醒你一个真实的并发风险,它比你更认真。

?.let(适合单个表达式)

user?.let { u ->
    render(u.name)     // it / u 是局部的,天然可智能转换
}

注意它和 ① 的区别:?.let表达式,能接着往下链;而 ?: return提前退出,剩下的代码不用缩进。函数里有一长串逻辑时,① 更好读;只是想「有值就做一件事」,② 更贴切(这正是第 10 章决策树的判据)。

when (val x = expr)(分派时)

when (val state = viewModel.state) {   // 在 when 头上捞成 val
    is Success -> render(state.data)   // 智能转换生效
    is Error   -> toast(state.msg)
    Loading    -> spinner()
}

这个写法应该成为你的默认习惯:只要 when 的主语是个属性或函数调用,就用 when (val x = ...)。既拿到智能转换,又避免了重复求值。

教编译器:contracts

现在来到本章真正有意思的部分。看这段代码:

fun String?.isNotNullOrBlank(): Boolean = this != null && this.isNotBlank()

fun greet(name: String?) {
    if (name.isNotNullOrBlank()) {
        println(name.length)      // ✗ 编译错误:name 仍然是 String?
    }
}

你和编译器都知道,走进 if 里面 name 一定不是 null。但编译器看不进函数体 —— 对它来说 isNotNullOrBlank() 只是一个返回 Boolean 的黑盒。

奇怪的是,标准库的 isNullOrBlank() 就能触发智能转换:

if (!name.isNullOrBlank()) {
    println(name.length)          // ✓ 这个居然能转!
}

凭什么它可以?因为它带了一份合同(contract) —— 一份写给编译器的、关于「这个函数会做什么」的声明。你也可以写:

import kotlin.contracts.*

@OptIn(ExperimentalContracts::class)
fun String?.isNotNullOrBlank(): Boolean {
    contract {
        // 「如果我返回 true,那么 this 一定不是 null」
        returns(true) implies (this@isNotNullOrBlank != null)
    }
    return this != null && this.isNotBlank()
}

fun greet(name: String?) {
    if (name.isNotNullOrBlank()) {
        println(name.length)      // ✓ 现在可以了
    }
}

contract { }不参与运行时(它会被编译器擦掉),它唯一的作用是向数据流分析器传达一条你保证成立的事实

⚠ 炉渣 · 编译器不验证你的合同

这是 contracts 最危险的地方:编译器完全相信你,一个字都不查。你可以写一份彻头彻尾的谎言:

fun String?.lie(): Boolean {
    contract { returns(true) implies (this@lie != null) }
    return true          // 撒谎:返回 true 但 this 可能是 null
}

if (name.lie()) println(name.length)   // 编译通过 → 运行时 NPE

你亲手在类型系统上凿了个洞。所以:写 contract 时要像写 !! 一样谨慎 —— 它们本质上是同一类东西(对编译器的断言),只不过 contract 的作用范围是所有调用点,一旦写错,破坏力大得多。

另外它至今仍是 实验 API(需要 @OptIn(ExperimentalContracts::class)),且只能写在顶层函数上,不能写在成员函数上。

另一种合同:callsInPlace

contracts 的第二种形态解决的是另一个问题。先看这段为什么不能编译:

fun myRun(block: () -> Unit) { block() }

fun f() {
    val x: Int
    myRun { x = 1 }      // ✗ 报错:captured member values initialization is forbidden
    println(x)           // ✗ 报错:x 可能未初始化
}

编译器的顾虑:myRun 可能一次都不调用 block(那 x 就没被赋值),也可能调用两次(那 val x 就被赋值了两次,违反了 val 的定义)。它不知道,所以只能全部拒绝。

而标准库的 runletapply 都能这么用:

val x: Int
run { x = 1 }      // ✓ 完全合法
println(x)         // ✓ 编译器知道 x 一定被初始化了一次

秘密还是合同:

@OptIn(ExperimentalContracts::class)
inline fun myRun(block: () -> Unit) {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)   // 「我保证:不多不少调用一次」
    }
    block()
}

InvocationKind 有四档:EXACTLY_ONCE(正好一次)、AT_LEAST_ONCEAT_MOST_ONCE(最多一次,比如 ?.let)、UNKNOWN。声明了它,编译器才敢做两件事: 允许在 lambda 里初始化外部的 val 把 lambda 里的数据流分析结果(比如「这里做过判空」)带出到 lambda 外面

这解释了一个你可能一直觉得理所当然的现象:requireNotNull(x) 之后 x 就变成非空了、check(cond) 之后条件成立了 —— 全都是 contracts 在起作用。标准库里到处都是这种合同,你天天在享受它,只是没见过它的样子。

什么时候值得自己写 contract

说实话:大多数业务代码不需要。它是写工具函数/写库时的东西。三个真实值得写的场景:

  • 自定义的校验函数fun Response?.isValid(): Boolean,让调用方 if (resp.isValid()) 之后能直接用 resp.body
  • 自定义的作用域函数:你写了个 inline fun <T> T.applyIf(cond: Boolean, block: T.() -> Unit): T,加上 callsInPlace(block, AT_MOST_ONCE) 才能和标准库的作用域函数一样好用。
  • 自定义的断言:项目里的 fun requireLoggedIn(user: User?),加 returns() implies (user != null),调用之后编译器就认它非空了。
✦ 新炉料 · when 的数据流穷尽检查 2.3 转正

K2 之后,编译器的数据流分析强了不止一档。2.3 把「基于数据流的穷尽性检查」转正了 —— 编译器现在能利用前面代码建立的事实来判断 when 是否已经穷尽:

fun f(x: Any) {
    if (x !is Int && x !is String) return    // 排除掉其他一切

    val r = when (x) {      // 编译器知道:此处 x 只可能是 Int 或 String
        is Int -> "int"
        is String -> "str"
    }                       // ✓ 不用再写 else 了
}

以前你必须补一个 else -> error("unreachable") —— 而那个 else 是有害的:将来真的多出一种类型时,它会默默吞掉,而不是编译报错。现在编译器自己能推出来。

这是「K2 让编译器更聪明」的一个具体例子。类似的改进散布在整个 2.x 系列里,你什么都不用改,升级即得。

▸ 旧模具 · Java 有什么

Java 16 的模式匹配 instanceof 给了你一个局部版本的智能转换:

if (obj instanceof String s && s.length() > 5) {
    System.out.println(s.toUpperCase());   // s 已经是 String
}

好用,但它只是「检查时顺手绑个变量」,没有真正的数据流分析:Java 不会因为你前面写了 if (x == null) return; 就允许你后面把 x 当非空用(因为它的类型系统里压根没有「非空」这个概念)。

至于 contracts —— Java 完全没有对应物。你能写的只有 @Contract 注解(IntelliJ 私有的,只影响 IDE 检查,不影响编译)。「让类型信息穿过函数边界」这件事,Java 的类型系统还做不到。

✚ 动手锻打

1. 在你的项目里搜 Smart cast to 相关的 !!(就是那些「明明判过空还得加 !!」的地方)。逐个改成「捞成局部 val」或 ?.let改的过程中留意:有多少处其实暴露了真实的并发隐患(一个 var 属性在两次读之间确实可能被改)?

2. 找出项目里的自定义校验函数(isValidXxx()hasData() 之类),给其中一个加上 contract { returns(true) implies (...) }。然后在调用点感受一下:智能转换生效之后,下游少写了多少 !!

3. 把 when (state) 全部改写成 when (val s = state),体会一下分支里能直接用 s.data 的爽快。

下一章进入 Kotlin 泛型 —— 那个让你每隔几个月就要重新查一遍「inout 到底哪个是哪个」的地方。这次我们把它一次性钉死。