Kotlin inline:你以为它只是个性能优化?

1 阅读4分钟

in.png

前几天和吴彦祖吃饭,聊到他最近在用 Kotlin 写一个 Android 项目。

饭吃到一半,他突然放下筷子问我:"我在标准库里看到很多函数都标了 inline,像 repeat()let() 这些。我知道它跟性能有关,但它到底是怎么工作的?"

这个问题其实很有代表性——很多开发者知道 inline 能优化性能,但对它的完整能力了解有限。

所以借这篇文章,系统地聊一聊。

inline 关键字用于优化接受 lambda 参数的高阶函数,通过减少函数调用(尤其是涉及 lambda 表达式时)带来的运行时开销。

当一个函数标记为 inline 后,Kotlin 编译器会将该函数的函数体直接替换到每个调用处,从而无需为函数对象分配内存,也避免了 lambda 调用的开销。

如何工作

通常情况下,传递 lambda 作为参数需要创建一个对象来表示该 lambda,然后调用其方法。

这在性能敏感的场景中会带来额外开销。

将函数标记为 inline 则可以消除这种开销——编译器会把实际函数体和 lambda 代码直接插入调用位置。来看一个例子:

inline fun performOperation(operation: () -> Unit) {
    println("Starting operation...")
    operation()
    println("Operation completed.")
}

fun main() {
    performOperation {
        println("关注 RockByte 公众号")
    }
}

这里 performOperation 被标记为 inline,编译器会把对 performOperation 的调用替换为其函数体(包括 lambda 代码),从而减少对象分配,提升运行时性能。

好处

让我们深入看看 inline 究竟能带来什么好处。如果不使用 inline,每次传递 lambda 给函数时,编译器都必须创建一个 Function 对象来承载该 lambda,这牵涉到:

  1. 对象分配:在堆上新建一个对象来保存 lambda 的代码。
  2. 内存开销:该对象占用内存。
  3. 虚方法调用:调用 lambda 需要通过虚方法调用(invoke()),比直接方法调用慢。

在频繁调用的函数中(尤其是循环内部),大量小型 Function 对象的创建开销会逐渐累积,给 GC 带来压力,最终影响整体性能。

inline 关键字彻底解决了这个问题。当函数被内联后,编译器不再为 lambda 创建 Function 对象,而是把 lambda 体直接嵌入调用处。

// --- 非内联函数 ---
fun nonInlinedAction(block: () -> Unit) {
    println("Before action")
    block() // 这里是对 Function 对象的虚调用
    println("After action")
}

// --- 内联函数 ---
inline fun inlinedAction(block: () -> Unit) {
    println("Before action")
    block() // lambda 中的代码会被复制到这里
    println("After action")
}

fun main() {
    nonInlinedAction { println("Executing non-inlined action") }
    inlinedAction { println("Executing inlined action") }
}

// --- 编译器大致会生成这样的代码 ---
fun main_compiled() {
    // nonInlinedAction:创建 Function 对象并调用函数
    nonInlinedAction(Function0 { println("Executing non-inlined action") })

    // inlinedAction:全部代码被直接复制,无对象,无方法调用
    println("Before action")
    println("Executing inlined action") // 无对象,无虚调用
    println("After action")
}

inline 最核心的性能收益,就是消除对象创建和虚方法调用。

非局部返回

out.png

内联带来的另一个直接而强大的能力是非局部返回。

在普通的非内联 lambda 中,你只能用 return@label 退出 lambda 自身,而不能用裸 return 退出外层函数。

但由于内联 lambda 的代码被直接复制到了调用函数中,lambda 内的 return 语句表现得就像它原本就在调用函数中一样——它可以直接退出整个外层函数。

来看一段代码:

inline fun forEach(numbers: List<Int>, action: (Int) -> Unit) {
    for (number in numbers) {
        action(number)
    }
}

