[译]Effective Kotlin系列之探索高阶函数中inline修饰符(三)

2,016

简述: 不知道是否有小伙伴还记得我们之前的Effective Kotlin翻译系列,之前一直忙于赶时髦研究Kotlin 1.3中的新特性。把此系列耽搁了,赶完时髦了还是得踏实探究本质和基础,从今天开始我们将继续探索Effective Kotlin系列,今天是Effective Kotlin第三讲。

翻译说明:

原标题: Effective Kotlin: Consider inline modifier for higher-order functions

原文地址: blog.kotlin-academy.com/effective-k…

原文作者: Marcin Moskala

你或许已经注意到了所有集合操作的函数都是内联的(inline)。你是否问过自己它们为什么要这么定义呢? 例如,这是Kotlin标准库中的filter函数的简化版本的源码:

inline fun <T> Iterable<T>.filter(predicate: (T)->Boolean): List<T>{
    val destination = ArrayList<T>()
    for (element in this) 
        if (predicate(element))
            destination.add(element)
    return destination
}

这个inline修饰符到底有多重要呢? 假设我们有5000件商品,我们需要对已经购买的商品累计算出总价。我们可以通过以下方式完成:

products.filter{ it.bought }.sumByDouble { it.price }

在我的机器上,运行上述代码平均需要38毫秒。如果这个函数不是内联的话会是多长时间呢? 不是内联在我的机器上大概平均42毫秒。你们可以自己检查尝试下,这里是完整源码. 这似乎看起来差距不是很大,但每调用一次这个函数对集合进行处理时,你都会注意到这个时间差距大约为10%左右。

当我们修改lambda表达式中的局部变量时,可以发现差距将会更大。对比下面两个函数:

inline fun repeat(times: Int, action: (Int) -> Unit) {
    for (index in 0 until times) {
        action(index)
    }
}

fun noinlineRepeat(times: Int, action: (Int) -> Unit) {
    for (index in 0 until times) {
        action(index)
    }
}

你可能已经注意到除了函数名不一样之外,唯一的区别就是第一个函数使用inline修饰符,而第二个函数没有。用法也是完全一样的:

var a = 0
repeat(100_000_000) {
    a += 1
}
var b = 0
noinlineRepeat(100_000_000) {
    b += 1
}

上述代码在执行时间上对比有很大的差异。内联的repeat函数平均运行时间是0.335ns, 而noinlineRepeat函数平均运行时间是153980484.884ns。大概是内联repeat函数运行时间的466000倍! 你们可以自己检查尝试下,这里是完整源码.

为什么这个如此重要呢? 这种性能的提升是否有其他的成本呢? 我们应该什么时候使用内联(inline)修饰符呢?这些都是重点问题,我们将尽力回答这些问题。然而这一切都需要从最基本的问题开始: 内联修饰符到底有什么作用?

内联修饰符有什么作用?

我们都知道函数通常是如何被调用的。先执行跳转到函数体,然后执行函数体内所有的语句,最后跳回到最初调用函数的位置。

尽管强行对函数使用inline修饰符标记,但是编译器将会以不同的方式来对它进行处理。在代码编译期间,它用它的主体替换这样的函数调用。 print函数是inline函数:

public inline fun print(message: Int) {
    System.out.print(message)
}

当我们在main函数中调用它时:

fun main(args: Array<String>) {
    print(2)
    print(2)
}

编译后,它将变成下面这样:

 public static final void main(@NotNull String[] args) {
    System.out.print(2)
    System.out.print(2)
}

这里有一点不一样的是我们不需要跳回到另一个函数中。虽然这种影响可以忽略不计。这就是为什么你定义这样的内联函数时会在IDEA IntelliJ中发出以下警告:

为什么IntelliJ建议我们在含有lambda表达式作为形参的函数中使用内联呢?因为当我们内联函数体时,我们不需要从参数中创建lambda表达式实例,而是可以将它们内联到函数调用中来。这个是上述repeat函数的调用:

repeat(100) { println("A") }

将会编译成这样:

for (index in 0 until 1000) {
    println("A")
}

