[译]Kotlin中内联类的自动装箱和高性能探索(二)

2,139

翻译说明:

原标题: Inline Classes and Autoboxing in Kotlin

原文地址: typealias.com/guides/inli…

原文作者: Dave Leeds

在上一篇文章中,我们知道了Kotlin的实验阶段的新特性内联类是如何让我们"创建需要的数据类型但是不会损失我们需要的性能"。我们了解到:

  • 1、内联类包装了基础类型的值
  • 2、当代码被编译的时候,内联类的实例将会被替换成基础类型的值
  • 3、这可以大大提高我们应用程序的性能,特别是当基础类型是一个基本数据类型时。

但是在某些情况下,内联类实际上比传统的普通类执行速度更慢! 在这篇文章中,我们将去探索在不同的场景下使用内联类编译代码中到底会发生什么- 因为如果我们知道如何高效地使用他们,我们才能从中获得更高的性能。

请记住-内联类始终还是一个实验性的特性。尽管我一直在写内联类系列的文章,并且内联类也会经历很多的迭代和修改。本文目前基于Kotlin 1.3 Release Candidate 146中实现的内联类。

此外,如果你还没有阅读过有关内联类的文章,那么你首先要阅读上一篇文章 [译]Kotlin中内联类(inline class)完全解析(一)。那样你就会全身心投入并准备好阅读这篇文章。

好的,让我们现在开始吧!

高性能的奥秘

Alan被彻底激怒了!在学习完内联类之后,他决定开始在他正在研究的游戏原型中使用内联类。为了看看内联类比传统的普通类到底有多好,他在他游戏评分系统中写了一些有关内联类的代码:

interface Amount { val value: Int }
inline class Points(override val value: Int) : Amount

private var totalScore = 0L

fun main() {
    repeat(1_000_000) {
        val points = Points(it)

        repeat(10_000) {
            addToScore(points)
        }
    }
}

fun addToScore(amount: Amount) {
    totalScore += amount.value
}

Alan编写了这段代码的测试用例。然后,他删除第二行inline关键字,并再次运行这个测试用例。

令他惊讶的是,使用内联修饰符inline运行速度实际上明显比没有内联情况慢很多。

“到底发生了什么?”他想知道。

虽然说内联类可以比传统的普通类更高性能运行,但是这一切都取决于我们如何合理使用它们-因为我们如何使用它们决定了值是否在编译代码中真的进行内联操作。

这是正确的 - 内联类的实例并不总是在编译的代码中内联。

什么时候内联类不会被内联

让我们再一起看下Alan的代码,看看我们是否可以弄明白为什么他写的内联类可能没有被内联。

我们先来看下这段代码:

interface Amount { val value: Int }
inline class Points(override val value: Int) : Amount

在这段代码中,内联类Points实现了Amount接口。当我们调用addToScore()函数时,会引发一个有趣的现象,尽管...

fun addToScore(amount: Amount) {
    totalScore += amount.value
}

addToScore()函数可以接收任何Amount类型的对象。由于PointsAmount的子类型,所以我们可以传入一个Points类型实例对象给这个函数。

这是基本的常识,没问题吧?

但是... 假设我们的Points类的实例都是内联的-也就是说,在源码被编译的阶段,它们(Points类的实例)会被基础类型(这里是Int整数类型)给替换掉。-可是addToScore()函数怎么能接收一个基础类型(这里是Int整数类型)的实参呢?毕竟,基础类型Int并没有去实现Amount的接口。

那么编译后的代码怎么可能会向addToScore函数发送一个Int类型(更确切的说是Java中的int类型)的实参,因为int类型是不会去实现Amount接口的。

答案当然是它不能啊!

因此,在这种场景下,Kotlin还是继续使用为Points类型,而不是在编译代码中使用整数替换。我们将这个Points类称为包装类型,而不是基础类型Int

最重要的是需要注意,这并不意味这该类永远不会被内联。它只意味着代码中某些地方没有被内联。例如,让我们来看一下Alan中的代码,看看Points什么时候是内联的,什么时候不是内联的。

