重学 Kotlin —— inline,包治百病的性能良药?

本文永久更新地址:https://xiaozhuanlan.com/topic/3458207169

重学 Kotlin 已经来到了第三期,前面已经介绍了:

object,史上最 “快” 单例 ?

typealias ,穿了马甲,我就不认识你了?

今天的主角是 inline ,这不是一个 Kotlin 特有的概念,大多数编程语言都支持内联。

内联函数 的语义很简单:把函数体复制粘贴到函数调用处 。使用起来也毫无困难,用 inline关键字修饰函数即可。

然而问题的关键并不是如何使用 inline ,而是什么时候使用  inline ?  既然 Kotlin 提供了内联,它肯定是为了性能优化而存在的,那么,它又真的是包治百病的性能良药吗?

今天,我们就一起来刨根挖底,寻找一下答案。

目录

  1. inline 的本质

  2. 建议我不要用 inline ?

  3. Java 支持内联吗?

  4. 拯救 Lambda

  5. Java 是如何优化 Lambda 的?

  6. 不想内联怎么办?

  7. 如何从 Lambda 返回?

  8. 最后

inline 的本质

前面已经说过  inline 就是 把函数体复制粘贴到函数调用处 ,完全是编译器的小把戏。本着严谨科学的态度,我们还是来反编译验证一下。

inline fun test() {
    println("I'm a inline function")
}

fun run() { test() }

run() 函数中调用了内联函数 test()。反编译查看对应的 java 代码:

public static final void test() {
    String var1 = "I'm a inline function";
    System.out.println(var1);
}

public static final void run() {
    String var1 = "I'm a inline function";
    System.out.println(var1);
}

可以看到 run() 函数中并没有直接调用 test() 函数,而是把 test() 函数的代码直接放入自己的函数体中。这就是 inline 的功效。

那么,问题就来了。这样就可以提高运行效率吗?如果可以,为这里是 重学 Kotlin 系列第二篇,本文永久更新地址 :https://xiaozhuanlan.com/topic/0846175293 。

可能还真的就不认识了。

今天的主角是 type alias,翻译过来叫 类型别名。先来看一下文章目录:

  1. 什么是 typealias ?

  2. typealias 的本质

  3. typealias 存在的意义是什么?

  4. typealias 的使用注意事项

什么是 typealias ?

这是一个很基础的关键字,但可能很多人没有使用过。它的作用十分简单,给已有类型取一个别名,可以像使用原类型一样使用这个 “类型别名”

举个简单的例子:

typealias Name = String
val name : Name = "java"
println(name.length)

String 取个别名 Name ,在使用过程中,NameString 是完全等价的。

既然是等价的,使用别名的意义是什么呢?

别急,typealias 不仅仅支持给类取别名,它的用法丰富的让你想象不到。

// 类和接口
typealias Name = String
typealias Data = Serializable

// 函数类型
typealias GetName = () -> String
typealias Handler = CoroutineScope.() -> Unit

// 泛型
typealias P<T> = Comparable<T>
typealias Pairs<K, V> = HashMap<K, V>
typealias Numbers = Array<Number>

// object
object Single {}
typealias X = Single

class Util {
    companion object {
        val count = 1
    }
}
typealias Count = Util.Companion

// inner class
typealias NotificationBuilder = NotificationCompat.Builder

class Outer { inner class Inner }
typealias In = Outer.Inner

// 枚举
enum class Color { RED, YELLOW, GREEN }
typealias color = Color

// 注解
typealias Jvm = JvmStatic

上面的枚举用法中,需要注意的一点是,只能为枚举类 Color 取别名,无法为具体的枚举值取别名 。诸如 typealias Red = Color.RED 是不允许的。

几乎没有 typealias 不适用的类型。说到现在,你又得疑问了,类型别名存在的意义是什么 ?这样简单的取个别名,为什么不直接使用原类型呢 ?

typealias 的本质

暂且别急,我们先来看一下 typealias 的实现原理,说不定可以有一些发现。

反编译下面这个简单的例子:

typealias Binary = ByteArray
fun getBinary(string: String) : Binary = string.toByteArray()

查看其 Java 代码 :

public final class TypealiasKt {
   @NotNull
   public static final byte[] getBinary(@NotNull String string) {
      Intrinsics.checkParameterIsNotNull(string, "string");
      Charset var2 = Charsets.UTF_8;
      boolean var3 = false;
      byte[] var10000 = string.getBytes(var2);
      Intrinsics.checkExpressionValueIsNotNull(var10000, "(this as java.lang.String).getBytes(charset)");
      return var10000;
   }
}

代码中根本找不到类型别名 Binary 的踪影。经过编译之后,类型别名会被原类型直接替换。这仅仅只是 Kotlin 丰富的语法糖之一,编译器在其中做了一些手脚。

