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

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

https://qiita.com/advent-calendar/2021/kotlin

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

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

[公式ドキュメント](https://dogwood008.github.io/kotlin-web-site-ja/docs/reference/data-classes.html)では以下のように説明されています。

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

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

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

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

```kotlin
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`関数によってこのルールを突破することができてしまいます。

```kotlin
// ファクトリ関数で生成する際には必ず半角スペース区切りで結合した文字列が出力される
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280230/2bc0cf94-003b-610b-1c4f-db9ac07131e2.png)

つまり、`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. `という警告が発せられます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280230/c593a910-15b2-2d91-acce-c1d37064ab8c.png)

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

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

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

```kotlin:Intellij&nbsp;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は適用できない
`lombok`の`EqualsAndHashCode`アノテーション等を適用できないかも一応確認してみましたが、残念ながら`Kotlin`のコードに対して適用する方法は提供されていないようでした。

## sealed classでラップする[^youtrack]
[^youtrack]: こちらは[`YouTrack`にて紹介されていたやり方](https://youtrack.jetbrains.com/issue/KT-11914#focus=Comments-27-4244038.0-0)です。

`sealed class`は、同一パッケージ内でしか継承できない`abstract class`です。
同じ制約を持つ`interface`として、`sealed interface`も存在します（詳しくは[公式ドキュメント](https://kotlinlang.org/docs/sealed-classes.html)をご覧ください）。

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

```kotlin
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`を用いる方法です。

https://github.com/AhmedMourad0/no-copy

このプラグインを適用した上で、`copy`関数を封じたい`data class`に`NoCopy`アノテーションを付与すると、`copy`関数を呼び出すことができなくなります。

```kotlin: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)
    }
}
```

```kotlin: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`などの開発手法では重要な要素であるため、実務で頭を悩ませた方も多いのではないでしょうか。

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

https://youtrack.jetbrains.com/issue/KT-11914