简述: 不知道是否有小伙伴还记得我们之前的 Effective Kotlin 翻译系列, 之前一直忙于赶时髦研究 Kotlin 1.3 中的新特性. 把此系列耽搁了, 赶完时髦了还是得踏实探究本质和基础, 从今天开始我们将继续探索 Effective Kotlin 系列, 今天是 Effective Kotlin 第三讲.
翻译说明:
原标题: Effective Kotlin: Consider inline modifier for higher-order functions
你或许已经注意到了所有集合操作的函数都是内联的(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")替换了内联函数 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 翻译系列, 欢迎继续关注~~~
Kotlin 系列文章, 欢迎查看:
Effective Kotlin 翻译系列
[译]Effective Kotlin 系列之遇到多个构造器参数要考虑使用构建器(二)
[译]Effective Kotlin 系列之考虑使用静态工厂方法替代构造器(一)
原创系列:
Jetbrains 开发者日见闻 (三) 之 Kotlin1.3 新特性(inline class 篇)
JetBrains 开发者日见闻 (二) 之 Kotlin1.3 的新特性(Contract 契约与协程篇)
JetBrains 开发者日见闻 (一) 之 Kotlin/Native 尝鲜篇
教你如何攻克 Kotlin 中泛型型变的难点(实践篇)
教你如何攻克 Kotlin 中泛型型变的难点(下篇)
教你如何攻克 Kotlin 中泛型型变的难点(上篇)
Kotlin 的独门秘籍 Reified 实化类型参数(下篇)
有关 Kotlin 属性代理你需要知道的一切
浅谈 Kotlin 中的 Sequences 源码解析
浅谈 Kotlin 中集合和函数式 API 完全解析 - 上篇
浅谈 Kotlin 语法篇之 lambda 编译成字节码过程完全解析
浅谈 Kotlin 语法篇之 Lambda 表达式完全解析
浅谈 Kotlin 语法篇之扩展函数
浅谈 Kotlin 语法篇之顶层函数, 中缀调用, 解构声明
浅谈 Kotlin 语法篇之如何让函数更好地调用
浅谈 Kotlin 语法篇之变量和常量
浅谈 Kotlin 语法篇之基础语法
翻译系列:
[译]Kotlin 中内联类的自动装箱和高性能探索(二)
[译]Kotlin 中内联类 (inline class) 完全解析(一)
[译]Kotlin 的独门秘籍 Reified 实化类型参数(上篇)
[译]Kotlin 泛型中何时该用类型形参约束?
[译] 一个简单方式教你记住 Kotlin 的形参和实参
[译]Kotlin 中是应该定义函数还是定义属性?
[译]如何在你的 Kotlin 代码中移除所有的!!(非空断言)
[译]掌握 Kotlin 中的标准库函数: run,with,let,also 和 apply
[译]有关 Kotlin 类型别名 (typealias) 你需要知道的一切
[译]Kotlin 中是应该使用序列 (Sequences) 还是集合(Lists)?
[译]Kotlin 中的龟 (List) 兔(Sequence)赛跑
实战系列:
用 Kotlin 撸一个图片压缩插件 ImageSlimming - 导学篇(一)
用 Kotlin 撸一个图片压缩插件 - 插件基础篇(二)
用 Kotlin 撸一个图片压缩插件 - 实战篇(三)
来源: https://juejin.im/post/5c0e6b31f265da612637fd7d