[Kotlin] 全てをopenにするプラグイン vs 必ずfinalなdata class

6 min read読了の目安(約5900字

JPAを利用するときにKotlin All Open Compiler pluginを利用します。
これを利用するとIntelliJ IDEA上で data class@Embeddable アノテーションなどをつけても怒られなくなりました。

ん?data classは必ずfinalなのではないのか?どうなっているのだ!

data class「all-openプラグインなんかに負けるわけないんだから!」

TL;DR

data class「all-openプラグインには勝てなかったょ。。。」

(openかつtoStringなどを持つクラスが出来上がります)

しかしやらないほうがいいです。

理由:

  • 将来に渡って挙動が同じとは限らない
  • クラスを拡張されたときの各メソッドの挙動が想定外になる

動機

複合主キーを表現するために作成するクラスには equals()hashCode() がほしかった(検索や並び替えを実現するため)

つまりこれができたら嬉しいじゃん!と思った

@Embeddable
data class UserBookReviewPK(
    @Column(name = "user_id") var userId: Int,
    @Column(name = "book_id") var bookId: Int
) : Serializable {
    constructor() : this(0, 0)
}

検証

まずは普通のdata class。

package foo

data class Hoge(val a: Int)

これを IntelliJ IDEA上でバイトコード変換 && Javaコードにデコンパイル すると以下のようになります

package foo;

import kotlin.Metadata;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

@Metadata(
   mv = {1, 4, 0},
   bv = {1, 0, 3},
   k = 1,
   d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0010\b\n\u0002\b\u0006\n\u0002\u0010\u000b\n\u0002\b\u0003\n\u0002\u0010\u000e\n\u0000\b\u0086\b\u0018\u00002\u00020\u0001B\r\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0002\u0010\u0004J\t\u0010\u0007\u001a\u00020\u0003HÆ\u0003J\u0013\u0010\b\u001a\u00020\u00002\b\b\u0002\u0010\u0002\u001a\u00020\u0003HÆ\u0001J\u0013\u0010\t\u001a\u00020\n2\b\u0010\u000b\u001a\u0004\u0018\u00010\u0001HÖ\u0003J\t\u0010\f\u001a\u00020\u0003HÖ\u0001J\t\u0010\r\u001a\u00020\u000eHÖ\u0001R\u0011\u0010\u0002\u001a\u00020\u0003¢\u0006\b\n\u0000\u001a\u0004\b\u0005\u0010\u0006¨\u0006\u000f"},
   d2 = {"Lfoo/Hoge;", "", "a", "", "(I)V", "getA", "()I", "component1", "copy", "equals", "", "other", "hashCode", "toString", "", "socialsv.main"}
)
public final class Hoge {
   private final int a;

   public final int getA() {
      return this.a;
   }

   public Hoge(int a) {
      this.a = a;
   }

   public final int component1() {
      return this.a;
   }

   @NotNull
   public final Hoge copy(int a) {
      return new Hoge(a);
   }

   // $FF: synthetic method
   public static Hoge copy$default(Hoge var0, int var1, int var2, Object var3) {
      if ((var2 & 1) != 0) {
         var1 = var0.a;
      }

      return var0.copy(var1);
   }

   @NotNull
   public String toString() {
      return "Hoge(a=" + this.a + ")";
   }

   public int hashCode() {
      return this.a;
   }

   public boolean equals(@Nullable Object var1) {
      if (this != var1) {
         if (var1 instanceof Hoge) {
            Hoge var2 = (Hoge)var1;
            if (this.a == var2.a) {
               return true;
            }
         }

         return false;
      } else {
         return true;
      }
   }
}

ここで build.gradleallOpen に指定してある Embeddable アノテーションを利用します。

package foo

import javax.persistence.Embeddable

@Embeddable
data class Hoge(val a: Int)

トランスパイル↓

package foo;

import javax.persistence.Embeddable;
import kotlin.Metadata;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

@Embeddable
@Metadata(
   mv = {1, 4, 0},
   bv = {1, 0, 3},
   k = 1,
   d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0010\b\n\u0002\b\u0006\n\u0002\u0010\u000b\n\u0002\b\u0003\n\u0002\u0010\u000e\n\u0000\b\u0097\b\u0018\u00002\u00020\u0001B\r\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0002\u0010\u0004J\t\u0010\u0007\u001a\u00020\u0003HÆ\u0003J\u0013\u0010\b\u001a\u00020\u00002\b\b\u0002\u0010\u0002\u001a\u00020\u0003HÆ\u0001J\u0013\u0010\t\u001a\u00020\n2\b\u0010\u000b\u001a\u0004\u0018\u00010\u0001HÖ\u0003J\t\u0010\f\u001a\u00020\u0003HÖ\u0001J\t\u0010\r\u001a\u00020\u000eHÖ\u0001R\u0014\u0010\u0002\u001a\u00020\u0003X\u0096\u0004¢\u0006\b\n\u0000\u001a\u0004\b\u0005\u0010\u0006¨\u0006\u000f"},
   d2 = {"Lfoo/Hoge;", "", "a", "", "(I)V", "getA", "()I", "component1", "copy", "equals", "", "other", "hashCode", "toString", "", "socialsv.main"}
)
public class Hoge {
   private final int a;

   public int getA() {
      return this.a;
   }

   public Hoge(int a) {
      this.a = a;
   }

   public final int component1() {
      return this.getA();
   }

   @NotNull
   public final Hoge copy(int a) {
      return new Hoge(a);
   }

   // $FF: synthetic method
   public static Hoge copy$default(Hoge var0, int var1, int var2, Object var3) {
      if (var3 != null) {
         throw new UnsupportedOperationException("Super calls with default arguments not supported in this target, function: copy");
      } else {
         if ((var2 & 1) != 0) {
            var1 = var0.getA();
         }

         return var0.copy(var1);
      }
   }

   @NotNull
   public String toString() {
      return "Hoge(a=" + this.getA() + ")";
   }

   public int hashCode() {
      return this.getA();
   }

   public boolean equals(@Nullable Object var1) {
      if (this != var1) {
         if (var1 instanceof Hoge) {
            Hoge var2 = (Hoge)var1;
            if (this.getA() == var2.getA()) {
               return true;
            }
         }

         return false;
      } else {
         return true;
      }
   }
}

というわけで final が外れていて、かつ data class らしく toString() equals() などのメソッドが実装されています。

問題

結論でも書いたとおり、これはやらないほうが良いと思います。問題は2つあります;

挙動は変わる可能性がある

KotlinのIssueにこの話が上がっていました

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

ここではdata classがopenになる事実を認めながらも

However, I agree with @Alexey Belkov on that breaking data class contracts will eventually bring more harm than good.

と非推奨であることが述べられています。どちらかというと後述の問題点を指摘しているものですが、仕様が変更になる可能性も全然あると思います。

各メソッドの挙動が想定外になる

そもそも data class に生えてくる hashCode() toString() などのメソッドは final だからこそ非明示的に作成されても安心して利用できます。
例えば、finalでないクラスが実行中に拡張された場合、例えばメンバの追加が行われたりすると copy() の挙動は不正確になります。 hashCode() が正しく実装され直されるかもわかりません。

というわけで data class を無理やり open にするメリットはない、可能だけど、という学習になりました。