💎

Zenn問答第13回 「KotlinのValue Classと@JVMInline」

に公開

Zenn問答とは

「Zenn問答」とは、開発していて「なんとなく使ってるけど、ちゃんと理解してるかな?」という技術について、改めて時間をとって深掘りしてみようという企画です🧘🧘🧘

はじめに

Kotlinで開発していると、@JVMInlineアノテーションを付けたValue Classを目にすることがありませんか?でも、どういう仕組みで作られているかを正直知らずに使っていました。

今回のZenn問答では、Kotlinの@JVMInlineアノテーションとValue Classについて、その仕組みから実践的な使い方まで、深掘りしていきたいと思います。

@JVMInlineアノテーションとは何なのか?

@JVMInlineアノテーションは、Kotlinコンパイラに対して「このクラスはJVMでインライン化してほしい」ということを明示的に伝えるアノテーションです。Value Classと組み合わせて使用することで、コンパイル時にラッパークラスを排除し、より効率的なバイトコードを生成できます。

現状は基本的にはValue classとセットでの用法しか行わなさそうなので、アノテーションというよりかはValue classを使用する時に必ずつけないといけないものという理解でよさそうです。

@JvmInline
value class UserId(val value: String)

@JvmInline
value class ProductId(val value: String)

// コンパイル時に以下のように最適化される
// val userId: UserId = UserId("user123")
// ↓
// val userId: String = "user123"

パフォーマンスへの影響

data class NormalHeight(val value: Int)

@JvmInline
value class OptimizedHeight(val value: Int)

fun main() {
    f()
    g()
}

fun f(){
    val before = System.nanoTime()
    for (i in 0..10000000){
        NormalHeight(i)
    }
    val after = System.nanoTime()
    println(after-before)
}

fun g(){
    val before = System.nanoTime()
    for (i in 0..10000000){
        OptimizedHeight(i)
    }
    val after = System.nanoTime()
    println(after-before)
}

手元で簡易的に実行した結果、10197083nsと5809083nsで1.7倍くらいの性能差が見られました。
性能面だけでいうと、やるにこしたことはないけど、そこまで気にしなくてもいいかもというレベルだと思います。

Value Classのいいところ

Value Classのもう一つの大きな利点は、型安全性の向上です。プリミティブ型をラップすることで、引数の順序間違いなどのバグを防げます。data classや普通のクラスでもよさそうではありますが、単一プロパティーの場合にはvalue classを選択しない手はそこまで無い気はします。

@JvmInline
value class FirstName(val value: String)

@JvmInline
value class LastName(val value: String)

@JvmInline
value class Email(val value: String)

data class User(
    val firstName: FirstName,
    val lastName: LastName,
    val email: Email
)

fun createUser(firstName: FirstName, lastName: LastName, email: Email): User {
    return User(firstName, lastName, email)
}

// 引数の順序を間違えるとコンパイルエラーになる
// createUser(lastName, firstName, email) // コンパイルエラー!

制約と注意点

Value Classには以下の制約があります

  1. 単一プロパティのみ:プライマリコンストラクタで初期化された1つのプロパティのみ持てます
  2. 継承不可:他のクラスを継承できず、常にfinalです
  3. ボクシングの可能性:特定の状況でインライン化の最適化が無効になり、通常のオブジェクトとして扱われる場合があります

ちなみに、ボクシングとはvalue classが本来の最適化された形(プリミティブ値)ではなく、通常のオブジェクトとしてヒープに配置されることです。これにより、value classの主要な利点であるメモリ効率性とパフォーマンス向上が失われます。

ボクシングが発生するケース

@JvmInline
value class UserId(val value: String)

// 1. インターフェース経由での使用
interface Identifiable {
    val id: UserId
}
class User : Identifiable {
    override val id: UserId = UserId("user123") // ボクシングが発生
}

// 2. null許容型として使用
val nullableUserId: UserId? = UserId("test") // ボクシングが発生

// 3. ジェネリクスでの使用
val listOfIds: List<UserId> = listOf(UserId("1"), UserId("2")) // ボクシングが発生

// 4. Any型として扱う場合
val anyValue: Any = UserId("test") // ボクシングが発生

// 5. コレクションに格納する場合
val set: Set<UserId> = setOf(UserId("1"), UserId("2")) // ボクシングが発生

まあでも、型安全性は守られるので、パフォーマンスの恩恵は受けられないものの選択するメリットはありそうです。

将来の展望

@JVMInlineアノテーションは、将来のProject Valhalla JVMに対応するための準備でもあります。Project ValhallaではValue Typeのネイティブサポートが予定されており、現在の@JVMInlineアノテーションは、その移行をスムーズにするための橋渡し的な役割も果たしています。

おまけ

https://github.com/JetBrains/kotlin/blob/v2.3.0/compiler/frontend/src/org/jetbrains/kotlin/resolve/lazy/descriptors/LazyClassDescriptor.java#L158
にてアノテーションが回収された後、IrClass型が生成され、Inlineであるという情報が保持されます。IRはIntermediate Representationの略で、抽象構文木と機械語の中間の位置付けです。
なので、value classと書くだけでは現在のコンパイラでは不十分でコンパイル時にアノテーションで回収しているというわけですね。

最終的にこちらでunboxされ、バイトコードが生成されるようです。
https://github.com/JetBrains/kotlin/blob/v2.3.0/compiler/ir/backend.jvm/src/org/jetbrains/kotlin/backend/jvm/InlineClassAbi.kt#L35-L49

まとめ

Kotlinの@JVMInlineアノテーションとValue Classは、パフォーマンスと型安全性の両方を向上させる強力な機能でした。Value Classについてはもともと理解していたので、@JVMInlineのアノテーションだけ気になっていたのですが、将来的に言語レベルでサポートするための移行期間っぽいという理解に落ち着きました。

最後まで読んでいただき、ありがとうございました🙏

GitHubで編集を提案

Discussion