[译]探索Kotlin中隐藏的性能开销-Part 2

翻译说明:html

原标题: Exploring Kotlin’s hidden costs — Part 2java

原文地址: medium.com/@BladeCoder…算法

原文做者: Christophe Beyls设计模式

这是关于探索Kotlin中隐藏的性能开销的第2部分,若是你尚未看到第1部分,不要忘记阅读第1部分。数组

让咱们一块儿从底层从新探索和发现更多有关Kotlin语法实现细节。安全

局部函数

这是咱们以前第一篇文章中没有介绍过的一种函数: 就是像正常定义普通函数的语法同样,在其余函数体内部声明该函数。这些被称为局部函数,它们能访问到外部函数的做用域。bash

fun someMath(a: Int): Int {
    fun sumSquare(b: Int) = (a + b) * (a + b)

    return sumSquare(1) + sumSquare(2)
}
复制代码

咱们首先来讲下局部函数最大的局限性: 局部函数不能被声明成内联的(inline)而且函数体内含有局部函数的函数也不能被声明成内联的(inline). 在这种状况下没有任何有效的方法能够帮助你避免函数调用的开销。数据结构

通过编译后,这些局部函数会将被转化成Function对象, 就相似lambda表达式同样,而且一样具备上篇文章part1中讲到的关于非内联函数存在不少的限制。反编译后的java代码:app

public static final int someMath(final int a) {
   Function1 sumSquare$ = new Function1(1) {
      // $FF: synthetic method
      // $FF: bridge method
      //注: 这是Function1接口生成的泛型合成方法invoke
      public Object invoke(Object var1) {
         return Integer.valueOf(this.invoke(((Number)var1).intValue()));
      }

      //注: 实例的特定方法invoke
      public final int invoke(int b) {
         return (a + b) * (a + b);
      }
   };
   return sumSquare$.invoke(1) + sumSquare$.invoke(2);
}
复制代码

可是与lambda表达式相比,它对性能的影响要小得多: 因为该函数的实例对象是从调用方就知道的,因此它将直接调用该实例的特定方法invoke而不是从Function接口直接调用其泛型合成方法invoke。这就意味着从外部函数调用局部函数时,不会进行基本类型的转换或装箱操做. 咱们能够经过看下字节码来验证一下:jvm

ALOAD 1
   ICONST_1
   INVOKEVIRTUAL be/myapplication/MyClassKt$someMath$1.invoke    (I)I
   ALOAD 1
   ICONST_2
   INVOKEVIRTUAL be/myapplication/MyClassKt$someMath$1.invoke    (I)I
   IADD //加法操做
   IRETURN
复制代码

咱们能够看到被调用两次的函数是接收一个 Int 类型的参数而且返回一个 Int 类型的函数,而且加法操做是当即执行的,而无需任何中间的装箱、拆箱操做。

固然,在每次方法被调用期间仍会建立一个新的Function对象。可是这个能够经过将局部函数改写为非捕获的方式来避免这种状况:

fun someMath(a: Int): Int {
    fun sumSquare(a: Int, b: Int) = (a + b) * (a + b)

    return sumSquare(a, 1) + sumSquare(a, 2)
}
复制代码

如今相同的Function实例将会被复用,仍然不会进行强制的转换或装箱操做。与普通的私有函数相比,此局部函数的惟一劣势就是使用一些方法生成额外的类。

局部函数是私有函数的替代品,其附加好处是可以访问外部函数的局部变量。然而这种好处会伴随着为外部函数每次调用建立Function对象的隐性成本,所以首选使用非捕获的局部函数。

空安全

Kotlin语言的最好特性之一就是,它在可空类型和非空类型之间作出了明显清晰的界限区分。这使得编译能够经过在运行时禁止将非null或可为null的值分配给非null变量的任何代码来有效防止意外的NullPointerException.

非空参数的运行时检查

下面咱们来声明一个使用非null字符串做为采纳数的公有函数:

fun sayHello(who: String) {
    println("Hello $who")
}
复制代码

如今来看下对应的反编译后Java代码:

public static final void sayHello(@NotNull String who) {
   Intrinsics.checkParameterIsNotNull(who, "who");//执行静态函数进行非空检查
   String var1 = "Hello " + who;
   System.out.println(var1);
}
复制代码

请注意,Kotlin编译器对Java是很是友好的,能够看到在函数参数上自动添加了@NotNull注解,所以Java工具可使用此注解在传递空值的时候显示警告。

