Kotlin インライン関数(Inline functions)

インライン関数(Inline functions)

高階関数を使用すると、特定のランタイムペナルティが発生する。各関数はオブジェクトであり、クロージャ、つまり関数本体でアクセスされる変数をキャプチャする。メモリ割り当て(関数オブジェクトとクラスの両方に該当)と仮想呼び出しは、ランタイムオーバーヘッドを発生させる。

そのような場合は、オーバーヘッドのあるラムダをインライン(inline)として指定することで、多くの部分を解決できる。次に示す関数はこの状況の良い例である。lock()関数は呼び出し地点で簡単にインライン化できる。次のような場合を考えてみよう。

lock(l) { foo() }

パラメータと呼び出しを生成するために関数オブジェクトを作成する代わりに、コンパイラは次のようなコードを作る。

l.lock()
try {
  foo()
}
finally {
  l.unlock()
}

コンパイラにこの処理を行わせるには、inline修飾子(modifier)をlock()関数に次のように明示する必要がある。

inline fun lock<T>(lock: Lock, body: () -> T): T {
  // ...
}

inline修飾子は、関数自体と関数に渡されたラムダの両方に影響する。それらはすべての呼び出し地点でインライン化される。

インライン化によって生成されるコードは大きくなる可能性があるが、合理的な方法で、つまり大きな関数をインライン化しないように実行すれば、特にループ内の「メガモーフィック(megamorphic)」な呼び出し部分で性能が向上する。

インライン関数をバイトコードに変換

ここまでがインライン関数に関する公式ドキュメントの内容である。これだけでは理解しにくいかもしれない。これはコンパイルされたバイトコードで確認すると理解しやすい。

次の例では、関数にinlineが宣言されている場合と宣言されていない場合を、それぞれバイトコードに変換して確認してみる。

次のコードは、calc関数にinlineを宣言していない場合である。

fun calc(a: Int, b: Int, op: (Int, Int) -> Int): Int {
    println("called calc function")
    return op(a, b)
}

fun main() {
    var result = calc(2, 3) { a: Int, b: Int -> a + b }
    println(result)
}

Output:

called calc function
5

上のコードをバイトコードに変換したコードは次のとおりである。

   public static final int calc(int a, int b, @NotNull Function2 op) {
      Intrinsics.checkNotNullParameter(op, "op");
      String var3 = "called calc function";
      boolean var4 = false;
      System.out.println(var3);
      return ((Number)op.invoke(a, b)).intValue();
   }

   public static final void main() {
      int result = calc(2, 3, (Function2)null.INSTANCE);
      boolean var1 = false;
      System.out.println(result);
   }

そして今回は、calc関数にinline修飾子を次のように宣言した。

inline fun calc(a: Int, b: Int, op: (Int, Int) -> Int): Int {
    println("called calc function")
    return op(a, b)
}

今回もバイトコードに変換して確認してみよう。

   public static final int calc(int a, int b, @NotNull Function2 op) {
      int $i$f$calc = 0;
      Intrinsics.checkNotNullParameter(op, "op");
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      return ((Number)op.invoke(a, b)).intValue();
   }

   public static final void main() {
      byte a$iv = 2;
      int b$iv = 3;
      int $i$f$calc = false;
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      int var8 = false;
      int result = a$iv + b$iv;
      boolean var9 = false;
      System.out.println(result);
   }

上の2つのバイトコードを比較すると、calc関数のソースはほぼ同じように変換されているが、main関数が異なることが分かる。インライン関数ではない場合はFunction2オブジェクトを作成して呼び出しているが、インライン関数の場合はcalcのコード内容がコピーされて呼び出されていることを確認できる。

上の2つのコード例の結果は、当然どちらも同じく5が出力される。

このように、inlineを使うことで関数をインライン関数としてコンパイルできる。プログラムサイズは大きくなるが、関数呼び出しのオーバーヘッドを減らして処理を速くできる。これはコレクションの要素を反復するときなどに効果的である。

noinline