正如你所看见的那样,lambda表达式的主体println("A")替换了内联函数repeataction(index)的调用。让我们看另一外个例子。filter函数的用法:

val products2 = products.filter { it.bought }

将被替换为:

val destination = ArrayList<T>()
for (element in this) 
    if (predicate(element))
        destination.add(element)
val products2 = destination

这是一项非常重要的改进。这是因为JVM天然地不支持lambda表达式。说清楚lambda表达式是如何被编译的是件很复杂的事。但总的来说,有两种结果:

  • 匿名类
  • 单独的类

我们来看个例子。我们有以下lambda表达式:

val lambda: ()->Unit = {
    // body
}

它变成了JVM中的匿名类:

// Java
Function0 lambda = new Function0() {
   public Object invoke() {
      // code
   }
};

或者它变成了单独的文件中定义的普通类:

// Java
// Additional class in separate file
public class TestInlineKt$lambda implements Function0 {
   public Object invoke() {
      // code
   }
}
// Usage
Function0 lambda = new TestInlineKt$lambda()

第二种效率更高,我们尽可能使用这种。仅仅当我们需要使用局部变量时,第一种才是必要的。

这就是为什么当我们修改局部变量时,repeatnoinlineRepeat之间存在如此之大的运行速度差异的原因。非内联函数中的Lambda需要编译为匿名类。这是一个巨大的性能开销,从而导致它们的创建和使用都较慢。当我们使用内联函数时,我们根本不需要创建任何其他类。自己检查一下。编译这段代码并把它反编译为Java代码:

fun main(args: Array<String>) {
    var a = 0
    repeat(100_000_000) {
        a += 1
    }
    var b = 0
    noinlineRepeat(100_000_000) {
        b += 1
    }
}

你会发现一些相似的东西:

/ Java
public static final void main(@NotNull String[] args) {
   int a = 0;
   int times$iv = 100000000;
   int var3 = 0;

   for(int var4 = times$iv; var3 < var4; ++var3) {
      ++a;
   }

   final IntRef b = new IntRef();
   b.element = 0;
   noinlineRepeat(100000000, (Function1)(new Function1() {
      public Object invoke(Object var1) {
         ++b.element;
         return Unit.INSTANCE;
      }
   }));
}

filter函数例子中,使用内联函数改进效果不是那么明显,这是因为lambda表达式在非内联函数中是编译成普通的类而非匿名类。所以它的创建和使用效率还算比较高,但仍有性能开销,所以也就证明了最开始那个filter例子为什么只有10%的运行速度差异。

集合流处理方式与经典处理方式

内联修饰符是一个非常关键的元素,它能使集合流处理的方式与基于循环的经典处理方式一样高效。它经过一次又一次的测试,在代码可读性和性能方面已经优化到极点了,并且相比之下经典处理方式总是有很大的成本。例如,下面的代码:

return data.filter { filterLoad(it) }.map { mapLoad(it) }

工作原理与下面代码相同并具有相同的执行时间:

val list = ArrayList<String>()
for (it in data) {
    if (filterLoad(it)) {
        val value = mapLoad(it)
        list.add(value)
    }
}
return list

基准测量的具体结果(源码在这里):

Benchmark           (size) Mode  Cnt        Score    Error  Units
filterAndMap           10  avgt  200      561.249 ±      1  ns/op
filterAndMap         1000  avgt  200    29803.183 ±    127  ns/op
filterAndMap       100000  avgt  200  3859008.234 ±  50022  ns/op

filterAndMapManual     10  avgt  200      526.825 ±      1  ns/op
filterAndMapManual   1000  avgt  200    28420.161 ±     94  ns/op
filterAndMapManual 100000  avgt  200  3831213.798 ±  34858  ns/op

从程序的角度来看,这两个函数几乎相同。尽管从可读性的角度来看第一种方式要好很多,这就是为什么我们应该总是宁愿使用智能的集合流处理函数而不是自己去实现整个处理过程。此外如果stalib库中集合处理函数不能满足我们的需求时,请不要犹豫,自己动手编写集合处理函数。例如,当我需要转置集合中的集合时,这是我在上一个项目中添加的函数:

