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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?