インライン関数に渡されるラムダも一緒にインライン化されるが、noinline修飾子で指定することで、そのラムダだけをインライン化しないようにできる。

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

インライン可能な(Inlinable)ラムダは、インライン関数内でのみ呼び出されるか、インライン可能なパラメータとして渡すことができる。ただし、これはnoinlineによって必要に応じて操作できる。たとえば、フィールドに保持したり、どこかへ渡したりする場合などである。

インライン関数にインライン可能な関数パラメータがなく、具体化された型パラメータ(Reified type parameters)も指定されていない場合、コンパイラは警告を発生させる。そのような関数をインラインとして指定する意味がないためである。それでも使用する必要がある場合は、@Suppress("NOTHING_TO_INLINE")を追加して警告を発生させないようにできる。

noinlineラムダをバイトコードに変換

今回も簡単な例をバイトコードに変換し、noinline修飾子を確認してみる。

次の例では、インライン関数のラムダパラメータopnoinline修飾子が宣言されている。

inline fun calc2(a: Int, b: Int, noinline op: (Int, Int) -> Int): Int {
    println("called calc function")
    return op(a, b)
}

fun main() {
    var result = calc2(2, 3) { a: Int, b: Int -> a + b }
    println(result)
}

ラムダにnoinlineを宣言した上のコードをバイトコードに変換してみよう。

   public static final int calc2(int a, int b, @NotNull Function2 op) {
      int $i$f$calc2 = 0;
      Intrinsics.checkNotNullParameter(op, "op");
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      return ((Number)op.invoke(a, b)).intValue();
   }

   public static final void main() {
      byte a$iv = 2;
      byte b$iv = 3;
      Function2 op$iv = (Function2)null.INSTANCE;
      int $i$f$calc2 = false;
      String var5 = "called calc function";
      boolean var6 = false;
      System.out.println(var5);
      int result = ((Number)op$iv.invoke(Integer.valueOf(a$iv), Integer.valueOf(b$iv))).intValue();
      boolean var7 = false;
      System.out.println(result);
   }

今度はnoinline修飾子の宣言をなくし、バイトコードに変換してみよう。

inline fun calc2(a: Int, b: Int, op: (Int, Int) -> Int): Int {
    println("called calc function")
    return op(a, b)
}
   public static final int calc2(int a, int b, @NotNull Function2 op) {
      int $i$f$calc2 = 0;
      Intrinsics.checkNotNullParameter(op, "op");
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      return ((Number)op.invoke(a, b)).intValue();
   }

   public static final void main() {
      byte a$iv = 2;
      int b$iv = 3;
      int $i$f$calc2 = false;
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      int var8 = false;
      int result = a$iv + b$iv;
      boolean var9 = false;
      System.out.println(result);
   }

上のバイトコードを比較すると、noinline修飾子で宣言された場合はFunction2オブジェクトを宣言して直接呼び出しており、calc2関数の本体はコピーされていないことが分かる。一方、noinline修飾子が宣言されていないバイトコードでは、ラムダ関数の本体(a$iv + b$iv)がコピーされて実行されていることが分かる。

非ローカルreturn(non-local returns)

Kotlinでは、名前付き関数や匿名関数を終了するには通常ラベルなしのreturnを使用するが、ラムダを終了するにはラベルを使う必要がある。これは、ラムダを囲む関数(enclosing function)から戻ることができないため、ラムダ内でreturnだけを使うことが禁止されているからである。

fun ordinaryFunction(block: () -> Unit) {
    println("Hello!!")
}

fun main() {
    ordinaryFunction {
        return // ERROR: ここからreturnできない。
    }
}

しかし、ラムダがインライン化されている関数の場合、returnも同様にインライン化できるため、これは許可される。

inline fun inlinedFunction(block: () -> Unit) {
    println("Hello!!")
}

fun main() {
    inlinedFunction {
        return // OK: ラムダはインライン化された。
    }
}

ラムダの中にあるが、囲んでいる関数から抜けるreturnを非ローカルreturn(non-local return)という。この分類の構文は、一般的にインライン関数に囲まれたループで発生する。

