Kotlin里有个特别好用的关键字叫inline,它可以帮你对做了标记的函数进行内联优化。所谓内联就是,调用的函数在编译的时候会变成代码内嵌的形式。
这样的好处很明显,调用栈变浅了嘛,对吧?
不过实际上这种对调用栈的效果非常小,小到了应该被忽略的程度。是应该被忽略,不是可以被忽略,因为这种优化不仅没啥用,而且还可能因为代码多处拷贝而导致编译生成的字节码膨胀,从而变成负优化。所以,这种东西我们要他干嘛呢?
正文在这里
Java里有个概念叫编译时常量Compile-time Constant,直观地讲就是这个变量的值固定不变的,并且编译器在编译的时候就能确定这个变量的值。具体到代码上,就是这个变量需要是final的,类型只能是字符串或者基本类型,并且这个变量需要在声明的时候就赋值,等号右边还不能太复杂。总之就是你得让编辑器一眼瞟过去就能看出结果。这种编译时常量,会被编辑器以内联的形式进行编译,也就是直接把你的值拿过去替换掉用处的变量名来编译。这样依赖,程序结构就变简单了,编辑器和JVM也方便做各种优化。这,就是编译时常量的作用。
这种编译时常量,到了Kotlin里有了一个专有的关键字,叫const:一个变量如果以const val开头,它就会被编辑器当做编译时常量来进行内联式编译:
——当然你得符合编译时常量的特征啊,不让会报错,不给编。
inline
让变量内联用的是const;而除了变量,Kotlin还增加了对函数进行内联的支持。在Kotlin里,你给一个函数加上inline关键字,这个函数就会被以内联的方式进行编译。但!虽然同为内联,inline关键字的作用和目的跟const是完全不同的。
编译时常量为什么这么多限制?因为只有符合这些限制,编译器和JVM才有能力做优化,从而这种内联操作也才有意义。稍微复杂一点,就优化不懂了。什么叫「稍微复杂」我不知道,但是函数内联这种操作,绝对算得上是相当复杂了,绝对优化不懂的。其实真要较真起来,函数的内联确实会产生一种被动的优化,就是刚才我说的:去掉一个函数, 调用栈少了一层,性能的损耗肯定会少一些,但实际上调用栈本身所造成的性能损耗本来就非常小的,这个优化跟没优化差不多。这个事实可能不太符合我们的直觉,但你这样想一下:让我们看到的各种性能优化范围里,你有没有见过类似「少写几个方法来减少调用栈」这样的优化策略?没有吧?为什么?因为这种优化没有意义。而同时,函数内联不同于常量内联的地方在于,函数体通常比常量复杂多了,而函数内联会导致函数体被拷贝在每个调用处,如果函数体比较大而被调用处又2比较多,就会导致编译处的字节码变大很多。我们都知道编译结果的压缩是应用优化的一大指标,而函数内联对于这项指标是明显不利的。所以靠inline来做性能优化?不存在的。
那么问题就来了:inline是干嘛用的呢?
事实上,inline关键字不止可以内联自己的内部代码,还可以内联自己内部的内部的代码。什么叫「内部的内部」?就是自己的函数类型的参数。
例如我把hello()函数的定义改成这样,给它增加一个函数类型的参数:
相应地,在调用处也需要填写这个参数。
我可以填成匿名函数的形式:
也可以简单点,写成Lambda表达式:
因为Java并没有对函数类型的变量的原生支持,Kotlin需要想办法来让这种自己新引入的概念在JAVM中落地。而它想的办法是什么呢?就是用一个JVM对象来作为函数类型的变量的实际载体,让这个对象去执行实际的代码。也就是说,在我对代码做了刚才那种修改之后,程序在每次调用hello()的时候都会创建一个对象来执行Lambda表达式里的代码,虽然这个对象是用一下之后马上就被抛弃,但它确实被创建了。
这有什么坏处?其实一般情况下也没什么坏处。多创建个对象算什么?但是你想一下,如果这种函数被放在循环里执行:
内存占用是不是一下子就飙起来了?而且关键的是,你作为函数的创建者,并不知道、也没法规定别人在什么地方调用这个函数,也就是说,这个函数是否出现在循环或者界面刷新之类的高频场景里,是完全不可空的。这样依赖……这一类函数就全都有了性能隐患了。高阶函数是Kotlin相比起Java很方便地一个特性,但确有这么一个性能隐患,这……还让人怎么放心用啊?
这就是inline关键字出场的时候了。
刚才我说了,inline关键字不止可以内联自己的内部代码,还可以内联自己内部的内部的代码,意思是什么呢,就是你的函数在被加了inline关键字之后,编译器在编译时不仅会把函数内联过来,而且会把内部的函数类型的参数——那就是那些Lambda表达式——也内联过来。换句话说,这个函数被编译器贴过来的时候是完全展开铺平的:
经过这种优化,是不是就避免了函数类型的参数所造成的临时对象的创建了?这样的话,是不是就不怕在循环或界面刷新这样的高频场景里调用它们了?
这,就是inline关键字的用处:高阶函数(High-order FUnctions)有它们天然的性能缺陷,我们通过inline关键字让函数用内联的方式进行编译,来减少参数对象的创建,从而避免出现性能问题。
所以,inline是用来优化的吗?是,但你不能无脑使用它,你需要确定它可以带来优化再去用它,否则可能会变成负优化。其实换个角度想想:既然inline是优化,为什么Kotlin没有直接开启它,而要把它做成选项,而且还是个默认关闭的选项?就是因为它还真不一定是优化,加不加它需要我们自己去判断。那怎么去做这个判断呢?很简单,如果你写的高阶函数,会有函数类型的参数,加上inline就对了。
嗯……不过如果你们团队对于包大小有非常极致的追求,也可以选择酌情使用inline,比如对代码做严格要求,只有会被频繁调用的高阶函数才使用inline——这个可能在实施上会有点难度,一般来说,按我刚才说的原则就已经够了。
另外,Kotlin的官网源码里还有一个inline的另类用法:在函数里直接调用Java的静态方法:
用偷天换日的方式来去掉了这些Java的静态方法的前缀,让调用更简单:
这个很有必要跟大家提一下:这种用法不是inline被创造的初衷,也不是inline的核心意义,这属于一种相对偏门的另类用法。——不过这么用没什么问题啊,因为它的函数体简洁,并不会造成字节码膨胀的问题。你如果有类似的场景,也可以这种用。
讲到这儿……应该知道内联函数怎么用了吧?那我们就……继续深入一下?
noinline
说完inline,我们来说另一个关键字:noinline。noinline的意思很直白:inline是内联,而noinline就是不内联。不过它不是作用于函数,而是作用于函数的参数:对于一个标记了inline的内联函数,你可以对它的任何一个或多个函数类型的参数添加noinline关键字:
添加了之后,这个参数就不会参与内联了“
好理解吧?好理解是好理解,(皱眉)可是这有什么用啊?为什么要关闭这种优化?
首先我们要知道,函数类型的参数,它本质上是个对象。我们可以把这个对象当做函数来调用,这也是最常见的用法:
但同时我们也可以把它当做对象来用。比如把它当做返回值:
但当我们把函数进行内联的时候,它内部的这些参数就不再是对象了,因为他们会被编辑器拿到调用处去展开。也就是说,当你的函数被这样调用的时候:
代码会被这样编译:
哎?请问你找谁啊?
发现问题了吗?当一个函数被内联之后,它内部的那些函数类型的参数就不再是对象了,因为它们的壳被脱掉了。换句话说,对于编译之后的字节码来说,这个对象根本就不存在。一个不存在的对象,你怎么使用?
所以当你要把一个这样的参数当做对象使用的时候,Android Studio会报错,告诉你这没法编译:
加上noinline之后,这个参数就不会参与内联了:
那我们就也可以正常使用它了。
所以,noinline的作用是什么?是用来局部地、指向性地关掉函数的内联优化的。既然是优化,为什么要关掉?因为这种优化会导致函数中的函数类型的参数无法被当做对象使用,也就是说,这种优化会对Kotlin的功能做出一定程度的收窄。而当你需要这个功能的时候,就要手动关闭优化了。这也是inline默认是关闭、需要手动开启的另一个原因:它会收窄Kotlin的功能。
那么,我们应该怎么判断什么时候用noinline呢?很简单,比inline还要简单:你不用判断,Android Studio会告诉你的。当你在内联函数里对函数类型的参数使用了风骚操作,Android Studio拒绝编译的时候,你再加上noinline就可以了。
crossinline
最后再来说crossinline。这是个很有意思的关键字,刚才讲的noinline是局部关闭内联优化对吧?而这个crossinline,是局部加强内联优化。
我们先来看代码。这里有一个内联寒素,还有一个对它的调用:
接入我往这个Lambda表达式里加一个return:
这个return会结束哪个函数的执行?是它外面的hello()还是再往外一层main()?
按照通常的规则,肯定是结束hello()的对吧?因为hello()离它近啊,,return所结束的肯定是直接包裹住它的那个函数。可是大家想一想,这个hello()是内联函数对不对?内联函数在编译优化之后会怎么样?会被铺平是不是?而这个调用,在铺平后变成这个样子:
那你再看看,return结束的是哪个函数?是外层的对吧?也就是说,对于内联函数,它的参数中Lambda的return结束的不是这个内联函数,而是那个调用这个内联函数的更外层的函数。是这个道理吧!
道理是这个道理,但这就有问题了。什么问题?我一个return结束哪个函数,竟然要看这个函数是不是内联函数!那岂不是我每次写这种代码都得钻到原函数里去看看有没有inline关键字,才能知道我的代码会怎么执行?那这也太难了吧!
这种不一致性会给我们带来极大困扰,因此Kotlin制定了一条规则:Lambda表达式里不允许使用return,除非—— 这个Lambda是内联函数的参数。
那这样的话规则就简单了:
- Lambda里的return,结束的不是直接的外层函数,而是外层再外层的函数;
- 但只有内联函数Lambda参数可以使用return。
这样就既消了歧义,也避免了需要反复查看每个函数是不是内联函数的麻烦。
不过……我们如果把事情再变复杂一点——最后一次了,不会更复杂了:
这次,我用runOnUiThread()把这个参数放在了主线程执行,这是一种很常见的操作。
但,这就带来了一个麻烦:本来在调用处最后那行return是要结束它外层再外层的main()函数的,但现在因为它被放在runOnUiThread()里,hello()对它的调用就变成了间接调用。所谓间接调用,直白点说就是它的外层的hello()函数之间的关系被切断了。和hello()的关系被切断,那就更够不着更外层的main()了,也就是说这个间接调用 ,导致Lambda里的return无法结束最外面的main()函数了。
这就表示什么?当内联函数的Lambda参数在函数内部是间接调用的时候,Lambda里面的return会无法按照预期的行为进行工作。
这就比较严重了,因为这造成了Kotlin这个语言的稳定性的问题、结果是不可预测的,这能行吗,是吧?
那怎么办?
Kotlin的选择依然是霸气一刀切:内联函数里的函数类型的参数,不允许这种间接调用:
解决了!解决不了问题,我就解决提出问题的人。
那我如果真的有这种需求呢?如果我真的需要间接调用,怎么办?使用crossinline
crossinline也是一个用在参数上的关键字。放你给一个需要被间接调用的参数加上crossinline,就对它解除了这个限制,从而就可以对它进行间接调用了:
不过这就又会导致前面说过的「不一致」的问题,比如如果我在这个Lambda里加上一句return
它结束了是谁?是包着它的runOnUiThread(),还是依然是最外层的main()?
对于这种不一致,Kotlin增加了一条额外规定:内联函数里被crossinline修饰的函数类型的参数,将不再享有「Lambda表达式可以使用return」的福利。所以这个return并不会面临「要结束谁」的问题,而是直接就不允许这么写。
也就是说,间接调用和Lambda的return,你只能选一个。
那我如果就是两个都想要,怎么办呢?——这个我就没办法了,真不行。
所以什么时候需要crossinline?当你需要突破内联函数的「不能间接调用参数」的限制的时候。但其实和noinline一样,你并不需要亲自去判断,只要在看到Android Studio给你报错的时候把它加上就行了。
总结
inline、noinline和crossinline是三个很有用也很好用的关键字,我们在Kotlin的官方源码以及各种开源库的源码里也常见到它们。
到现在,它们的含义和使用已经讲完了,过程很复杂,但结论很简单。总结下来就是:
- inline可以让你用内联——也就是函数内容直接插到调用处——的方式来优化代码结构,从而减少函数类型的对对象的创建;
- noinline是局部关掉这个优化,来摆脱inline带来的「不能把函数类型的参数当做对象使用」的限制;
- crossinline是局部加强这个优化,让内联函数里的函数类型的参数可以被当做对象使用。
版权声明
本文首发于:Kotlin 源码里成吨的 noinline 和 crossinline 是干嘛的?看完这个视频你转头也写了一吨
微信公众号:扔物线