Kotlin 的 Contracts API,告诉编译器一些事

Kotlin 的 Contracts API,告诉编译器一些事

Kotlin 的 Contracts API,告诉编译器一些事


「Kotlin 的 Contracts API,告诉编译器一些事」


去年重构一个老旧 Android 模块时,我抽了一个非常普通的高阶函数,想给 Trace.beginSectionTrace.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 我保证只会按我描述的方式执行,你可以放心做智能转换和初始化检查。" 标准库的 letalsoapplytakeIf 在 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:不承诺任何调用次数

  • 标准库里的 runletalsoapply 都是 EXACTLY_ONCE。而像 Iterable.forEach 这种,虽然标准库源码里也加了 contract,但其实是 AT_LEAST_ONCE,因为它会遍历每个元素,对每个元素执行一次 lambda,整体上看调用次数等于集合大小。


    自己手写 contract 时,选择错误的 InvocationKind 不会导致编译错误,但会让编译器基于错误的前提做优化或类型推导,进而产生误导。比如你把一个可能执行两次的 lambda 标记成 EXACTLY_ONCE,编译器会允许你在里面给外层的 val

    Scrcpy 的投屏控制,开发者调试神器 2026-08-19
    Jetpack Compose 的 TextField 性能,长文本输入的卡顿 2026-08-19

    评论区