11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Kotlin】data classで生成されるcopy関数の不都合な点について

Posted at

この記事はKotlin Advent Calendar 2021の7日目の記事になりました。

前書き

data classcopy関数の不都合な点に関しては幾つかの場所で聞いたことが有り、自分でも実感していましたが、QiitaZennといったプラットフォームの中で言及されている記事を見つけられなかったため、執筆しました。
なお、Kotlinのバージョンとしては1.6.10を想定しています。

data classについて

Kotlinでは、classdata classとして定義することで、equals/hashCodetoStringといった関数が適切に実装されたクラスを簡単に作ることができます。

公式ドキュメントでは以下のように説明されています。

プライマリコンストラクタで宣言されたすべてのプロパティから、コンパイラは自動的に次のメンバを推論します:

  • equals() / hashCode() のペア、
  • "User(name=John, age=42)" 形式の toString() 、
  • 宣言した順番でプロパティに対応する componentN() 関数、
  • copy() 関数(下記参照)。
  • これらの機能のいずれかが明示的にクラス本体に定義されているか、基本型から継承されている場合は、生成されません。
data class User(val name: String, val age: Int)

特にequals/hashCodeを自前で実装するのは手間であるため、それらを生成してくれるdata classKotlinを書く上では半ば必須と言える機能です。
一方、ここで生成されるcopy関数には不都合な点が有ります。

copy関数の不都合な点

例えば、Userクラスには「名前は性・名を半角スペース区切りで結合したものに制限する。このため、ファクトリ関数以外では生成されたくない」というようなルールが有ったとします。
以下のサンプルコードでは、constructorprivateになっているため、このルールを達成できているように見えます。

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(...というようなエラーになってしまいます。
image.png

つまり、data classにする限り、現状では通常の方法でcopy関数を無効化することはできません。

補足: Intellij IDEAでの警告について

紹介したサンプルのように、data classprimary constructorprivateにした場合、Intellij IDEAからはPrivate primary constructor is exposed via the generated 'copy()' method of a 'data' class.という警告が発せられます。
image.png

copy関数への対処

次に、自分の知っている範囲で、Kotlincopy関数に対処する方法を3つ紹介します1
ただし、どの方法も欠点が有るため、状況によってはcopy関数による問題を無視してしまうことも手かなと個人的には考えています。

普通のclassとして定義する

Intellij IDEAを使っているなら、equals/hashCodeといった関数も手動で生成することができるため、思い切ってdata classをやめてしまうことも考えられます。

Intellij IDEAでequals/hashCodeを生成した様子
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は適用できない

lombokEqualsAndHashCodeアノテーション等を適用できないかも一応確認してみましたが、残念ながらKotlinのコードに対して適用する方法は提供されていないようでした。

sealed classでラップする2

sealed classは、同一パッケージ内でしか継承できないabstract classです。
同じ制約を持つinterfaceとして、sealed interfaceも存在します(詳しくは公式ドキュメントをご覧ください)。

以下のように、Userクラスはsealed class/interfaceとして定義し、実体はprivatedata classとして定義すれば、外部からはUserクラスしか見ることができません。
このため、ファクトリ関数以外からの生成や、copy関数を呼び出すことができなくなります。
また、通常のabstract classinterfaceと異なり、外部で継承してインスタンス化することもできません。

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 classNoCopyアノテーションを付与すると、copy関数を呼び出すことができなくなります。

no-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)
    }
}
copyを呼び出した様子
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などの開発手法では重要な要素であるため、実務で頭を悩ませた方も多いのではないでしょうか。

今回紹介した問題に関しては、KotlinYouTrackでも議論が進められています。
data classcopy関数に悩んだことの有る方は是非up voteをお願いします。


  1. 今回の例で言うと、例えば名前の生成ルールを表すclassを導入することでも解決できそうに見えますが、そちらでもdata classを用いれば同じ問題が生じるため、説明を省いています。 

  2. こちらはYouTrackにて紹介されていたやり方です。 

11
7
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
11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?