简述: 不知道是否有小伙伴还记得咱们以前的Effective Kotlin翻译系列,以前一直忙于赶时髦研究Kotlin 1.3中的新特性。把此系列耽搁了,赶完时髦了仍是得踏实探究本质和基础,从今天开始咱们将继续探索Effective Kotlin系列,今天是Effective Kotlin第三讲。java
翻译说明:git
原标题: Effective Kotlin: Consider inline modifier for higher-order functionsgithub
原文地址: blog.kotlin-academy.com/effective-k…bash
原文做者: Marcin Moskalaapp
你或许已经注意到了全部集合操做的函数都是内联的(inline)。你是否问过本身它们为何要这么定义呢? 例如,这是Kotlin标准库中的filter
函数的简化版本的源码:ide
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表达式中的局部变量时,能够发现差距将会更大。对比下面两个函数:post
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")
替换了内联函数repeat
中action(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()
复制代码
第二种效率更高,咱们尽量使用这种。仅仅当咱们须要使用局部变量时,第一种才是必要的。
这就是为何当咱们修改局部变量时,repeat
和noinlineRepeat
之间存在如此之大的运行速度差别的缘由。非内联函数中的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翻译系列,欢迎继续关注~~~
Effective Kotlin翻译系列
原创系列:
翻译系列:
实战系列:
欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章,每周会不按期翻译一篇Kotlin国外技术文章。若是你也喜欢Kotlin,欢迎加入咱们~~~