Kotlin中的inline

函数的频繁调用开销

在编程中我们一般习惯将特定的功能封装成一个函数,使我们的代码结构清晰,实现模块化。同时使用函数能够避免将相同代码重写多次的麻烦,从而减少可代码的体积。但是函数的每一次调用都会带来性能开销(创建栈帧 压栈、出栈,创建局部变量表,操作数栈,保存函数出口信息),函数的频繁调用势必会带来性能的下降

这里我们假设实现一个简单的图片滤镜功能,滤镜filter001要实现的效果就是首先提高亮度,然后把图片变为黑白

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Kotlin Code
fun filter001(img: Image) {
val length: Int = img.length
for (i in 0 until length) {
var pixel = img.pixels[i]
//提高亮度
pixel = brighten(pixel)
//黑白处理
pixel = gray(pixel)
img.pixels[i] = pixel
}
}
// 修改亮度
fun brighten(pixel: Int): Int {
Adjust the brightness of a pixel....
}
// 黑白
fun gray(pixel: Int): Int {
Convert a pixel to black-and-white....
}

通常我们我会提亮操作和黑白处理分别封装成单独的方法,使我们的代码变得清晰可读,上面的代码很好理解,就是循环取出图像上的每一个像素做处理,如果处理的是一张4000万像素的图片,我们则需要对4000万个像素做亮度黑白处理,伴随而来就是8000万次的函数调用开销。

这就是我们对特定功能封装之后所付出的代价,如果不对提亮操作和黑白处理做单独的封装,我们代码可读性/可维护性就会下降。我们的代码除了给编译器看,同时也要给人看。因此保持代码的清晰结构和可读性尤为重要。一边是性能,一边是代码的清晰度,如何选择 ?

inline 函数

Kotlin中为我们提供inline关键字,inline的意思是告诉编译器在编译时展开。inline关键字可用于修饰一个函数,称之为内联函数,告诉编译器在编译时对该函数的调用处对函数展开,以消除函数调用带来的开销。
如:

1
2
3
//Kotlin Code
inline fun brighten(pixel: Int): Int { .... }
inline fun gray(pixel: Int): Int { .... }

上面的代码我为brightengray函数加上inline修饰,编译器在编译时会在调用处对函数展开,类似下面的效果:

1
2
3
4
5
6
7
8
9
10
11
12
// Kotlin Code
fun filter001(img: Image) {
val length: Int = img.length
for (i in 0 until length) {
var pixel = img.pixels[i]

[code : Adjust the brightness of a pixel....]
[code : Convert a pixel to black-and-white....]

img.pixels[i] = pixel
}
}

对于inline函数可以简单的理解为,编译时把函数体复制到了函数调用处,函数的调用不存在了。这样以来我们就通过inline即保证了代码的清晰结构,同时也消除的函数频繁调用带来的性能开销。另外内联之后,编译器可以获得更多的上下文信息,进一步加大了编译器对代码的优化空间。借助于inline函数的编译时展开,Kotlin还为我们提供reified关键字来实现具体化的类型参数,前段时间我写过一篇关于泛型擦除文章有提到,具体可以参看《浅谈JVM泛型》

inline 与 lambda

上面我们讲到了通过内联函数消除函数频繁调用的开销,然而在Kotlin中最能体现inline价值的则是高阶函数中的 lambda。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//Kotlin Code
fun higherOrder(x: Int, y: Int, λ: (Int, Int) -> Int): Int {
return λ(x, y)
}

fun main() {
//label 1
var i = higherOrder(1, 2) { x, y -> x + y }
println(i) //result is 3

//label 2
var j = higherOrder(3, 4) { x, y -> x + y }
println(j) //result is 7

//label 3
for (m in 1..10000) {
var n = higherOrder(1, 2) { x, y -> x + m * y }
println(n)
}
}

高阶函数higherOrder接收两个Int类型的值和一个lambdaKotlin目前编译的字节码最低要支持jvm6,但是jvm7.0开始才引入invokedynamic指令,所以lambda的调用不能通过invokedynamic指令完成。

lambda 脱糖(desugar)

由于Kotlin要兼容jvm6,在Kotlin中目前lambda就是一个语法糖,对lambda的支持依赖Kotlin 编译器的对lambda的语法脱糖,上面的代码语法脱糖后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//Kotlin Code
fun higherOrder(x: Int, y: Int, @NotNull function2: Function2<Int, Int, Int>): Int {
return function2.invoke(x, y)
}

class Function2Impl() : Lambda<Int>(2), Function2<Int, Int, Int> {

override fun invoke(x: Int, y: Int): Int {
return x + y
}

companion object {
val INSTANCE: Function2Impl = Function2Impl()
}
}

fun main() {
//label 1
val i = higherOrder(1, 2, Function2Impl.INSTANCE)
println(i)

//label 2
val j = higherOrder(3, 4, Function2Impl.INSTANCE)
println(j)

//label 3
for (m in 1..10000) {
val n = higherOrder(1, 2, object : Function2<Int, Int, Int> {
override fun invoke(x: Int, y: Int): Int {
return x + m * y
}
})
println(n)
}
}

Android开发中也有不少支持Java8lambda的解决方案(RetroLambdaJackD8),与Kotlin的方案类似,都是语法脱糖

λ: (Int, Int) -> Int是两个输入一个输出,所以脱糖后由Function2实现。label 1label 2处共享一个实例对象,label 3处的lambda实现要依赖外部的m变量,所以每一次循环都要创建一个匿名内部类的实例。这里大家已经可以看出我们所编写简单的高价函数的调用的开销,要不停的创建Function2的匿名内部类实例,并且之后还有invoke方法的调用开销。要解决这个问题,当然还是inline关键字

