- Kotlin
Kotlin data classの使い方|copy・equalsとclass差
Kotlinのdata classとは、データを持つことが主な役割のクラスに data を付けるだけで、equals・hashCode・toString・copy・componentN をコンパイラが自動で用意してくれる仕組みです。 ふつうの class では「同じ中身でも == が false」「println するとハッシュ値が出る」となる場面が、data class では中身で比較・表示されるようになります。
この記事では、class と data class を同じコードで動かした比較表を最初に置き、そのあとで copy・equals・分解宣言の使い方を出力つきで示します。さらに、つまずきやすい3点(クラス本体のプロパティは対象外、copy は浅いコピー、配列プロパティの equals)と、HashMap のキーに var を持つ data class を使ったときの挙動を再現コードで確かめ、data object・value class・Java record との違いも一覧にしました。
確認環境:Ubuntu 24.04 / Kotlin 2.4.20(kotlinc-jvm)/ OpenJDK 21.0.10。掲載したコードはすべてこの環境でコンパイル・実行し、出力をそのまま載せています。実行コマンドは kotlinc cmp.kt -include-runtime -d cmp.jar && java -jar cmp.jar の形です。
class と data class の違い早見表
class PlainUser(val name: String, val age: Int) と data class DataUser(val name: String, val age: Int) を、どちらも ("taro", 20) で2つずつ作って比べた結果です(コードは次の章)。
| 比べる項目 | class | data class |
|---|---|---|
println(obj)(toString) |
PlainUser@(ハッシュ値) |
DataUser(name=taro, age=20) |
同じ中身どうしの ==(equals) |
false | true |
同じ中身どうしの ===(参照) |
false | false |
| 同じ中身どうしの hashCode が等しいか | false | true |
setOf(a, b).size |
2 | 1 |
obj.copy(age = 21) |
コンパイルエラー | DataUser(name=taro, age=21) |
val (name, age) = obj(分解宣言) |
コンパイルエラー | taro / 20 |
| 継承される側(open)にする | できる | できない |
| 対象になるプロパティ | (自動生成なし) | 主コンストラクタの引数だけ |
data class を選ぶ目安は、「中身が同じなら同じものとして扱いたいか」です。API の応答、設定値、画面に渡す値、Map のキーなどが向いています。接続やファイルのように「同じ中身でも別物」として扱いたいものは、ふつうの class が合います。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Kotlin data classの使い方|copy・equalsとclass差」
class と data class を同じコードで比べる
次のコードは、そのまま貼り付けて動かせます。ふつうの class の toString はハッシュ値が実行のたびに変わるため、その部分だけ「(ハッシュ値)」に置き換えて表示しています。
class PlainUser(val name: String, val age: Int)
data class DataUser(val name: String, val age: Int)
fun main() {
val p1 = PlainUser("taro", 20)
val p2 = PlainUser("taro", 20)
val d1 = DataUser("taro", 20)
val d2 = DataUser("taro", 20)
println("--- toString")
println(p1.toString().replace(Regex("@.*"), "@(ハッシュ値)"))
println(d1)
println("--- equals (==)")
println(p1 == p2)
println(d1 == d2)
println("--- 参照の比較 (===)")
println(p1 === p2)
println(d1 === d2)
println("--- hashCode が等しいか")
println(p1.hashCode() == p2.hashCode())
println(d1.hashCode() == d2.hashCode())
println("--- Set に入れたときの件数")
println(setOf(p1, p2).size)
println(setOf(d1, d2).size)
println("--- copy")
val d3 = d1.copy(age = 21)
println(d3)
println(d1)
println("--- componentN / 分解宣言")
val (name, age) = d1
println("$name / $age / ${d1.component1()} / ${d1.component2()}")
}
実行結果:
--- toString
PlainUser@(ハッシュ値)
DataUser(name=taro, age=20)
--- equals (==)
false
true
--- 参照の比較 (===)
false
false
--- hashCode が等しいか
false
true
--- Set に入れたときの件数
2
1
--- copy
DataUser(name=taro, age=21)
DataUser(name=taro, age=20)
--- componentN / 分解宣言
taro / 20 / taro / 20
各ブロックの1行目が class、2行目が data class です。== と hashCode、Set に入れたときの件数がそろって変わる点に注目してください。equals と hashCode はセットで生成されるので、Set や Map のキーに入れても中身で判定されます。
ふつうの class で copy や分解宣言を書くと、コンパイルエラーになります。
class PlainUser(val name: String, val age: Int)
fun main() {
val p1 = PlainUser("taro", 20)
val p2 = p1.copy(age = 21)
val (name, age) = p1
}
cmperr.kt:5:17: error: unresolved reference 'copy' on receiver of type 'PlainUser'.
val p2 = p1.copy(age = 21)
^^^^
cmperr.kt:6:23: error: destructuring of type 'PlainUser' requires operator function 'component1()'.
val (name, age) = p1
^^
cmperr.kt:6:23: error: destructuring of type 'PlainUser' requires operator function 'component2()'.
val (name, age) = p1
^^
自動生成される5つの関数
| 関数 | 役割 | 例 | 出力 |
|---|---|---|---|
equals() |
中身が等しいかを判定(== で呼ばれる) |
d1 == d2 |
true |
hashCode() |
equals と対になるハッシュ値 | d1.hashCode() == d2.hashCode() |
true |
toString() |
クラス名(名前=値, ...) の文字列 |
println(d1) |
DataUser(name=taro, age=20) |
copy() |
一部だけ変えた新しいインスタンスを作る | d1.copy(age = 21) |
DataUser(name=taro, age=21) |
componentN() |
宣言順に値を取り出す(分解宣言で使われる) | val (name, age) = d1 |
taro / 20 |
data class の基本の使い方
既定値、名前付き引数、分解宣言、リストとの組み合わせ、検証つきの data class をまとめて確かめます。
data class Item(val name: String, val price: Int = 0, val tags: List<String> = emptyList())
data class Email(val value: String) {
init {
require("@" in value) { "メールアドレスの形式ではありません: $value" }
}
}
data class Secret(val user: String, val password: String) {
override fun toString() = "Secret(user=$user, password=****)"
}
fun main() {
// 既定値と名前付き引数
println(Item("pen"))
println(Item(name = "note", tags = listOf("paper")))
// 分解宣言は「宣言順」。名前ではなく位置で決まる
val (a, b) = Item("pen", 120)
println("$a $b")
val (_, price) = Item("pen", 120)
println(price)
// リストやマップと組み合わせる
val items = listOf(Item("pen", 120), Item("note", 300), Item("pen", 120))
println(items.distinct())
println(items.groupBy { it.name }.mapValues { it.value.size })
for ((name, p) in items.take(2)) println("$name=$p")
println(items.sortedWith(compareBy({ it.price }, { it.name })).first())
// init での検証は copy のときも動く
val e = Email("a@example.com")
try {
e.copy(value = "broken")
} catch (ex: IllegalArgumentException) {
println(ex.message)
}
// toString だけ上書き
println(Secret("taro", "p@ss"))
// nullable を含む等価判定
data class N(val x: String?)
println(N(null) == N(null))
// Double の NaN と -0.0
data class F(val v: Double)
println(F(Double.NaN) == F(Double.NaN))
println(F(0.0) == F(-0.0))
}
実行結果:
Item(name=pen, price=0, tags=[])
Item(name=note, price=0, tags=[paper])
pen 120
120
[Item(name=pen, price=120, tags=[]), Item(name=note, price=300, tags=[])]
{pen=2, note=1}
pen=120
note=300
Item(name=pen, price=120, tags=[])
メールアドレスの形式ではありません: broken
Secret(user=taro, password=****)
true
true
false
押さえておきたい点を、出力の順に説明します。
- 既定値を付けておくと、必要な引数だけ書けます。
Item("pen")は price=0、tags=[] になりました。 - 分解宣言は「名前」ではなく「宣言順」で決まります。
val (a, b) = Item("pen", 120)のaは1番目の name、bは2番目の price です。変数名を(price, name)と書いても入れ替わりません。使わない位置は_で飛ばせます。 - equals があるので
distinct()やgroupByがそのまま使えます。 同じ中身のItem("pen", 120)が2件あっても、distinct()で1件になりました。 initに書いた検証は copy のときも動きます。 copy も主コンストラクタを通るためで、e.copy(value = "broken")はメッセージ付きで止まりました。- toString だけを自分で書くこともできます。 自分で書いた関数は自動生成で上書きされません。パスワードのように表示したくない値を伏せるときに使えます。
- null を含む比較も安全で、
N(null) == N(null)は true です。Double はNaNどうしが等しく(true)、0.0と-0.0は等しくない(false)という結果になりました。
copy の使い方:一部だけ変えた新しいインスタンスを作る
copy() は、元のインスタンスを変えずに、指定したプロパティだけ差し替えた新しいインスタンスを返します。プロパティを val にしておき、変更は copy で行うと、「いつの間にか値が変わっていた」という状況を避けられます。
val d3 = d1.copy(age = 21) // DataUser(name=taro, age=21)
println(d1) // DataUser(name=taro, age=20) 元はそのまま
(最初の比較コードの「--- copy」の部分です。)
copy は浅いコピー:中の MutableList は共有される
copy が複製するのは「data class の1階層ぶん」だけです。プロパティが指しているリストやオブジェクトは複製されず、同じものを指したままになります。
data class Cart(val owner: String, val items: MutableList<String>)
fun main() {
val a = Cart("taro", mutableListOf("pen"))
val b = a.copy(owner = "jiro")
b.items.add("note") // b だけ変えたつもり
println(a)
println(b)
println(a.items === b.items) // 同じリストを指している
}
実行結果:
Cart(owner=taro, items=[pen, note])
Cart(owner=jiro, items=[pen, note])
true
b にだけ "note" を足したつもりが、a にも入っています。a.items === b.items が true なので、2つのインスタンスが同じリストを見ていることが分かります。公式ドキュメントでも、copy は shallow copy(浅いコピー)で、他のオブジェクトへの参照は共有されると説明されています。
直し方:読み取り専用 List を持たせる
data class Cart(val owner: String, val items: List<String>)
fun main() {
val a = Cart("taro", listOf("pen"))
// 読み取り専用 List を持たせ、変更は「新しいリストを渡した copy」で行う
val b = a.copy(owner = "jiro", items = a.items + "note")
println(a)
println(b)
println(a.items === b.items)
// MutableList を持たせたい場合は、コピー時にリストも複製する
data class MCart(val owner: String, val items: MutableList<String>)
val m1 = MCart("taro", mutableListOf("pen"))
val m2 = m1.copy(items = m1.items.toMutableList())
m2.items.add("note")
println(m1)
println(m2)
}
実行結果:
Cart(owner=taro, items=[pen])
Cart(owner=jiro, items=[pen, note])
false
MCart(owner=taro, items=[pen])
MCart(owner=taro, items=[pen, note])
| 方法 | 書き方 | 向いている場面 |
|---|---|---|
| 読み取り専用の List を持たせる | val items: List<String> + copy(items = a.items + "note") |
基本はこちら。共有されても変更されない |
| copy のときにリストも複製する | copy(items = m1.items.toMutableList()) |
MutableList を持たせる必要があるとき |
| 入れ子の data class も copy する | a.copy(address = a.address.copy(city = "Osaka")) の形 |
入れ子の値を変えたいとき |
入れ子の data class を copy する例と、算出プロパティの例も動かしておきます。
data class Address(val city: String)
data class Member(val name: String, val age: Int, val address: Address) {
val isAdult get() = age >= 18 // 算出プロパティ(比較・表示の対象外)
}
fun main() {
val a = Member("taro", 20, Address("Tokyo"))
val b = a.copy(address = a.address.copy(city = "Osaka"))
println(a)
println(b)
println(a.isAdult)
val (x, y) = Pair("a", 1)
println("$x $y")
}
実行結果:
Member(name=taro, age=20, address=Address(city=Tokyo))
Member(name=taro, age=20, address=Address(city=Osaka))
true
a 1
プロパティを val と読み取り専用コレクションでそろえると、浅いコピーでも困る場面がなくなります。共有されているものが変更されないからです。
クラス本体に書いたプロパティは equals・toString・copy の対象外
自動生成の関数が見るのは、主コンストラクタ(クラス名のうしろのかっこ)に書いたプロパティだけです。波かっこの中に書いたプロパティは含まれません。
data class Member(val name: String) {
var age: Int = 0 // クラス本体のプロパティ
}
fun main() {
val a = Member("taro").apply { age = 20 }
val b = Member("taro").apply { age = 55 }
println(a) // age は表示されない
println(a == b) // age が違っても true
println(a.hashCode() == b.hashCode())
val c = a.copy()
println(c.age) // copy で age は引き継がれない
}
実行結果:
Member(name=taro)
true
true
0
age が 20 と 55 で違っていても == は true、toString にも age は出ません。copy() した結果の age は 0(初期値)に戻っています。
| やりたいこと | 書く場所 |
|---|---|
| 比較・表示・copy に含めたい | 主コンストラクタ:data class Member(val name: String, val age: Int) |
| 比較に含めたくない補助的な値(キャッシュなど) | クラス本体 |
| 他のプロパティから計算できる値 | クラス本体に val isAdult get() = age >= 18 のような算出プロパティ |
「比較に入れたくない値をあえて本体に置く」という使い方もできますが、copy で引き継がれない点には気を付けます。
配列プロパティは equals が中身で比較されない
Kotlin の配列(Array)は、== で比べると参照の比較になります。そのため、配列をプロパティに持つ data class は、中身が同じでも == が false になります。
data class Tags(val id: Int, val values: Array<String>)
data class TagList(val id: Int, val values: List<String>)
fun main() {
val a = Tags(1, arrayOf("x", "y"))
val b = Tags(1, arrayOf("x", "y"))
println(a == b) // false
println(a.toString().replace(Regex("@[0-9a-f]+"), "@(ハッシュ値)"))
println(a.values.contentEquals(b.values)) // true
val c = TagList(1, listOf("x", "y"))
val d = TagList(1, listOf("x", "y"))
println(c == d) // true
println(c)
}
実行結果:
false
Tags(id=1, values=[x, y])
true
true
TagList(id=1, values=[x, y])
1行目が配列を持つ Tags どうしの比較(false)で、contentEquals で配列だけ比べると true です。同じ内容を List で持つ TagList は == が true になります。なお、toString のほうは配列の中身が [x, y] と表示されました。
直し方
いちばん手軽なのは、配列の代わりに List を使うことです。配列を使う必要がある場合は、equals と hashCode を自分で書きます。
data class Tags(val id: Int, val values: Array<String>) {
override fun equals(other: Any?): Boolean =
other is Tags && id == other.id && values.contentEquals(other.values)
override fun hashCode(): Int = 31 * id + values.contentHashCode()
override fun toString(): String = "Tags(id=$id, values=${values.contentToString()})"
}
fun main() {
val a = Tags(1, arrayOf("x", "y"))
val b = Tags(1, arrayOf("x", "y"))
println(a == b)
println(a)
println(setOf(a, b).size)
}
実行結果:
true
Tags(id=1, values=[x, y])
1
equals を書いたら hashCode も必ずそろえます。片方だけだと、Set や Map に入れたときに同じ中身が別物として扱われます。
HashMap のキーに var を持つ data class を使うとどうなるか
data class の hashCode はプロパティの値から計算されます。キーとして入れたあとでプロパティを書き換えると、ハッシュ値が変わり、同じインスタンスでも取り出せなくなります。
data class Key(var code: String)
fun main() {
val key = Key("A")
val map = hashMapOf(key to "りんご")
println(map[key]) // りんご
key.code = "B" // キーにしたあとで書き換える
println(map[key]) // 同じインスタンスでも取り出せない
println(map[Key("A")]) // 元の値でも取り出せない
println(map[Key("B")]) // 新しい値でも取り出せない
println(map.size) // 要素は残っている
println(map)
println(map.containsKey(key))
val set = hashSetOf(Key("X"))
val k = set.first()
k.code = "Y"
println(set.contains(k)) // false
}
実行結果:
りんご
null
null
null
1
{Key(code=B)=りんご}
false
false
要素は1件残っていて、表示も {Key(code=B)=りんご} と見えているのに、map[key]、map[Key("A")]、map[Key("B")] のどれでも取り出せません。HashMap は「入れたときのハッシュ値」で置き場所を決めているため、あとから値が変わると探す場所がずれるのです。HashSet でも同じで、set.contains(k) が false になりました。
直し方:キーにする data class は val だけにする
data class Key(val code: String)
fun main() {
val key = Key("A")
val map = hashMapOf(key to "りんご")
val newKey = key.copy(code = "B") // 元のキーは変わらない
println(map[key])
println(map[Key("A")])
println(map[newKey])
}
実行結果:
りんご
りんご
null
| 用途 | プロパティ | 理由 |
|---|---|---|
| Map のキー、Set の要素 | val のみ | ハッシュ値が変わらない |
| 値として持ち回る | val を基本に、変更は copy | 変更の箇所が追いやすい |
| 入力フォームのように何度も書き換える | var も選べる | キーや Set には入れない前提で |
data class にできる条件と継承のルール
公式ドキュメントでは、data class の条件として次が挙げられています。
- 主コンストラクタに引数が1つ以上ある
- 主コンストラクタの引数はすべて val か var
- abstract・open・sealed・inner にはできない
実際にコンパイルして、エラー文を確かめました。
data class A()
data class B(name: String)
open data class C(val x: Int)
data class D(val x: Int)
class E(x: Int) : D(x)
abstract data class F(val x: Int)
rule.kt:1:13: error: data class must have at least one primary constructor parameter.
data class A()
^^
rule.kt:2:14: error: primary constructor of data class must only have property ('val' / 'var') parameters.
data class B(name: String)
^^^^^^^^^^^^
rule.kt:3:1: error: modifier 'open' is incompatible with 'data'.
open data class C(val x: Int)
^^^^
rule.kt:3:6: error: modifier 'data' is incompatible with 'open'.
open data class C(val x: Int)
^^^^
rule.kt:5:19: error: this type is final, so it cannot be extended.
class E(x: Int) : D(x)
^
rule.kt:6:1: error: modifier 'abstract' is incompatible with 'data'.
abstract data class F(val x: Int)
^^^^^^^^
rule.kt:6:10: error: modifier 'data' is incompatible with 'abstract'.
abstract data class F(val x: Int)
^^^^
data class G(vararg val xs: Int)
sealed data class H(val x: Int)
rule2.kt:1:14: error: primary constructor vararg parameters are prohibited for data classes.
data class G(vararg val xs: Int)
^^^^^^^^^^^^^^^^^^
rule2.kt:2:1: error: modifier 'sealed' is incompatible with 'data'.
sealed data class H(val x: Int)
^^^^^^
rule2.kt:2:8: error: modifier 'data' is incompatible with 'sealed'.
sealed data class H(val x: Int)
^^^^
| やろうとしたこと | エラー文の要点 | 代わりの書き方 |
|---|---|---|
| 引数なしの data class | must have at least one primary constructor parameter | data object にする |
| val / var のない引数 | must only have property ('val' / 'var') parameters | val を付ける、または本体で受ける |
| open / abstract / sealed を付ける | modifier ... is incompatible with 'data' | 親を sealed interface や abstract class にする |
| data class を継承する | this type is final, so it cannot be extended | 共通部分をインターフェースにする |
| vararg の引数 | vararg parameters are prohibited for data classes | List で受ける |
data class は「継承される側」にはなれないが、「継承する側」にはなれる
data class 自身は final ですが、インターフェースの実装や、ほかのクラスの継承はできます。sealed interface と組み合わせる形がよく使われます。
sealed interface Result
data class Success(val value: String) : Result
data class Failure(val reason: String) : Result
data object Empty : Result
abstract class Shape(val label: String)
data class Circle(val r: Double) : Shape("circle")
fun show(r: Result) = when (r) {
is Success -> "OK: ${r.value}"
is Failure -> "エラー: ${r.reason}"
Empty -> "空"
}
fun main() {
println(show(Success("done")))
println(show(Failure("timeout")))
println(show(Empty))
val c = Circle(1.5)
println("$c / ${c.label}")
}
実行結果:
OK: done
エラー: timeout
空
Circle(r=1.5) / circle
when で sealed interface の全種類を並べると、else が要りません。種類を増やしたときに、対応が抜けている when をコンパイラが教えてくれます。
主コンストラクタを private にしたときの copy
ファクトリ関数(of)で値を整えてから作らせたい場合、主コンストラクタを private にします。このとき copy をそのまま使えると、整える処理を通らないインスタンスが作れてしまいます。Kotlin 2.4.20 でコンパイルすると、次の警告が出ました(抜粋)。
priv.kt:1:18: warning: non-public primary constructor is exposed via the generated 'copy()' method of the 'data' class.
The generated 'copy()' will change its visibility in future releases.
...
This will become an error in language version 2.5. See https://youtrack.jetbrains.com/issue/KT-11914.
警告のとおり @ConsistentCopyVisibility を付けると、copy もコンストラクタと同じ private になります。
@ConsistentCopyVisibility
data class Token private constructor(val value: String) {
companion object {
fun of(raw: String): Token = Token(raw.trim().lowercase())
}
}
fun main() {
val t = Token.of(" ABC ")
println(t)
val t2 = t.copy(value = " RAW ")
}
priv2.kt:11:16: error: cannot access 'fun copy(value: String = ...): Token': it is private in 'Token'.
val t2 = t.copy(value = " RAW ")
^^^^
クラスの外からの copy がコンパイルエラーになりました。外から copy を呼ぶ行を外せば、Token.of 経由のインスタンスだけが作られます。
@ConsistentCopyVisibility
data class Token private constructor(val value: String) {
companion object {
fun of(raw: String): Token = Token(raw.trim().lowercase())
}
}
fun main() {
val t = Token.of(" ABC ")
println(t)
println(t == Token.of("abc"))
}
実行結果:
Token(value=abc)
true
data object・value class・Java record との違い一覧
名前が似ているものを、実際に動かして比べました。
data class Point(val x: Int, val y: Int)
data object Loading
object PlainLoading
@JvmInline
value class UserId(val value: Int)
enum class Color { RED, GREEN }
fun main() {
println(Point(1, 2))
println(Loading)
println(PlainLoading.toString().replace(Regex("@[0-9a-f]+"), "@(ハッシュ値)"))
println(UserId(7))
println(UserId(7) == UserId(7))
println(Color.RED)
// Pair / Triple も data class
val p = Pair("a", 1)
println(p)
println(p.copy(second = 2))
}
実行結果:
Point(x=1, y=2)
Loading
PlainLoading@(ハッシュ値)
UserId(value=7)
true
RED
(a, 1)
(a, 2)
| 種類 | 宣言の例 | println の結果 | == |
copy | 分解宣言 | 向いている用途 |
|---|---|---|---|---|---|---|
| data class | data class Point(val x: Int, val y: Int) |
Point(x=1, y=2) |
中身で比較 | あり | あり | 複数の値をまとめて持つ |
| data object | data object Loading |
Loading |
1つだけなので常に同じ | なし | なし | 値を持たない状態(読み込み中、空など) |
| object | object PlainLoading |
PlainLoading@(ハッシュ値) |
1つだけなので常に同じ | なし | なし | 表示名にこだわらないシングルトン |
| value class | @JvmInline value class UserId(val value: Int) |
UserId(value=7) |
中身で比較 | なし | なし | 1つの値に型で意味を付ける(ID など) |
| enum class | enum class Color { RED, GREEN } |
RED |
同じ定数なら true | なし | なし | 決まった選択肢 |
| Pair / Triple | Pair("a", 1) |
(a, 1) |
中身で比較 | あり | あり | 一時的な2〜3個の組 |
| Java record | record Point(int x, int y) {} |
Point[x=1, y=2] |
中身で比較 | なし | なし(p.x() で読む) |
Java 側で値をまとめる |
data object と value class に copy や分解宣言を書くと、コンパイルエラーになります。
data object Loading
@JvmInline
value class UserId(val value: Int)
fun main() {
Loading.copy()
val (a) = UserId(1)
UserId(1).copy(value = 2)
}
kerr.kt:7:13: error: unresolved reference 'copy'.
Loading.copy()
^^^^
kerr.kt:8:15: error: destructuring of type 'UserId' requires operator function 'component1()'.
val (a) = UserId(1)
^^^^^^^^^
kerr.kt:9:15: error: unresolved reference 'copy' on receiver of type 'UserId'.
UserId(1).copy(value = 2)
^^^^
公式ドキュメントでは、data object に copy がない理由を「シングルトンのコピーが作れてしまうと、1つだけという前提に合わなくなるため」、componentN がない理由を「取り出すデータプロパティを持たないため」と説明しています。また、data object には equals と hashCode を自分で書けないとも書かれています。
Java record と並べて確かめる
Java 21 で record を動かした結果です。
import java.util.*;
public class Rec {
record Point(int x, int y) {}
record Tags(int id, String[] values) {}
public static void main(String[] a) {
var p = new Point(1, 2);
System.out.println(p);
System.out.println(p.equals(new Point(1, 2)));
System.out.println(p.x());
var q = new Point(p.x(), 5); // copy に相当する機能は自分で書く
System.out.println(q);
System.out.println(new Tags(1, new String[]{"x"}).equals(new Tags(1, new String[]{"x"})));
}
}
実行結果(java Rec.java):
Point[x=1, y=2]
true
1
Point[x=1, y=5]
false
| 項目 | Kotlin data class | Java record |
|---|---|---|
| toString の形 | Point(x=1, y=2) |
Point[x=1, y=2] |
| 値の読み方 | p.x |
p.x() |
| 一部だけ変えたコピー | p.copy(y = 5) |
new Point(p.x(), 5) と自分で書く |
| プロパティの変更 | val / var を選べる | 変更できない |
| 配列を持たせたときの equals | false(参照の比較) | false(最後の行の出力) |
| 既定値・名前付き引数 | あり | なし |
配列の比較が中身で行われない点は、Kotlin の data class も Java の record も同じでした。どちらも List を使うと中身で比較されます。
よくあるつまずきと直し方の一覧
| 起きること | 原因 | 直し方 |
|---|---|---|
中身が違うのに == が true |
違うのがクラス本体のプロパティ | 比較したい値を主コンストラクタへ移す |
| copy したら片方の変更がもう片方にも反映 | 浅いコピーで MutableList を共有 | 読み取り専用 List にする、copy 時に複製する |
中身が同じなのに == が false |
配列プロパティ | List にする、equals / hashCode を書く |
| Map から取り出せない | キーの var を入れたあとで変更した | キーは val だけにして copy で作り直す |
| 分解宣言で値が入れ替わる | 宣言順と変数の順が違う | 宣言順に合わせる、プロパティ名で読む |
| toString にパスワードなどが出る | 全プロパティが自動で表示される | toString を自分で書く |
| open を付けたらエラー | data class は継承される側になれない | sealed interface を親にする |
チェックリスト
- [ ] 比較・表示に使いたい値は、すべて主コンストラクタに書いた
- [ ] プロパティは val を基本にして、変更は copy で行っている
- [ ] コレクションは読み取り専用(List・Set・Map)で持たせている
- [ ] 配列ではなく List を使っている
- [ ] Map のキーや Set の要素にする data class に var がない
- [ ] 表示したくない値がある場合は toString を上書きしている
よくある質問
Q. class と data class はどちらを使えばよいですか?
A. 「中身が同じなら同じもの」として扱いたいデータには data class、状態や振る舞いが中心で「同じ中身でも別物」として扱いたいものには class が合います。迷ったら、== で比べたときにどうなってほしいかを考えると決めやすくなります。
Q. copy は深いコピーですか?
A. 浅いコピーです。プロパティが指すリストやオブジェクトは複製されず共有されます。読み取り専用の List を持たせておけば共有されても変更されないので、通常はそれで十分です。MutableList を持たせる場合は copy(items = items.toMutableList()) のように複製を渡します。
Q. data class は継承できますか? A. data class を親にすることはできません(open を付けるとコンパイルエラー)。data class がインターフェースを実装したり、abstract class を継承したりすることはできます。種類を分けたいときは、sealed interface を親にして data class と data object を並べる形が使いやすくなります。
Q. equals や toString を自分で書いてもよいですか? A. 書けます。自分で書いたものが優先され、残りは自動生成されます。equals を書いたら hashCode もそろえてください。なお、公式ドキュメントでは、componentN と copy は自分で実装できないと説明されています。
まとめ
- data class は equals・hashCode・toString・copy・componentN を自動で用意する。同じ中身なら
==が true、Set でも1件になる - 対象は主コンストラクタのプロパティだけ。クラス本体のプロパティは比較・表示・copy に含まれない
- copy は浅いコピー。MutableList は共有されるので、読み取り専用 List を持たせるか複製を渡す
- 配列プロパティは
==が参照の比較になる。List にするか、equals と hashCode を書く - Map のキーにする data class は val だけにする。入れたあとで値を変えると取り出せなくなる
- data class は継承される側になれない。sealed interface と組み合わせると種類分けがしやすい
- 値のない状態は data object、1つの値に意味を付けるなら value class。copy と分解宣言は data class だけ
参考・出典
- Kotlin 公式ドキュメント「Data classes」 https://kotlinlang.org/docs/data-classes.html
- Kotlin 公式ドキュメント「Object declarations and expressions」(Data objects) https://kotlinlang.org/docs/object-declarations.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Kotlin data classの使い方|copy・equalsとclass差」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。