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

2,220 阅读14分钟

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

翻译说明:

原标题# Exploring Kotlin’s hidden costs — Part 3

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

原文作者: Christophe Beyls

代理属性和Range

在发布有关Kotlin编程语言的性能开销系列的前两篇文章之后,我收到了很多不错的反馈,甚至还包括 Jake Wharton 大神他自己。所以你还没看前两篇文章,千万不要错过哦。

在第3部分中,我们将揭开更多有关Kotlin编译器的秘密,并提供如何编写更高效代码的新技巧。

一、代理属性

代理属性是一种其getter和可选的setter的内部实现可由代理的外部对象提供的属性。它可以允许复用自定义属性的内部实现。

class Example {
    var p: String by Delegate()
}

这个代理对象必须实现一个 operator getVlue()函数,以及一个 setValue()函数来用于属性的读/写. 这些函数将接收包含对象实例 以及属性的metadata元数据 作为额外参数(比如它的属性名)。

当类中声明一个代理属性时,编译将生成以下代码(下面是反编译后的Java代码):

public final class Example {
   @NotNull
   private final Delegate p$delegate = new Delegate();
   // $FF: synthetic field
   static final KProperty[] ?delegatedProperties = new KProperty[]{(KProperty)Reflection.mutableProperty1(new MutablePropertyReference1Impl(Reflection.getOrCreateKotlinClass(Example.class), "p", "getP()Ljava/lang/String;"))};

   @NotNull
   public final String getP() {
      return this.p$delegate.getValue(this, ?delegatedProperties[0]);
   }

   public final void setP(@NotNull String var1) {
      Intrinsics.checkParameterIsNotNull(var1, "<set-?>");
      this.p$delegate.setValue(this, ?delegatedProperties[0], var1);
   }
}

一些静态属性metadata元数据被添加到类中。代理将在类的构造器中进行初始化,然后在每次读取或写入属性时都调用该代理。

代理实例

在上述例子中,将会创建一个新的代理对象的实例来实现该属性。当代理实例是有状态的时候, 这就是必需的,例如在计算本地缓存属性的值时.

class StringDelegate {
    private var cache: String? = null

    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        var result = cache
        if (result == null) {
            result = someOperation()
            cache = result
        }
        return result
    }
}

如果还需要通过其构造函数传递的额外参数,则还需要创建一个新的代理实例:

class Example {
    private val nameView by BindViewDelegate<TextView>(R.id.name)
}

但是在某些情况下,只需要一个代理实例就可以实现任意属性: 当代理实例是无状态的时候,并且它执行所需的唯一变量就是对象实例和属性名称(然而这些编译器都直接提供了)。在这种情况下,可以通过将代理实例声明成object对象表达式而不是一个来使得成为单例

例如,下面的代理单例实例检索其标记名称与Android Activity 中的属性名称来匹配Fragment.

object FragmentDelegate {
    operator fun getValue(thisRef: Activity, property: KProperty<*>): Fragment? {
        return thisRef.fragmentManager.findFragmentByTag(property.name)
    }
}

同样,任意的对象都可以扩展成代理。此外getValue()setValue()还可以声明成扩展函数。Kotlin中已经提供了内置的扩展函数,例如允许将MapMutableMap实例作为代理实例,并将属性的名称作为key.

如果你选择在同一个类中实现多个属性复用同一个局部代理实例的话,那么需要在类的构造器中初始化此实例。

注意: 从Kotlin1.1开始,也可以在函数中声明局部变量作为代理属性。那么在这种情况下,代理实例可以延迟初始化,直到在函数中声明变量为止。

在类中声明的每个代理属性都涉及到其关联的代理对象创建的性能开销,并向该类中添加一些metadata元数据。必要的时候,可以尝试为不同属性复用同一个代理实例。在你声明大量代理属性的时候,还需要考虑代理属性是否你的最佳选择。

泛型代理

还可以以泛型的方式声明代理函数,因此同一个代理类可以用任意的属性类型。

private var maxDelay: Long by SharedPreferencesDelegate<Long>()

但是,如果像上面例子那样使用具有原生类型属性的泛型代理的话,即便声明的原生类型为非null,每次读取或写入该属性时都避免不了装箱和拆箱的发生

对于非null原生类型的代理属性,最好使用为该特定值类型创建特定的代理类,而不是泛型代理,以避免在每次访问该属性时产生的装箱开销

标准库代理: lazy()

Kotlin内置了一些标准库代理函数来覆盖常见的情况,例如 Delegates.notNull(),Delegates.observable()lazy().

lazy(initializer: () -> T) 是一个为只读属性返回代理对象的函数,该属性是通过在其首次被读取的时,lazy函数参数lambda initializer执行来初始化的。

private val dateFormat: DateFormat by lazy {
    SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
}

