LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

This post is also available in the following languages. English

コード品質向上のテクニック:第14回 責任を課すというたった一つの責任

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 14 回です。Weekly Report については、第 1 回の記事を参照してください。

責任を課すというたった一つの責任

花火・ロケット・プロダクトのどれかをローンチする、「ローンチボタン」を実装したいとしましょう。また、このローンチボタンは「押されたときに何をローンチするか」を動的に変える必要があるとします。

以下の LaunchButtonBinder は、ローンチするロジック Launcher をコンストラクタ引数として受け取ります。そして、ボタンが押されたときに適切な Launcher が実行されるよう、イベントリスナを init で登録しています。

class LaunchButtonBinder(
    button: ObservableButton,
    private val fireworkLauncher: Launcher,
    private val rocketLauncher: Launcher,
    private val productLauncher: Launcher    
) {
    var launchMode: LaunchMode = LaunchMode.OFF

    init {
        button.observePush {
            launchMode.launcherSelector(this).launch()
        }
    }

    enum class LaunchMode(val launcherSelector: LaunchButtonBinder.() -> Launcher) {
        OFF({ Launcher.NOP_LAUNCHER }),
        FIREWORK(LaunchButtonBinder::fireworkLauncher),
        ROCKET(LaunchButtonBinder::rocketLauncher),
        PRODUCT(LaunchButtonBinder::productLauncher)
    }
}

このバインダークラスは十分にシンプルなのですが、「ボタンが押されたときのロジックをバインドする」動作と「どの Launcher が有効であるかを決める」という 2 つの責任があるとも見なせます。そこで以下のように、1 つのバインダークラスでは 1 つの Launcher を結びつけるように変更し、Launcher を選択する機能は別のクラス LaunchBinderSelector として実装しました。

このコードでは、LaunchBinderSelector のインスタンス作成時、各 Launcher ごとに LaunchButtonBinder のインスタンスを作ります。そして、各 LaunchButtonBinder でイベントリスナを登録しているため、ボタンに登録されるリスナは計 3 つです。さらに、3 つのリスナ中でどれが実行されるかを制御するために、LaunchButtonBinder.isEnabled というプロパティが追加されました。

class LaunchButtonBinder(
    button: ObservableButton,
    private val launcher: Launcher
) {
    var isEnabled: Boolean = false

    init {
        button.observePush {
            if (isEnabled) {
                launcher.launch()  
            }
        }
    }
}

class LaunchBinderSelector(
    button: ObservableButton,
    fireworkLauncher: Launcher,
    rocketLauncher: Launcher,
    productLauncher: Launcher
) {
    private val binders: Map<LaunchMode, LaunchButtonBinder> = mapOf(
        LaunchMode.FIREWORK to LaunchButtonBinder(button, fireworkLauncher),
        LaunchMode.ROCKET to LaunchButtonBinder(button, rocketLauncher),
        LaunchMode.PRODUCT to LaunchButtonBinder(button, productLauncher)
    )

    fun setLaunchMode(newMode: LaunchMode) = LaunchMode.entries.forEach { mode ->
        binders[mode]?.isEnabled = newMode == mode
    }

    enum class LaunchMode { OFF, FIREWORK, ROCKET, PRODUCT }
}

こうすることで、ボタンに対するロジックの実装と Launcher の責任を分離することができました。しかし、逆に新たな問題も発生しています。それは何でしょうか?

責任のたらい回し

問題点の 1 つとして、「仕様の制約を保証する責任が分散してしまっている」ことが挙げられます。

変更前のコードでは、ボタンを押したときに高々 1 つのものしかローンチしないという制約がありました。新たなコードでも、それは forEachisEnabledgetBinder の組み合わせによって実現されているのですが、それが正しいかを確認するためには、すべてのコードを読まなければなりません。この問題は、「どの Launcher が選択されているか」を示すプロパティを LaunchBinderSelector に追加することで解決できます。しかし、そうすると今度は、追加したプロパティと LaunchButtonBinder.isEnabled とで状態の重複が発生してしまいます。

さらにもう 1 つ、詳細の隠蔽に関する問題もあります。

分割されたコードにおいて、LaunchBinderSelector の役割は LaunchButtonBinder の選択だけにしたはずです。しかし、LaunchButtonBinder のインスタンスを作るために Launcher に直接依存しなくてはなりません。LauncherLaunchBinderSelector から隠蔽するためには、 LaunchButtonBinder のインスタンスを直接保持することも考えられます。

class LaunchButtonBinder(
    button: ObservableButton,
    private val launchAction: () -> Unit,
) {
    var isEnabled: Boolean = false

    init {
        button.observePush {
            if (isEnabled) {
                launchAction()
            }
        }
    }
}

class LaunchBinderSelector(
    private val fireworkBinder: LaunchButtonBinder,
    private val rocketBinder: LaunchButtonBinder,
    private val productBinder: LaunchButtonBinder,
) {
    fun setLaunchMode(newMode: LaunchMode) = LaunchMode.entries.forEach { mode ->
        getBinder(mode)?.isEnabled = newMode == mode
    }

    private fun getBinder(mode: LaunchMode): LaunchButtonBinder? = when (mode) {
        LaunchMode.OFF -> null
        LaunchMode.FIREWORK -> fireworkBinder
        LaunchMode.ROCKET -> rocketBinder
        LaunchMode.PRODUCT -> productBinder
    }

    enum class LaunchMode { OFF, FIREWORK, ROCKET, PRODUCT }
}

しかし、これはこれで、呼び出し元がすべての依存関係 (Selector, Binder, Button, Launcher) を解決せねばならなくなります。結果として、呼び出し元が肥大化したり (いわゆる “god class”)、 全体像を掴むために多数のクラスを読む必要が生じたり (いわゆる “ravioli code”) します。

LaunchBinderSelector(
    LaunchButtonBinder(button, fireworkLauncher),
    LaunchButtonBinder(button, rocketLauncher),
    LaunchButtonBinder(button, productLauncher)
)

責任を考える責任

最初の LaunchButtonBinder の実装は、仕様の制約の実装を一ヶ所で行っているという利点がるため、総合的に考えるとこれ以上クラスを分割する必要はなかったと考えられます。

クラスを分割する際は、個々のクラス内の責任範囲や凝集度に過度に集中するあまり、呼び出し元の責任の肥大化を招いたり、クラス・モジュール間の依存関係や結合度の悪化を招いたりすることを避ける という点に気を配る必要があります。


一言まとめ

責任の分割によって依存関係が複雑になることがある点に注意する。

キーワード: single responsibility, dependency, implicit constraints

コード品質向上のテクニックの他の記事を読む

コード品質向上のテクニックの記事一覧