炉 III · 塑形CH 09进度 09/26

inline:零开销 lambda 与非局部返回

有个问题你可能从没想过:为什么 Kotlin 敢让标准库里全是高阶函数?forEachmapletapplyusesynchronized —— 你在每一层循环里随手写 lambda,从不担心性能。而同样的写法在 Java 里,每个 lambda 都是一个对象,Android 团队为此专门警告过别在 onDraw 里用 Stream。差别就在一个关键字上:inline。它把 lambda 粘贴进调用点,让抽象的代价归零 —— 然后顺手白送了两样 Java 永远给不了的东西。

inline非局部返回crossinlinereified

先看它做了什么

六个形态,点着看。重点看第 1 和第 2 个的差别 —— 那是整章的地基:

三样收益

① 零开销的抽象

非 inline 的高阶函数:每次调用 new 一个 FunctionN 对象(如果 lambda 捕获了变量),再走一次虚方法调用。放在每帧执行的代码里、放在长列表的循环里,分配就开始累积,GC 就开始工作,帧就开始掉。

inline 之后:函数体和 lambda 体一起被粘贴到调用点,对象没了,虚调用没了,字节码和你手写循环一模一样

这就是标准库敢那么设计的底气。你写:

users.filter { it.isActive }.map { it.name }.forEach { println(it) }

编译后就是三个普通的 for 循环(外加两个中间 List —— 这个第 22 章再算账)。抽象是免费的。

② 非局部返回:Java 做不到的事

fun findAdmin(users: List<User>): User? {
    users.forEach {
        if (it.isAdmin) return it      // 直接从 findAdmin 返回!
    }
    return null
}

这个 return 返回的是 findAdmin,不是 lambda。为什么能这么写?因为 forEach 是 inline 的 —— 展开之后那就是一个普通 for 循环里的普通 return。

Java 永远做不到这件事。Java lambda 里的 return 只能返回 lambda 自己,想跳出外层方法,你得用 OptionalanyMatch、或者一个丑陋的标志变量。

这个能力的价值远不止「少写几行」。它意味着:inline 的高阶函数可以像控制流结构一样使用 —— forEach 用起来和 for 没有任何区别,use 用起来和 try-with-resources 没有区别。你可以用函数「造」出新的语法结构,而它们和内置语法享有同等待遇。这是第 11 章 DSL 的另一半地基。

✦ 新炉料 · 非局部 break / continue 2.1 转正

非局部返回有了很久,但 breakcontinue 一直缺席 —— 直到 2.1:

for (page in pages) {
    page.items.forEach {
        if (it.isCorrupt) break     // 2.1 起:直接跳出外层 for
        if (it.isSkipped) continue  // 同上
        process(it)
    }
}

以前你只能写 return@forEach(那只是 continue 的效果),想 break 得改成带标签的 for 循环,或者用 takeWhile 绕。现在补齐了 —— 同一套 inline 机制,只是编译器终于允许了另外两个关键字。

③ reified:从类型擦除里越狱

第 5 章讲过,这里只补一句因果关系reified 之所以只能用在 inline 函数上,是因为它依赖「函数体被粘贴到调用点」这件事 —— 只有在调用点,编译器才知道 T 的真身,才能把 T::class 替换成 User::class

inline 是因,reified 是果。理解了这一点,你就再也不会问「为什么不能在普通函数上用 reified」。

三个修饰词,一条判据

noinlinecrossinline 常被死记硬背。其实它们都来自同一条原理:

◆ 锭 · 内联后的 lambda 不是一个「值」

它只是一段被粘贴的代码。所以它不能被存起来、不能被传给别的函数、不能赋给变量、不能在「以后」执行。

  • 想把它当值用(存进 map、传给另一个非 inline 函数)→ noinline,让它变回真对象。
  • 想让它在别的执行上下文里跑(塞进一个匿名回调、另一个线程、构造器)→ crossinline:它仍然被内联(不生成对象),但禁止非局部返回 —— 因为那时外层函数早就返回了,「从外层函数返回」无处可归。

所以下次看到 crossinline,你应该立刻读出:「这个 lambda 会被推迟执行」。这是它在向你传递的信息,不是编译器在找茬。

inline fun View.onClick(crossinline action: () -> Unit) {
    setOnClickListener {         // action 会活到「用户点击的那一刻」
        action()                 // 那时 onClick 早已返回
    }
}

代价:代码体积

inline 不是白吃的午餐。每一个调用点都会得到一份函数体的副本。一个被调用 200 次的 inline 函数,就是 200 份副本。

