Kotlin data class について思ったこと

本記事は [Kotlin Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kotlin) 11日目の記事です。

今年は久しぶりに個人的に記事を書いてみました。
最終日ですが、なんとか書いたので空いてる日に投稿します。

ここ何年かは他の人が書いたコードを見る時間の方が多く感じるほどレビューをする機会が増えていて、その中で思ったKotlinの`data class`ついて改めて書いてみます。

# Kotlin data class とは

公式ドキュメントはこちらです

https://kotlinlang.org/docs/data-classes.html

書いてあることそのままではありますが、こちらにもまとめておきます

## 便利なところ

役立つ以下のメソッドが自動生成されます。

- `equals` / `hashCode`
- `toString`
- `componentN`
- `copy`


また、分割宣言ができるのも便利です。
標準ライブラリとして提供されている `Pair` / `Triple` も data class ですが、以下のように書くことができます。

```kotlin
fun main(){
    val (name, age) = Pair("sada", 10)    
    println("${name}は${age}才です")    // sadaは10才です
}
```


## 制約

データクラスには以下のような制約があります。 

- プライマリーコンストラクタには少なくとも１つのパラメータが必要です
- すべてのプライマリ コンストラクター パラメーターは、val または var として宣言する必要があります
- `abstract` / `open` / `sealed` / `inner` にすることはできません

# アンチパターン

data class はすごい便利なので、ついデータを持つクラスとして安易に作りがちです。

ただし、data class として定義するとコンストラクタでインスタンス生成が自由にできてしまいます。（`copy`メソッドも同様です。）

そのため、簡単に不正な状態が作れてしまうことがあります。（生焼けオブジェクト）

具体的なコードを例にしたいと思います

```kotlin
data class Amount(
    val taxExcludedPrice: Int,
    val taxRate: Float,
) {
    val taxAmount: Float
    	get() = (taxExcludedPrice * taxRate)

    val taxIncludePrice: Int
      	get() = (taxExcludedPrice + taxAmount).toInt()
}
```

上記のような金額クラスがあったとします。
（あくまで例なので、細かいところがざっくりなのはご了承ください🙇）

ここで100円（消費税10%）のものを計算してみると以下のようになります。

```kotlin
fun main(){
    val amount = Amount(100, 0.1f)
    println(amount.taxIncludePrice)      // 110
}
```

ぱっと見大丈夫そうですが、以下のようにしたらどうでしょう？

```kotlin
fun main(){
    val amount = Amount(100, -0.1f)
    println(amount.taxIncludePrice)      // 90
}
```

金額が減ってしまいましたねw
このように簡単に不正な状態がつくれてしまいます。

ではどうするとよいでしょうか？

例えば以下のようなFactoryメソッドを作ってみます。

```kotlin
fun main(){
    val amount = createAmount(100, -1.0f)  // Exception in thread "main" java.lang.IllegalArgumentException: tax rate is invalid. -1.0
    println(amount.taxIncludePrice)
}

fun createAmount(amount: Int, rate: Float): Amount {
    if (rate < 0.0f) {
        throw IllegalArgumentException("tax rate is invalid. $rate")
    }
    return Amount(amount, rate)
}
```

とりあえず良さそうにも見えます。
しかし、これではAmountを生成する箇所すべてに同じようなロジックを作る必要が出てきます。
これでは低凝集な状態になってしまいます。

また、インスタンス生成するところが防げても`copy`メソッドを使えば、結局不正な状態が作れてしまいます。

```kotlin
fun main(){
    val amount = createAmount(100, 0.1f)
    val copyAmount = amount.copy(taxRate = -0.1f)
    println(copyAmount.taxIncludePrice)          // 90
}

fun createAmount(amount: Int, rate: Float): Amount {
    if (rate < 0.0f) {
        throw IllegalArgumentException("tax rate is invalid. $rate")
    }
    return Amount(amount, rate)
}
```

これらの課題を整理すると以下の点があるかと思います

- コンストラクタで自由にインスタンス生成ができるのを防ぎたい
- copyメソッドは使えないようにしたい
- 低凝集にならないようにしたい

それぞれの解決策としては以下のようなものが考えられます。

- `private constructor`にする
- `data class`をやめる
- 不正値のチェックはクラス内に定義する

ではコードにしてみましょう

```kotlin
class Amount private constructor(
    val taxExcludedPrice: Int,
    val taxRate: Float,
) {
    companion object {
        fun create(taxExcludedPrice: Int, taxRate: Float): Amount {
            if (isValidRate(taxRate).not()) {
                throw IllegalArgumentException("tax rate is invalid. $taxRate")
            }
            return Amount(taxExcludedPrice, taxRate)
        }

        fun isValidRate(rate: Float): Boolean {
            return (rate >= 0.0f)
        }
    }

    val taxAmount: Float
    	get() = (taxExcludedPrice * taxRate)

    val taxIncludePrice: Int
      	get() = (taxExcludedPrice + taxAmount).toInt()
}

fun main(){
    val amount1 = Amount.create(100, 0.1f)
    println(amount1.taxIncludePrice)        // 110

    val amount2 = Amount.create(100, -0.1f) // Exception in thread "main" java.lang.IllegalArgumentException: tax rate is invalid. -0.1
    println(amount2.taxIncludePrice)
}
```

このようにすることで、安全かつ高凝集になります。

※`taxRate`を`Float`ではなく標準税率と軽減税率を表すタイプコード（`enum class`など）を用意すれば十分じゃね？ってご意見もあるかとは思います。本当にごもっともな話ですが、本記事の趣旨とは少し異なるので、言わないでください。あくまでコード例と捉えていただければと。


# `data class`の使い所

では`data class`を使わない方が良いのかというとそうではありません。
使い所を間違えなければ良いと思います。

例えば

- Viewにバインドするためだけに利用するDTO（Data Transfer Object）
- APIレスポンスのJSONをパースするための入れ物

といったようなデータ保持や転送のみを目的としている場合は有用ではないでしょうか。

# まとめ

`data class`はもちろん便利だけど、用途を考えずに多用すると、気づいたら品質の悪いコードができてしまうことがあります。
これは`data class`に限った話ではなくコードすべてに言えることだとは思います。

より良いコードを書くために、この記事が何かしらの助けになったら幸いです。
最後まで読んでいただきありがとうございました！