tl;dr
- enum classにはordinalがあるが、外部出力(例:DBへの保存)にordinalを使ってはいけない
- 外部出力したい場合は自分で値を定義する
はじめに
別記事 で enum class <-> intの相互変換にoridnalプロパティを利用するコードを書きました。
それ自体は別にいいのですが、そのenum classを利用しているのがエンティティクラスというユースケースでした。
エンティティといえばDB保存が絡む可能性があるなー悪用されるとドハマリを招くなーでも記事の本題とはズレるなーと思ったので別途記事を起こしました。
なお、参照元の記事はAndroid Kotlinでしたが、本記事の対象はAndroid Kotlinを含むKotlin全般です。
前提:enum classのordinal
enum classにはordinalというプロパティが存在します。
これは要素に1対1対応するint値を返すプロパティです。
例えば以下のようなenum classがあったとしましょう。
enum class Todofuken {
HOKKAIDO,
TOKYO,
OKINAWA
}
このとき、それぞれのordinalの値は以下のとおりとなります。
-
Todofuken.HOKKAIDO.ordinal
-> 0 -
Todofuken.TOKYO.ordinal
-> 1 -
Todofuken.OKINAWA.ordinal
-> 2
また、逆変換も以下のとおり可能です。
-
Todofuken.values()[0]
->HOKKAIDO
-
Todofuken.values()[1]
->TOKYO
-
Todofuken.values()[2]
->OKINAWA
問題:ordinalを使うと改修時にDBから値を正しく復元できないことがある
さて、何らかのフレームワークを使っていると、フレームワークのインタフェースがint型を求めているため、enumをintに変換しなければならないケースが出てきます。
つまり、ユーザーコードとフレームワークの間でenum <-> intの相互変換が必要になってきます。
この時、上述のordinalを使った変換/逆変換は要素とint値の対応表を用意する必要がないという意味で楽なので、選択肢の一つになるでしょう。
実際、変換/逆変換の結果を使われる箇所が"閉じている"場合は特に問題はないです。
しかし、"開いている" ―― 例えばその値がDBに保存される場合は注意が必要です。
例えば、以下のようなエンティティクラスをDBに保存するケースを考えます。
data class User(val name: String, val from: Todofuken)
DBでは name
は文字列型として、 from
はint型として保存することになったとします。
このとき、Userクラス上で以下のデータは、
name | from |
---|---|
"toma" | Todofuken.HOKKAIDO |
"mao" | Todofuken.TOKYO |
"gaku" | Todofuken.OKINAWA |
DB上では以下のように保存されます。
name | from |
---|---|
"toma" | 0 |
"mao" | 1 |
"gaku" | 2 |
ここで仕様変更により Todofuken
に新たな要素 OSAKA
を追加することになったとしましょう。
ISO 3166-2:JP の存在を知っていると、 OSAKA
は TOKYO
と OKINAWA
の間に挿入したくなります。
enum class Todofuken {
HOKKAIDO,
TOKYO,
+ OSAKA,
OKINAWA
}
ソースコードに上記の改修を加え、動作確認してみましょう。
すると、DB上に保存されたデータは以下のとおり復元されるはずです。
name | from |
---|---|
"toma" | Todofuken.HOKKAIDO |
"mao" | Todofuken.TOKYO |
"gaku" | Todofuken.OSAKA |
"gaku"がOSAKA出身になってしまいました。
ここで改めて各要素のordinalを調べてみましょう。
-
Todofuken.HOKKAIDO.ordinal
-> 0 -
Todofuken.TOKYO.ordinal
-> 1 Todofuken.OSAKA.ordinal
-> 2-
Todofuken.OKINAWA.ordinal
-> 3
OKINAWAが3になり、代わりにOSAKAが2になっています。
同様に逆変換も、
-
Todofuken.values()[0]
->HOKKAIDO
-
Todofuken.values()[1]
->TOKYO
-
Todofuken.values()[2]
->OSAKA
-
Todofuken.values()[3]
->OKINAWA
3がOKINAWAになり、2がOSAKAになっています。
原因:ordinalはソースコード上の出現順である
ordinalはソースコード上の要素の定義順が割り振られます。
今回、OSAKAをTOKYOとOKINAWAの間に差し込んだので、それで順番がずれてしまったのです。
本来、enumの要素には順番の概念はないはずなので新規の要素は末尾に追加すればいいはずですが、とはいえenumが表現しているものは順番の概念がある場合があり(例:ISO 3166-2:JP)、美意識として要素を末尾ではなく真ん中に挿入したいことはよくあります。
しかし、DB保存のように、外部出力が行われる箇所で真ん中に挿入すると、ordinalを使っていた場合にズレてしまいます。
解決方法:ordinalを使うな
ordinalの値はソースコードに依存しすぎています。
DB保存のように外部出力する場合は面倒でもenum classとintの対応表を作るようにしましょう。
enum classの場合、ユーザー定義のプロパティを対応表代わりにすることができます。
enum class Todofuken(val code: Int) {
HOKKAIDO(0),
TOKYO(1),
OKINAWA(2);
companion object {
fun fromCode(val code: Int): Todofuken {
return Todofuken.values().first { it.code == code }
}
}
}
int値に変換する場合は Todofuken#code
を参照します。
int値から Todofuken
に変換する場合は Todofuken.fromCode(<int値>)
で取得することができます。
OSAKA
を追加したい場合、
enum class Todofuken(val code: Int) {
HOKKAIDO(0),
TOKYO(1),
+ OSAKA(3),
OKINAWA(2);
companion object {
fun fromCode(val code: Int): Todofuken {
return Todofuken.values().first { it.code == code }
}
}
}
とすれば、ソースコード上の並びはISO 3166-2:JPに従いつつ、DBからの復元も下位互換性を保って正常に実行できます。
上述のコードはordinalを使ってしまっていたコードからの改修を意識したのでint値を0から割り振りましたが、新規に作り始めるなら、
enum class Todofuken(val code: Int) {
HOKKAIDO(1),
TOKYO(13),
OKINAWA(47);
...
}
のようにはじめからISO 3166-2:JPのコード値にするのがいいと思います。
おわりに
実はこのやらかしはKotlinに限った話ではなく、列挙型と定義順序を整数型として相互変換できる全ての言語で起こりえます。
最近は(列挙型であっても)変換処理をフレームワークが担ったり自動出力されたコードが担ったりしてわざわざ手で書くことも少なくなったためそもそも遭遇することも少なくなったと思いますが、こういうこともあるんだよと覚えておいてもらえたらと思います。