エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

JavaのutilメソッドがKotlin拡張関数のなかまになりたそうにこちらをみている!

f:id:oboenikui:20211206130527p:plain

以下の StringUtil.printHello がKotlinの拡張関数の仲間になりたそうにこちらを見ています。あなたは仲間にしてあげられますか?

// StringUtil.java
public final class StringUtil {
    public static void printHello(final String target) {
        System.out.println("Hello, " + target);
    }
}

本記事は以下のAdvent Calendarの記事です。


こんにちは、星川 (@oboenikui) です。皆さんはKotlinで書かれたコードとJavaで書かれたコードの見分けがつくでしょうか?

👩‍💻「変数の宣言で左側にvalが使われていたらKotlin、型名が使われていたらJavaでしょ?」
👨‍💻「メソッドの定義でfunが使われていたらほぼ間違いなくKotlinだよ」
 ︙

そうですね、色々見分けるポイントがあります。
では、コンパイル済みのclassファイルやjarファイルではどうでしょう?

本記事では、KotlinコンパイラがどのようにしてKotlinとJavaでコンパイルされたコードを見分けているのか理解し、冒頭の printHello メソッドをKotlinに書き換えることなく、以下のKotlinコードとほぼ等価なトップレベル拡張関数として認識させてみます。

// StringExt.kt
fun String.printHello() {
    println("Hello, $this")
}

※ 実際の利用が推奨されるものではありません。

デコンパイルして仕様を知る

上記の StringExt.kt をIntelliJ IDEAの機能でShow Kotlin Bytecode → Decompileしてみましょう。すると以下のようなJavaコードを得られます。

@Metadata(
   mv = {1, 6, 0},
   k = 2,
   d1 = {"\u0000\f\n\u0000\n\u0002\u0010\u0002\n\u0002\u0010\u000e\n\u0000\u001a\n\u0010\u0000\u001a\u00020\u0001*\u00020\u0002¨\u0006\u0003"},
   d2 = {"printHello", "", "", "kotlin-lib"}
)
public final class StringExtKt {
   public static final void printHello(@NotNull String $this$printHello) {
      Intrinsics.checkNotNullParameter($this$printHello, "$this$printHello");
      String var1 = "Hello, " + $this$printHello;
      System.out.println(var1);
   }
}

ここからわかることについて以下で見ていきます。

トップレベル関数・拡張関数

JavaとKotlinを併用した経験のある方は目にしたことがあるのではないかと思いますが、Kotlinのトップレベル関数は (Kotlinのファイル名)Kt という名前のクラスのstaticメソッドとしてコンパイルされます。また、拡張関数はレシーバークラスを第一引数に取るメソッドとなります。

ただし、Ktをつけたら即トップレベル関数として認識されるかというとそうではありません。次に@Metadataアノテーションについて見ていきます。

Metadata

Kotlinからコンパイルされたクラスには、@Metadataというアノテーションが付加されます。StringExtKtクラスに付加されている@Metadataアノテーションはこのようになっていますね。

@Metadata(
   mv = {1, 6, 0},
   k = 2,
   d1 = {"\u0000\f\n\u0000\n\u0002\u0010\u0002\n\u0002\u0010\u000e\n\u0000\u001a\n\u0010\u0000\u001a\u00020\u0001*\u00020\u0002¨\u0006\u0003"},
   d2 = {"printHello", "", "", "kotlin-lib"}
)

以下ではそれぞれのパラメータについて解説していきます。

metadataVersion (mv)

mvmetadataVersion の短縮名で、その名の通りメタデータのバージョンを表します。

上の例では、Kotlin 1.6.0のコンパイラを使用したため、 {1, 6, 0} という配列が設定されています。

kind (k)

kkind の短縮名で、そのクラスの種類を表します。現在以下の5種類が定義されており、それぞれintの値が割り振られています。

  • CLASS_KIND (1)
  • FILE_FACADE_KIND (2)
  • SYNTHETIC_CLASS_KIND (3)
  • MULTI_FILE_CLASS_FACADE_KIND (4)
  • MULTI_FILE_CLASS_PART_KIND (5)

