为何Kotlin内联函数有访问限制

262 阅读3分钟

1.png

Kotlin 开发者都知道内联函数的基本定义。

内联函数会告诉编译器将其内部的所有代码行插入到调用处(即调用该函数的地方)。

例如,你创建了一个内联函数:

object Chairman {
    inline fun introduction() {
        println("I'm a person")
    }
}

然后调用这个函数时:

fun main() {
    Chairman.introduction()
}

如果你通过 Tools -> Kotlin -> Show Kotlin Bytecode 然后选择 Decompile 来反编译上述代码,你会看到转换后的 Java 代码:

public static final void main() {
   Chairman this_$iv = Chairman.INSTANCE;
   int $i$f$introduction = 0;
   System.out.println("I'm a person");
}

内联函数像我们期望的那样,产生了效果。

但我们的编译器会像下面这样对上述内联函数发出警告。

2.png

预期的内联性能影响是微不足道的。内联对于带有函数类型参数的函数最合适。

那么,让我们来讨论一下何时应该将函数设为内联的正确使用场景。

使用场景

3.png

当函数的参数为函数类型(也就是 lambda 表达式)时,我们可以将该函数设为内联函数(此时编译器没有警告,也就是上面编译器所述:内联对于带有函数类型参数的函数最合适.)。

所以,让我们用一个 lambda 参数来修改上述函数:

inline fun introduction(callback:() -> String) {
    println("I'm a ${callback.invoke()}")
}

现在编译器不会给出任何警告了。很好!

上述函数的调用方式如下:

Chairman.introduction { "Super man" }

这样,上述函数中 lambda 表达式的代码将被插入到调用处,从而减少了函数调用的开销。

我们看下展开后的 Java 代码:

public static final void main() {
   Chairman this_$iv = Chairman.INSTANCE;
   int $i$f$introduction = 0;
   StringBuilder var3 = (new StringBuilder()).append("I'm a ");
   int var2 = 0;
   System.out.println(var3.append("Super man").toString());
}

但我们不应该不必要地创建内联函数,因为这可能会增加生成的字节码的大小。

限制

4.jpg

内联函数有一个私有修饰符访问限制:在一个内联函数中,我们不能访问私有属性。

例如,如果有一个私有变量:

private var name = "Stevin"

而我们尝试在一个内联函数中像下面这样访问它:

object Chairman {
    private var name = "Stevin"
    inline fun introduction(callback:() -> String) {
        println("I'm $name , a ${callback.invoke()}")
    }
}

我们会得到以下编译错误。

5.png

所以我们现在有几个选择:

  1. 将内联函数也设为私有
private inline fun introduction(callback:() -> String) {
    println("I'm $name , a ${callback.invoke()}")
}
  1. 将内联函数也设为 internal
internal inline fun introduction(callback:() -> String) {
    println("I'm $name , a ${callback.invoke()}")
}
  1. 更改 name 变量的可见性
object Chairman {
    var name = "Stevin" // 去掉了 private
    inline fun introduction(callback:() -> String) {
        println("I'm $name , a ${callback.invoke()}")
    }
}

上述第三种还有别的更改方法,大家可以尝试按照编译器给的提示进行更改。但由于那不是本文的重点,所以就不一一展示了。

现在,让我们探讨一下为什么内联函数被设计为不能访问私有属性。

为什么?

6.jpg

JVM 中,访问保护不仅在编译期执行,还会在运行时强制执行,因此调用代码本身必须拥有正确的访问权限。

Kotlin 中的内联函数旨在通过在编译时将函数调用替换为函数的实际代码来进行性能优化。由于这种机制,public 的内联函数不能访问类的私有成员,因为内联后的代码会成为调用作用域的一部分,而该作用域可能无法访问这些私有成员。

如果内联函数可以访问私有成员,就会破坏封装性,并可能导致意外的副作用或安全漏洞(实际上 JVM 也不允许这么做)。

我们看一下第二种改法,为什么 internal inline 就可以呢?

二进制兼容性

Kotlin 中的二进制兼容性是指不同版本的库或模块能够一起工作而无需重新编译的能力。

举个例子,现在有两个 Jar 包,AB。在 A 中我们使用了 B 中的 API

通常情况下,B 中的 API 只要声明不变,那么 API 内部无论如何变更,都不影响 A 的使用,即使 B 更换了好几个版本。

但是如果 BAPI 是内联的,而且调用到了 API 内部的私有变量,一切就不一样,一旦这个私有变量发生变化,因为代码展开的缘故,A 就必须重新编译以确保正确的调用。

也就是说,B 新版本中的更改破坏了针对旧版本编译代码的兼容性,运行时可能就会有错误或意外行为。

而如果 BAPI 声明是 internal inline 的,那么这个函数只是针对 B 模块内部可用,A 是不能使用的,所以不存在二进制兼容性问题。

总结

综上所述,在以下情况下使用内联函数:

  • 我们希望减少函数调用的开销。
  • 函数将 lambda 表达式作为参数。
  • 涵盖上述提到的正确使用场景(访问限制)。