こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 52 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)
分岐を伐り根を枯らす
あるサービスに、3 つのアカウントタイプ (無料 、ビジネス、プレミアム) があるとしましょう。さらに、アカウントタイプに応じて、3 つの UI 要素 (背景、アイコン、テキスト) の表示を、以下のように切り替えたいとします。
UI element \ account type | Free | Business | Premium |
---|---|---|---|
Background color | gray | blue | yellow |
Icon image | 👤 | 👔 | 👑 |
Type text | Free... | Business | Premium!! |
この 2 次元の表を実装するには、以下のように、アカウントタイプか UI 要素のどちらかでコードを分ける必要があります。
Option 1 縦軸で切る: アカウントタイプごとに関数やコードブロックを分ける。分けたコード内で、すべての UI 要素を設定する。
Option 2 横軸で切る: UI 要素ごとに関数やコードブロックを分ける。分けたコード内で、アカウントタイプに応じた要素の値を決定する。
可読性や頑健性の観点では、どちらのオプションが好ましいでしょうか?
分岐を閉じ込める
Option 2、つまり UI 要素ごとにコードを分割する方がよいケースが多いです。その理由を探るためにも、Option 1 と 2 それぞれの実装を見比べてみましょう。以下の実装ではどちらも、分割したコードを補助的な private 関数として抽出しています。
Option 1
/**
* Updates UI to indicate a given account type.
*/
fun updateAccountTypeLayout(accountType: AccountType) {
when (accountType) {
AccountType.FREE -> updateLayoutForFree()
AccountType.BUSINESS -> updateLayoutForBusiness()
AccountType.PREMIUM -> updateLayoutForPremium()
}
}
private fun updateLayoutForFree() {
backgroundUiElement.color = Color.GRAY
accountTypeIconUiElement.image = imageProvider.get(FREE_IMAGE_ID)
accountTypeTextUiElement.text = "Free..."
}
private fun updateLayoutForBusiness() {
backgroundUiElement.color = Color.BLUE
accountTypeIconUiElement.image = imageProvider.get(BUSINESS_IMAGE_ID)
accountTypeTextUiElement.text = "Business"
}
private fun updateLayoutForPremium() {
backgroundUiElement.color = Color.YELLOW
accountTypeIconUiElement.image = imageProvider.get(PREMIUM_IMAGE_ID)
accountTypeTextUiElement.text = "Premium!!"
}
Option 2
/**
* Updates UI (background color, icon, and text) to indicate
* a given account type.
*/
fun updateAccountTypeLayout(accountType: AccountType) {
updateBackgroundColor(accountType)
updateIconImage(accountType)
updateTypeText(accountType)
}
private fun updateBackgroundColor(accountType: AccountType) {
backgroundUiElement.color = when (accountType) {
AccountType.FREE -> Color.GRAY
AccountType.BUSINESS -> Color.BLUE
AccountType.PREMIUM -> Color.YELLOW
}
}
private fun updateIconImage(accountType: AccountType) {
val iconId = when (accountType) {
AccountType.FREE -> FREE_IMAGE_ID
AccountType.BUSINESS -> BUSINESS_IMAGE_ID
AccountType.PREMIUM -> PREMIUM_IMAGE_ID
}
accountTypeIconUiElement.image = imageProvider.get(iconId)
}
private fun updateTypeText(accountType: AccountType) {
accountTypeTextUiElement.text = when (accountType) {
AccountType.FREE -> "Free..."
AccountType.BUSINESS -> "Business"
AccountType.PREMIUM -> "Premium!!"
}
}
Option 2 には次のような 2 つの利点があります。
- 補助的な private 関数の詳細を読まずとも、主となる関数 (
updateAccountTypeLayout
) を読むだけで動作が想像できる: Option 1 では、updateAccountTypeLayout
を読んだだけでは「アカウントタイプごとに UI 更新ロジックが異なる」としか分からない。一方で、Option 2 では「背景、アイコン、テキストの 3 つを更新する」ことが分かるようになっている。 - 新しい UI 要素やアカウントタイプを追加するときに、すべての組み合わせが実装されていることを保証できる: Option 1 では、新しいアカウントタイプ
ULTIMATE
を追加したとき、補助的な関数内でaccountTypeTextUiElement
の更新を忘れてしまっても、コンパイル時に検出できない。さらに、新しい UI 要素を追加したときは、すべての既存の関数を更新しなくてはならないが、それを忘れてもやはり検出できない。一方で Option 2 では、アカウントタイプと UI 要素の組み合わせに実装漏れがある場合は、when
式によってコンパイル時に検出できる。
また、Option 2 に対しては、補助的な関数に抽出するコードの範囲を最適化するといった、さらなるリファクタリングを行えます。Option 2-a では、副作用のあるコードを updateAccountTypeLayout
に移すことで、補助的な関数を参照透過にしています。
Option 2-a
fun updateAccountTypeLayout(accountType: AccountType) {
backgroundUiElement.color = getBackgroundColorInt(accountType)
val iconId = getIconId(accountType)
accountTypeIconUiElement.image = imageProvider.get(iconId)
accountTypeTextUiElement.text = getTypeText(accountType)
}
private fun getBackgroundColorInt(accountType: AccountType): Int = when (accountType) {
AccountType.FREE -> Color.GRAY
AccountType.BUSINESS -> Color.BLUE
AccountType.PREMIUM -> Color.YELLOW
}
これを更に発展させ、Option 2-b のように複数の値を 1 つのデータモデルにまとめてもよいでしょう。
Option 2-b
fun updateAccountTypeLayout(accountType: AccountType) {
backgroundUiElement.color = accountType.backgroundColorInt
accountTypeIconUiElement.image = imageProvider.get(accountType.iconId)
accountTypeTextUiElement.text = accountType.typeText
}
// Case 1: Data model by an enum class
enum class AccountType(
val backgroundColorInt: Int,
val iconId: Int,
val typeText: String
) {
FREE(Color.GRAY, FREE_IMAGE_ID, "Free..."),
...
}
// Case 2: Data model by normal class
class AccountTypeLayoutModel(
val backgroundColorInt: Int,
val iconId: Int,
val typeText: String
) {
companion object {
val FREE = AccountTypeLayoutModel(Color.GRAY, FREE_IMAGE_ID, "Free...")
...
}
}
条件分岐については、 分岐が重複することを避けるよりも、分岐のスコープを小さくする方がよいことが多い です。似たような分岐があっても、when
式を使うなど、網羅性を保証することで更新漏れを防ぐことができます。特に、大規模なサービスやアプリケーションの場合、無理に分岐を一箇所にまとめようとしてしまうと、その分岐内に複数のモジュールやレイヤにまたがった処理が記述されてしまうことがあります。分岐のスコープを小さくすることで、各モジュールやレイヤに責任を閉じ込めやすくなります。
網羅性を保証できない場合
多くの動的型付けの言語など、使用している言語によっては、when
式のような網羅性を保証できる条件分岐がない場合もあります。Java も switch
で enum の網羅性を保証できるようになったのは 14 以降であり、それまでは default
で null
のような無効な値を使ったり、デフォルト値にフォールバックしたり、IllegalArgumentException
のような例外を使用する必要がありました。
@Nullable
Integer getBackgroundColorInt(@NonNull AccountType accountType) {
switch(accountType) {
case FREE:
return Color.GRAY;
...
default:
return null;
}
このように、プロダクションコードで網羅性を保証できない場合は、網羅されていることを確認するテストコード を書くとよいでしょう。以下のテストでは、未知の accountType
がないことを保証しています。新たな AccountType
が追加されたにも関わらず getBackgroundColorInt
の更新を忘れている場合は、assertNotNull
が失敗します。
@Test
public void testBackgroundColorCompleteness() {
for (AccountType type : AccountType.values()) {
assertNotNull(
testTarget.getBackgroundColorInt(type),
"`getBackgroundColorInt` implementation is missing for the type " + type);
}
}
一言まとめ
条件分岐の重複を避けるよりも、スコープを小さくすることを優先したほうがよいことが多い。
キーワード: conditional branch
, completeness
, extraction
次回は年明けから連載再開いたします。