※ それぞれの説明は以下のファイルをご覧ください。

github.com

今回はFILE_FACADE_KINDというタイプになるので、2が設定されています。

data1 (d1), data2 (d2)

メソッドなどのKotlinにおける定義をProtocolBufferでエンコードされたバイナリとして保存しているのがdata1、それに関連した文字列を保持するのがdata2です。

data1には以下のように文字列テーブルの情報とクラスの情報が含まれます。

文字コードの情報 (1バイト)
※ Stringからbyte配列に変換するために必要
文字コードの情報 (1バイト)...
文字列テーブルのサイズN (1バイト)
文字列テーブルのサイズN (1バイト)
文字列テーブル (Nバイト)
文字列テーブル (Nバイト)
クラス情報 (残りすべて)
※ kindによって異なる
クラス情報※ kindによって異なる...

data1は

"\u0000\f\n\u0000\n\u0002\u0010\u0002\n\u0002\u0010\u000e\n\u0000\u001a\n\u0010\u0000\u001a\u00020\u0001*\u00020\u0002¨\u0006\u0003"

と設定されていますが、HEX表記で分割すると、

  • 00 ⋯ UTF-8でエンコードされていることを表す
  • 0C ⋯ 文字列テーブルのサイズが12バイト
  • 0A 00 0A 02 10 02 0A 02 10 0E 0A 00 ⋯ 文字列テーブル (ProtocolBufferのバイナリ)
  • 1A 0A 10 00 1A 02 30 01 2A 02 30 02 A8 06 03 ⋯ FileFacadeタイプのデータ (ProtocolBufferのバイナリ)

となります。

文字列テーブルとクラス情報はKotlinのリポジトリとProtocol Bufferのprotocコマンドでデコード可能です。

$ git clone https://github.com/JetBrains/kotlin
$ cd kotlin
$ echo -ne '\x0A\x00\x0A\x02\x10\x02\x0A\x02\x10\x0E\x0A\x00' | protoc --decode=org.jetbrains.kotlin.metadata.jvm.StringTableTypes  core/metadata.jvm/src/jvm_metadata.proto # 文字列テーブルのデコード
record {
}
record {
  predefined_index: 2
}
record {
  predefined_index: 14
}
record {
}
$ echo -ne '\x1A\x0A\x10\x00\x1A\x02\x30\x01\x2A\x02\x30\x02\xA8\x06\x03' | protoc --decode=org.jetbrains.kotlin.metadata.Package core/metadata.jvm/src/jvm_metadata.proto # FileFacadeデータのデコード
function {
  name: 0
  return_type {
    class_name: 1
  }
  receiver_type {
    class_name: 2
  }
}
[org.jetbrains.kotlin.metadata.jvm.package_module_name]: 3

まず、文字列テーブルから読んでいきましょう。data2の文字列の配列 ({"printHello", "", "", "kotlin-lib"}) と、文字列テーブルのrecordは1:1で対応しています。

0, 3番目のrecordは定義が空です。そのため文字列はdata2そのままで、それぞれ"printHello", "kotlin-lib"となります。1, 2番目はrecordを読むと、事前に定義された2番目と14番目の文字列であると読み取れます。この「事前に定義された文字列」というのは以下の文字列のリストのことです。

github.com

すなわち、0~3のそれぞれの値は以下のとおりです。

0: "printHello"
1: "kotlin/Unit"
2: "kotlin/String"
3: "kotlin-lib"

これらを基に、FileFacadeデータを読んでいきましょう。

そのまま読むと、このファイルには、名前が0で、戻り値型が1、レシーバータイプが2の関数が含まれること、またパッケージモジュール名が3であることが読み取れます。

これらの数字は上で解明した文字列テーブルのインデックスです。置き換えて読むと、「名前がprintHelloで、戻り値型がkotlin/Unit、レシーバータイプがkotlin/Stringの関数が含まれる、またパッケージモジュール名がkotlin-libである」と解釈できます。