后果在 Android 上是实打实的:APK 变大、方法数变多、指令缓存命中率下降。所以:

函数长这样该 inline 吗为什么
带 lambda 参数,函数体很短✓ 应该省下的对象分配 > 多出的字节码。标准库全是这种
带 lambda 参数,但函数体很长(几十行)⚠ 谨慎每个调用点都复制几十行。考虑:把长的部分抽成一个非 inline 的私有函数,只让外壳 inline
没有 lambda 参数✗ 不要没有 lambda 可省,纯粹是代码膨胀。编译器会直接警告你("Expected performance impact is insignificant")—— 见到这条警告就把 inline 删掉,别 suppress
要用 reified✓ 必须reified 只能靠 inline 实现,此时代码体积是必要成本
⚠ 炉渣 · inline 函数不能访问 private 成员

一个 public inline fun 的函数体会被粘贴到调用方的字节码里 —— 而调用方可能在另一个模块。所以它不能引用你的 private/internal 成员(那些成员在调用方那里是不可见的),编译器会直接报错:

e: Public-API inline function cannot access non-public-API 'private fun helper()'

这个限制在写库时很烦人。绕法是把内部成员标成 @PublishedApi internal —— 意思是「它在字节码层面是 public 的(所以 inline 函数能碰它),但你们别当公开 API 用」。

另一个后果更隐蔽:inline 函数的函数体是二进制兼容性的一部分。你改了一个 public inline 函数的实现,已经编译好的调用方不会更新(它们体内粘的还是旧副本)—— 除非重新编译。给库作者的忠告:公开 API 上慎用 inline,除非你确实需要它。

顺便认一下你天天在用的 inline 函数

知道了机制,回头看标准库会有新的理解:

函数inline 给了它什么
forEach / map / filter / fold零分配的集合操作。它们和手写 for 循环的字节码几乎一样
let / run / apply / also / with作用域函数完全免费。这就是为什么你可以在热路径上随便用 ?.let(第 10 章)
use { }等价于 Java 的 try-with-resources,但它是一个普通函数 —— 语言不需要为它加语法
synchronized(lock) { }同上:Java 里它是关键字,Kotlin 里它只是个 inline 函数
repeat(n) { }同上:Java 需要 for 语法,Kotlin 用函数就够了
measureTimeMillis { }你自己也能写一个一模一样的(demo 里那个就是)

注意这张表在暗示什么:Kotlin 之所以能保持语言核心的小巧,正是因为 inline 让「库函数」拥有了「语言结构」的性能和行为。Java 必须把 synchronizedtry-with-resources 做进语法;Kotlin 把它们做成了函数 —— 于是你也能造自己的

▸ 旧模具 · Java 的 lambda 为什么便宜不下来

Java 的 lambda 用 invokedynamic 实现,JVM 在运行时为它生成一个类。无捕获的 lambda 会被缓存成单例(和 Kotlin 一样),但捕获了变量的 lambda 每次都要分配

JIT 的逃逸分析有时能把这个分配优化掉 —— 但那是「有时」,不是「保证」,而且在 Android 上(ART 的优化能力弱于服务端 JVM)更不可靠。这就是为什么 Android 性能指南长期建议「热路径上别用 Stream」。

Kotlin 的 inline 把这件事从「运行时优化」变成了「编译期保证」 —— 不依赖 JIT 的心情。这是本章最硬的一块收益,也是 Java 短期内补不上的:它需要在语言层面引入内联语义,而 Java 至今没有这个计划。

✚ 动手锻打

1. 搜项目里所有的 inline fun。逐个问:它有 lambda 参数吗?有 reified 吗?两个都没有的,删掉 inline(如果编译器给了那条「性能影响微不足道」的警告,那就是它在替你判决)。

2. 找一个你写过的回调式 API(setOnXxxListener 之类的封装),看它的 lambda 参数是不是该标 crossinline。如果编译器已经逼你标了 —— 现在你知道为什么了。

3. 用 inline + reified 写一个项目里能用的工具函数:inline fun <reified T : Activity> Context.start(vararg extras: Pair<String, Any>)。然后替换掉几处手写的 Intent 构造。

4. 反编译一个用了 forEach { } 的函数(第 2 章的开关),确认那里确实没有 new Function1。眼见为实。

下一章:扩展函数的静态分派真相,以及那个万年争论 —— let / run / also / apply / with 到底该用哪个。这次给你一棵能点到底的决策树。