fun printFirstNegative(numbers: List<Int>) {
    forEach(numbers) {
        if (it < 0) {
            // 非局部返回:这个 return 退出的是 printFirstNegative,而不仅仅是 lambda
            println("找到第一个负数:$it")
            return
        }
    }
    // 如果 lambda 中触发了 return,这行及之后的代码都不会执行
    println("遍历完成,没有找到负数")
}

fun main() {
    printFirstNegative(listOf(3, 1, -2, 5))
    // 输出:找到第一个负数:-2
    // 注意:"遍历完成,没有找到负数" 不会被打印
}

这里的关键是:return 写在 lambda 的花括号 {} 里,但它退出的不是 lambda,而是整个外层函数 printFirstNegative。这就像你在 printFirstNegative 的函数体内直接写了 return 一样。

如果 forEach 不是 inline 函数,上面的代码将无法编译——普通 lambda 不允许用裸 return 退出外层函数,你只能用 return@forEach 来退出 lambda 自身。

这个特性让内联函数的行为更接近 forwhile 这样的语言内建控制流结构——你可以用 return 随时跳出外层函数,就像在普通循环里写 return 一样自然。

reified:运行时可用的泛型

inline 解锁的另一大特性是 reified 类型参数。

在 JVM 上,泛型会在运行时被擦除(type erasure),正常情况下你无法在运行时拿到泛型参数 T 的类型(比如不能写 T::class)。

但当你把函数标记为 inline 并配合 reified 关键字,类型信息就能在运行时保留下来——这对类型检查和类型转换特别有用。

inline fun <reified T> isInstance(value: Any): Boolean {
    return value is T
}

fun main() {
    println(isInstance<String>("Hello"))  // true
    println(isInstance<Int>("Hello"))    // false
}

这里 reifiedT 在运行时可以被用于 is T 这样的类型检查操作。

这在构建简洁、类型安全的 API 时非常实用,尤其是在库开发中。

Android 中一个典型场景就是按类型查找 FragmentfindFragment<MyFragment>()

何时不该用

虽然 inline 能带来显著的性能和可读性收益,但它并非万能。不当或过度使用反而会适得其反。以下是应当避免使用 inline 的关键场景:

  1. 代码体积膨胀:内联意味着把函数体复制到每个调用处。小函数还好,大函数会造成代码膨胀和二进制体积增大,进而影响性能(比如更多的 CPU 缓存未命中)。
  2. 不适合大型函数:包含大量逻辑的函数最好保持非内联。在多个调用处重复大段代码会拖慢运行时效率,也会让 APK 更臃肿。
  3. 不可被重写:内联函数在编译时直接替换,因此隐式地具有 final 语义,子类无法重写。这限制了多态场景下的灵活性。
  4. 无 lambda 参数时意义不大:如果函数不接受 lambda 参数,inline 避免 lambda 对象创建的核心优势就没了。这种情况下,JVM 的 JIT 编译器通常比开发者更擅长做内联优化。(不过,如果需要使用 reified 类型参数,即使没有 lambda 参数也必须使用 inline。)
  5. 不适用于递归和私有函数:递归函数无法内联,编译器会直接报错。同样,如果私有函数不接受 lambda 参数,标记 inline 的额外收益有限——JIT 编译器通常已经能自行优化。

小结

inline 关键字通过在调用处内联函数体和 lambda,优化了高阶函数的运行时性能,减少了内存开销。它还赋予了 reified 泛型等在运行时保留类型信息的能力。但使用时务必审慎——既要用它来提升性能,也要避免代码膨胀,保持代码的可维护性。

进阶:inline 属性

inline 属性 是使用了 inline 修饰符的属性,其 getter / setter 会在调用处被内联。

这意味着访问属性时不会产生方法调用开销——getter/setter 内的代码会在编译期直接替换到调用处。inline 属性特别适合逻辑简单的属性。

来看一个示例:

inline var calculatedValue: Int
    get() = someComplexCalculation() // getter 逻辑被内联
    set(value) {
        saveResult(value) // setter 逻辑被内联
    }

