Kotlin 的 Contracts API,告诉编译器一些事
「Kotlin 的 Contracts API,告诉编译器一些事」
去年重构一个老旧 Android 模块时,我抽了一个非常普通的高阶函数,想给 Trace.beginSection 和 Trace.endSection 包一层,让 Systrace 的打点不至于在 try/finally 里写得太散。函数体大概长这样:
inline fun <T> traceSection(label: String, block: () -> T): T {
Trace.beginSection(label)
try {
return block()
} finally {
Trace.endSection()
}
}逻辑没毛病,但调用处一编译就报错。我在 lambda 里试图初始化一个 lateinit 之外的全局 val,编译器抛出来这么一句:
Captured values initialization is forbidden due to no guaranteed execution把 traceSection 换成 kotlin.run,一模一样的调用方式,居然过了。当时挺纳闷,都是 inline 高阶函数,凭什么标准库可以,我写的就不行。翻源码看到 run 的实现里多了一段之前被我忽略的东西:
public inline fun <R> run(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}这就是 Kotlin 的 Contracts API。它的作用用一句话概括,就是让你有机会亲口告诉编译器:"这个 lambda 我保证只会按我描述的方式执行,你可以放心做智能转换和初始化检查。" 标准库的 let、also、apply、takeIf 在 Kotlin 1.3 之后陆续都加上了类似的 contract 声明,只是日常调用时隐藏在源码里,大多数人不曾注意。
高阶函数里初始化 `val` 的编译错误
Kotlin 对捕获变量的初始化有严格规则。如果编译器无法证明一个 lambda 在被调用时一定会执行,它就会禁止你在 lambda 里给外层 val 赋值,防止出现"可能未初始化"的运行时错误。这和 Java 的 definite assignment 分析是同一类问题,但 Kotlin 因为支持高阶函数,情况更复杂。
我遇到的具体场景是这样的:在一个 RecyclerView 的 onBindViewHolder 里,有一个根据 viewType 决定初始化逻辑的写法,想用一个封装的 traceSection 包裹,结果编译器拒绝在 lambda 里给 val binding 赋值。换成 run 立刻解决,因为 run 内部通过 contract 向编译器承诺了 block 会且只会执行一次(EXACTLY_ONCE)。编译器拿到这个承诺,就能推导出该 lambda 内的代码路径一定会走到,从而允许初始化。
这个机制并不限于初始化。一旦编译器确认了 lambda 的执行次数和条件,它还能在 lambda 内部做更激进的 smart cast。比如你在 let 块里对可空类型做了判断,出了块之后编译器仍然记得非空状态,很大程度上也依赖 callsInPlace 的契约信息。
`callsInPlace` 与四种 InvocationKind
Contracts API 目前对普通开发者最实用的入口就是 callsInPlace。它位于 kotlin.contracts 包下,和 InvocationKind 枚举一起,用来描述函数对其 lambda 参数的实际调用情况。
InvocationKind 有四个值:
EXACTLY_ONCE:保证 lambda 会被调用且仅调用一次AT_LEAST_ONCE:至少一次,可能多次AT_MOST_ONCE:要么不调用,要么调用一次UNKNOWN:不承诺任何调用次数标准库里的 run、let、also、apply 都是 EXACTLY_ONCE。而像 Iterable.forEach 这种,虽然标准库源码里也加了 contract,但其实是 AT_LEAST_ONCE,因为它会遍历每个元素,对每个元素执行一次 lambda,整体上看调用次数等于集合大小。
自己手写 contract 时,选择错误的 InvocationKind 不会导致编译错误,但会让编译器基于错误的前提做优化或类型推导,进而产生误导。比如你把一个可能执行两次的 lambda 标记成 EXACTLY_ONCE,编译器会允许你在里面给外层的 val