fun hasZeros(ints: List<Int>): Boolean {
  ints.forEach {
    if (it == 0) return true // hasZerosからreturnされる。
  }
  return false
}

一部のインライン関数は、渡されたラムダを関数本体で直接呼び出すのではなく、ローカルオブジェクト(local object)やネストされた関数(nested function)など別の実行コンテキストから呼び出す場合がある。このような場合、ラムダからの非ローカル制御フロー(non-local flow)も許可されない。これを解決するには、ラムダパラメータにcrossinline修飾子を明示する必要がある。

inline fun f(crossinline body: () -> Unit) {
    Runnable {
        body()
    }.run()
    // ...
}

fun main() {
    f { println("Hello!")}
}

breakcontinueはインラインラムダではまだ使用できないが、サポートする予定である。

具体化された型パラメータ(Reified type parameters)

ときには、パラメータとして渡された型にアクセスする必要がある。

fun <T> TreeNode.findParentOfType(clazz: Class<T>): T? {
    var p = parent
    while (p != null && !clazz.isInstance(p)) {
        p = p.parent
    }
    @Suppress("UNCHECKED_CAST")
    return p as T?
}

ここではtreeをたどりながらリフレクションを使い、そのノードが特定の型を持つかどうかを確認している。まったく問題はないが、呼び出し地点はあまりすっきりして見えない。

treeNode.findParentOfType(MyTreeNode::class.java)

より良い解決方法は、単にこの関数に型を渡すことである。次のように呼び出せる。

treeNode.findParentOfType<MyTreeNode>()

この機能を使うには、インライン関数が具体化型パラメータ(reified type parameters)をサポートしているため、次のように書ける。

inline fun <reified T> TreeNode.findParentOfType(): T? {
    var p = parent
    while (p != null && p !is T) {
        p = p.parent
    }
    return p as T?
}

上のコードでは、ほぼ通常のクラスのように関数内部でアクセスできるよう、reified修飾子を使って型パラメータを修飾している。関数がインライン化されているためリフレクションは不要であり、!isasのような通常の演算子を使用できる。また、上のようにtreeNode.findParentOfType<MyTreeNodeType>()関数を呼び出せる。

多くの場合リフレクションは不要かもしれないが、具体化された型パラメータと一緒に使用できる。

inline fun <reified T> membersOf() = T::class.members

fun main(s: Array<String>) {
    println(membersOf<StringBuilder>().joinToString("\n"))
}

インラインとして指定していない通常の関数は、具体化パラメータを持つことができない。ランタイム表現を持たない型、たとえばreifiedではないパラメータやNothingのような仮想的な型は、reifiedパラメータの引数として使用できない。

インラインプロパティ(Inline properties)

インライン修飾子は、バッキングフィールド(backing field)を持たないプロパティのアクセサに使用できる。個別のプロパティアクセサに指定できる。

val foo: Foo
    inline get() = Foo()

var bar: Bar
    get() = ...
    inline set(v) { ... }

両方のアクセサをインラインとして保存されるように、プロパティ全体に宣言することもできる。

inline var bar: Bar
    get() = ...
    set(v) { ... }

呼び出し側では、インラインアクセサは通常のインライン関数と同じようにインライン化される。

公開APIインライン関数に対する制限事項(Restrictions for public API inline functions)

インライン関数は、publicprotectedprivateinternalとして宣言されていなければ、モジュールの公開APIと見なされる。他のモジュールから呼び出すことができ、その呼び出し側でもインライン化される。

これは、呼び出しモジュールが変更後に再コンパイルされない場合、インライン関数を宣言するモジュールの変更によってバイナリ非互換性の危険を持つことになる。

モジュールの非公開API変更によってこのような非互換性が発生する危険を取り除くため、公開APIインライン関数は非公開および内部宣言や、その本体内で非公開API宣言を使用できない。

internal宣言は@PublishedApiで注釈を付けることで、APIインライン関数から使用できる。内部インライン関数が@PublishedApiとして宣言されると、その関数の本体も公開されているかのように確認される。

参考