5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KotlinAdvent Calendar 2024

Day 11

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

Last updated at Posted at 2024-12-25

本記事は Kotlin Advent Calendar 2024 11日目の記事です。

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

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

Kotlin data class とは

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

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

便利なところ

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

  • equals / hashCode
  • toString
  • componentN
  • copy

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

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

制約

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

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

アンチパターン

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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メソッドを使えば、結局不正な状態が作れてしまいます。

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をやめる
  • 不正値のチェックはクラス内に定義する

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

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)
}

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

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

data classの使い所

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

例えば

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

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

まとめ

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

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

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?