这是一种将昂贵的初始化操作延迟到实际需要使用之前的巧妙方法,可以在保持代码可读性的同时又提高了性能。

需要注意到的是,lazy()函数不是内联函数,并且作为参数传递的lambda将编译成独立的Function类,并且不会在返回的代理对象内进行内联。

通常会被人忽略的是lazy()另一重载函数实际上还隐藏一个可选的模式参数来确定应该返回3种不同类型的代理中的一种:

public fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
public fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
        when (mode) {
            LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
            LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
            LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
        }

默认的模式是 LazyThreadSafetyMode.SYNCHRONIZED 将执行相对开销昂贵的双重锁的检查,这是为了保证在多线程环境下读取属性时,初始化块可以安全运行。

如果你明确知道当前环境是单线程(例如主线程)访问属性,那么可以通过显式使用 LazyThreadSafetyMode.NONE 来完全避免双重锁的检查所带来昂贵的开销。

val dateFormat: DateFormat by lazy(LazyThreadSafetyMode.NONE) {
    SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
}

使用lazy()代理可以按需延迟昂贵的初始化,此外可以指定线程安全的模式以避免不必要的双重锁检查。

二、Ranges(区间)

区间是一种用于表示Kotlin中的一组有限值的特殊表达式。这些值可以是任意Comparable类型。这些表达式由创建用于实现ClosedRange对象的函数形成。用于创建区间的主要函数是 ..操作符。

区间包含的测试

区间表达式主要目的是使用in!in 运算符来判断是否包含某个值

if (i in 1..10) {
    println(i)
}

该实现特地针对非null原生类型区间(有: Int, Long, Byte, Short, Float, Double或Char)进行了优化,因此上面例子可以高效编译成如下形式:

if(1 <= i && i <= 10) {
   System.out.println(i);
}

性能开销几乎为0,没有额外的对象分配。区间也可以和任意其他非原生Comparable类型一起使用。

if (name in "Alfred".."Alicia") {
    println(name)
}

在Kotlin 1.1.50之前,编译以上示例时始终会创建一个临时的ClosedRange对象。但是从1.1.50之后,已经对它的实现进行了优化,以避免Comparable类型额外开销分配:

if(name.compareTo("Alfred") >= 0) {
   if(name.compareTo("Alicia") <= 0) {
      System.out.println(name);
   }
}

此外,区间检查还包括应用再 when 表达式中

val message = when (statusCode) {
    in 200..299 -> "OK"
    in 300..399 -> "Find it somewhere else"
    else -> "Oops"
}

这使代码比一系列if {...} else if {...}语句更具可读性,并且效率更高。

但是,在区间包含检查中,当区间的声明之间至少存在一个间接过程时,会有一个小的性能开销。 比如下面这段Kotlin代码:

private val myRange get() = 1..10

fun rangeTest(i: Int) {
    if (i in myRange) {
        println(i)
    }
}

上述代码会造成在编译后额外创建一个IntRange对象:

private final IntRange getMyRange() {
   return new IntRange(1, 10);
}

public final void rangeTest(int i) {
   if(this.getMyRange().contains(i)) {
      System.out.println(i);
   }
}

即使将属性getter声明成内联函数也不能避免创建IntRange对象。在这种情况下,Kotlin 1.1编译器已经改进了。 由于这些特定的区间类存在,至少在比较原生类型时不会出现装箱过程。

尝试在没有间接声明过程区间检查中使用直接声明区间的方式,来避免额外区间对象的创建分配,另外,可以将它们声明成常量以此来复用他们。

迭代: for循环

整数类型区间(除Float或Double之外的任何原生类型的区间)也是级数: 可以对其进行迭代。这允许用较短的语法替换经典的Java for循环。

for (i in 1..10) {
    println(i)
}

这可以以零开销方式编译为可比较的优化代码:

int i = 1;
for(byte var2 = 11; i < var2; ++i) {
   System.out.println(i);
}

如果向后迭代,请使用 downTo() 中缀函数来替代

for (i in 10 downTo 1) {
    println(i)
}

同样,使用此构造进行编译后的开销为零:

int i = 10;
byte var1 = 1;
while(true) {
   System.out.println(i);
   if(i == var1) {
      return;
   }
   --i;
}

还有一个有用的until()中缀函数可以迭代直到但不包括区间上限值。

for (i in 0 until size) {
    println(i)
}

当本文的原始版本发布时,调用此函数用于生成次优代码。自Kotlin 1.1.4起,情况已大大改善,并且编译器现在生成等效的Java for循环:

int i = 0;
for(int var2 = size; i < var2; ++i) {
   System.out.println(i);
}

但是,其他迭代变体的优化效果也不佳

这是另一种使用reversed() 函数与区间组合的方法,可以向后迭代并产生与downTo()完全相同的结果。

for (i in (1..10).reversed()) {
    println(i)
}

