1. 概要

Kotlinでは、関数は第一級市民であるため、他の通常のタイプと同じように関数を渡したり、返したりすることができます。 ただし、実行時にこれらの関数を表現すると、いくつかの制限やパフォーマンスの問題が発生する場合があります。

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

2. 楽園でのトラブル

2.1. Kotlinのラムダのオーバーヘッド

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

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

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

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

public static final <T> Collection<T> filter(Collection<T>, kotlin.jvm.functions.Function1<T, Boolean>);

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

さて、これをKotlinで呼び出すと、次のようになります。

sampleCollection.filter { it == 1 }

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

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

高階関数を宣言するたびに、それらの特別なFunction*タイプのインスタンスが少なくとも1つ作成されます

たとえば、Java8がラムダで行うようにinvokedynamicを使用する代わりに、Kotlinがこれを行うのはなぜですか? X206X]

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

Kotlinラムダにカプセル化された操作を実際に実行するには、高階関数(この場合は filter )が、新しいインスタンスでinvokeという名前の特別なメソッドを呼び出す必要があります。 追加の呼び出しにより、結果としてオーバーヘッドが増加します。

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

  1. 特別なタイプのインスタンスが少なくとも1つ作成され、ヒープに格納されます
  2. 追加のメソッド呼び出しは常に発生します

もう1つのインスタンス割り当てともう1つの仮想メソッド呼び出しはそれほど悪くないようですよね?

2.2. クロージャ

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

後者と同じように、 ラムダ式は、そのクロージャー、つまり外部スコープで宣言された変数にアクセスできます。 ラムダがクロージャーから変数をキャプチャすると、Kotlinはキャプチャするラムダコードとともに変数を保存します。

ラムダが変数をキャプチャすると、余分なメモリ割り当てがさらに悪化します。 JVMは、呼び出しごとに関数型インスタンスを作成します。 非キャプチャラムダの場合、これらの関数タイプのインスタンスはシングルトンの1つだけになります。

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

fun <T> Collection<T>.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."<init>":(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が確率変数をキャプチャする方法を示しています。 これは、キャプチャされた各変数がコンストラクター引数として渡されるため、メモリオーバーヘッドが発生することを意味します。

2.3. 型消去

JVMのジェネリックに関しては、そもそもパラダイスではありませんでした。 とにかく、Kotlinは実行時に汎用タイプ情報を消去します。 つまり、汎用クラスのインスタンスは、実行時にその型パラメーターを保持しません

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

3. インライン関数

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

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

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

結局のところ、Kotlinのインライン関数を使用すると、両方を使用できます!素敵でエレガントなラムダを記述でき、コンパイラーがインライン化された直接コードを生成します。すべてその上にインラインを配置する必要があります。

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

インライン関数を使用する場合、コンパイラーは関数本体をインライン化します。 つまり、関数が呼び出される場所に直接本体を置き換えます。 デフォルトでは、コンパイラーは関数自体とそれに渡されるラムダの両方のコードをインライン化します。

たとえば、コンパイラは次のように変換します。

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)

インライン関数を使用する場合、追加のオブジェクト割り当てや追加の仮想メソッド呼び出しはありません

ただし、インライン関数は、生成されたコードがかなり大きくなる可能性があるため、特に長い関数の場合は、過度に使用しないでください。

3.2. インラインなし

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

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

3.3. インライン具体化

前に見たように、Kotlinは実行時にジェネリック型情報を消去しますが、インライン関数の場合、この制限を回避できます。 つまり、コンパイラーはインライン関数の汎用型情報を再定義できます。

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

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

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

3.4. 非ローカル返品

Kotlinでは、 return式(非修飾returnとも呼ばれます)は、名前付き関数または匿名関数を終了する場合にのみ使用できます。

fun namedFunction(): Int {
    return 42
}

fun anonymous(): () -> Int {
    // anonymous function
    return fun(): Int {
        return 42
    }
}

どちらの例でも、関数は名前付きまたは匿名であるため、return式は有効です。

ただし、非修飾の戻り式を使用してラムダ式を終了することはできません。 これをよりよく理解するために、さらに別のホイールを作り直してみましょう。

fun <T> List<T>.eachIndexed(f: (Int, T) -> Unit) {
    for (i in indices) {
        f(i, this[i])
    }
}

この関数は、各要素に対して指定されたコードブロック(関数 f )を実行し、要素にシーケンシャルインデックスを提供します。 この関数を使用して、別の関数を作成してみましょう。

fun <T> List<T>.indexOf(x: T): Int {
    eachIndexed { index, value -> 
        if (value == x) {
            return index
        }
    }
    
    return -1
}

この関数は、受信リストで指定された要素を検索し、見つかった要素のインデックスまたは-1を返すことになっています。 ただし、修飾されていない戻り式を使用してラムダを終了できないため、関数はコンパイルすらしません

Kotlin: 'return' is not allowed here

この制限の回避策として、eachIndexed関数をインライン化できます

inline fun <T> List<T>.eachIndexed(f: (Int, T) -> Unit) {
    for (i in indices) {
        f(i, this[i])
    }
}

次に、indexOf関数を実際に使用できます。

val found = numbers.indexOf(5)

インライン関数は単なるソースコードのアーティファクトであり、実行時に現れません。 したがって、 インラインラムダから戻ることは、囲んでいる関数から戻ることと同じです。 

4.4。 制限事項

一般的、 ラムダが直接呼び出されるか、別のインライン関数に渡される場合にのみ、ラムダパラメーターを使用して関数をインライン化できます。 それ以外の場合、コンパイラーはコンパイラー・エラーによるインライン化を防ぎます。

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

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

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

5.5。 結論

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

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

いつものように、すべての例はGitHubから入手できます。