Infinito Nirone 7

白羽の矢を刺すスタイル

Kotlin の enum class とシリアライズで気をつけること

Kotlin の enum クラスは、ほぼ Javaenum と同じように扱うことができます。

次の Kotlin コードは FOOBAR という Hoge 型の定数を定義しています。

enum class Hoge {
  FOO,
  BAR
}

enum クラスで定義した定数は String への変換 (nameメソッド) と、 String からの変換 (valueOfメソッド) をサポートしているので、簡単にシリアライズ・デシリアライズができます。またenum クラスに振る舞いを記述できるので、フィールドをもたせたり、メソッドを呼び出したりするコードも動きます。

enum class Hoge(val flag: Boolean) {
  FOO(true),
  BAR(false);

  fun getSomething(): String {
    return "Hello, world!"
  }
}

メソッドは abstract で定義したり、インタフェースを実装したりもできます。この場合、個々の定数の定義で具体的な処理を記述します。

enum class Hoge {
  FOO {
    override fun getSomething(): String {
      return "Hello, world!"
    }
  },
  BAR {
    override fun getSomething(): String {
      return "Hi, world!"
    }
  };

  abstract fun getSomething(): String
}

ここまでがおさらいです。

ここで次のように String? から任意の型のオブジェクトへ変換する Generic なメソッドを考えます。ストレージに書き込んだ文字列から期待する型への変換を行うようなメソッドです。

@Suppress("UNCHECKED_CAST")
fun <T : Any> String?.convert(fallback: T): T {
  return when (fallback) {
    is String -> {
      this ?: fallback
    }
    is Int -> {
      this?.let {
        Integer.valueOf(this)
      } ?: 0
    }
    is Enum<*> -> {
      this?.let {
        val method = fallback.javaClass.getDeclaredMethod("valueOf", String::class.java)
        method.invoke(null, this)
      } ?: fallback
    }
    else -> {
      throw IllegalArgumentException("")
    }
  } as T
}

このメソッドを、次のように標準入力で与えられた文字列からデシリアライズする目的で使ってみましょう。標準入力で FOO と入力すると、正しく定数に変換されます。

fun main(args: Array<String>) {
  println(readLine().convert<Hoge>(Hoge.FOO))
}

enum class Hoge {
  FOO,
  BAR
}

では次の場合はどうなるでしょうか。Hoge には abstract メソッドが定義されています。

fun main(args: Array<String>) {
  println(readLine().convert<Hoge>(Hoge.FOO))
}

enum class Hoge {
  FOO {
    override fun getSomething(): String {
      return "Hello, world!"
    }
  },
  BAR {
    override fun getSomething(): String {
      return "Hi, world!"
    }
  };

  abstract fun getSomething(): String
}

上記のコードの場合、FOO を標準入力に入れると次のようなエラーで異常終了します。

Exception in thread "main" java.lang.NoSuchMethodException: dev.keithyokoma.Hoge$FOO.valueOf(java.lang.String)
    at java.lang.Class.getDeclaredMethod(Class.java:2130)
    at dev.keithyokoma.MainKt.convert(Main.kt:22)
    at dev.keithyokoma.MainKt.main(Main.kt:6)

この挙動の差は生成されるバイトコードの違いを見ると分かります。

メソッドの定義がなかったり、実装を enum クラスの宣言に書く場合は次のようなバイトコードが生成されます。

public final enum dev/keithyokoma/Hoge extends java/lang/Enum  {
  // access flags 0x4019
  public final static enum Ldev/keithyokoma/Hoge; FOO
  // access flags 0x4019
  public final static enum Ldev/keithyokoma/Hoge; BAR
}

一方で、abstract メソッドやインタフェースのメソッドの実装を個別の定数で行う場合は次ようなバイトコードが生成されます。

public abstract enum dev/keithyokoma/Hoge extends java/lang/Enum  {

  // access flags 0x18
  final static INNERCLASS dev/keithyokoma/Hoge$FOO dev/keithyokoma/Hoge FOO
  // access flags 0x18
  final static INNERCLASS dev/keithyokoma/Hoge$BAR dev/keithyokoma/Hoge BAR

  // access flags 0x4019
  public final static enum Ldev/keithyokoma/Hoge; FOO
  // access flags 0x4019
  public final static enum Ldev/keithyokoma/Hoge; BAR
}

なにやら増えていますね。内部クラスとして Hoge$FOOHoge$BAR があるように見えます。そして実行時のエラーでは、Hoge$FOO に対して valueOf メソッドを探したが見つからなかった、というメッセージが出ています。

enum クラス内の個別の定数で実装を定義する場合 Hoge.FOOHoge$FOO 型で、Hoge.BARHoge$BAR 型として解釈されます。これらはすべて単なるクラスなので、Generic な型変換コードの val method = fallback.javaClass.getDeclaredMethod("valueOf", String::class.java)valueOf メソッドを見つけられません (Hoge$FOO 型の Hoge.FOO が格納された fallback 変数に対し valueOf を探そうとするが、valueOf メソッドは Hoge のクラスメソッドとして定義されるので見つからない)。

Hoge$FOO 型と Hoge$BAR 型はどちらも Hoge 型のサブクラスになっているので、次のように修正すると期待通りの動作をします。 fallback の型が enum かどうかを判別し、enum でなければその親クラスから valueOf メソッドを探します。

@Suppress("UNCHECKED_CAST")
fun <T : Any> String?.convert(fallback: T): T {
  return when (fallback) {
    // ...
    is Enum<*> -> {
      this?.let {
        val clazz = fallback.javaClass
        val method = if (clazz.isEnum) {
          clazz.getDeclaredMethod("valueOf", String::class.java)
        } else {
          clazz.superclass.getDeclaredMethod("valueOf", String::class.java)
        }
        method.invoke(null, this)
      } ?: fallback
    }
    // ...
  } as T
}

コンパイラの吐き出すバイトコードまでを考慮しないといけないコードになってしまいました (enum クラスは文法上継承できないはずなので、isEnumfalse のときに superclass を参照しにいくのは一見とても奇妙)。

ちなみに、Java で同様のコードを書いて検証してみたところ、Java でも同じ挙動をしました。

もし、型パラメータの T から直接 Class クラスを参照できれば、このような回りくどい記述は必要ありません。 次のコードでは正しく valueOf メソッドが探せます。

inline fun <reified T : Any> String?.convert(fallback: T): T {
  return when (fallback) {
    // ...
    is Enum<*> -> {
      this?.let {
        val method = T::class.java.getDeclaredMethod("valueOf", String::class.java)
        method.invoke(null, this)
    } ?: fallback
  }
  // ...
  } as T
}

2019/09/03 16:00 追記

型パラメータ TEnum<T> に限定できる場合は、リフレクションは不要になり、代わりに enumValueOf を使います (thank you @red_fat_daruma !)。

inline fun <reified T : Enum<T>> String?.convert(fallback: T): T {
  return this?.let { value ->
    enumValueOf<T>(value)
  } ?: fallback
}