fun main() {
    repeat(1_000_000) {
        val points = Points(it) // <-- Points is inlined as an Int here(Points类在这是内联的,并被当做Int替换)

        repeat(10_000) {
            addToScore(points)  // <-- Can't pass Int here, so sends it
                                //     as an instance of Points instead.(因为这里不能被传入Int,所以这里必须传入Points实例)
        }
    }
}

编译器将尽可能使用基础类型(例如,Int,编译为int),但是当它不能被当做基础类型使用时,它会自动实例化包装类型的实例(例如,Points)并把它传递出去。可以想象下这是编译后的代码(在Java中)大致如下:

public static void main(String[] arg) {
  for(int i = 0; i < 1000000; i++) {
     int points = i;                     // <--- Inlined here(此处内联)

     for(short k = 0; k < 10000; k++) {
        addToScore(new Points(points));  // <--- Automatic instantiation!(自动实例化)
     }
  }
}

您可以将Points类想象为包装基础Int值的箱子。

因为编译器会自动将值放入箱子中,所以我们把这个过程叫做自动装箱

现在我们知道了为什么Alan的代码在使用内联类的时候运行速度会比普通类要慢。每次调用addToScore()函数时,都会自动实例化一个新的Points类的实例。所以在内部循环迭代过程中总共发生100亿次堆分配过程,这就是速度减慢的原因。 (相比之下,使用传统的普通类,而堆分配过程只发生在外层for循环中,总共也只有100万次).

这种自动装箱过程一般还是很有用的-它是保证类型安全所必需的操作,当然,它同时也带来了性能开销成本,每次创建一个堆上新对象时就会存在这样性能开销。所以这就意味着作为开发者,了解哪种场景下会发生Kotlin进行自动装箱操作是非常重要的,这样我们就可以更明智地决定如何去使用内联类了。

那么,接下来让我们一起来看看自动装箱过程可能会在哪些场景被触发!

引用超类型时会触发自动装箱操作

正如我们所看到的那样,当我们将Points对象传递给接收Amount类型作为形参的函数式,就触发了自动装箱操作。

即使你的内联类没有去实现接口,但是必须记住一点,内联类和普通类一样,所有内联类都是Any的子类型。所以当你将内联类的实例赋值给Any类型的变量或者传递给Any类型作为形参的函数时,都会触发预期中的自动装箱操作。

例如,假设我们有一个可以记录日志的服务接口:

interface LogService {
    fun log(any: Any)
}

由于这个log()函数可以接收一个Any类型的实参,一旦你传入一个Points的实例给这个函数,那么这个实例就会触发自动装箱操作。

val points = Points(5)
logService.log(points) // <--- Autoboxing happens here(此处发生自动装箱操作)

总之一句话 - 当你使用内联类的实例(其中需要超类型)时,可能会触发自动装箱。

自动装箱与泛型

当您使用具有泛型的内联类时,也会发生自动装箱。例如:

val points = Points(5)

val scoreAudit = listOf(points)      // <-- Autoboxing here(此处发生自动装箱操作)

fun <T> log(item: T) {
    println(item)
}

log(points)                          // <-- Autoboxing here(此处发生自动装箱操作)

在使用泛型时,Kotlin为我们自动装箱是件好事,否则我们会在编译代码中会遇到类型安全的问题。例如,类似于我们之前的场景,将整数类型的值插入到MutableList<Amount>集合类型中是不安全的,因为整数类型并没有去实现Amount的接口。

而且,一旦考虑到与Java互操作时,它就会变得更加复杂,例如:

  • 如果Java将List<Points>保存为List<Integer>,它是否应该可以将该类型的集合传递给如下这个Kotlin函数呢?
fun receive(list: List<Int>)
  • Java将它传递给下面这个Kotlin函数又会怎么样呢?
fun receive(list: List<Amount>)
  • Java能否可以构建自己的整数集合并把它传递给下面这个Kotlin函数?
fun receive(list: List<Points>)

相反,Kotlin通过自动装箱的操作来避免了内联类和泛型一起使用时的问题。

