本記事は 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)
}
このようにすることで、安全かつ高凝集になります。
※taxRate
をFloat
ではなく標準税率と軽減税率を表すタイプコード(enum class
など)を用意すれば十分じゃね?ってご意見もあるかとは思います。本当にごもっともな話ですが、本記事の趣旨とは少し異なるので、言わないでください。あくまでコード例と捉えていただければと。
data class
の使い所
ではdata class
を使わない方が良いのかというとそうではありません。
使い所を間違えなければ良いと思います。
例えば
- Viewにバインドするためだけに利用するDTO(Data Transfer Object)
- APIレスポンスのJSONをパースするための入れ物
といったようなデータ保持や転送のみを目的としている場合は有用ではないでしょうか。
まとめ
data class
はもちろん便利だけど、用途を考えずに多用すると、気づいたら品質の悪いコードができてしまうことがあります。
これはdata class
に限った話ではなくコードすべてに言えることだとは思います。
より良いコードを書くために、この記事が何かしらの助けになったら幸いです。
最後まで読んでいただきありがとうございました!