こんにちは。コミュニケーションアプリ「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 つのものしかローンチしないという制約がありました。新たなコードでも、それは forEach
・isEnabled
・getBinder
の組み合わせによって実現されているのですが、それが正しいかを確認するためには、すべてのコードを読まなければなりません。この問題は、「どの Launcher
が選択されているか」を示すプロパティを LaunchBinderSelector
に追加することで解決できます。しかし、そうすると今度は、追加したプロパティと LaunchButtonBinder.isEnabled
とで状態の重複が発生してしまいます。
さらにもう 1 つ、詳細の隠蔽に関する問題もあります。
分割されたコードにおいて、LaunchBinderSelector
の役割は LaunchButtonBinder
の選択だけにしたはずです。しかし、LaunchButtonBinder
のインスタンスを作るために Launcher
に直接依存しなくてはなりません。Launcher
を LaunchBinderSelector
から隠蔽するためには、 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