はじめに
こんにちは、マイクロアドでソフトウェアエンジニアをしている宮田です。
この記事では、kotlin-reflect
の機能を代替しうるライブラリとして、kotlinx-metadata-jvm
について紹介します。
Maven Repository: org.jetbrains.kotlinx » kotlinx-metadata-jvm
この記事を作成した時点でのバージョンは以下の通りです。
Kotlin
: 1.5.21kotlinx-metadata-jvm
: 0.3.0
Kotlin
(JVM
)でのリフレクションについて
kotlinx-metadata-jvm
に関する話の前に、JVM
環境のKotlin
でのリフレクションについて軽く触れます。
Kotlin
でリフレクションをフルに活用するためには、kotlin-reflect
を依存に追加する必要が有ります。
Maven Repository: org.jetbrains.kotlin » kotlin-reflect
これを利用しない場合、例えばKFunction
(Kotlin
上のコンストラクタやメソッド)の引数や戻り値の型といった情報を取得することもできません。
kotlin-reflect
を用いる際の問題点
このように、Kotlin
でリフレクションを取り扱う際にはほぼ必須と言えるkotlin-reflect
ですが、利用にあたっては幾つかの問題点が有ります。
まず、kotlin-reflect
は1.5.21
の時点で3MB弱ものサイズがある、非常に大きなライブラリです。
単純比較できるものでもありませんが、kotlin-stdlib-jdk8
のサイズが15KB、spring-boot
のサイズですら1.3MBという点からも、その巨大さが伝わるかなと思います。
このサイズは、例えばAndroid
のような利用可能なリソースに大きな制限のある環境では非常に大きな問題となります。
そうでなかったとしても、これだけサイズが大きいというのはあまり好ましいものではありません。
また、kotlin-reflect
経由の処理はJava
のリフレクションと比べて非常に遅く、処理速度面も良いとは言えません。
Java
リフレクションでの代替について
「kotlin-reflect
がダメなら、Java
のリフレクションだけでなんとかならないのか」と思われるかもしれません。
これに関しては、単純な内容はともかく、網羅的な処理を作ることは難しいです。
理由は、Kotlin
上に定義した情報が必ずしもJava
上で取得できるとは限らないためです。
kotlin-reflect
を使わなかった場合取得の難しい情報としては、例えば以下が有ります。
- コンストラクタのパラメータ名1
get~
というメソッドが、Kotlin
上でプロパティとして定義されていたのか、単純にそのような名前の関数として定義されていたのか- 関数が
JvmName
で名付けられていた場合の元の名前 value class
(inline class
)がunbox
されて使われていた場合の、unbox
前の型
kotlinx-metadata-jvm
について
ここまで説明してきた通り、kotlin-reflect
無しでKotlin
のリフレクションをフルに活用することは困難が伴い、一方でkotlin-reflect
を用いることにも幾つかの課題が有ります。
他の回避方法としてはコード生成を用いることも考えられますが、リフレクションを用いるのに比べれば敷居が高く、こちらも困難を伴います。
そんな状況を解決しうるのが、kotlinx-metadata-jvm
というライブラリです。
kotlinx-metadata-jvm
は何をできるのか
kotlinx-metadata-jvm
は、kotlin.Metadata
アノテーション(以後Metadata
アノテーションとのみ記述します)の中身を読んで、そこから情報を読み出すことができるライブラリです。
Metadata
アノテーションとは、Kotlin
上の定義に関する情報を保持するためのアノテーションです。
この中に保持されている情報は、Kotlin
上の当該クラスに定義されているプロパティや関数、型や名前などで、先ほど紹介したようなJava
にコンパイルされた際に失われる情報もこのアノテーションの中に入っています。
Metadata
アノテーションは、Kotlin
のソースをコンパイルした際に、クラスに対して自動的に付与されます。
Kotlin
のソースのみ触っている場合Metadata
アノテーションを目にすることはほぼ有りませんが、デコンパイルすることで直接確認することもできます。
例えば、kotlin.reflect.KClass
をデコンパイルすると、以下のようにMetadata
アノテーションが付与されていることを確認できます。
d1
にはバイト列が格納されていますが、これはprotobuf
に変換されたメタデータです。
/* 略 */ import kotlin.Metadata; /* 略 */ // d1, d2に関しては長すぎるため省略 @Metadata( mv = {1, 5, 1}, k = 1, d1 = {"\u0000d\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0000\n\u0002..."}, d2 = {"Lkotlin/reflect/KClass;", "T", "", "Lkotlin/reflect/KDeclarationContainer;", ...} ) public interface KClass extends KDeclarationContainer, KAnnotatedElement, KClassifier {
このメタデータから読みだした情報に基づいてJava
リフレクションによって処理を行うことで、kotlin-reflect
無しでも処理を行うことができるようになります。
kotlinx-metadata-jvm
を使うメリット
kotlinx-metadata-jvm
を使うメリットは、先程紹介したkotlin-reflect
の問題点を解決できることです。
まず、kotlinx-metadata-jvm
のサイズは1MB程度であり、kotlin-reflect
の3MBを置き換えることができれば、リソースの節約に繋がります。
また、kotlin-reflect
に比べると、Java
のリフレクションを直接用いて処理する部分が多くなるため、処理速度の向上も見込めます。
kotlinx-metadata-jvm
を使ってみる
kotlinx-metadata-jvm
を使うサンプルとして、引数名 -> 引数のMap
からプライマリコンストラクタを呼び出すシンプルなマッパーの実装例を紹介します。
import java.lang.reflect.Constructor import kotlinx.metadata.KmClass import kotlinx.metadata.KmConstructor import kotlinx.metadata.jvm.KotlinClassHeader import kotlinx.metadata.jvm.KotlinClassMetadata import kotlinx.metadata.jvm.signature fun Metadata.toKotlinClassHeader() = KotlinClassHeader( this.kind, this.metadataVersion, this.data1, this.data2, this.extraString, this.packageName, this.extraInt ) class SimpleMapper<T : Any>(private val clazz: Class<T>) { private val kmConstructor: KmConstructor private val actualConstructor: Constructor<T> init { // メタデータアノテーションの取得(簡単のため取得できなかった場合は無視している) val metadata: Metadata = clazz.getAnnotation(Metadata::class.java)!! // メタデータ -> KmClass(kotlinx-metadata-jvmにおけるKClass的な立ち位置)への変換 val kmClass: KmClass = metadata .toKotlinClassHeader() .let { // KotlinClassMetadataにはいくつか継承先があるが、ここでは簡単のためClassで決め打ちしている KotlinClassMetadata.read(it) as KotlinClassMetadata.Class }.toKmClass() // 呼び出し対象とするコンストラクタの情報取得(簡単のため無条件にプライマリコンストラクタを対象にしている) kmConstructor = kmClass.constructors.first() // リフレクションで呼び出し対象コンストラクタの実体の取得(jvmMethodSignatureに関しては別途解説) @Suppress("UNCHECKED_CAST") actualConstructor = clazz.declaredConstructors .first { it.jvmMethodSignature == kmConstructor.signature!!.asString() } as Constructor<T> } fun map(args: Map<String, *>): T { // 名前をキーに引数の読み出し val args = kmConstructor.valueParameters.map { args[it.name] }.toTypedArray() return actualConstructor.newInstance(*args) } }
実際にこのマッパーを利用するコードです。
data class Sample(val foo: Int, val bar: String, val baz: Boolean) fun main() { val mapper = SimpleMapper(Sample::class.java) val args = mapOf("foo" to 1, "bar" to "bar", "baz" to true) val result = mapper.map(args) // -> Sample(foo=1, bar=bar, baz=true) }
補足: jvmMethodSignature
について
この拡張プロパティは下記のコードをお借りする想定です。
MoshiX/JvmDescriptors.kt at ZacSweers/MoshiX
このコードに関しては、長くなるためこの記事では触れませんが、引数の型に関するJVM
上での表現を比較して一致を見ています。
kotlinx-metadata-jvm
が適さない場面
ここまでkotlinx-metadata-jvm
の利点に関して触れてきましたが、kotlinx-metadata-jvm
が適さない場面も有ります。
まず、kotlinx-metadata-jvm
を使ったコードとkotlin-reflect
を使ったコードを比べた場合、後者の方が簡単で、求められる知識も少なくなります。
従って、kotlin-reflect
で十分な場合には、わざわざkotlinx-metadata-jvm
を使う必要は無いと言えるでしょう。
また、極限まで性能を求める必要のある場面では、その部分だけ手動で作成したり、コード生成を用いるなどして、一切のリフレクション処理を行わない形にするべきです。
終わりに
この記事ではkotlinx-metadata-jvm
について紹介しました。
このライブラリは、kotlin-reflect
を用いずにKotlin
向けのリフレクション処理を書くことができる非常に強力なライブラリで、適切な場面で使えば強力な成果を得ることができます。
また、このライブラリを使いこなすためにはKotlin
やJVM
の深い部分に触れることになるので、興味のある方は是非触って頂ければと思います。
マイクロアドではKotlin
を用いた管理画面のサーバーサイド開発にチャレンジしたいという仲間を募集しています。
また、サーバサイドエンジニアだけでなく機械学習エンジニア、フロントエンジニア、インフラエンジニアなど幅広く募集しています。気になった方は以下からご応募いただければと思います。
-
一応
-parameters
オプションを付けてコンパイルすれば、java.reflect.Constructor
から取得可能です。↩