こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 9 回です。Weekly Report については、第 1 回の記事を参照してください。
来た道を戻れ
ネットワークやファイルシステムといった I/O を使 う場合、I/O 上のデータ表現とコード上のデータ表現との間で相互に変換する必要があります。その典型例の一つが、インターフェース記述言語 (IDL) やデータベーススキーマなどの外部で定義されたデータと、コード上で定義されたモデルクラスとの相互変換でしょう。このとき、「状態」や「タイプ」を意味する値を使う場合は、コード上の表現として列挙型を使うこともあります。
以下のコードでは、データベースで使われている値と列挙子を相互変換するために Map
を使っています。
enum class AccountType { FREE, PREMIUM, ULTIMATE }
val DB_VALUE_TO_ACCOUNT_TYPE_MAP: Map<Int, AccountType> = mapOf( // or SparseArray
0 to AccountType.FREE,
1 to AccountType.PREMIUM,
2 to AccountType.ULTIMATE
)
val ACCOUNT_TYPE_TO_DB_VALUE_MAP: Map<AccountType, Int> = mapOf( // or EnumMap
AccountType.FREE to 0,
AccountType.PREMIUM to 1,
AccountType.ULTIMATE to 2
)
このコードに問題点はなにかありますか?
並行する 2 つの道路
このコードでは、データベースの値から列挙型への変換と、その逆の変換が別々に定義されているため、以下の 2 つの問題が発生しています。
- 仕様変更時に両方の
Map
を更新しなければならない。 - 2 つの変換の間で、互いに対応が取れていることを保証できない。
この問題を解決するためには、 「逆向きの変換」をもう一方の変換を使って求める とよいでしょう。特に、列挙子のプロパティや when
/switch
式が使える場合、それらを Map
の代わりに使うことで、すべての列挙子が網羅されていることを保証しやすくなります。
実装例 1: 列挙子のプロパティ & 逆変換マップ
列挙子のプロパティを使うことで、すべての列挙子がそれに対応するデータベースの値を持つことを保証できます。Kotlin の場合は associateBy
関数を使うことで、dbValue
から AccountType
への Map
を作ることができます。他の多くの言語でも、map
といった関数や列挙子に対するループを書くことで、同様のことが行なえます。
enum class AccountType(val dbValue: Int) {
FREE(0),
PREMIUM(1),
ULTIMATE(2);
companion object {
val DB_VALUE_TO_TYPE_MAP: Map<Int, AccountType> =
AccountType.values().associateBy(AccountType::dbValue)
}
}
以下のコードは、変換を行っている例です。
accountType.dbValue // AccountType to DB value
AccountType.DB_VALUE_TO_TYPE_MAP[dbValue] // DB value to AccountType
この実装方法は単純なのが利点ですが、AccountType
が広い範囲で使われるモデルの場合は、dbValue
も広い範囲で見えてしまう点が問題になることもあります。また、複数のデータ表現間で相互変換が必要になる場合は、 dbValue
に相当する値を複数定義する必要があるため、混乱を招きやすくなります。以下の AccountType
の実装は、複数の変換のための値を持っているのですが、このまま変換先・元が増えると、クラス定義が簡単に肥大化してしま うのは容易に想像できると思います。その場合は、次の “実装例 2” の適用を選択肢に入れてください。
enum class AccountType(
val dbValue: Int,
val fooApiJsonValue: String,
val barApiJsonValue: String
) {
...
}
実装例 2: 個別のレイヤ内で変換を実装 & 逆変換マップ
変換を使うコードが機能・レイヤ・スコープ内に閉じている場合、その範囲内で変換を定義する方法もあります。以下のコードでは、serverValue
と AccountType
の相互変換を、AccountTypeNetworkClient
に閉じた範囲で定義しています。ここで、AccountType
から serverValue
への変換については、when
式を使うことですべての列挙子が網羅されていることを保証しています。
class AccountTypeNetworkClient {
/* snip */
companion object {
private fun AccountType.toServerValue(): String = when (this) {
AccountType.FREE -> "free"
AccountType.PREMIUM -> "premium"
AccountType.ULTIMATE -> "ultimate"
}
private val SERVER_VALUE_TO_TYPE_MAP: Map<String, AccountType> =
AccountType.values().associateBy { it.toServerValue() }
}
}
また、Mapper
や Converter
というクラスを個別に定義するのも 1 つの案です。プラットフォームによっては、そのような実装が簡単にできるようになっています。ただし、Mapper
や Converter
を実装する場合でも、順方向の変換と、逆方向の変換は同じファイルやクラス内に定義するべきです。
単射を保証するユニットテスト
上記の実装例は両方とも、すべての列挙子に対応する値があること (列挙子が定義域であること) を保証しています。ただし、値が重複していないこと (単射であること) は保証していません。値の重複がないことを保証するには、以下のようなテストを書くと良いでしょう。
assertEquals(DB_VALUE_TO_TYPE_MAP.size, AccountType.values().size)
一言まとめ
双方向の変換を行う場合、一方の変換ロジックからもう一方のロジックを演繹的に求めるのが好ましい。
キーワード: type conversion
, bidirectional
, single source of truth