[Kotlin]インライン関数 – inlineの一言で何が変わるのか

inline修飾子が付いた関数はインライン関数として動きます。インライン関数は主にラムダを取る高階関数に使用されますが、その利点はメモリのオーバーヘッドを抑えられることです。

今回はバイトコードを逆コンパイルしながら、

  • インライン関数の使い方
  • 通常の関数との違い
  • オーバーヘッドを抑えられる理由

についてお話したいと思います。

インライン関数の使い方

使い方はとっても簡単。関数定義にinline修飾子を付け足すだけです。

inline fun rep(str: String,func: (Int)->String){
    val num = (1..100).shuffled().last()
    println(str+func(num))
}

定義方法はこれ以上無いほど単純ですが、これを使うと何が変わるのでしょう?

すごく簡単に説明すると、通常の関数はラムダを1つのオブジェクトとして扱い、インライン関数は関数の処理をコード実行部分に展開します。

という説明だけでは実際何がどうなっているのか分かりにくいので、ここからはIntelliJにどうなっているのか見せてもらいましょう。

通常の関数を逆コンパイルする

通常の関数を呼び出した場合、コンパイラはどのような処理をするのか。下の単純なコードを元に、バイトコードの逆コンパイルを実行して確認していきます。

fun main(){
    rep("ピザ") {"って${it}回言ってみて"}
}

fun rep(str: String,func: (Int)->String){
    val num = (1..100).shuffled().last()
    println(str+func(num))
}
//ピザって96回言ってみて
バイトコードの逆コンパイル:
IntelliJで行う場合はメニューの「ツール」→「Kotlin」→「Kotlinバイトコードの表示」と選択して、出てきたコードの左上「逆コンパイル」をクリックします。

上のコードがJVMにどう伝わっているのか見てみましょう。inline修飾子を使用していない、通常の関数を逆コンパイルした結果がこちらです。

import kotlin.Metadata;
import kotlin.collections.CollectionsKt;
import kotlin.jvm.functions.Function1;     //Function1をインポート
import kotlin.jvm.internal.Intrinsics;
import kotlin.ranges.IntRange;
import org.jetbrains.annotations.NotNull;

@Metadata(
//この部分は省略しています
)
public final class SandboxKt {
   public static final void main() {     //実際に実行されるコードはここから3行
      rep("ピザ", (Function1)null.INSTANCE);     //関数呼び出し部分
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }

//ここから下は関数定義
   public static final void rep(@NotNull String str, @NotNull Function1 func) {     
//Function1が関数の第二引数。funcという名前で扱われる
      Intrinsics.checkParameterIsNotNull(str, "str");
      Intrinsics.checkParameterIsNotNull(func, "func");  //funcのnullチェック
      byte var3 = 1;
      int num = ((Number)CollectionsKt.last(CollectionsKt.shuffled((Iterable)(new IntRange(var3, 100))))).intValue();
      String var5 = str + (String)func.invoke(num);   //func.invoke()
      boolean var4 = false;
      System.out.println(var5);     //関数の最終行
   }
}

長いですが見るべき部分はそう多くありません。まず注目するのは「Function1」です。

Function1とは何か

コンパイル後のコードではラムダそのままの記述は見当たりません。代わりに「Function1」という表記が所々にあるのが分かります。

このFunction1はinvokeメソッドのみを持つインターフェイスです。

3行目でインポートされ、22行目(関数のパラメータ)で「func」と別名を付けられたFunction1は、28行目でinvokeメソッドを呼び出し、それが「var5」という変数名でインスタンス化されているのがわかります。

このfunc.invoke(num)がつまりはラムダの実行結果です。

invokeの直訳は「呼び出す」「発動する」

呼び出しと定義の関係はそのまま

もう1つのポイントは、Kotlinのコードと比べて「実行部分に大きな違いが無い」ということです。

