第46条:给高阶函数使用 inline 修饰符
你可能已经注意到了,几乎所有的 Kotlin stdlib 中的高阶函数都有一个 inline
(内联)修饰符,你有没有思考过为什么要这样定义呢?例如,下面是 Kotlin 中 repeat
函数的实现:
这个 inline
修饰符的作用是:在编译期间,所有对这个函数的调用都替换成其函数体。所以,所有在 repeat
内部调用的函数参数都会被替换成它们的函数体。那么 repeat
的调用:
在编译期间,将会变成下面这样:
与正常调用函数的方式相比,上面的方式则是一个显著的变化。在普通函数中,执行跳转到该函数体,调用所有语句,然后跳回调用该函数的位置。而用函数体嵌入来代替调用则是一种截然不同的行为。
这种行为有几个好处:
类型参数可以具体化(
reified
)高阶函数在内联时速度更快
允许非本地的返回
使用这个修饰符也有一些成本。让我们来学习下 inline
修饰符的优点和缺点。
类型参数可以被具体化(reified)
Java 的旧版本中没有泛型,它是在2004年J2SE 5.0版本中添加到 Java 编程语言中的。但是它们仍然没有出现在 JVM 字节码中。因此在编译的过程中,泛型被擦除了。例如 List<Int>
被编译成 List
。这就是我们不能检查对象是否为 List<Int>
类型,只能检查它是否为 List
的原因。
出于同样的原因,不能对泛型进行操作。
我们可以通过内联函数来克服这个限制。通过使用 reified
修饰符,用函数体来替代函数调用,所以这其实是用实参来代替形参:
在编译期间, printTypeName
的函数体替换了其引用,并且实参替换了形参:
reified
是一个很有用的修饰符。例如,它在标准库中 filterIsInstance
中使用,用来过滤特定类型的元素:
高阶函数在内联时速度更快
更具体地说,所有函数在内联时都会变得稍微快一些,不需要在执行时跳转,也不需要跟踪之后的栈。这就是 stdlib 中经常使用的小函数基本上都内联的原因:
但是,当函数没有任何实参时,使用这种方法和不使用的差异几乎是微不足道的。这就是 IntelliJ 给出这样一个警告的原因。
为了理解其中的原因,我们首先要理解函数作为对象进行操作的问题是什么。这些类型的对象(使用函数字面量创建)需要以某种方式保存。在 Kotlin/JS 中,这很简单,因为 JavaScript 视函数为一等公民。因此在 Kotlin/JS,它要么是一个函数,要么是一个函数引用。在 Kotlin/JVM 上,需要用 JVM 匿名类或普通类来创建对象,因此,下面的 lambda 表达式:
将被编译成一个类,要么是 JVM 上的一个匿名类:
要么是被编译在一个单独文件中定义的普通类:
这两种做法之间没有显著差异。
注意,函数类型转化为 Function0
类型,这就是 JVM 中没有参数的函数类型被编译之后的类型。其它函数类型亦是如此:
()->Unit
被编译为Function0<Unit>
()->Int
被编译为Function0<Int>
(Int)->Int
被编译为Function1<Int, Int>
(Int, Int)->Int
被编译为Function2<Int, Int, Int>
所有这些接口都是 Kotlin 编译器生成的,但是你不能在 Kotlin 中显式的使用它们,因为它们是按需生成。我们应该改用函数类型,知道函数类型只是一个接口可能会让你看到一些新花样:
正如_第45条:避免不必要的对象创建_所述,将函数包装到对象中会降低代码的运行速度,这就是为什么下面两个函数中,第一个函数会更快:
这种差异是显而易见的,虽然在现实看起来少有区别,但如果我们设计好了测试,你就可以很清楚地看到它们的差异:
第一个程序在我的电脑上平均运行189ms,第二个则为447ms。这种差异来源于这么一个事实:在第一个函数中,我们只对数字进行迭代,并调用一个空的函数;在第二个函数中,我们在每次的数字迭代时,都会引用一个对象,而该对象调用一个空函数。所有这些区别都是因为我们使用了一个额外的对象(第45条:避免必要的对象创建)。
为了展示一个更典型的例子,假设我们有5000种产品,我们需要把已经购买的产品的价格累加起来,我们可以简单地这样做:
在我的机器上,平均需要38ms来计算。如果 filter
和 sumByDouble
函数没有内联,那会是多少? 在我的机器上是42ms,这看起来并没有什么差异,但实际上你对集合进行处理时,使用内联每次都大约快10%左右。
当我们在函数中获取局部元素时,内联函数和非内联函数之间的更显著的区别就会凸显出来。获取的值也需要包装到某个对象中,并且无论如何使用它,都需要使用该对象来完成。例如,在以下代码中:
局部变量不能在非内联 lambda 中直接使用,这就是为什么在编译时,它会被包装成一个引用对象:
这是一个更重要的区别,因为这些对象通常会被多次使用:每次我们都要使用由 fun
关键字创建的函数。例如,在上面的例子中,一次循环里我们调用了两次函数(a.elemtent
),因此,局部对象将被使用2* 100,000,000次。为了了解这种差异,让我们对比以下函数:
我的机器上第一个函数需要30ms,第二需要274ms。这是由于函数被编译为对象,并且需要包装局部变量这一事实的影响。这是一个显著的区别,因为在大多数情况下,我们不知道高阶函数如何使用,所以当我们定义高阶函数(例如用于集合处理)时,最好将其内联。这就是为什么 stdlib 中大多数高阶函数都是内联的。
允许非本地的返回
之前定义的 repeatNoninlin
看起来很像一个流程控制结构,只需将它与for循环进行比较:
尽管一个显著的区别是,其内部是不允许返回的。
这是没有使用内联函数编译的结果。当代码位于另一个类中时,就不能直接从 main
函数返回。当函数内联时,就没有这种限制了,无论如何,代码都将位于 main
函数中:
正因如此,函数看起来和表现起来就更像流程控制了:
inline 修饰符的开销
内联是一个有用的修饰符,但它不应该被到处使用。内联函数不能是递归的。否则它们会无限替换它们调用,周期性循环调用尤其危险,因为直到目前 IntelliJ 中都没有错误的提示:
内联函数不能使用具有更严格可见性的元素。不能在公共内联函数中使用私有内部函数或属性。事实上,内联函数不能使用任何具有更严格可见性的函数:
所以它们不能用来隐藏实现,很少会在类中使用它们。
最后,它们很容易让代码膨胀。为了让你看到这个膨胀率,现在假设我很喜欢打印3,我定义了下面这个函数:
我喜欢调用它3次,所以我添加了这个函数:
仍然不满意,我又定义了以下函数:
看看它们都被编译成了什么? 前两个比较容易阅读:
接下来的两个被编译成下面的函数:
这是一个极端的例子,但是它展示出内联函数的一个很大问题:当我们过度使用它们时,代码增长的非常快。我在一个真实项目中遇到了这个问题。有太多内联函数相互调用是危险的,因为我们的代码可能开始呈指数膨胀。
crossinlin 和 noinline
有些情况下,我们想内联一个函数,但由于某些原因,我们不能内联所有的函数类型参数。在这种情况下,我们可以使用一下修饰符:
crossinline
—— 意味着该函数应该内联,但不允许非本地的返回。当此函数在一个不允许非局部返回的作用域中使用时,我们使可以用它。例如在用在一个没有内联的 lambda 中noinline
—— 意味着该参数根本不应该内联。它主要用作:内联函数调用另一个非内联函数时传入的参数
了解这两个修饰符的含义是好的, 但我们可以不用记住它们,因为当需要它们时,IntelliJ IDEA 会有建议的提示:
总结
使用内联函数的主要场景有:
经常要使用的功能,例如打印
需要指定类型作为参数传递的函数,例如
filterIsInstance
当定义高阶函数时,特别是工具函数,如集合处理函数(
map
、filter
、flatMap
、joinToString
)、作用域函数(如also
、apply
、let
)或顶级使用函数(如repeat
、run
、with
)
我们很少使用内联函数来定义接口,当一个内联函数调用其它内联函数时,我们应该小心,请记住,代码会不断膨胀。
Last updated