value class と data class の違い
単一プロパティの値オブジェクトを Kotlin で実装するとき、@JvmInline value class と data class のどちらを使うか迷うことがあります。
どちらも「中身の値が同じなら同じもの」という値オブジェクトの性質は満たせます。違いは主に 実行時の表現 と 言語機能の使いやすさ に現れます。
このページでは次の順で整理します。
コード例は次の記事を参照してください。
まず全体像を表にまとめます。続けて、なぜそうなるのか をひとつずつ説明します。
| 観点 | @JvmInline value class | data class |
|---|---|---|
| 実行時のコスト | 多くの場面でラップせず、中身の型(例: UUID)として扱われる | 基本的には通常のオブジェクトとして扱われる |
| 等価性 | ラップした値で比較 | コンストラクタの全プロパティで比較 |
| プロパティ数 | 1 つだけ | 1 つ以上。複数プロパティを自然に扱える |
toString | 表示形式を明確にしたい場合は自分で定義する | UserId(value=...) のように自動生成される |
| デバッガでの見え方 | 中身の型に見えることがある | UserId として見えやすい |
| ライブラリ連携 | JSON シリアライズなどで追加設定が必要なことがある | フレームワークのサポートがわかりやすいことが多い |
実行時のコスト
Section titled “実行時のコスト”@JvmInline value class は、コンパイル時には UserId という独立した型ですが、JVM 上では 中身の UUID にできるだけ寄せて表現 されます。
そのため、引数の受け渡しなどでは余分なラッパーオブジェクトを作らずに済むことが多いです。
@JvmInlinevalue class UserId(val value: UUID)
fun findUser(id: UserId) { ... }UserId は「UUID を誤って渡すとコンパイルエラーにするための型」として機能し、余分なオブジェクトを作らない ことが多いです。
data class は通常のクラスと同様、基本的には UserId インスタンスがオブジェクトとして扱われます。
1 件あたりの差は小さく見えますが、ID のように大量に扱う値では、メモリや GC への影響が積み重なることがあります。
data class UserId(val value: UUID)呼び出し側のコードは同じ形に書けますが、メモリや GC の観点では value class のほうが軽い ことが多いです。
値オブジェクトに必要なのは「中身が同じなら同じものとして扱う」ことです。どちらもこの要件は満たせます。
value class… ラップした値(例:UUID)が同じなら等しいと判定されるdata class… コンストラクタの全プロパティが同じなら等しいと判定される
単一プロパティであれば結果はほぼ同じですが、仕組みが異なります。
data class はプロパティが増えたときも、自動生成された equals / hashCode がそのまま使えるのが利点です。
プロパティ数と検証範囲
Section titled “プロパティ数と検証範囲”value class は 単一のプロパティだけ を持てます。
そのため、UserId や Email のように、1 つの値を型で包みたい場合に向いています。
@JvmInlinevalue class Email(val value: String) { init { require(value.contains("@")) { "メールアドレスの形式が不正です" } }}data class は複数のプロパティを持てるため、Address や Money のように、複数の属性をまとめて 1 つの概念として扱いたい場合に向いています。
値そのものの制約だけでなく、プロパティ間の整合性も init で検証できます。
enum class Currency { JPY, USD}
data class Money( val amount: Long, val currency: Currency,) { init { require(amount >= 0) { "金額は0以上である必要があります" } }}検証に init を使えるかどうかは、両者の大きな違いではありません。
判断の中心は、表したい値が 単一の値 なのか、複数の属性の組み合わせ なのかです。
toString
Section titled “toString”data class は toString が自動生成され、UserId(value=550e8400-e29b-...) のように 型名とプロパティ名付き で表示されます。ログやデバッグで「何の値か」がわかりやすいです。
value class はコンパイル後の表現が中身の型に近くなるため、ログ・デバッガ・ライブラリ経由では見え方が環境に左右されることがあります。
表示形式を明確にしたい場合は、自分で toString を定義します。
デバッガでの見え方
Section titled “デバッガでの見え方”value class は JVM 上で中身の型に近い形で表現されるため、デバッガ上では UUID として表示される ことがあります。
コンパイル時の型安全性は保たれますが、実行時の見た目は中身の型に近くなります。
data class は通常のオブジェクトとして扱われるため、変数の型名 UserId として表示されやすい です。
チームの好みや、デバッグのしやすさによっては、この差が選定理由になることもあります。
ライブラリ連携
Section titled “ライブラリ連携”Jackson や kotlinx.serialization など、多くのライブラリは 通常のクラスや data class を前提 に設計されています。
data class なら、アノテーションを付けるなど比較的素直に連携できることが多いです。
value class はコンパイル後の表現が中身の型に近くなるため、シリアライズ時に追加の設定やカスタムコンバータが必要になる ことがあります。
API の入出力や ORM との接続を早い段階で組むプロジェクトでは、data class のほうが手戻りが少ない場合があります。
設計・実装の違い
Section titled “設計・実装の違い”比較一覧で見た特性を踏まえると、次のような場面で使い分けることになります。
@JvmInline value class が向く場面
Section titled “@JvmInline value class が向く場面”- 識別子(
UserId,OrderId)のように 型を分けたいだけ の単純なラップ - パフォーマンスやメモリを気にしたい
- 単一の値に対する制約を小さく閉じ込めたい
data class が向く場面
Section titled “data class が向く場面”- 複数プロパティをまとめて扱い、プロパティ間の整合性 も検証したい
- シリアライズ・ORM・テストツールなど、オブジェクトとして扱うライブラリ との相性を優先したい
- チーム内で
data classのほうが読みやすい・慣れている
よくある誤解
Section titled “よくある誤解”比較一覧の「実行時のコスト」で触れた「実行時に中身の型として扱われる」という説明を見ると、UserId が UUID や OrderId と互換性があるのでは? と感じることがあります。これは誤解です。
UserId を UUID や OrderId の代わりに渡せるわけではない
Section titled “UserId を UUID や OrderId の代わりに渡せるわけではない”Kotlin のコード上では、UserId を UUID や OrderId の代わりにそのまま渡すことはできません。
「実行時に中身の型として扱われる」は、メモリ上の表現の話 であり、型の互換性の話ではありません。
コンパイル時には UserId・OrderId・UUID はそれぞれ別の型として扱われます。
import java.util.UUID
@JvmInlinevalue class UserId(val value: UUID) { companion object { fun generate(): UserId = UserId(UUID.randomUUID()) }}
@JvmInlinevalue class OrderId(val value: UUID) { companion object { fun of(value: UUID): OrderId = OrderId(value) }}
fun findUser(id: UserId) { ... }fun findOrder(id: OrderId) { ... }fun saveRaw(id: UUID) { ... }
val userId = UserId.generate()
findUser(userId) // OKfindOrder(userId) // コンパイルエラー(OrderId が必要)saveRaw(userId) // コンパイルエラー(UUID が必要)saveRaw(userId.value) // OK(明示的に取り出した場合のみ)value class の目的は、UUID としてどこでも使えるようにすることではなく、素の UUID より取り違えにくくすることです。
data class でも同様に、暗黙の型変換は起きません。
本当の注意点は .value で型を剥がすこと
Section titled “本当の注意点は .value で型を剥がすこと”コンパイラが防げないのは、.value を経由して別の ID 型を作る ケースです。
val userId = UserId.generate()val orderId = OrderId.of(userId.value) // コンパイルは通るが、意味的には危険このような取り違えを防ぐには、次のような工夫が有効です。
.valueは DB 層やシリアライズ層など、ドメインの外側 でだけ使う- 可能なら
valueをprivateにし、変換はファクトリやリポジトリに閉じる - ドメイン層の関数は
UUIDではなくUserId/OrderIdを引数に取る
どちらを選ぶか
Section titled “どちらを選ぶか”比較一覧と設計・実装の違いを踏まえた判断の目安を、表にまとめます。
| 状況 | おすすめ |
|---|---|
| UUID などの識別子を型安全にラップしたい | まず @JvmInline value class |
| 複数プロパティの整合性を検証したい | data class |
| ライブラリ連携でつまずきやすい | data class を検討 |
| 呼び出し側の API を揃えたい | どちらでも可(ファクトリを共通化すれば同じにできる) |
実際のプロジェクトでは どちらか一方を選んで統一 するのが一般的です。
識別子のように単純なラップが中心なら @JvmInline value class、複数プロパティの表現やツール連携を優先するなら data class、という判断で十分なことが多いです。
具体的な書き方は次の記事を参照してください。