typealias 存在的意义是什么 ?

现在,你估计更加困惑了。

开发者手动声明一个类型别名,编译器再自动替换回原类型。意义何在?

唯一能想到的一点大概只有 "代码可读性" ,这里的代码可读性还要打上了一个大大的引号。

复杂的业务逻辑下,你的代码中可能会出现超长命名,多参数,多泛型类型的类名,接口名,函数名。

typealias FileTable<K> = MutableMap<K, MutableList<File>>
typealias OnPermissionResult = ActivityCompat.OnRequestPermissionsResultCallback
typealias SimpleName = LonglonglonglonglonglonglonglonglonglonglonglonglonglonglongName

用类型别名来代替原本可读性不好(名字太长或者声明复杂)的类型名,这可能就是 typealias 的主要作用。

至于到底有没有提升可读性?我觉得这是有代价的。因此而丧失的是直观的类型声明。以上面代码块中的 FileTable 为例,一眼看过去,你能发现它是一个 MutableMap<K, MutableList<File>> 吗?肯定不能。特别在团队开发中,除了这个代码的贡献者,可能每一位都要到类型别名声明处进行查看。

有人可能也会有不一样的声音。统一的全局声明很正常,而且也方便做统一修改,避免到代码使用处一一修改。况且 IDE 都会自动提示类型别名的声明。没有不使用 typealias 的道理。

所以,这是一个仁者见仁,智者见智的问题。你觉得有用就可以使用,任何一门技术肯定都有它的使用场景,并没有必要去过多争论。

我用协程还是用 RxJava?我用 LiveData 还是用事件总线?我用 ViewBinding 还是 DataBinding ?......

这几个问题可能比较常见,但是上面的每一组选择,如果你真正深入了解其技术本质的话,就会发现它们的使用场景并不一样,也就不会存在 如何选择 的疑问了。

typealias 使用注意事项

有点跑偏了,再回到 typealias 。后面再说一些 typealias 的注意事项,内容会比较零散,后续也可能继续增加。

typealias 可以写在哪里?

只能声明在文件顶层位置,其他任何地方都不行。

与 Java 如何交互?

拒绝和 Java 进行交互。

禁止套娃!

首先我们是可以 给别名取别名 的,如下所示:

typealias Names<T> = Array<T>
typealias SubNames<T> = Names<T>

虽然没有太大意义,但是语法上是正确的。

下面这样套娃肯定是不行的。

typealias R = R

typealias L = List<L>
typealias A<T> = List<A<T>>

typealias R1 = (Int) -> R2
typealias R2 = (R1) -> Int

上面的每一行代码都是无法编译的。

可见性

顶层位置的 typealias 可以使用可见性修饰符 public 、 private 、 internal 。同时,typealias 不能修改原有类型的可见性。举个例子:

private class LongName{}
typealias ShortName = LongName // 'public' typealias exposes 'private' in expanded type LongName

上面的代码会编译失败, public 的类型别名无法应用在 private 的原类型上。类型别名和原类型的可见性必须保持一致。

导入同名类的处理

对于在同一个类中导入两个同名类,通常的做法是, import 其中一个类,另一个使用全限定名。如下所示:

fun main() {
    val user1 = User()
    val user2 = com.test2.User()
}

这样或多或少不大美观,可以使用 typealias 处理一下。

typealias User2 = com.test2.User

fun main() {
    val user1 = User()
    val user2 = User2()
}

另外, import ... as ... 也可以解决这个问题。

import com.test1.User
import com.test2.User as User2

fun main() {
    val user1 = User()
    val user2 = User2()
}

最后

不妨翻翻你的代码库,看看有没有可以使用 typealias 进行优化的 “烂” 命名。如果有的话,欢迎来到评论区交流分享。

往期目录 :object,史上最 “快” 单例 ?什么?

我们先从 JVM 的方法执行机制说起。

JVM 进行方法调用和方法执行依赖 栈帧,每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

线程的栈帧是存储在虚拟机栈中,以上面示例代码的 未内联 版本为例,对应的方法执行过程和对应的栈帧结构如下所示:

未内联的情况下,整个执行过程中会产生两个方法栈帧,每一个方法栈帧都包括了 局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息 。

使用内联的情况下,只需要一个方法栈帧,降低了方法调用的成本。

乍一看,的确的提高了运行效率,毕竟少用一个栈帧嘛。

然而?

建议不要使用 inline ?

一切看起来都很美好,除了 IDE 给我的刺眼提示。

Expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of functional types

大致意思是在这里使用内联对性能的影响微乎其微,或者说没有什么意义。Kotlin 的内联最好用在函数参数类型中。

不急着解释,首先来一发灵魂拷问。

Java 支持内联吗?

