かけ算的に条件分岐が増えるコードをすっきり記述する

## はじめに

コードを書いているとときどき、かけ算的（組み合わせ的）に条件分岐が発生することがあります。
例えば、

- 新規ユーザーか既存ユーザーかなどのユーザー種別によって送るメールの文面を変えたり、送らなかったりする。文面の種類も複数ある。
- 外部サービスAPIの取得結果によって処理が分岐する。さらに内部のデータベースの状態によっても対応が変わる。

などなど。

こういったときに見通しを悪くすることなく、変更に強くするにはどうしていったらいいか、というのがこの記事のテーマです。
コード例はPythonで示しますが、考え方はプログラミング言語によらず、実装もオブジェクト指向の言語であればだいたい同じ実装ができると思います。

## 例） 映画の料金設定

ここでは映画の料金設定を例として見ていきたいと思います。
例えば、購入者には一般の大人、学生、シニアがおり、それぞれ時間帯（日中、レイトショー）に応じて料金が変わる、というようなケースです。

| | 日中 | レイトショー |
|:--|:--|:--|
|一般| 1800円 | 1300円 |
|学生| 1500円 | 1500円 |
|シニア| 1100円 | 1100円 |


仮に基準料金（1800円）に対する割引料金ルールが存在し、そのルール適用をコードで表現することを考えてみます。

基準の金額からの割引料金ルール

| | 平日 | 平日（レイトショー） |
|:--|:--|:--|
|一般| +-0円 | -500円 |
|学生| -300円 | -500円 |
|シニア| -700円 | -700円 |

### 手続き的に書いた場合

こうしたとき、単純に手続き的な条件分岐で書いていくと次のようにつらい感じになります。

```py
from datetime import datetime
import enum


# 鑑賞者の区分
class ViewerType(enum.Enum):
  ADULT = "adult"
  STUDENT = "student"
  SENIOR = "senior"


# 鑑賞者区分、映画の開始時間、基準料金を元に鑑賞料金を返す
def charge(viewer_type: ViewerType, movie_start_at: datetime  base_charge: int) -> int:
  # 20時以降はレイトショー
  if movie_start_at.hour < 20:
    if viewer_type == ViewerType.ADULT:
      return base_charge
    elif viewer_type == ViewerType.STUDENT:
      return base_charge - 300
    else:
      return base_charge - 700

  if viewer_type == ViewerType.ADULT or viewer_type == ViewerType.STUDENT:
    return base_charge - 500
  else:
    return base_charge - 700
```

パッと見ても全体の見通しが悪く、ちゃんと漏れなく実装されているかが伝わりにくいと思います。
また、会員登録者といった購入者の種別が増えたり、シニア料金のルールが変わったとき、どこに処理を追加してよいのかも分かりにくいです。

### 条件分岐を文脈（コンテキスト）として捉える

こういったとき、**やりたい処理に対して影響を与える出来事（条件分岐の元となるデータ）**を**文脈（コンテキスト）**として捉えると見通しをよくできます。

今回の場合はレイトショーかどうかの基準となる**上映開始時間**を、最終的な**料金を決定するための文脈（コンテキスト）**としてコードで表現します。

```py
from datetime import datetime
import dataclasses

@dataclasses.dataclass
class MovieStartAtContext:
  movie_start_at: datetime

  def is_late_show(self) -> bool:
    return self.movie_start_at.hour >= 20
```

ひとまずこれで**上映開始時間時間がレイトショーかどうか**という文脈（コンテキスト）は表現できましたが、これを先ほどの手続き的なコードに展開してしまうと意味がありません。

重要なのは、この**判断の上でコードが実行できる**ようにしてあげることです。

```py
from datetime import datetime
import dataclasses

@dataclasses.dataclass
class MovieStartAtContext:
  movie_start_at: datetime

  def is_late_show(self) -> bool:
    return self.movie_start_at.hour >= 20

  # 鑑賞者別の料金計算
  #
  # レイトショーかどうかによって、鑑賞者（Viewer）の呼び出されるメソッドが変わる
  #
  # - レイトショーの場合: late_show_charge()
  # - 日中の場合:      normal_charge()
  def charge_for(self, viewer: Viewer, base_charge: int) -> int:
    if self.is_late_show():
      return viewer.late_show_charge(base_charge)

    return viewer.normal_charge(base_charge)
```

このようにすることで、 鑑賞者の種別ごとにレイトショー用、通常料金用それぞれの計算メソッドを定義してあげればよくなります（適切なメソッドを呼び出すのは上映時間コンテキスト側の責務）。

### 鑑賞者別の料金計算ロジックの実装

鑑賞者別の料金計算の実装は次のようになります。

```py
class Viewer:
  def normal_charge(self, base_charge: int) -> int:
    pass

  def late_show_charge(self, base_charge: int) -> int:
    pass


class AdultViewer(Viewer):
  def normal_charge(self, base_charge: int) -> int:
    return base_charge

  def late_show_charge(self, base_charge: int) -> int:
    return base_charge - 500


class StudentViewer(Viewer):
  def normal_charge(self, base_charge: int) -> int:
    return base_charge - 300

  def late_show_charge(self, base_charge: int) -> int:
    return base_charge - 500


class SeniorViewer(Viewer):
  def normal_charge(self, base_charge: int) -> int:
    return base_charge - 700

  def late_show_charge(self, base_charge: int) -> int:
    return base_charge - 700
```

割引料金ルールの表がほぼそのままコードに表現されているのがわかると思います。

基準の金額からの割引料金ルール

| | 平日 | 平日（レイトショー） |
|:--|:--|:--|
|一般| +-0円 | -500円 |
|学生| -300円 | -500円 |
|シニア| -700円 | -700円 |

### コードの統合

最終的に、これまで定義したコードを統合すると、もともとの`charge()`関数は次のようになります。

```py
class ViewerFactory:
  viewer_mapping = {
    ViewerType.ADULT: AdultViewer(),
    ViewerType.STUDENT: StudentViewer(),
    ViewerType.SENIOR: SeniorViewer()
  }

  @classmethod
  def create(cls, viewer_type: ViewerType) -> Viewer:
    return cls.viewer_mapping[viewer_type]


def charge(viewer_type: ViewerType, movie_start_at: datetime, base_charge: int) -> int:
  context = MovieStartAtContext(movie_start_at)

  viewer = ViewerFactory.create(viewer_type)

  return context.charge_for(viewer, base_charge)
```

このようにしてあげることで、例にあげた会員登録者という種別が増えたときは新たな鑑賞者クラスを定義してあげればよいですし、休日料金といった新たな料金区分が増えた場合でも、実装箇所に迷うことなく実装してあげることができると思います。


## まとめ


条件分岐が、かけ算的（組み合わせ的）に発生するとき

- 分岐の原因となっている出来事（データ）をコンテキストとして捉えてみる
- そのコンテキストをオブジェクトとして表現し、条件判断をそこに集約する
- さらに、集約した条件判断の上で処理を実行できるようにする

うまくいけば、コードをすっきり拡張しやすくすることができます。
実装の参考になればうれしいです。