KotlinのData ClassとCopy関数

1. 概要

data classはKotlinの機能の一つで、JavaでのDTO、model、value objectといったデータだけを持つクラスの実装を、はるかに簡潔に書くことができます。この記事では、data classに対してKotlinが自動生成するもの、copy()関数で一部のフィールドを修正したコピーを作成する方法、そして値比較の誤り、data classでのvarの使用、データクラス継承といった一般的な落とし穴について学んでいきます。

2. Data Classとは

クラスにdataキーワードを付けて宣言すると、Kotlinのコンパイラは自動的にプライマリコンストラクタで宣言されたプロパティに基づいて、いくつかの関数を生成します。

data class Customer(
    val id: Long,
    val name: String,
    val email: String
)

わずか4行のコードで、Kotlinは以下を自動生成します:

  • id、name、emailに基づいたequals() / hashCode()
  • Customer(id=1, name=..., email=...)形式のtoString()
  • いくつかのフィールドを変更できるコピーを作成するcopy()
  • 分割宣言(destructuring declaration)をサポートするcomponentN()

Javaと比較すると、constructorやgetter、equals、hashCode、toStringを書く必要があり、同じ機能に約40-50行かかります。

2.1. toString()のデフォルト実装

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    println(customer)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)

自動生成されたtoString()はログやデバッグの際に非常に便利で、自分で書く必要がありません。

3. equals()とhashCode()による値比較

data classと通常のクラスの最大の違いは、equals()が値による比較(structural equality)を行い、参照による比較ではないということです。

fun main() {
    val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
    val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

    println(c1 == c2)   // 値による比較
    println(c1 === c2)  // 参照による比較
}
true
false
  • ==はequals()を呼び出し、異なる参照であっても同じフィールドを持つ2つのオブジェクトは等しいとみなされます。
  • ===は2つの変数がメモリ内の同じオブジェクトを指すかどうかをチェックします。

注意: 通常のクラス(data classではない場合)では、==はデフォルトで参照による比較を行い、===と同じように動作します。ただし、equals()を自分でoverrideすれば異なります。

hashCode()もプロパティに基づいて生成されるため、data classをHashMapのキーやHashSetの要素として安全に使用できます。

fun main() {
    val set = hashSetOf(
        Customer(1, "An Nguyen", "an@nonladev.com"),
        Customer(1, "An Nguyen", "an@nonladev.com")
    )
    println(set.size)
}
1

値が完全に同じ2つのオブジェクトは同じものと見なされ、HashSetは1つの要素のみを保持します。

4. copy()関数 — 不変なコピーの作成

プロパティにvalを使用する場合、Customerは不変(immutable)です。作成後、フィールドを直接変更することはできません。いくつかの値を“変更”するため、Kotlinはcopy()関数を生成します。これは新しいオブジェクトを作成し、渡されなかったフィールドはそのまま保持されます。

fun main() {
    val original = Customer(1, "An Nguyen", "an@nonladev.com")
    val updated = original.copy(email = "an.new@nonladev.com")

    println(original)
    println(updated)
}
Customer(id=1, name=An Nguyen, email=an@nonladev.com)
Customer(id=1, name=An Nguyen, email=an.new@nonladev.com)
  • originalは変更されません。これはimmutabilityの本質です。
  • updatedは新しいオブジェクトで、emailだけが異なり、他のフィールドは元のままコピーされます。

これはUI(Compose、MVI)での状態操作やビジネスロジックでエンティティを更新する際に、直接的なmutationを避けるのに非常に一般的なパターンです。

4.1. 名前付き引数を使ったcopy()

複数のパラメータを同時に渡すことができ、名前付き引数(named arguments)を使って順番の間違いを避けることができます:

val promoted = original.copy(name = "An Nguyen (VIP)", email = "vip@nonladev.com")

名前付き引数を使わずに位置による指定をした場合、クラスに同じ型のStringフィールドが多くある時にコードが混乱しやすくなります。

5. Shallow Copy — Mutableなオブジェクトを含む場合の落とし穴

copy()はshallow copyのみを作成します。つまり、フィールドの参照をコピーしますが、内部の複雑なオブジェクトのdeep copyは行いません。

data class Order(
    val id: Long,
    val items: MutableList<String>
)

fun main() {
    val order1 = Order(1, mutableListOf("Book"))
    val order2 = order1.copy()

    order2.items.add("Pen")

    println(order1.items) // 影響を受けた!
    println(order2.items)
}
[Book, Pen]
[Book, Pen]
  • order2 = order1.copy()は新しいOrderオブジェクトを作成しますが、フィールドitemsは同じMutableListを指しています。
  • order2.itemsを修正するとorder1.itemsも変わります。これはdata class内でmutableなcollectionを使う場合に起こりやすいエラーです。

解決方法: immutableなListを使う(toList()など)か、コンストラクタ内でcollectionを自分でコピーします。

data class Order(
    val id: Long,
    val items: List<String>
)

fun main() {
    val order1 = Order(1, listOf("Book"))
    val order2 = order1.copy(items = order1.items + "Pen")

    println(order1.items)
    println(order2.items)
}
[Book]
[Book, Pen]

immutableなList<String>を使い、演算子+で新しいリストを作成して、mutationする代わりに、data classのimmutabilityの本質を保ちます。