你可以说不支持,因为 Java 并没有提供类似 inline 的显示声明内联函数的方法。

但是 JVM 是支持的。Java 把内联优化交给虚拟机来进行,从而避免开发者的滥用。

典型的一种滥用, 内联超长方法 ,极大的增大字节码长度,反而得不偿失。你可以注意 Kotlin 标准库中的内联函数,基本都是简短的函数。

对于普通的函数调用,JVM 已经提供了足够的内联支持。因此,在 Kotlin 中,没有必要为普通函数使用内联,交给 JVM 就行了。

另外,Java 代码是由 javac 编译的,Kotlin 代码是由 kotlinc 编译的,而 JVM 可以对字节码做统一的内联优化。所以,可以推断出,不管是 javac ,还是 kotlinc,在编译期是没有内联优化的。

至于 JVM 具体的内联优化机制,我了解的并不多,这里就不做过多介绍了。后续如果我看到相关资料,会到这里进行补充。

所以,上一节中 IDE 给开发者的提示就很明了了。

JVM 已经提供了内联支持,所以没有必要在 Kotlin 中内联普通函数。

那么问题又来了。既然 JVM 已经支持内联优化,Kotlin 的内联存在的意义是什么 ? 答案就是 Lambda  。

拯救 Lambda

为什么要拯救 Lambda,我们首先得知道Kotlin 的 Lambda 对于 JVM 而言究竟是什么。

Kotlin 标准库中有一个叫 runCatching  的函数,我在这里实现一个简化版本 runCatch ,参数是一个函数类型。

fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    runCatch { println("xxx") }
}

反编译生成的 Java 代码如下所示:

public final class InlineKt {
    public static final void runCatch(@NotNull Function0<Unit> block) {
        Intrinsics.checkParameterIsNotNull(block, (String)"block");
        try {
            block.invoke();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static final void run() {
        InlineKt.runCatch((Function0<Unit>)((Function0)run.1.INSTANCE));
    }
}

static final class InlineKt.run.1 extends Lambda implements Function0<Unit> {
    public static final InlineKt.run.1 INSTANCE = new /* invalid duplicate definition of identical inner class */;

    public final void invoke() {
        String string = "xxx";
        boolean bl = false;
        System.out.println((Object)string);
    }

    InlineKt.run.1() {
    }
}

Kotlin 自诞生之初,就以 兼容 Java 为首要目标。因此,Kotlin 对于 Lambda 表达式的处理是编译生成匿名类。

经过编译器编译之后, runCatch() 方法中的 Lambda 参数被替换为 Function0<Unit> 类型,在 run() 方法中实际调用 runCatch()  时传入的参数是实现了 Function0<Unit>  接口的 InlineKt.run.1  ,并重写 了 invoke() 方法。

所以,调用 runCatch() 的时候,会创建一个额外的类 InlineKt.run.1。这是 Lambda 没有捕捉变量的场景。如果捕捉了变量,表现会怎么样?

fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    var message = "xxx"
    runCatch { println(message) }
}

在 Lambda 内部捕捉了外部变量 message  ,其对应的 java 代码如下所示:

public final class InlineKt {
    public static final void runCatch(@NotNull Function0<Unit> block) {
        Intrinsics.checkParameterIsNotNull(block, (String)"block");
        try {
            block.invoke();
        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static final void run() {
        void message;
        Ref.ObjectRef objectRef = new Ref.ObjectRef();
        objectRef.element = "xxx";
        // 这里每次运行都会 new 一个对象
        InlineKt.runCatch((Function0<Unit>)((Function0)new Function0<Unit>((Ref.ObjectRef)message){
            final /* synthetic */ Ref.ObjectRef $message;

            public final void invoke() {
                String string = (String)this.$message.element;
                boolean bl = false;
                System.out.println((Object)string);
            }
            {
                this.$message = objectRef;
                super(0);
            }
        }));
    }
}

如果 Lambda 捕捉了外部变量,那么每次运行都会 new 一个持有外部变量值的 Function0<Unit>  对象。这比未发生捕捉变量的情况更加糟糕。

总而言之,Kotlin 的 Lambda 为了完全兼容到 Java6,不仅增大了编译代码的体积,也带来了额外的运行时开销。为了解决这个问题,Kotlin 提供了 inline 关键字。

Kotlin 内联函数的作用是消除 lambda 带来的额外开销

runCatch()  加持 inline :

inline fun runCatch(block: () -> Unit){
    try {
        block()
    }catch (e:Exception){
        e.printStackTrace()
    }
}

fun run(){
    var message = "xxx"
    runCatch { println(message) }
}

反编译查看 java 代码:

public static final void run() {
      Object message = "xxx";
      boolean var1 = false;
      try {
         int var2 = false;
         System.out.println(message);
      } catch (Exception var5) {
         var5.printStackTrace();
      }
}

runCatch() 的代码被直接内联到 run() 方法中,没有额外生成其他类,消除了 Lambda 带来的额外开销。

Java 是如何优化 Lambda 的?

既然 Kotlin 的 Lambda 存在性能问题,那旁边的 Java 大兄弟肯定也逃脱不了。

从 Java8 开始,Java 借助 invokedynamic 来完成的 Lambda 的优化。

invokedynamic  用于支持动态语言调用。在首次调用时,它会生成一个调用点,并绑定该调用点对应的方法句柄。后续调用时,直接运行该调用点对应的方法句柄即可。说直白一点,第一次调用 invokeddynamic 时,会找到此处应该运行的方法并绑定, 后续运行时就直接告诉你这里应该执行哪个方法。

关于 invokedynamic 的详细介绍,可以阅读极客时间专栏 《深入拆解Java虚拟机》的第 8,9 两讲。

JVM 是怎么实现 invokedynamic 的?(上)

JVM 是怎么实现 invokedynamic 的?(下)

不想内联怎么办?

一个高阶函数一旦被标记为内联,它的方法体和所有 Lambda 参数都会被内联。

inline fun test(block1: () -> Unit, block2: () -> Unit) {
    block1()
    println("xxx")
    block2()
}

test() 函数被标记为了 inline  ,所以它的函数体以及两个 Lambda 参数都会被内联。但是由于我要传入的 block1  代码块巨长(或者其他原因),我并不想将其内联,这时候就要使用 noinline  。

inline fun test(noinline block1: () -> Unit, block2: () -> Unit) {
    block1()
    println("xxx")
    block2()
}

这样, block1 就不会被内联了。篇幅原因,这里就不展示 Java 代码了,相信你也能很容易理解 noinline

如何从 Lambda 返回?

首先,普通的 lambda 是不允许直接使用 return 的 。

fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    // 普通 lambda 不允许 return
    runCatch { return }
}

上面的代码没有办法通过编译,IDE 会提示你 return is not allowed here 。而  inline  可以让我们突破这个限制。

// 标记为 inline
inline fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return }
}