我们已经看到超类型和泛型两种场景下如何触发自动装箱操作。其实我们还有一个值得去深究的场景 - 那就是可空性的场景!

自动装箱和可空性

当涉及到可空类型的值时,也可能会触发自动装箱操作。这个规则有点不同,主要取决于基础类型是引用类型还是基本数据类型。所以让我们一次性来搞定它们。

引用类型

当我们讨论内联类的可空性时,有两种场景可以为空:

  • 1、内联类自己的基础类型存在可空和非空的情况
  • 2、使用内联类的地方存在可空和非空的情况

例如:

// 1. The underlying type itself can be nullable (`String?`)
// 1. 基础类型自己存在可空
inline class Nickname(val value: String?)

// 2. The usage can be nullable (`Nickname?`)
//使用内联类时存在可空
fun logNickname(nickname: Nickname?) {
    // ...
}

由于我们有两种场景,并且每个场景下又存在非空与可空两种情况,因为总共需要考虑四种情况。所以我们为如下四种场景制作一张真值表!

对于每一种情况,我们将考虑:

  • 1、基础类型的可空和非空
  • 2、使用内联类地方的可空和非空
  • 3、以及每种情况编译后的是否触发自动装箱操作

好消息的是,当基础类型是引用类型时,大多数的情况下,使用的内联类都将被编译成基础类型。这就意味着基础类型的值可以被使用且不会触发自动装箱操作。

这里只有一种情况会触发自动装箱操作,我们需要注意 - 当基础类型和使用类型都为可空类型时。

为什么在这种情况下会触发自动装箱操作?

因为当这两种场景都存在值可空情况下,你最终得到的将是不同的代码分支,具体取决于这两种场景哪一种是空的。例如,看看这段代码:

inline class Nickname(val value: String?)

fun greet(name: Nickname?) {
    if (name == null) {
        println("Who's there?")
    } else if (name.value == null) {
        println("Hello, there.")
    } else {
        println("Greetings, ${name.value}")
    }
}

fun main() {
    greet(Nickname("T-Bone"))
    greet(Nickname(null))
    greet(null)
}

如果name形参是使用了基础类型的值-换句话说,如果编译的代码是void greet(String name)-那么它就不可能出现下面三个判断分支。那就不清楚name是否为空是应该打印Who's There还是Hello There.

相反,函数如果编译成这样void greet(NickName name)将是有效的.这意味着只要我们调用该函数,Kotlin就会根据需要自动触发装箱操作来包装基础类型的值。

嗯,这是可以为空的引用类型!但是可以为空的基本数据类型呢?

基本数据类型

当内联类、基本数据类型和可空性这三种因素碰在一起,我们会得到一些有趣的自动装箱的场景。正如我们在上面的引用类型中看到的那样,可空性出现场景取决于基础类型可空或非空以及使用内联类地方的可空或非空。

// 1. The underlying type itself can be nullable (`Int?`)
// 1. 基础类型自己存在可空
inline class Anniversary(val value: Int?)

// 2. The usage can be nullable (`Anniversary?`)
//使用内联类时存在可空
fun celebrate(anniversary: Anniversary?) {
    // ...
}

让我们构建一个真值表,就像对上面的引用类型一样做出的总结

正如你所看到的那样,上面表格中对于基本数据类型的结果除了场景B不一样,其他的场景都和引用类型分析结果一样。但是这里面还是涉及到了其他很多知识,所以让我们花点时间一一分析下每一种情况。

对于场景A. 很容易就能分析出来。因为这里根本就没有可空类型(都是非空类型),所以类型是内联的,正如我们所期望的那样。

对于场景B. 这是一种完全不同于上一个真值表中的场景,不知道你是否还记得,JVM上的intboolean等其他基本数据类型实际上是不能为null的。因此,为了更好兼容null,Kotlin在此使用了包装类型(也就触发了自动装箱操作)

