この記事はKotlin Advent Calendar 2021
の7日目の記事になりました。
前書き
data class
のcopy
関数の不都合な点に関しては幾つかの場所で聞いたことが有り、自分でも実感していましたが、Qiita
やZenn
といったプラットフォームの中で言及されている記事を見つけられなかったため、執筆しました。
なお、Kotlin
のバージョンとしては1.6.10
を想定しています。
data classについて
Kotlin
では、class
をdata class
として定義することで、equals
/hashCode
、toString
といった関数が適切に実装されたクラスを簡単に作ることができます。
公式ドキュメントでは以下のように説明されています。
プライマリコンストラクタで宣言されたすべてのプロパティから、コンパイラは自動的に次のメンバを推論します:
- equals() / hashCode() のペア、
- "User(name=John, age=42)" 形式の toString() 、
- 宣言した順番でプロパティに対応する componentN() 関数、
- copy() 関数(下記参照)。
- これらの機能のいずれかが明示的にクラス本体に定義されているか、基本型から継承されている場合は、生成されません。
data class User(val name: String, val age: Int)
特にequals
/hashCode
を自前で実装するのは手間であるため、それらを生成してくれるdata class
はKotlin
を書く上では半ば必須と言える機能です。
一方、ここで生成されるcopy
関数には不都合な点が有ります。
copy関数の不都合な点
例えば、User
クラスには「名前は性・名を半角スペース区切りで結合したものに制限する。このため、ファクトリ関数以外では生成されたくない」というようなルールが有ったとします。
以下のサンプルコードでは、constructor
がprivate
になっているため、このルールを達成できているように見えます。
data class User private constructor(val name: String, val age: Int) {
companion object {
// ファクトリ関数
fun of(firstName: String, lastName: String, age: Int) =
User("$firstName $lastName", age)
}
}
一方、以下のサンプルコードの通り、実際にはcopy
関数によってこのルールを突破することができてしまいます。
// ファクトリ関数で生成する際には必ず半角スペース区切りで結合した文字列が出力される
val originalUser = User.of("foo", "bar", 20)
println(originalUser.name) // -> foo bar
// copy時にはルールを無視した値を設定できる!
val copyUser = originalUser.copy(name = "hoge")
println(copyUser.name) // -> hoge
また、このcopy
関数はdata class
内でoverride
することもできません。
画像のように、Conflicting overloads: public final fun copy(...
というようなエラーになってしまいます。
つまり、data class
にする限り、現状では通常の方法でcopy
関数を無効化することはできません。
補足: Intellij IDEAでの警告について
紹介したサンプルのように、data class
でprimary constructor
をprivate
にした場合、Intellij IDEA
からはPrivate primary constructor is exposed via the generated 'copy()' method of a 'data' class.
という警告が発せられます。
copy関数への対処
次に、自分の知っている範囲で、Kotlin
でcopy
関数に対処する方法を3つ紹介します1。
ただし、どの方法も欠点が有るため、状況によってはcopy
関数による問題を無視してしまうことも手かなと個人的には考えています。
普通のclassとして定義する
Intellij IDEA
を使っているなら、equals
/hashCode
といった関数も手動で生成することができるため、思い切ってdata class
をやめてしまうことも考えられます。
class User(val name: String, val age: Int) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as User
if (name != other.name) return false
if (age != other.age) return false
return true
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + age
return result
}
}
ただし、この方法では、コードが冗長になる、フィールドを追加した場合に各関数を更新し忘れる危険が出るといった問題は生じます。
補足: Kotlinのコードにlombokは適用できない
lombok
のEqualsAndHashCode
アノテーション等を適用できないかも一応確認してみましたが、残念ながらKotlin
のコードに対して適用する方法は提供されていないようでした。
sealed classでラップする2
sealed class
は、同一パッケージ内でしか継承できないabstract class
です。
同じ制約を持つinterface
として、sealed interface
も存在します(詳しくは公式ドキュメントをご覧ください)。
以下のように、User
クラスはsealed class
/interface
として定義し、実体はprivate
なdata class
として定義すれば、外部からはUser
クラスしか見ることができません。
このため、ファクトリ関数以外からの生成や、copy
関数を呼び出すことができなくなります。
また、通常のabstract class
やinterface
と異なり、外部で継承してインスタンス化することもできません。
sealed interface User {
val name: String
val age: Int
companion object {
// ファクトリ関数
fun of(firstName: String, lastName: String, age: Int): User =
UserImpl("$firstName $lastName", age)
}
private data class UserImpl(override val name: String, override val age: Int) : User
}
普通のclass
として定義する方法に比べると、コードが冗長になる問題は解消されていませんが、フィールドを追加した際に各関数の更新を忘れる問題に関しては回避できます。
no-copyプラグインを用いる
最後に紹介するのは、dev.ahmedmourad.nocopy.nocopy-gradle-plugin
を用いる方法です。
このプラグインを適用した上で、copy
関数を封じたいdata class
にNoCopy
アノテーションを付与すると、copy
関数を呼び出すことができなくなります。
import dev.ahmedmourad.nocopy.annotations.NoCopy
@NoCopy
data class User private constructor(val name: String, val age: Int) {
companion object {
// ファクトリ関数
fun of(firstName: String, lastName: String, age: Int) =
User("$firstName $lastName", age)
}
}
val originalUser = User.of("foo", "bar", 20)
// no-copy適用後はコンパイルエラーになる
val copyUser = originalUser.copy(name = "hoge")
この方法は紹介した中で最もスマートに見えますが、以下の問題点が有ります。
-
Gradle Plugin
しか提供されていないため、Maven
では利用に手間がある - 個人運営のプロジェクトであり、特に最近はメンテナンスが滞っているように見える
-
Kotlin Compiler Plugin
は安定したAPI
ではないため、kotlinc
のバージョンアップによって壊れる可能性が有る
終わりに
この記事ではdata class
で生成されるcopy
関数の不都合な点についてまとめました。
生成ルールの徹底は特にDDD
などの開発手法では重要な要素であるため、実務で頭を悩ませた方も多いのではないでしょうか。
今回紹介した問題に関しては、Kotlin
のYouTrack
でも議論が進められています。
data class
のcopy
関数に悩んだことの有る方は是非up vote
をお願いします。
-
今回の例で言うと、例えば名前の生成ルールを表す
class
を導入することでも解決できそうに見えますが、そちらでもdata class
を用いれば同じ問題が生じるため、説明を省いています。 ↩ -
こちらは
YouTrack
にて紹介されていたやり方です。 ↩