多年以后,当我面对 Kotlin 源码满屏的inline/crossinline/noinline时,将会想起用 Eclipse 手动创建Java匿名内部类的那个遥远的下午。
一切要用Java和匿名内部类讲起。
一、Java 与匿名内部类
Java 中最开始要使用高阶函数,需要先定义一个接口,比如Android里常用的 OnClickListener:
public interface OnClickListener {
void onClick(View v);
}
然后使用的时候,需要创建一个实现这个接口的匿名内部类,这就是最原始的类似高阶函数的写法:
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
System.out.println("Hello World");
}
});
从 Java 8 开始,符合函数式接口规范(只包含一个抽象方法)的接口,可以用 Lambda 表达式简写成这样:
view.setOnClickListener(v -> System.out.println("Hello World"));
这下代码少了不少,看起来终于“函数式”了一点。然而使用时需要先创建接口,虽然有这个特性,但是用的人也不多,在当时已经算是进阶用法了。
二、Kotlin 与高阶函数
后来有了 Kotlin ,Kotlin最大的优点之一就是原生支持 Lambda 和高阶函数了。Kotlin 并不需要提前定义接口,只要在函数参数中写函数类型就行了:
fun wrapper(block: () -> Unit) {
block()
}
调用方式:
wrapper {
println("Inside Block")
}
看上去非常函数式,一个类也没写,但实际上……
查看 Kotlin 字节码(Tools -> Kotlin -> Show Kotlin Bytecode),可以看到下面的内容:
public final static wrapper(Lkotlin/jvm/functions/Function0;)V
// annotable parameter count: 1 (invisible)
@Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0
L0
ALOAD 0
LDC "block"
INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullParameter (Ljava/lang/Object;Ljava/lang/String;)V
L1
LINENUMBER 3 L1
ALOAD 0
INVOKEINTERFACE kotlin/jvm/functions/Function0.invoke ()Ljava/lang/Object; (itf)
POP
L2
LINENUMBER 5 L2
RETURN
L3
LOCALVARIABLE block Lkotlin/jvm/functions/Function0; L0 L3 0
MAXSTACK = 2
MAXLOCALS = 1
虽然一堆字节码看起来让人头晕,但是其实认得几个关键词就够用了:wrapper、Function0、Ojbect、invoke。
原来,lambda函数 wrapper中的函数类型参数block,被编译成了 Function0 类型的参数,并通过 invoke() 方法调用它。那Function0是什么呢?
Function0 是 Kotlin 中预先定义好的函数接口,表示函数接收0个参数,而接收一个参数的则使用Function1,两个参数使用Function2,以此类推。源码在Functions.kt,如下:
// Functions.kt
public interface Function0<out R> : Function<R> {
public operator fun invoke(): R
}
public interface Function1<in P1, out R> : Function<R> {
public operator fun invoke(p1: P1): R
}
...
public interface Function22...
...
小问题:Functions.kt只定义到了Function22,那超过22个参数的函数怎么办?
所以,实际上,Kotlin跟Java的Lambda基本没区别?都是使用接口和匿名内部类的形式。
但是如果是要创建类的话,那或多或少会有一些性能开销,有些高阶函数可能会被频繁调用。
三、inline 的作用:避免对象和调用开销
这个时候,Kotlin 提供了 inline 关键字。加上 inline 后,编译器会将函数和它的 lambda 参数的代码,在调用处直接展开,从而避免函数调用和对象创建。
示例:
// 加上inline关键字
inline fun wrapper(block: () -> Unit) {
println("Start")
block()
println("End")
}
调用:
wrapper {
println("Inside Block")
}
这段代码编译后,大致会变成:
println("Start")
println("Inside Block")
println("End")
可以打开 Kotlin Bytecode 面板,切换“Inline”选项查看区别。
代码展开了,性能更好了,也没生成对象,非常的清晰明了。
如果一切就止步于此,我也只需要学一个关键字,但显然没这么简单...
四、inline 带来的非局部 return 行为
当 inline 函数中的 lambda 含有 return 时,会出现一些有点“反直觉”的行为。
比如下面的代码:
// 定义
inline fun wrapper(block: () -> Unit) {
println("Start")
block()
println("End")
}
// 调用
fun test() {
wrapper {
println("Inside")
return
}
println("Outside")
}
按常规理解会打印三行日志:“End”不输出,“Outside”能输出,但实际输出的结果是:
Start
Inside
也就是说调用者“Outside”也没有输出:“难道这个 return 结束了外部的函数?"
没错,这个 return 实际上直接跳出了 test() 函数,这也是 inline 带来的特性之一:
非局部返回:return会跳出外部函数
由于使用了 inline,block 的内容被直接展开,return也会被展开,相当于变成这样:
fun test() {
println("Start")
println("Inside")
return
println("End") // 不执行
println("Outside") // 不执行
}
五、crossinline:阻止非局部返回
好吧,inline是将函数直接展开,相当于复制粘贴到这里,return就返回调用者了,也算合理。
但是假如这个函数将在异步异步线程中调用呢?例如:
inline fun runAsync(block: () -> Unit) {
thread {
block()
}
}
调用:
fun main() {
runAsync {
println("Inside thread")
return
}
}
假设inline后展开:
fun main() {
thread {
println("Inside thread")
return
}
}
思考一下,这样会有什么问题:main() 执行完就返回了,而线程在另一个栈异步执行,按照上文inline中return的逻辑,这个这个 return 会跳出 main()。 但是问题就出现了:
main()已经结束了,这个时候再返回main就会引起异常。
所以这种情况是不被允许的,block()在编译器中会直接报下面的错误:
// 此处无法内联“block: () -> Unit”:它可能包含非局部返回。
// 请将 “crossinline” 修饰符添加到参数声明 “block: () -> Unit”。
Cannot inline 'block: () -> Unit' here: it might contain non-local returns.
Add 'crossinline' modifier to parameter declaration 'block: () -> Unit'.
提示我们需要加上crossinline修饰符:
inline fun runAsync(crossinline block: () -> Unit) {
thread {
block()
}
}
之后,编译器会阻止lambda 中的 return 非局部返回,只能使用return@xxx的局部返回。
六、noinline:不内联
在 inline 函数中,所有 lambda 参数默认都会被内联。但有时候可能想保留 lambda 作为对象使用。
例如:
inline fun wrapper(block: () -> Unit) {
val b = block // ❌ 报错
b()
}
编译器会报错,因为试图把一个 inline 的参数当作值存到变量里。
为什么会报错?让我们从最开始思考:
lambda会创建函数对象而增加性能消耗inline为了优化性能而将函数直接展开,不创建函数对象- 现在需要把函数当成变量传递,所以又需要函数对象
那到底是不需要对象优化性能,还是需要对象进行传递?编译器也蒙圈了,这个地方构成了语义冲突,所以这个被当成变量使用的函数,就无法inline了。这个时候可以对这个参数加上 noinline,告诉编译器这个参数不内联展开:
inline fun wrapper(noinline block: () -> Unit) {
val b = block // ✅ 编译通过
b()
}
多个参数的场景(有的函数参数要展开,而有的函数参数要当成变量不能展开):
inline fun run(block1: () -> Unit, noinline block2: () -> Unit) {
val task = block2 // 可以编译
task()
block1()
}
七、总结
inline 系列关键字的行为对比如下:
| 修饰符 | 是否内联 | 是否允许非局部 return | 是否可以保存/传递 lambda |
|---|---|---|---|
inline | ✅ | ✅ | ❌ |
crossinline | ✅ | ❌ | ❌ |
noinline | ❌ | ❌ | ✅ |
- Kotlin 的 lambda 也会生成函数对象,所以会带来一定的性能开销。
inline可以将函数在调用处展开,减少对象创建,同时如果写了return,也会一起展开,导致跳出调用者。 - 在线程或协程中,
return会造成跳出栈帧的语义错误,所以编译器不允许,使用crossinline可以让编译器阻止非局部return。 inline是为了避免生成函数对象,而把 lambda 存到变量里传来传去又需要函数对象,造成了冲突,这种要使用noinline。- 如果搞不懂,可以不用。
思考
为什么Koltin要搞非局部返回?
没有非局部 return,代码逻辑一样能写,但表达力会变差,控制流会变复杂,尤其是在组合式 API、DSL、协程这些地方。(参考AI的理解)
- 理想写法(有非局部 return)
inline fun doIf(condition: Boolean, block: () -> Unit) {
if (condition) block()
}
fun login(user: String?) {
doIf(user == null) {
println("no user")
return // ✅ 非局部 return:直接跳出 login()
}
println("login user: $user")
}
这段代码可读性非常高,像是在自然叙述:
如果用户是 null,就执行块并跳出函数;否则继续执行。
- 如果没有非局部 return,会怎么样?
那 block 就不能直接 return 出 login(),它只能跳出 lambda 本身。所以这段代码必须“手动补救”,如下面这种加flag的写法:
fun login(user: String?) {
var shouldReturn = false
doIf(user == null) {
println("no user")
shouldReturn = true
}
if (shouldReturn) return
println("login user: $user")
}