对于场景C. 这种场景就更有意思了。一般来说,当你有一个类似Int可以为空的基本数据类型时,在Kotlin中,这种基本数据类型会在编译的时候转换成Java中的基本数据类型对应的包装器类型-例如Integer,它(不像int)可以兼容null值。对于场景C而言,实际上在使用内联类地方编译时候却使用基础类型,因为它本身恰好是一个Java中基本包装器类型。所以在某种层面上,你可以说基础类型被自动装箱了,但是这种自动装箱操作和内联类根本就没有任何关系。

对于场景D. 类似于上面引用类型看到的那样,当基本类型自身为可空以及使用内联类地方为可空时,Kotlin将在编译时使用包装器类型。具体原因和引用类型同理。

其他需要牢记的点

我们已经介绍了可能导致自动装箱的主要场景。在使用内联类时,你可能会发现对Kotlin源码编译后的字节码进行反编译,然后根据反编译的Java代码来分析是否出现自动装箱有很大的帮助。

要在IntelliJ或Android Studio中执行此操作,只需转到Tools - > Kotlin - >Show Kotlin Bytecode,然后单击Decompile按钮。

此外,请记住还有很多其他层面上都有可能影响内联类的性能。即使你对自动装箱有了充分的了解,编译器优化(Kotlin编译器和JIT编译器)之类的东西也会导致与我们的预期性能相差很大。如果需要真正了解编码决策对性能的影响,唯一的办法就是使用基准测试工具(比如JMH)实际运行测试。

总结

在本文中,我们探讨了使用内联类会出现一些性能影响,并了解到哪些场景下会进行自动装箱。我们已经看到如何使用内联类并会对其性能产生影响,包括涉及到一些具体的使用场景:

  • 超类型
  • 泛型
  • 可空性

现在我们知道这一点,我们可以做出更加明智的选择,来高效使用内联类。

你准备好自己开始使用内联类了吗? 你无需等待-你现在就可以在IDE中尝试使用它!

译者有话说

这篇文章可以说得上是我看过最好的一篇有关Kotlin内联类性能优化的文章了,感觉非常不错,作者分析得很全面也很深入。就连官方也没有给出过如此详细介绍。关于译文中有几点我需要补充一下:

  • 对于Alan那段糟糕的代码使用inline class和普通class代码比较,粗略算了下时间,对比了真的比较惊人:

可以看到inline class看似是个性能优化操作,但是使用不当性能反而比普通类更加差。

  • 有关译文中的基础类型、基本数据类型、引用类型做一个对比解释,怕有人发蒙。

基础类型: 实际上是针对内联类中包装的那个值的类型,它和基础数据类型不是一个东西。这么说吧,基础类型既可以是基本数据类型也可以是引用类型

基本数据类型: 实际上就是常用的Int、Float、Double、Short、Long等类型,注意String是引用类型

引用类型: 实际上就是除了基本数据类型就是引用类型,String和我们平时自定义的类的类型都属于引用类型。

  • 关于上述基本数据类型中的场景B,可能大家还是有点不能理解。这里给大家具体再分析下。

对于基础数据类型场景B,为什么会出现自动装箱操作? 这是因为在Kotlin中使用内联类的时候用了可空类型,我们可以用反证法来理解下,假设使用可空类型的内联类地方被编译成Java中的int等基本数据类型,在Kotlin中类似如下代码:

inline class Age(val value: Int)

fun howOld(age: Age?) {
    if(age == null){
        ...
    }
}

编译成类似如下代码:


void howOld(int age){
    if(age == null){//这样的代码是会报错的
        ...
    }
}

所以原假设不成立,Kotlin为了兼容null,不得不把它自动装箱使用包装器类型。

到这里有关内联类的知识文章就完全结束了,由于内联类还是一个实验性的特性,后期正式版本的API可能会有变动,当然我也紧跟官方最新动态,如果变动会尽快以文章形式总结出来。如果你这一期内联类知识掌握了,后面在怎么变动,你都能很快掌握它,并也会得到更多自己的体会。欢迎继续关注~~~

Kotlin系列文章,欢迎查看:

原创系列:

翻译系列:

实战系列:

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