kotlin_module

トップレベル関数や拡張関数といった情報の埋め込みは、ここまで説明した話が全てです。実際、クラス名変更とアノテーション付加済みのStringExtKtクラスをコンパイルし、IntelliJ IDEAのデコンパイラ機能で表示すると、Kotlinのコードからコンパイルされたように表示されます。

f:id:oboenikui:20211206104202p:plain

ただ残念ながらまだコンパイル時にはJavaのstaticメソッドと認識され、拡張関数としての利用はできません。コンパイル時にKotlinのクラスや関数であると認識してもらうにはkotlin_moduleファイルにて対象のクラスが指定されている必要があります。これは通常jarファイルのMETA-INFディレクトリ内に (モジュール名).kotlin_module という名で配置されます。

バイナリエディタで開いてみると以下のようになっています。

f:id:oboenikui:20211206104557p:plain
とあるプロジェクトのkotlin_moduleファイル

最初の20バイトはバージョン互換性に関する情報です。Kotlinでは通常1以上のメジャーバージョンおよび2以上のマイナーバージョンでコンパイルされたjarファイルは互換性がないものとして振る舞うようです。例えばKotlin 1.4.32のコンパイラはKotlin 1.6.0でコンパイルされたjarファイルと互換性がないです。そのため、上記のように、コンパイラのバージョン番号 (1.6.0) が記録されています。

また、21バイト目以降はProtocol Bufferでエンコードされたバイナリです。その内容をデコードすると以下のようになります。

package_parts {
  package_fq_name: "com.oboenikui.jatlin.kotlin"
  short_class_name: "MainKt"
}
string_table {
}
qualified_name_table {
}

端的に説明すると、これはどのクラスがKotlinでコンパイルされたのかを、モジュール単位で記録するものです。このモジュールには、com.oboenikui.jatlin.kotlin.MainKt というKotlinからコンパイルされたクラスが含まれていることを意味します。

JavaをKotlin化してみる

さて、今までの情報を基に冒頭のJavaコードをKotlin化してみましょう。

やることは3つです。ちなみにモジュール名は java-lib としました。

  1. StringUtilからStringExtKtにリネーム
  2. @Metadataアノテーションを付加
  3. java-lib.kotlin_metadata ファイルを作成

Javaファイルの編集

リネームと@Metadataアノテーションを付加します。@Metadataにわたすパラメータは幸いほぼ同じで、異なるのはモジュール名を java-lib とすることくらいです。

@Metadata(
        mv = {1, 6, 0},
        k = 2,
        d1 = {"\u0000\f\n\u0000\n\u0002\u0010\u0002\n\u0002\u0010\u000e\n\u0000\u001a\n\u0010\u0000\u001a\u00020\u0001*\u00020\u0002\u00a8\u0006\u0003"},
        d2 = {"printHello", "", "", "java-lib"})
public final class StringExtKt {
    @NotNull
    public static void printHello(final String $this) {
        System.out.println("Hello, " + $this);
    }
}

※ 実際のKotlinコードにはnullチェック処理も含まれます。

java-lib.kotlin_metadata ファイルの作成

パッケージ名とクラス名を修正したファイルをresources/META-INFディレクトリ内に作成します。

f:id:oboenikui:20211206105806p:plain
java-lib.kotlin_module

jarファイルの生成

あとはjarファイルを生成し、別のKotlinを使ったプロジェクトに読み込ませると正しく認識します。

f:id:oboenikui:20211206110426p:plain

また、このmain関数を実行すると正常に出力されます🎉

f:id:oboenikui:20211206120402p:plain
main関数の実行結果

本記事のサンプルコードは以下のリポジトリで公開しています。

github.com

We're hiring!

弊社ではAndroidアプリだけでなく、サーバーサイドでもKotlinを広く採用しています。製品コードでJavaコードを魔改造してKotlin化はしていませんが、興味を持たれた方は下記よりお問い合わせください。

jobs.m3.com