上面的代码是可以正常编译运行的。和之前的例子唯一的区别就是多了 inline 。

既然允许 return ,那么这里究竟是从 Lambda 中返回,继续运行后面的代码?还是直接结束外层函数的运行呢?看一下 run() 方法的执行结果。

before lambda

从运行结果来看,是直接结束外层函数的运行。其实不难理解,这个 return 是直接内联到 run() 方法内部的,相当于在 run() 方法中直接调用 return。从反编译的 java 代码看,一目了然。

   public static final void run() {
      boolean var0 = false;

      try {
         String var1 = "before lambda";
         System.out.print(var1);
         int var2 = false;
      } catch (Exception var3) {
         var3.printStackTrace();
      }
   }

编译器直接把 return 之后的代码优化掉了。这样的场景叫做 non-local return (非局部返回) 。

但是有些时候我并不想直接退出外层函数,而是仅仅退出 Lambda 的运行,就可以这样写。

inline fun runCatch(block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    // 从 lambda 中返回
    runCatch { return@runCatch }
}

return@label,这样就会继续执行 Lambda 之后的代码了。这样的场景叫做 局部返回  。

还有一种场景,我是 API 的设计者,我不想 API 使用者进行非局部返回 ,改变我的代码流程。同时我又想使用 inline ,这样其实是冲突的。前面介绍过,内联会让 Lambda 允许非局部返回。

crossinline  就是为了解决这一冲突而生。它可以在保持内联的情况下,禁止 lambda 从外层函数直接返回。

inline fun runCatch(crossinline block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return }
}

添加  crossinline 之后,上面的代码将无法编译。但下面的代码仍然是可以编译运行的。

inline fun runCatch(crossinline block: () -> Unit) {
    try {
        print("before lambda")
        block()
        print("after lambda")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

fun run() {
    runCatch { return@runCatch }
}

crossinline 可以阻止非局部返回,但并不能阻止局部返回,其实也没有必要。

最后

关于内联函数,一口气说了这么多,总结一下。

在 Kotlin 中,内联函数是用来弥补高阶函数中 Lambda 带来的额外运行开销的。对于普通函数,没有必要使用内联,因为 JVM 已经提供了一定的内联支持。

对指定的 Lambda 参数使用 noinline ,可以避免该 Lambda 被内联。

普通的 Lambda 不支持非局部返回,内联之后允许非局部返回。既要内联,又要禁止非局部返回,请使用 crossinline

除了内联函数之外,Kotlin 1.3 开始支持 inline class ,但这是一个实验性 API,需要手动开启编译器支持。不知道大家对内联类有什么独特的看法,欢迎在评论区交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章