fun <E> List<List<E>>.transpose(): List<List<E>> {
    if (isEmpty()) return this

    val width = first().size
    if (any { it.size != width }) {
        throw IllegalArgumentException("All nested lists must have the same size, but sizes were ${map { it.size }}")
    }

    return (0 until width).map { col ->
        (0 until size).map { row -> this[row][col] }
    }
}

记得写一些单元测试:

class TransposeTest {

    private val list = listOf(listOf(1, 2, 3), listOf(4, 5, 6))

    @Test
    fun `Transposition of transposition is identity`() {
        Assert.assertEquals(list, list.transpose().transpose())
    }

    @Test
    fun `Simple transposition test`() {
        val transposed = listOf(listOf(1, 4), listOf(2, 5), listOf(3, 6))
        assertEquals(transposed, list.transpose())
    }
}

内联修饰符的成本

内联不应该被过度使用,因为它也是有成本的。我想在代码中打印出更多的数字2, 所以我就定义了下面这个函数:

inline fun twoPrintTwo() {
    print(2)
    print(2)
}

这对我来说可能还不够,所以我添加了这个函数:

inline fun twoTwoPrintTwo() {
    twoPrintTwo()
    twoPrintTwo()
}

还是不满意。我又定义了以下这两个函数:

inline fun twoTwoTwoPrintTwo() {
    twoTwoPrintTwo()
    twoTwoPrintTwo()
}

fun twoTwoTwoTwoPrintTwo() {
    twoTwoTwoPrintTwo()
    twoTwoTwoPrintTwo()
}

然后我决定检查编译后的代码中发生了什么,所以我将编译为JVM字节码然后将它反编译成Java代码。twoTwoPrintTwo函数已经很长了:

public static final void twoTwoPrintTwo() {
   byte var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
}

但是twoTwoTwoTwoPrintTwo就更加恐怖了

public static final void twoTwoTwoTwoPrintTwo() {
   byte var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
   var1 = 2;
   System.out.print(var1);
}

这说明了内联函数的主要问题: 当我们过度使用它们时,会使得代码体积不断增大。这实际上就是为什么当我们使用他们时IntelliJ会给出警告提示。

内联修饰符在不同方面的用法

内联修饰符因为它特殊的语法特性而发生的变化远远超过我们在本篇文章中看到的内容。它可以实化泛型类型。但是它也有一些局限性。虽然这与Effective Kotlin系列无关并且属于是另外一个话题。如果你想要我阐述更多有关它,请在Twitter或评论中表达你的想法。

一般来说,我们应该什么时候使用内联修饰符呢?

我们使用内联修饰符时最常见的场景就是把函数作为另一个函数的参数时(高阶函数)。集合或字符串处理(如filter,map或者joinToString)或者一些独立的函数(如repeat)就是很好的例子。

这就是为什么inline修饰符经常被库开发人员用来做一些重要优化的原因了。他们应该知道它是如何工作的,哪里还需要被改进以及使用成本是什么。当我们使用函数类型作为参数来定义自己的工具类函数时,我们也需要在项目中使用inline修饰符。当我们没有函数类型作为参数,没有reified实化类型参数并且也不需要非本地返回时,那么我们很可能不应该使用inline修饰符了。这就是为什么我们在非上述情况下使用inline修饰符会在Android Studio或IDEA IntelliJ得到一个警告原因。

译者有话说

这是Effective Kotlin系列第三篇文章,讲得是inline内联函数存在使用时潜在隐患,一旦使用不当或者过度使用就会造成性能上损失。基于这一点原作者从发现问题到剖析整个inline内联函数原理以及最后如何去选择在哪种场景下使用内联函数。我相信有了这篇文章,你对Kotlin中的内联函数应该是了然于胸了吧。后面会继续Effective Kotlin翻译系列,欢迎继续关注~~~

Kotlin系列文章,欢迎查看:

Effective Kotlin翻译系列

原创系列:

翻译系列:

实战系列:

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