稀土掘金技术社区

看到Kotlin里满屏的 inline,我真的想 Java 了

❝

多年以后,当我面对 Kotlin 源码满屏的inline/crossinline/noinline时,将会想起用 Eclipse 手动创建Java匿名内部类的那个遥远的下午。

❞

一切要用Java和匿名内部类讲起。

一、Java 与匿名内部类

Java 中最开始要使用高阶函数,需要先定义一个接口,比如Android里常用的 OnClickListener:

public interface OnClickListener {    voidonClick(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 并不需要提前定义接口,只要在函数参数中写函数类型就行了:

funwrapper(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.ktpublic interface Function0<out R> : Function<R> {    public operator funinvoke(): R}public interface Function1<in P1, out R> : Function<R> {    public operator funinvoke(p1: P1): R}...public interface Function22......
❝

小问题:Functions.kt只定义到了Function22,那超过22个参数的函数怎么办?

❞

所以,实际上,Kotlin跟Java的Lambda基本没区别?都是使用接口和匿名内部类的形式。
但是如果是要创建类的话,那或多或少会有一些性能开销,有些高阶函数可能会被频繁调用。

三、inline 的作用:避免对象和调用开销

这个时候,Kotlin 提供了 inline 关键字。加上 inline 后,编译器会将函数和它的 lambda 参数的代码,在调用处直接展开,从而避免函数调用和对象创建。

示例:

// 加上inline关键字inline funwrapper(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 funwrapper(block: () -> Unit) {    println("Start")    block()    println("End")}// 调用funtest() {    wrapper {        println("Inside")        return    }    println("Outside")}

按常规理解会打印三行日志:“End”不输出,“Outside”能输出,但实际输出的结果是:

Start  Inside

也就是说调用者“Outside”也没有输出:“难道这个 return 结束了外部的函数?"
没错,这个 return 实际上直接跳出了 test() 函数,这也是 inline 带来的特性之一:
「非局部返回:return会跳出外部函数」
由于使用了 inline,block 的内容被直接展开,return也会被展开,相当于变成这样:

funtest() {    println("Start")    println("Inside")    return    println("End")     // 不执行    println("Outside") // 不执行}

五、crossinline:阻止非局部返回

好吧,inline是将函数直接展开,相当于复制粘贴到这里,return就返回调用者了,也算合理。
但是假如「这个函数将在异步异步线程中调用呢」?例如:

inline funrunAsync(block: () -> Unit) {    thread {        block()    }}

调用:

funmain() {    runAsync {        println("Inside thread")        return    }}

假设inline后展开:

funmain() {    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 funrunAsync(crossinline block: () -> Unit) {    thread {        block()    }}

之后,编译器会阻止lambda 中的 return 非局部返回,只能使用return@xxx的局部返回。

六、noinline:不内联

在 inline 函数中,所有 lambda 参数默认都会被内联。但有时候可能想保留 lambda 作为对象使用。
例如:

inline funwrapper(block: () -> Unit) {    val b = block  // ❌ 报错    b()}

编译器会报错,因为试图把一个 inline 的参数当作值存到变量里。
为什么会报错?让我们从最开始思考:

❝
  • lambda 会创建函数对象而增加性能消耗
  • inline 为了优化性能而将函数直接展开,不创建函数对象
  • 现在需要把函数当成变量传递,所以又需要函数对象
❞

那到底是不需要对象优化性能,还是需要对象进行传递?编译器也蒙圈了,这个地方构成了语义冲突,所以这个被当成变量使用的函数,就无法inline了。这个时候可以对这个参数加上 noinline,告诉编译器这个参数不内联展开:

inline funwrapper(noinline block: () -> Unit) {    val b = block  // ✅ 编译通过    b()}

多个参数的场景(有的函数参数要展开,而有的函数参数要当成变量不能展开):

inline funrun(block1: () -> Unit, noinline block2: () -> Unit) {    val task = block2  // 可以编译    task()    block1()}

七、总结

inline 系列关键字的行为对比如下:

修饰符
是否内联
是否允许非局部 return
是否可以保存/传递 lambda
inline
✅
✅
❌
crossinline
✅
❌
❌
noinline
❌
❌
✅
  1. Kotlin 的 lambda 也会生成函数对象,所以会带来一定的性能开销。inline 可以将函数在调用处展开,减少对象创建,同时如果写了 return,也会一起展开,导致跳出调用者。
  2. 在线程或协程中,return 会造成跳出栈帧的语义错误,所以编译器不允许,使用 crossinline 可以让编译器阻止非局部 return。
  3. inline 是为了避免生成函数对象,而把 lambda 存到变量里传来传去又需要函数对象,造成了冲突,这种要使用 noinline。
  4. 如果搞不懂,可以不用。

思考

为什么Koltin要搞非局部返回?

❝

没有非局部 return,代码逻辑一样能写,但「表达力会变差,控制流会变复杂」,尤其是在「组合式 API、DSL、协程」这些地方。(参考AI的理解)

❞
  • 理想写法(有非局部 return)
inline fundoIf(condition: Boolean, block: () -> Unit) {    if (condition) block()}
funlogin(user: String?) {    doIf(user == null) {        println("no user")        return  // ✅ 非局部 return:直接跳出 login()    }
    println("login user: $user")}

这段代码可读性非常高,像是在自然叙述:

❝

如果用户是 null,就执行块并跳出函数;否则继续执行。

❞
  • 如果没有非局部 return,会怎么样?

那 block 就不能直接 return 出 login(),它只能跳出 lambda 本身。所以这段代码必须“手动补救”,如下面这种加flag的写法:

funlogin(user: String?) {    var shouldReturn = false
    doIf(user == null) {        println("no user")        shouldReturn = true    }
    if (shouldReturn) return
    println("login user: $user")}