実際に実行される12~14行目のコードの内容はKotlinの1~3行目とあまり変わりなく、rep関数を呼び出すという部分に関しては同じです。覚えておいてください。

インライン関数を逆コンパイルする

では次にインライン関数です。

fun main(){
    rep("ピザ") {"って${it}回言ってみて"}
}

inline fun rep(str: String,func: (Int)->String){
    val num = (1..100).shuffled().last()
    println(str+func(num))
}

同じように逆コンパイルしてみます。

import kotlin.Metadata;
import kotlin.collections.CollectionsKt;
import kotlin.jvm.functions.Function1;     //Function1をインポート
import kotlin.jvm.internal.Intrinsics;
import kotlin.ranges.IntRange;
import org.jetbrains.annotations.NotNull;

@Metadata(
//省略しています
)
public final class SandboxKt {
   public static final void main() {    //ここから23行目までが実行される
      String str$iv = "ピザ";
      int $i$f$rep = false;
      byte var2 = 1;
      int num$iv = ((Number)CollectionsKt.last(CollectionsKt.shuffled((Iterable)(new IntRange(var2, 100))))).intValue();
      StringBuilder var6 = (new StringBuilder()).append(str$iv);
      int var5 = false;
      String var7 = "って" + num$iv + "回言ってみて";
      String var8 = var6.append(var7).toString();
      boolean var4 = false;
      System.out.println(var8);
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
//ここから下は関数定義
   public static final void rep(@NotNull String str, @NotNull Function1 func) {
//Function1が関数の第二引数。funcという名前で扱われる
      int $i$f$rep = 0;
      Intrinsics.checkParameterIsNotNull(str, "str");
      Intrinsics.checkParameterIsNotNull(func, "func");   //func
      byte var4 = 1;
      int num = ((Number)CollectionsKt.last(CollectionsKt.shuffled((Iterable)(new IntRange(var4, 100))))).intValue();
      String var6 = str + (String)func.invoke(num);   //func.invoke()
      boolean var5 = false;
      System.out.println(var6);
   }
}

結果は見た目にもかなり違ったものになりますが、一体何が違うのでしょうか。

Function1

まずはFunction1について見てみます。3行目でインポートされ、関数定義のパラメータで「func」として扱われ、その後でfunc.invoke(num)とメソッドを使用しています。

こう見ると全く同じに見えますよね?

Function1の流れだけ見れば全く同じです。しかしこのコードでは、Function1が実際に使われることは一生ありません。その理由が次です。

関数の処理は展開される

実際に実行されるのはコードの12~23行目。ハッキリと違うのは、ここにユーザー定義関数の呼び出しは一切無いということです。

関数はラムダもろともコードの実行部分に展開され、文字通り「インラインの」(埋め込まれた)処理として書き直されています。これがインライン関数と通常の関数の違いです。

オーバーヘッドを防ぐ

通常の関数では

  1. 関数内でラムダがインスタンス化される
  2. その関数を実行部分で呼び出す

処理が生じています。インスタンスはラムダを記述すればするほど増えていきますが、問題はこの処理がメモリを消費し、動作を重くする原因になり得るということです。

これをオーバーヘッドと言います。早い話が「余分な処理」です。この余分な処理を無くすための対策がinline修飾子です。

インライン関数が使えない場面

消費メモリを節約できるインライン関数は簡単に設定できて、その上便利です。しかし「どんなときでも」インライン関数を利用するのが正解かというとそうでもありません。例えばラムダを受け取る関数が再帰関数の場合です。

再帰関数をインライン化すると、関数の展開が無限ループのように繰り返されてしまいます。そういう場合にはインライン化するべきではありません。

noinline修飾子

関数内で使用される関数(例えば引数に取られるラムダ)をインライン化したくない場合、noinline修飾子を使いましょう。

inline fun rep(str: String, noinline func: (Int)->String){...}

こうすることで内部のラムダはインライン化されず、通常の方法で呼び出されます。

タイトルとURLをコピーしました