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

翻译说明:

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

原文地址: https://medium.com/@BladeCoder/exploring-kotlins-hidden-costs-part-2-324a4a50b70

原文作者: Christophe Beyls

代理属性和Range

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

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

一、代理属性

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

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

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

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

代理实例

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

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

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

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

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

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

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

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

泛型代理

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

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

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

标准库代理: lazy()

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

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

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

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

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

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

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

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

二、Ranges(区间)

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

区间包含的测试

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

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

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

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

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

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

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

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

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

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

迭代: for循环

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

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

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

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

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

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

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

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

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

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

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

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

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

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

迭代: for-each()

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

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

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

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

迭代: collection indices

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

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

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

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

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

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

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

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

总结

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