1
2
3
4
//Kotlin Code
inline fun higherOrder(x: Int, y: Int, λ: (Int, Int) -> Int): Int {
return λ(x, y)
}

inline关键字修饰的高阶函数,会传递给它的lambda表达式参数。

igherOrder函数加上inline之后,编译内联之后的代码类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Kotlin Code
fun main() {
//label 1
val i = 1 + 2
println(i)

//label 2
val j = 3 + 4
println(j)

//label 3
for (m in 1..10000) {
var n = 1 + m * 2
println(n)
}
}

没错higherOrderinline修饰后,不但自身函数体在编译时展开了,它的参数λ: (Int, Int) -> Int也展开了。所以我们可以用inline关键字来解决lambda的性能问题。

noinline

上面我们提到被inline修饰的高阶函数不但自身的函数体会展开,他的lambda参数也会展开,由于lambda被展开了,所以它将不能被存储和传递。如果不想lambda参数被展开可以通过noinlinelambda修饰。

1
2
3
4
5
6
7
//Kotlin Code
fun f2: (Int) -> Unit) {}

inline fun higherOrder1: () -> Int, λ2: (Int) -> Unit) {
λ1.invoke()
f(λ2) //compile error!
}
1
2
3
4
5
6
7
//Kotlin Code
fun f2: (Int) -> Unit) {}

inline fun higherOrder1: () -> Int,noinline λ2: (Int) -> Unit) {
λ1.invoke()
f(λ2) //compile ok!
}

内联函数中的lambda参数noline修饰后不会伴随内联函数一块展开。上面的代码,λ1会展开,λ2不会展开。

crossInline

non-local-return

Kotlin裸return只允许退出函数,不允许退出lambdalambda的退出要通过return@label的方式,普通的lambda(非inline)中也禁止使用裸return

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Kotlin Code
fun highOrder(λ: () -> Unit) {
println("1")
λ.invoke()
println("2")
}

fun main() {
highOrder {
return //compile error!
}

highOrder {
return@highOrder // compile ok!
}
}

lambda被内联之后,是允许在lambda中使用裸return的,即非局部返回(non-local-return),因为lambda被展开了,return也伴随着lambda一块被展开了。也正式因为inline函数lambda都被展开了,所以被内联lambda中的rerurn返回的目标是调用该内联函数的函数,例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Kotlin Code
inline fun highOrder(λ: () -> Unit) {
println("1")
λ.invoke()
println("2")
}

fun main() {
highOrder {
return //compile ok!
}
println("3")
}
//输出结果:
1

上面的代码 highOrder中的return目标是main方法。所以下面的println("3")不会被执行,输出的结果是:1

crossInline

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Kotlin Code
inline fun highOrder(λ: () -> Unit) {
object : Runnable {
override fun run() {
println("1")
λ.invoke() //compile error!
println("2")
}
}.run()
}

fun main() {
highOrder {
return //compile ok!
}
println("3")
}

上面我们讲到在伴随内联函数一块展开的lambda中允许使用裸return按照语意上面代码中的return代表的是退出main方法,但是内联函数highOrder参数的λ并非在其函数体中直接执行,而是在另一个上下文中即Runnablerun方法run方法并不会被内联,所以例中的return无法从穿过run方法直接把main方法给返回了(二者执行不在同一个栈桢中),所以上面的代码是编译失败的。

为此,kotlin 为我们提供了另一个关键字crossinline用于修饰内联函数参数中lambda,表示该lambda可以在其他上下文中执行。crossinline修饰的lambda中禁止使用裸return 例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Kotlin Code
inline fun highOrder(crossinline λ: () -> Unit) {
object : Runnable {
override fun run() {
println("1")
λ.invoke() //compile ok!
println("2")
}
}.run()
}

fun main() {
highOrder {
return //compile error!
}
println("3")
}

inline properties(内联属性)

inline 关键字也可修饰属性,内联其getset方法,这里不做过多介绍。

inline class

Kotlin从 1.3 开始引入了inline class,内联类必须有且有一个属性(包装一个类型)。

1
2
3
4
5
6
7
8
9
10
11
// Kotlin code
inline class Name(val name: String) {
fun greet() {
println("Hello, $name")
}
}

fun main() {
var name = Name("Jim")
name.greet()
}

上面的编译后类似于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Java Code
public final class Name {

private final String name;

public static String constructor_impl(String name) {
return name;
}

public static final void greet_impl(String $this) {
String var1 = "Hello, " + $this;
System.out.println(var1);
}
//...
}

public static final void main() {
String name = Name.constructor-impl("Jim");
Name.greet-impl(name);
}

编译后Name类的实例方法变成静态方法调用处并没有创建Name类的实例,而是通过Name类的静态方法完成了功能实现。通过inline class我们可以避免对象的创建开销,例中对Name类的操作将在在栈上完成,不会为Name类在堆上创建实例,减轻 GC 压力,从而提高性能。

* 由于内联类使用的并不会创建具体实例,所以内联类中禁止使用init 代码块, inline class 目前还是一个实验性功能

Java 中的内联

Java中并没有为我们提供inline关键字,Java的静态编译器javac也不会对方法做内联优化。但是JIT(Jvm 运行时的即时编译器)会对热点代码做内联优化。如我们文章开始提到的图片滤镜的例子,brightengray方法在循环体中被频繁调用。即使我们不为这两个方法加inline修饰,在运行时JIT也会对其做内联优化。类似于kotlininline class JIT编译器运行时,也会对对象逃逸分析判断,如果对象未发生逃逸,则可以做栈上分配、标量替换

一些字节码优化工具如ProGuard在优化代码时候会按照规则对一些方法调用做内联优化。Android编译工具里面的R8也对内联优化做了支持。

references: