Kotlinのインライン関数

Kotlinのインライン関数

1. 概要

Kotlinでは、関数はfirst-class citizensであるため、他の通常の型と同じように、関数を渡したり返したりすることができます。 ただし、実行時のこれらの関数の表現により、いくつかの制限やパフォーマンスの複雑化が生じる場合があります。

このチュートリアルでは、最初にラムダとジェネリックに関する2つの一見無関係な問題を列挙し、次にInline Functionsを導入した後、それらがこれらの両方の懸念にどのように対処できるかを見ていきます。それでは始めましょう。

2. 楽園でのトラブル

2.1. コトリンのラムダのオーバーヘッド

Kotlinの第一級市民である機能の利点の1つは、動作の一部を他の機能に渡すことができることです。 関数をラムダとして渡すことで、意図をより簡潔でエレガントな方法で表現できますが、それは話の一部にすぎません。

ラムダのダークサイドを探求するために、filterコレクションへの拡張関数を宣言してホイールを再発明しましょう。

fun  Collection.filter(predicate: (T) -> Boolean): Collection = // Omitted

それでは、上記の関数がどのようにJavaにコンパイルされるかを見てみましょう。 パラメータとして渡されているpredicate関数に注目してください。

public static final  Collection filter(Collection, kotlin.jvm.functions.Function1);

Function1インターフェースを使用してpredicateがどのように処理されるかに注目してください。

さて、Kotlinでこれを呼び出すと:

sampleCollection.filter { it == 1 }

ラムダコードをラップするために、次のようなものが生成されます。

filter(sampleCollection, new Function1() {
  @Override
  public Boolean invoke(Integer param) {
    return param == 1;
  }
});

Every time we declare a higher-order function, at least one instance of those special Function* types will be created

Kotlinがinvokedynamic like how Java 8 does with lambdasを使用する代わりに、これを行うのはなぜですか? Simply put, Kotlin goes for Java 6 compatibility, and invokedynamic isn’t available until Java 7.

しかし、これで終わりではありません。 ご想像のとおり、型のインスタンスを作成するだけでは不十分です。

Kotlinラムダにカプセル化された操作を実際に実行するには、高階関数(この場合はfilter)が新しいインスタンスでinvokeという名前の特別なメソッドを呼び出す必要があります。 The result is more overhead due to the extra call.

したがって、要約すると、ラムダを関数に渡すと、内部で次のことが発生します。

  1. 特別なタイプの少なくとも1つのインスタンスが作成され、ヒープに格納されます

  2. 余分なメソッド呼び出しが常に発生します

もう1つのインスタンス割り当てともう1つの仮想メソッド呼び出しdoesn’t seem that bad, right?

2.2. クロージャ

前に見たように、ラムダを関数に渡すと、Javaのanonymous inner classesと同様に、関数型のインスタンスが作成されます。

後者の場合と同様に、 a lambda expression can access its closure, that is, variables declared in the outer scope. ラムダがクロージャから変数をキャプチャすると、Kotlinはキャプチャするラムダコードとともに変数を格納します。

The extra memory allocations get even worse when a lambda captures a variable: The JVM creates a function type instance on every invocation。 非キャプチャラムダの場合、これらの関数タイプのインスタンスは1つだけ、singletonになります。

これについてどのように確信していますか? 各コレクション要素に関数を適用する関数を宣言して、別のホイールを作り直してみましょう。

fun  Collection.each(block: (T) -> Unit) {
    for (e in this) block(e)
}

ばかげているように聞こえるかもしれませんが、ここでは、各コレクション要素に乱数を掛けます。

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val random = random()

    numbers.each { println(random * it) } // capturing the random variable
}

そして、javapを使用してバイトコードの内部を覗くと:

>> javap -c MainKt
public final class MainKt {
  public static final void main();
    Code:
      // Omitted
      51: new           #29                 // class MainKt$main$1
      54: dup
      55: fload_1
      56: invokespecial #33                 // Method MainKt$main$1."":(F)V
      59: checkcast     #35                 // class kotlin/jvm/functions/Function1
      62: invokestatic  #41                 // Method CollectionsKt.each:(Ljava/util/Collection;Lkotlin/jvm/functions/Function1;)V
      65: return

次に、インデックス51から、JVMが呼び出しごとにMainKt$main$1内部クラスの新しいインスタンスを作成していることがわかります。 また、インデックス56は、Kotlinがランダム変数をキャプチャする方法を示しています。 This means that each captured variable will be passed as constructor arguments, thus generating a memory overhead.

2.3. タイプ消去

JVMのジェネリックスに関して言えば、そもそもパラダイスではありませんでした。 とにかく、Kotlinは実行時に一般的な型情報を消去します。 つまり、an instance of a generic class doesn’t preserve its type parameters at runtimeです。

たとえば、List<Int>List<String>,のようないくつかのコレクションを宣言する場合、実行時に持っているのは生のListsだけです。 約束どおり、これは前の問題とは無関係のようですが、インライン関数が両方の問題の共通の解決策であることがわかります。

3. インライン関数

3.1. ラムダのオーバーヘッドの削除

ラムダを使用する場合、追加のメモリ割り当てと追加の仮想メソッド呼び出しにより、ランタイムのオーバーヘッドが発生します。 したがって、ラムダを使用する代わりに同じコードを直接実行する場合、実装はより効率的になります。

抽象化と効率のどちらかを選択する必要がありますか?

As is turns out, with inline functions in Kotlin we can have both!素敵でエレガントなラムダを書くことができます。and the compiler generates the inlined and direct code for us.必要なのはそれにinlineを置くことだけです。

inline fun  Collection.each(block: (T) -> Unit) {
    for (e in this) block(e)
}

When using inline functions, the compiler inlines the function body. That is, it substitutes the body directly into places where the function gets called.デフォルトでは、コンパイラは関数自体とそれに渡されるラムダの両方のコードをインライン化します。

たとえば、コンパイラは以下を翻訳します。

val numbers = listOf(1, 2, 3, 4, 5)
numbers.each { println(it) }

のようなものに:

val numbers = listOf(1, 2, 3, 4, 5)
for (number in numbers)
    println(number)

When using inline functions, there is no extra object allocation and no extra virtual method calls

ただし、インライン関数は、特に長い関数では使いすぎないようにしてください。インライン化により、生成されたコードがかなり大きくなる可能性があるためです。

3.2. インラインなし

デフォルトでは、インライン関数に渡されるすべてのラムダもインライン化されます。 ただし、一部のラムダをnoinlineキーワードでマークして、インライン化から除外することができます。

inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) { ... }

3.3. インライン具体化

前に見たように、Kotlinは実行時にジェネリック型情報を消去しますが、インライン関数の場合、この制限を回避できます。 That is, the compiler can reify generic type information for inline functions.

タイプパラメータをreified keywordでマークするだけです。

inline fun  Any.isA(): Boolean = this is T

inlinereifiedがないと、Kotlin Genericsの記事で詳しく説明しているように、isA関数はコンパイルされません。

4. 制限

通常、we can inline functions with lambda parameters only if the lambda is either called directly or passed to another inline function. それ以外の場合、コンパイラはコンパイラエラーによるインライン化を防ぎます。

たとえば、Kotlin標準ライブラリのreplace関数を見てみましょう。

inline fun CharSequence.replace(regex: Regex, noinline transform: (MatchResult) -> CharSequence): String =
    regex.replace(this, transform) // passing to a normal function

上記のスニペットは、ラムダtransform, を通常の関数replaceに渡します。したがって、noinlineになります。

5. 結論

この記事では、Kotlinでのラムダのパフォーマンスと型の消去に関する問題を扱いました。 次に、インライン関数を導入した後、これらが両方の問題にどのように対処できるかを見ました。

ただし、特に生成されるバイトコードのサイズが大きくなり、途中でいくつかのJVM最適化が失われる可能性があるため、関数本体が大きすぎる場合は、これらのタイプの関数を使いすぎないようにする必要があります。

いつものように、サンプルコードはour GitHub projectで入手できるので、必ずチェックしてください!