注意: 本当のdeep copy(ネストされたdata classが他のdata classを含む場合)が必要な場合、再帰的にコピーロジックを自分で書く必要があります。Kotlinはデフォルトではdeep copyを生成しません。

6. 分割宣言(Destructuring Declaration)

自動生成されたcomponentN()のおかげで、data classを複数の個別変数に分割宣言することができます:

fun main() {
    val customer = Customer(1, "An Nguyen", "an@nonladev.com")
    val (id, name, email) = customer

    println("$id - $name - $email")
}
1 - An Nguyen - an@nonladev.com

変数の順番はプライマリコンストラクタ内のプロパティの順番に対応し、名前ではありません。プロパティ宣言の順番を変更すると分割宣言が間違ってしまいます。リファクタリング時には注意が必要です。

注意: 分割宣言は少ないフィールド(2-3個)を持つdata classでのみ使うべきです。多くのフィールドを持つクラスの場合は、プロパティ名を直接使用してコードを読みやすくします。

7. Data Classの制限

7.1. 継承できない

data classは別のdata classを継承することはできません(openとdataを組み合わせてフィールドの自動継承はできません):

// コンパイルエラーになります:
// data class VipCustomer(val tier: String) : Customer(...)

Kotlinはdata classがinterfaceを実装することを許可し、またはプライマリコンストラクタにプロパティがない通常のクラス(non-data)を継承することのみを許可します。理由は、自動生成されたequals()/copy()は複数レベルのフィールド継承がある場合、正しく定義することが非常に難しいからです。

一般的な解決方法: 継承ではなくcomposition を使用します。

data class VipCustomer(
    val customer: Customer,
    val tier: String
)

7.2. プライマリコンストラクタは最低1つのプロパティが必要

// エラー: data classはプライマリコンストラクタに最低1つのパラメータが必要です
// data class Empty

7.3. プライマリコンストラクタ内のプロパティのみがカウントされます

data class Product(val name: String) {
    var discount: Int = 0
}

fun main() {
    val p1 = Product("Laptop").apply { discount = 10 }
    val p2 = Product("Laptop").apply { discount = 20 }

    println(p1 == p2) // true, discountはequals()に含まれません
}
true

discountはbodyで宣言されており、プライマリコンストラクタ内にないため、equals()、hashCode()、toString()、copy()に含まれません。これは混乱しやすいエラーです。比較に重要なフィールドの場合は、プライマリコンストラクタに含める必要があります。

8. JUnit 5とAssertJでのテスト

必要な依存関係(Gradle Kotlin DSL):

testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.assertj:assertj-core:3.26.0")

copy()とequals()の動作をテストするテストコードを書きます:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class CustomerTest {

    @Test
    fun `copy should keep unchanged fields and update specified ones`() {
        val original = Customer(1, "An Nguyen", "an@nonladev.com")

        val updated = original.copy(email = "an.new@nonladev.com")

        assertThat(updated.id).isEqualTo(original.id)
        assertThat(updated.name).isEqualTo(original.name)
        assertThat(updated.email).isEqualTo("an.new@nonladev.com")
        assertThat(original.email).isEqualTo("an@nonladev.com")
    }

    @Test
    fun `two customers with same fields should be equal`() {
        val c1 = Customer(1, "An Nguyen", "an@nonladev.com")
        val c2 = Customer(1, "An Nguyen", "an@nonladev.com")

        assertThat(c1).isEqualTo(c2)
        assertThat(c1).isNotSameAs(c2)
    }
}
  • isEqualToは値による比較をチェックし、data classのequals()の本質と一致しています。
  • isNotSameAsは、c1とc2が値として等しい場合でも、メモリ内の2つの異なるオブジェクトであることを確認しています。
  • 最初のテストはcopy()が正しく動作することを確認します。指定されなかったフィールドは保持され、指定されたフィールドが更新され、元のオブジェクトは変わりません。

9. Data Classを使うべきでない場合

  • JPA/Hibernateエンティティ: equals()/hashCode()がすべてのフィールド(persistする前に生成されるidのようなフィールドを含む)に基づいているため、HashSetでの使用やDB保存済み/未保存エンティティの比較時に問題が生じやすいです。エンティティに対して手動でequals()/hashCode()をidに基づいて書くべきです。
  • 複雑なビジネスロジックを持つクラス: data classはdata holderのためのものであり、クラスが多くの動作や多段階の継承が必要な場合には適切ではありません。
  • 多くのネストされたmutableフィールドを持つメモリ最適化クラス: shallow copyが制御困難なリスクをもたらす場合、builderパターンまたは通常のクラスの使用を検討してください。

10. まとめ

data classはKotlinでDTO/modelを扱う際にboilerplate codeを除去するのに役立ちます。equals()、hashCode()、toString()、copy()を自動生成し、分割宣言をサポートします。copy()関数はimmutabilityを保つための強力なツールですが、shallow copyのみであることを忘れずに。内部にcollectionやmutableなオブジェクトを含むフィールドに注意が必要です。JPA entityや複雑なビジネスロジックを持つクラスに対してdata classを使うことを避け、equals()とcopy()が期待通りに動作するよう、重要なフィールドは常にプライマリコンストラクタに含めてください。

kotlindata-classcopyimmutabilitykotlin-basics