可是,注解不足以强制外部调用者传入非null的值。所以,编译器还在函数的开头添加一个静态方法调用,该方法将检查参数,若是为null,则抛出IllegalArgumentException. 为了使不安全的调用者代码更易于修复,该函数将尽早且持续抛出异常,而不是将它置后抛出运行时的NullPointerException.

实际上,每一个公有的函数都有一个对Intrinsics.checkParameterIsNotNull()的静态调用,该调用为每一个非null引用参数添加。这些检查不会被添加到私有函数中,由于编译器保证了Kotlin类中的代码为null安全的。

这些静态调用对性能的影响几乎能够忽略不计,而且在调试和测试应用程序的时候很是有帮助。话虽如此,若是对于release版原本说你可能认为这是不必的额外开销。在这种状况下,可使用-Xno-param-assertions编译器选项或添加如下Proguard规则来禁止运行时的空检查:

-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
}
复制代码

可空的原生类型

有一点彷佛众所周知,但仍是在这里提醒下: 可空类型始终是引用类型。将原生类型的变量声明成可空类型能够防止Kotlin使用Java基本数据类型(例如intfloat), 而是使用装箱的引用类型(例如IntegerFloat),这会避免装箱和拆想操做带来的额外开销。

与Java相反的是它容许你草率地使用几乎像int变量的Integer变量,这都要归功于自动装箱和忽略了null的安全性,但是Kotlin则会强制你在使用可null的类型时编写空安全的代码,因次使用非null类型的好处就变得更显而易见了:

fun add(a: Int, b: Int): Int {
    return a + b
}
fun add(a: Int?, b: Int?): Int {
    return (a ?: 0) + (b ?: 0)
}
复制代码

尽量使用非null的原生类型,以此来提升代码可读性和性能。

关于数组

在Kotlin中存在3种类型的数组:

  • IntArray,FloatArray以及其余原生类型的数组。
    最终会编译成 int[],float[]以及其余对应基本数据类型的数组

  • Array<T>: 非空对象引用类型的数组
    这里会涉及到原生类型的装箱过程

  • Array<T?>: 可空对象引用类型的数组
    很明显,这里也会涉及到原生类型的装箱过程

若是你须要一个非null原生类型的数组,最好使用IntArray而不是Array<Int>以免装箱过程带来性能开销

可变数量的参数(Varargs)

相似Java, Kotlin容许使用可变数量的参数声明函数。只是声明的语法有点不同而已:

fun printDouble(vararg values: Int) {
    values.forEach { println(it * 2) }
}
复制代码

就像在Java中同样,vararg参数实际上被编译为给定类型的数组参数。而后,能够经过三种不一样的方式调用这些函数:

1.传递多个参数

printDouble(1, 2, 3)
复制代码

Kotlin编译器将将此代码转换为新数组的建立和初始化,就像Java编译器同样:

printDouble(new int[]{1, 2, 3});
复制代码

因此,建立新数组会产生开销,可是与Java相比,这并非什么新鲜事。

2.传递单个数组

这里不一样之处就是,在Java中,能够直接将现有的数组引用做为vararg参数传递。在Kotlin中,则须要使用伸展(spread)操做符:

val values = intArrayOf(1, 2, 3)
printDouble(*values)
复制代码

在Java中,数组引用按原样传递给函数,而无需分配额外的数组空间。然而,如你在反编译后java代码中所见,Kotlin伸展(spread)操做符的编译方式有所不一样:

int[] values = new int[]{1, 2, 3};
printDouble(Arrays.copyOf(values, values.length));
复制代码

调用函数时,始终会复制现有数组。好处是代码更安全:它容许函数修改数组而不影响调用者代码。可是它会分配额外的内存

请注意,使用Kotlin代码中可变数量的参数调用Java方法具备相同的效果。

3.传递数组和参数的混合

Kotlin伸展(spread)运算符的主要好处是它还容许在同一调用中将数组与其余参数混合在一块儿。

val values = intArrayOf(1, 2, 3)
printDouble(0, *values, 42)
复制代码

上述代码将会怎样编译呢?生成代码会十分有趣:

int[] values = new int[]{1, 2, 3};
IntSpreadBuilder var10000 = new IntSpreadBuilder(3);
var10000.add(0);
var10000.addSpread(values);
var10000.add(42);
printDouble(var10000.toArray());
复制代码

除了建立新数组以外,还使用一个临时生成器对象来计算最终数组大小并填充它。这给方法调用又增长了另外一笔小开销。

即便在使用现有数组中的值时,在Kotlin中调用具备可变数量参数的函数也会增长建立新临时数组的成本。对于重复调用该函数的性能相当重要的代码,请考虑添加具备实际数组参数而不是vararg的方法

感谢您的阅读,若是喜欢,请分享这篇文章。

继续阅读第3部分委托的属性范围

读者有话说

大概隔了好久好久以前,我好像写了一篇探索Kotlin中隐藏的性能开销系列的Part1. 若是没有读过第1篇建议也去读下第1篇,由于这个系列确实对你写出高效的Kotlin代码十分有帮助,也能帮助你从源码,编译层面认清Kotlin语法背后的原理。我更喜欢把这些写Kotlin代码技巧称为Effective Kotlin, 这也是我最初翻译这个系列文章的初衷。关于这篇文章,有几点我须要补充下:

一、为何非捕获局部函数能够减小开销

其实关于捕获和非捕获的概念,在以前文章中也有所说起,好比在讲变量的捕获,lambda的捕获和非捕获。

这里就以上述局部函数举例,下面对比下这两个函数:

//改写前的捕获局部函数
fun someMath(a: Int): Int {
    fun sumSquare(b: Int) = (a + b) * (a + b)//注意:局部函数这里的a是直接引用外部函数的参数a, 
    //由于局部函数特性能够访问外部函数的做用域,这里实际上就存在了变量的捕获,因此这里sumSquare称为捕获局部函数

    return sumSquare(1) + sumSquare(2)
}
//改写前反编译后代码
 public static final int someMath(final int a) {
      //建立Function1对象$fun$sumSquare$1,因此每调用一次someMath都会建立一个Function1对象
      <undefinedtype> $fun$sumSquare$1 = new Function1() {
         // $FF: synthetic method
         // $FF: bridge method
         public Object invoke(Object var1) {
            return this.invoke(((Number)var1).intValue());
         }

         public final int invoke(int b) {
            return (a + b) * (a + b);
         }
      };
      return $fun$sumSquare$1.invoke(1) + $fun$sumSquare$1.invoke(2);
   }
复制代码

捕获局部函数会生成额外的Function对象,因此咱们为了减小性能的开销尽可能使用非捕获局部函数。

//改写后的非捕获局部函数
fun someMath(a: Int): Int {
    //注意: 能够明显发现改写后a参数,直接由函数参数传入,而不是在局部函数直接引用外部函数的参数变量,这就是非捕获局部函数
    fun sumSquare(a: Int, b: Int) = (a + b) * (a + b)
    return sumSquare(a,1) + sumSquare(a,2)
}

//改写后反编译后代码
public static final int someMath(int a) {
    //注意:能够看到非捕获的局部函数实例是一个单例,屡次调用都只会复用以前的实例不会从新建立。
    <undefinedtype> $fun$sumSquare$1 = null.INSTANCE;
    return $fun$sumSquare$1.invoke(a, 1) $fun$sumSquare$1.invoke(a, 2);
}
复制代码

经过上述对比,应该很清楚知道了什么是捕获什么是非捕获以及为何非捕获局部函数会减小性能的开销。

二、总结下提升Kotlin代码性能开销几个点

  • 局部函数是私有函数的替代品,其附加好处是可以访问外部函数的局部变量。然而这种好处会伴随着为外部函数每次调用建立Function对象的隐性成本,所以首选使用非捕获的局部函数。
  • 对于release版本应用来讲,特别是Android应用,可使用-Xno-param-assertions编译器选项或添加如下Proguard规则来禁止运行时的空检查:
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
}
复制代码
  • 须要使用非null原生类型的数组时,最好使用IntArray而不是Array<Int>以免装箱过程带来性能开销

最后

首先想和一直关注我公众号和技术博客的老铁们说声抱歉,由于中间已经好久没更新技术文章,所以有不少人也离开了,但也有人一直默默支持。因此从今天起我又准备开始更新了文章。近期研究dart和flutter也有一段时间了,沉淀了一些技术心得,因此会不按期更新有关一些Dart和Flutter的文章,感谢关注,感谢理解。

欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章,每周会不按期翻译一篇Kotlin国外技术文章。若是你也喜欢Kotlin,欢迎加入咱们~~~

Kotlin系列文章,欢迎查看:

Kotlin邂逅设计模式系列:

数据结构与算法系列:

翻译系列:

原创系列:

Effective Kotlin翻译系列

实战系列:

相关文章
相关标签/搜索