Kotlin Vocabulary | 内联函数的原理与应用

Kotlin Vocabulary | 内联函数的原理与应用

我们的项目里常常会创建一些 Util 类,用于分类整理那些会在许多地方用到的小型函数 (也称实用函数),如果这类函数接收了另一个函数作为参数,则可能会造成一些额外的对象分配,通过使用 inline 关键字,您可以避免这种情况并提升应用性能。接下来我们就来看一看,当您把一个函数作为参数传递时发生了什么、inline 关键字 背后做了哪些工作,以及使用内联函数 (inline function) 时的注意事项。

函数调用——工作原理

我们在应用中常常要用到 SharedPreferences,现在假设您为了减少每次向 SharedPreferences 中写入内容时产生的模板代码,实现了以下实用函数:

fun SharedPreferences.edit(
    commit: Boolean = false,
    action: SharedPreferences.Editor.() -> Unit
) {
    val editor = edit()
    action(editor)
    if (commit) {
        editor.commit()
    } else {
        editor.apply()
    }
}

然后,您就可以用这个方法保存一个字符串 "token" :

private const val KEY_TOKEN = “token”

class PreferencesManager(private val preferences: SharedPreferences){
    fun saveToken(token: String) {
        preferences.edit { putString(KEY_TOKEN, token) }
    }
}

接下来我们看看,preferences.edit 被调用时其背后发生了什么。如果我们查看 Kotlin 字节码 (Tools > Kotlin > Decompiled Kotlin to Java),就能看到这里调用了 NEW 指令。所以虽然我们没有调用任何其他对象的构造函数,却还是创建出了一个新的对象:

NEW com/example/inlinefun/PreferencesManager$saveToken$1

为了便于理解,让我们查看一下反编译后的代码。我们的 saveToken 函数反编译后的代码如下 (我做了注释和格式化):

/* Copyright 2020 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
public final void saveToken(@NotNull final String token) {

    // 我们定义的修改 SharedPreferences 的扩展方法被调用了
    PreferenceManagerKt.edit$default(
        this.preferences, // SharedPreferences 实例对象
        false,// commit 标记的默认值
        (Function1)(new Function1() { // 为 action 参数创建了新的 Function 对象
            // $FF: synthetic method
            // $FF: bridge method
            public Object invoke(Object var1) {
                this.invoke((Editor)var1);
                return Unit.INSTANCE;
            }
            public final void invoke(@NotNull Editor $this$edit) {
                Intrinsics.checkParameterIsNotNull($this$edit, "$receiver");
                $this$edit.putString("token", token); // 我们 action 参数中的实现
            }
        }), 1, (Object)null);
}

每个高阶函数都会造成函数对象的创建和内存的分配,从而带来额外的运行时开销。

内联函数——工作原理

为了提升我们应用的性能,我们可以通过使用 inline 关键字,来减少函数对象的创建:

inline fun SharedPreferences.edit(
    commit: Boolean = false,
    action: SharedPreferences.Editor.() -> Unit
) { … }

现在,Kotlin 字节码中已经不包含任何 NEW 指令的调用了,下面是 saveToken 方法反编译出的 Java 代码 (做了注释和格式化):

/* Copyright 2020 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
public final void saveToken(@NotNull String token) {
  // SharedPreferences.edit 函数中的内容
  SharedPreferences $this$edit$iv = this.preferences;
  boolean commit$iv = false;
  int $i$f$edit = false;
  Editor editor$iv = $this$edit$iv.edit();
  Intrinsics.checkExpressionValueIsNotNull(editor$iv, "editor");
  int var7 = false;
  
  // action 参数中实现的内容
  editor$iv.putString("token", token);
  
  // SharedPreferences.edit 函数中的内容
  editor$iv.apply();
}

由于使用了 inline 关键字,编译器会将内联函数的内容复制到调用处,从而避免了创建新的函数对象。

应该在哪些地方使用 inline 标记?

⚠️  如果您试图标记为内联函数的函数,并没有接收另一个函数作为参数,您将无法获得明显的性能提升,而且 IDE 甚至会建议您移除 inline 标记:

⚠️  因为 inline 关键字可能会增加代码的生成量,所以一定要避免内联大型函数。举例来说,如果去查看 Kotlin 标准库中的内联函数,您会发现它们大部分都只有 1 - 3 行。

⚠️ 不要内联大型函数!

⚠️  使用内联函数时,您不能持有传入的函数参数对象的引用,也不能将传入的函数参数对象传递给另一个函数——这么做将会触发编译器报错,它会说您非法使用内联参数 (inline-parameter)。

举个例子,我们修改一下 edit 方法和 saveToken 方法。edit 方法获得了一个新的函数参数,并在随后将其传递给了另一个函数。saveToken 方法则会在新的函数参数中更新一个随意设置的模拟变量:

fun myFunction(importantAction: Int.() -> Unit) {
    importantAction(-1)
}

inline fun SharedPreferences.edit(
    commit: Boolean = false,
    importantAction: Int.() -> Unit = { },
    action: SharedPreferences.Editor.() -> Unit
) {
    myFunction(importantAction)
    ...
}
...
fun saveToken(token: String) {
    var dummy = 3
    preferences.edit(importantAction = { dummy = this}) {
         putString(KEY_TOKEN, token)
    }
}

我们将会看到 myFunction(importantAction) 产生了一个错误:

当遇到这种情况时,基于您函数的不同,有下面这些解决方案:

第一种情况 : 如果您的函数有多个函数参数,但是您需要持有其中某个的引用时,您可以将对应的参数标记为 noinline。

  • noinline

https://kotlinlang.org/docs/reference/inline-functions.html#noinline

通过使用 noinline,编译器就只会为对应函数创建新的 Function 对象,其余的则依旧会被内联。

我们的 edit 函数现在会变成下面这样:

inline fun SharedPreferences.edit(
    commit: Boolean = false,
    noinline importantAction: Int.() -> Unit = { },
    action: SharedPreferences.Editor.() -> Unit
) {
    myFunction(importantAction)
    ...
}

如果我们去查看字节码,将会看到这里出现了一个 NEW 指令的调用:

NEW com/example/inlinefun/PreferencesManager$saveToken$1

在反编译后的代码中,我们会看到如下内容 (加入了注释):

 /* Copyright 2020 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 */
public final void saveToken(@NotNull String token) {
   // saveToken 方法中的功能
   final IntRef x = new IntRef();
   x.element = 3;
   
   // 内联 edit 方法中的功能
   SharedPreferences $this$edit$iv = this.preferences;
 
   // noinline 函数声明导致 new Function 被调用
   Function1 importantAction$iv = (Function1)(new Function1() {
        // $FF: synthetic method
        // $FF: bridge method
        public Object invoke(Object var1) {
            this.invoke(((Number)var1).intValue());
            return Unit.INSTANCE;
        }
        public final void invoke(int $receiver) {
            // saveToken 的功能
           x.element = $receiver;
        }
   });
  
   // 内联 edit 方法中的功能
   boolean commit$iv = false;
   int $i$f$edit = false;
   PreferenceManagerKt.myFunction(importantAction$iv);
   Editor editor$iv = $this$edit$iv.edit();
   Intrinsics.checkExpressionValueIsNotNull(editor$iv, "editor");
   int var9 = false;
   editor$iv.putString("token", token);
   editor$iv.apply();
}

第二种情况 : 如果您的函数只接收一个函数作为参数,那么就干脆不要使用 inline。如果您执意使用 inline 关键字,就必须将参数标记为 noinline,但是这么一来,内联此方法的性能优势微乎其微。

为了减少 lambda 表达式带来的额外内存分配,建议您使用 inline 关键字!只需注意,标记对象最好是接收一个 lambda 表达式作为参数的小型函数。如果您需要持有 (作为内联函数参数的) lambda 表达式的引用,或者想要将它作为参数传递给另一个函数,使用 noinline 关键字标记对应参数即可。节约开销,从使用 inline 做起!

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
Android Developer | https://androiddevtools.cn and https://androidweekly.io WebMaster | GDG Zhengzhou Funder & Ex Organizer | http://Toast.show(∞) Podcast Host

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
Unable to sign you in. Please try again.
成功!您的帐户已完全激活,您现在可以访问所有内容。
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.