当访问或修改 calculatedValue 时,getter/setter 代码会直接被替换到原位置,省去了方法调用的开销。

这样做,有如下的优势:

  1. 减少开销:内联 getter/setter 消除了方法调用的运行时开销,特别适合那种频繁访问、逻辑轻量的属性。
  2. 性能提升:在频繁访问属性的关键代码路径中,inline 属性可以显著提升性能。
  3. 优化小逻辑:非常适合计算属性、状态判断或轻量数据转换。例如:
    inline val isUserActive: Boolean
        get() = System.currentTimeMillis() - lastActivityTime < ACTIVE_THRESHOLD
    

这和 inline 函数的大部分优点是类似的。

这里 isUserActive 每次访问都直接内联计算,无需方法调用。

当然,inline 属性虽然好用,这种做法也有一些限制。

它只适合简单逻辑。复杂的 getter/setter 内联会导致字节码膨胀。另外,带有幕后字段(backing field)的属性不能标记为 inline,因为幕后字段无法被直接内联。

这个特性,实际上是一条关于属性访问器的 inline 提案引入的,该提案中说明允许在调用处内联属性访问器,从而减少方法调用开销。

这个提案使得那些没有幕后字段的轻量属性从中获益最大,这样做既能提升性能,又能在函数式编程模式中更高效地使用。

但该特性明确不涉及幕后字段或复杂操作,以避免字节码膨胀和复杂度增加。

简单总结一下。

inline 属性通过消除函数调用开销、将 getter/setter 逻辑直接嵌入调用位置来优化性能。它们最适合轻量级、频繁访问的属性——简洁高效。审慎地使用 inline 属性,可以写出既清晰又高性能的代码。


进阶:非 suspend 的 inline 函数也能接受挂起 lambda

标准库中的常用函数 —— repeat()map()filter()

这类标准库函数之所以能在 lambda 中接受挂起函数,是因为它们都被声明为 inline 函数。这使得它们能无缝地与挂起 lambda 配合,尽管它们自身签名并非协程感知。

内联函数允许编译器在编译期将函数体直接插入调用代码。当挂起 lambda 传递给内联函数时,编译器会将函数体(包括 lambda 代码)直接插入到调用处。由于调用处本身位于协程(suspend 函数)内部,lambda 中的挂起调用自然继承了该协程上下文,从而能正确执行。

来看一段代码:

suspend fun printMessage(message: String) {
    println("Message: $message")
}

suspend fun main() {
    repeat(3) {
        printMessage("skydoves $it")
    }
}

这里 repeat()inline 函数。虽然 repeat() 自身不是挂起函数,但编译器将其函数体内联到了调用代码中,使得挂起 lambda { printMessage(...) } 能在协程中正常执行。

为什么 inline 函数能做到这一点?

内联函数在让挂起 lambda 与协程无缝配合方面扮演了关键角色。以下是背后的原理:

  1. Lambda 内联:调用内联函数时,编译器将函数调用替换为其函数体,并将 lambda 直接插入调用上下文。如果调用上下文在协程内部,lambda 就继承了该协程上下文,从而能执行挂起调用。
  2. 挂起 Lambda:Kotlin 支持用 suspend 修饰的挂起 lambda。当它们与内联函数结合使用时,编译器会生成协程感知的代码。
  3. 协程上下文:外围协程确保内联 lambda 中的挂起函数正确执行,而不会阻塞线程。

编译器处理上述 repeat() 的实际效果相当于:

suspend fun main() {
    for (i in 0 until 3) {
        printMessage("skydoves $i")
    }
}

内联过程把 repeat() 替换成了等价的循环,挂起函数 printMessage 得以直接执行。

总而言之。

repeat()map()filter() 之所以能接受挂起 lambda,是因为它们都是 inline 函数。内联让编译器能把函数调用替换为函数体,把挂起 lambda 无缝融入协程上下文。这充分体现了 Kotlin 内联函数与协程系统的强大与灵活性。