不幸的是,生成的编译代码就不那么漂亮:

IntProgression var10000 = RangesKt.reversed((IntProgression)(new IntRange(1, 10)));
int i = var10000.getFirst();
int var3 = var10000.getLast();
int var4 = var10000.getStep();
if(var4 > 0) {
   if(i > var3) {
      return;
   }
} else if(i < var3) {
   return;
}

while(true) {
   System.out.println(i);
   if(i == var3) {
      return;
   }

   i += var4;
}

将会创建一个临时的IntRange对象来表示区间,然后再创建另一个IntProgression对象来反转第一个对象的值。

事实上,创建一个progression的以上功能任何组合都会生成类似的代码,涉及到创建至少两个轻量级progression对象的小开销。

此规则也适用于使用step()中缀函数来修改progression, 即使步长是1:

for (i in 1..10 step 2) {
    println(i)
}

附带说明下,当生成的代码读取IntProgression的最后一个属性时,这将执行少量计算,以通过考虑边界和步长来确定区间的确切最后一个值。在上面的示例中,最后一个值应该为9。

若要在for循环中进行迭代,最好使用区间表达式,该区间表达式只涉及到对 ..downTo()untill()的单个函数调用,以避免创建临时progression对象的开销。

迭代: for-each()

与其使用for循环,不如尝试在区间上使用forEach()内联扩展函数来达到相同的结果。

(1..10).forEach {
    println(it)
}

但是,如果您仔细查看此处使用的forEach()函数的签名,你会注意到,它并没有针对区间进行优化,而只是针对Iterable进行了优化,因此需要创建一个迭代器。这是反编译后的Java代码表示形式:

Iterable $receiver$iv = (Iterable)(new IntRange(1, 10));
Iterator var1 = $receiver$iv.iterator();

while(var1.hasNext()) {
   int element$iv = ((IntIterator)var1).nextInt();
   System.out.println(element$iv);
}

该代码甚至比以前的示例效率更低,因为除了创建IntRange对象外, 你还必须还有创建一个IntIterator的开销。至少,这个会生成原生类型的值。

要对范围进行迭代,最好使用简单的for循环,而不是在其上调用forEach()函数,以避免迭代器对象的开销。

迭代: collection indices

Kotlin标准库提供了内置索引扩展属性,以生成数组索引和Collection索引的区间。

val list = listOf("A", "B", "C")
for (i in list.indices) {
    println(list[i])
}

令人惊讶的是,遍历 indices 的代码也被编译为优化的代码

List list = CollectionsKt.listOf(new String[]{"A", "B", "C"});
int i = 0;
for(int var2 = ((Collection)list).size(); i < var2; ++i) {
   Object var3 = list.get(i);
   System.out.println(var3);
}

在这里,我们可以看到根本没有创建IntRange对象,并且列表迭代尽可能高效。

这对于实现Collection的数组和类非常有效, 因此你可能会在自己定义类中定义自己的indices扩展,同时期望能达到相同的迭代性能.

inline val SparseArray<*>.indices: IntRange
    get() = 0 until size()

fun printValues(map: SparseArray<String>) {
    for (i in map.indices) {
        println(map.valueAt(i))
    }
}

但是,在编译之后,我们可以看到效率不高,因为编译器无法智能地避免创建区间对象:

public static final void printValues(@NotNull SparseArray map) {
   Intrinsics.checkParameterIsNotNull(map, "map");
   IntRange var10000 = RangesKt.until(0, map.size());
   int i = var10000.getFirst();
   int var2 = var10000.getLast();
   if(i <= var2) {
      while(true) {
         Object $receiver$iv = map.valueAt(i);
         System.out.println($receiver$iv);
         if(i == var2) {
            break;
         }
         ++i;
      }
   }
}

相反,我建议直接在for循环中使用until()函数

fun printValues(map: SparseArray<String>) {
    for (i in 0 until map.size()) {
        println(map.valueAt(i))
    }
}

当遍历未实现Collection接口的自定义集合时,最好直接在for循环中编写自己的索引范围,而不是依靠函数或属性来生成区间,以避免分配区间对象。

我希望这些对你的阅读和对我的写作一样有趣。你可能会在以后看到更多相关内容,但是前三部分涵盖了我计划最初编写的所有内容。如果你喜欢,请分享给他人,谢谢!

总结

到这里,有关探索Kotlin性能开销的系列文章终于暂时告于完结,说下自己切身感受,翻译这个系列对我平时在用Kotlin开发时有了很大的帮助,可以写出更加高效优秀的代码。所以我觉得有必要把它翻译出来和大家共享。下一站,我们将进入Kotlin协程~~~

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

Kotlin系列文章,欢迎查看:

Kotlin邂逅设计模式系列:

数据结构与算法系列:

翻译系列:

原创系列:

Effective Kotlin翻译系列

实战系列: