機能拡張しやすいPython関数の設計を考える - 5つのパターン

博報堂テクノロジーズの坂井です。
個人的にPythonでアプリケーションを実装する業務があり、「機能拡張しやすい・持続的に進化可能な設計にするには具体的にどうしたら」ということを考えていて、自分なりに整理したことを共有します。

## 前提
次のような構造を前提とします:

* ドメイン層.py
    * `domain_logic` 関数
    * `domain_service` 関数
* アプリケーション層.py
    * `application` 関数

```python: ドメイン層.py
def domain_logic(input: DomainInput) -> DomainOutput:
    # この中にドメイン固有のロジック

def domain_service(input: ServiceInput) -> ServiceOutput:
    # このなかで domain_logic を使う
    domain_output = domain_logic(domain_input)
```
```python: アプリケーション層.py
def application():
    # このなかで domain_service を使う
    domain_service_output = domain_service(domain_service_input)
```

依存関係を図にするとこんな感じ:
```mermaid
graph BT
    domain_service --> domain_logic
    application --> domain_service
```


ここで設計へ次のような要求があるとします:

* `domain_logic` は将来に渡ってバリエーションを増やして機能拡張できるよう対応したい。
* `ドメイン層.py` は中核的なモジュールで様々な場所から依存されているため、破壊的な変更を加えたくない。変更を加えるとしたら新しい関数やオブジェクトを書き足すだけにしたい（cf. [開放・閉鎖原則 - Wikipedia](https://ja.wikipedia.org/wiki/%E9%96%8B%E6%94%BE/%E9%96%89%E9%8E%96%E5%8E%9F%E5%89%87)）

たとえば私たちのような広告会社の場合、ドメイン層には様々な広告メディア固有の処理を書いていく必要があります。広告メディアは日々進化しつづけていますので、今後も新しい広告メディアが生まれるたびに容易に拡張できる構造にしたいというモチベーションがあります。

## 1. 引数でロジックを切り替え

ではドメインロジックのバリエーションを増やして機能拡張していく必要があるとき、どういう設計にしたらよいでしょうか。
一番最初に思いつくのは次のパターンです:

```python: ドメイン層.py
def domain_logic(input: DomainInput, logic_flag: str) -> DomainOutput:
    # 引数 flag でロジックを切り替え
    if logic_flag == "ロジック1":
        # ロジック1
    elif logic_flag == "ロジック2":
        # ロジック2

def domain_service(input: ServiceInput) -> ServiceOutput:
    if ...:
        logic_flag = "ロジック1":
    elif ...:
        logic_flag = "ロジック2":
    domain_output = domain_logic(domain_input, logic_flag)
```
```python: アプリケーション層.py
def application():
    domain_service_output = domain_service(domain_service_input)
```

`domain_logic` の内部に複数のロジックを直接書いて、`logic_flag` 引数によってどのロジックを使うか切り替えるようにしてみました。
もっとも素朴なパターンですが、

:::note alert
機能拡張のたびに `domain_service` や `domain_logic` の中に手を入れる必要があり、下手するとこれまで動いていたものを破壊してしまう可能性がある
:::

というデメリットを持っており、長く使われる予定のソフトウェアではあまり採用したくありません。

## 2. 関数自体を切り替え
```python: ドメイン層.py
def domain_logic1(input: DomainInput) -> DomainOutput:
    # ロジック1

def domain_logic2(input: DomainInput) -> DomainOutput:
    # ロジック2

def domain_service(input: ServiceInput) -> ServiceOutput:
    if ...:
        output = domain_logic1()
    elif ...:
        output = domain_logic2()
```
```python: アプリケーション層.py
def application():
    domain_service_output = domain_service(domain_service_input)
```

`domain_logic` をロジックごとに複数の関数に分けてみました。
これで

:::note info
`domain_logic` は機能拡張の際に書き足せばよく、破壊的な変更は受けない
:::

状態になりました。しかし、

:::note alert
`domain_service` は相変わらず機能拡張のたびに手を入れる必要がある
:::

というデメリットは残っています。


## 3. 関数オブジェクト渡し

ここで、Pythonでは関数がオブジェクトであり別の関数に渡すことができるという特性を使ってみます。

```python: ドメイン層.py
def domain_logic1(input: DomainInput) -> DomainOutput:
    # ロジック1

def domain_logic2(input: DomainInput) -> DomainOutput:
    # ロジック2

def domain_service(input: ServiceInput, domain_logic: Callable[DomainInput, DomainOutput]) -> ServiceOutput:
    domain_output = domain_logic(domain_input) # 渡された関数オブジェクトを使う
```
```python: アプリケーション層.py
def application():
    if ...:
        domain_logic = domain_logic1
    elif ...:
        domain_logic = domain_logic2
    domain_service_output = domain_service(domain_service_input, domain_logic)  # 関数オブジェクトを渡す
```

これで

:::note info
機能拡張の際に `domain_service` 関数に破壊的な変更が入る可能性がない
:::

という状況を作り出すことができました。

※これは一般的に "[ストラテジーパターン](https://ja.wikipedia.org/wiki/Strategy_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3)" とか "[依存性の注入](https://ja.wikipedia.org/wiki/%E4%BE%9D%E5%AD%98%E6%80%A7%E3%81%AE%E6%B3%A8%E5%85%A5)"と呼ばれるようです。

これでわりと充分なのですが、欲をいえば、

:::note alert
* `domain_logic1` や `domain_logic2` が似たような役割の関数であるという共通点が、コード上から明らかでない
* `domain_logic` が使える情報が `input: DomainInput` しかない。将来追加の情報が必要なロジックを作る必要がでたときに、また `domain_service` に情報を受け渡させる変更をしないといけない
:::

という新しい課題が気になってきました。

## 4. 関数クラスを導入
そこで、Pythonでは「関数もクラスとして定義できる」ことを利用します。

```python: ドメイン層.py
class DomainLogic:
    def __init__(self, logic_flag, data):
        self._logic_flag = logic_flag
        self._data = data
    def __call__(self, input: DomainInput) -> DomainOutput:  # __call__メソッドを実装すれば関数オブジェクトになる
        if self._logic_flag == "ロジック1":
            # ロジック1
            # self._dataが使える
        elif self._logic_flag == "ロジック2":
            # ロジック2
            # self._dataが使える

def domain_service(input: ServiceInput, domain_logic: DomainLogic) -> ServiceOutput:
    domain_output = domain_logic(domain_input) # 渡されたDomainLogicオブジェクトを関数呼び出し（__call__）
```
```python: アプリケーション層.py
def application():
    data = fetchdata()
    if ...:
        domain_logic = DomainLogic(logic_flag="ロジック1", data=data) # DomainLogicを構築 データも渡せる
    elif ...:
        domain_logic = DomainLogic(arg="ロジック2", data=data)
    domain_service_output = domain_service(domain_service_input, domain_logic)  # 関数オブジェクトを渡す
```

Pythonでは `__call__` メソッドを実装したクラスのインスタンスは自動的に関数オブジェクトとなり、 `domain_logic()` と関数として呼び出すことができます。これを活用して `DomainLogic` というクラスを新設し、これまでの `domain_logic` 関数はそのインスタンスだという位置づけにしました。

:::note 
* `domain_service` が `DomainLogic` オブジェクトを必要としていることがコードから伝わりクリーンになる
* `DomainLogic` のコンストラクタに情報 `data` を渡しておくことで、ロジックが追加的な情報を使った振る舞いを持てるようになる（しかもそれを `domain_service` は知らなくてよい）
:::

というメリットを新たに享受することができました。しかし一方で、


:::note alert
機能拡張のたびに `DomainLogic` の中に手を入れる必要がある
:::

というデメリットは戻ってきてしまいました。

## 5. 抽象関数クラスから継承する

最後に、Pythonの抽象クラス機能を使って抽象と具体を分離します。

```python: ドメイン抽象層.py
from abc import ABC

class DomainLogic(ABC):
    @abstractmethod
    def __call__(self, input: DomainInput) -> DomainOutput:
        # 中身は空っぽ
        pass

def domain_service(input: ServiceInput, domain_logic: DomainLogic) -> ServiceOutput:
    domain_output = domain_logic(domain_input)
```
```python: ドメイン具体層.py
# ドメイン具体層
class DomainLogic1(DomainLogic):
    def __init__(self, data1):
        self._data1 = data1
    def __call__(self, input: DomainInput) -> DomainOutput:
        # ロジック1
        # 内部で self._data1が使える

class DomainLogic2(DomainLogic):
    def __init__(self, data2):
        self._data2 = data2
    def __call__(self, input: DomainInput) -> DomainOutput:
        # ロジック2
        # 内部で self._data2が使える
```
```python: アプリケーション層.py
def application():
    if ...:
        data1 = fetchdata1()
        domain_logic = DomainLogic1(data1=data1) # DomainLogicを構築 データも渡せる
    elif ...:
        data2 = fetchdata2()
        domain_logic = DomainLogic2(data2=data2)
    domain_service_output = domain_service(domain_service_input, domain_logic)  # 関数オブジェクトを渡す
```

このように `DomainLogic` を抽象クラスとし、具体的なロジックはこれを継承させた `DomainLogic1`, `DomainLogic2` に担当してもらうようにしました。
※本来はインターフェースを使いたいところですが、Pythonにはインターフェースがないので代わりに抽象クラスが使われるのが一般的です。

これによって

:::note info
* `domain_service` を含むドメイン抽象層はずっしり安定し、機能拡張の際も触らなくてよくなった。ドメイン具体層に新しい`DomainLogic` サブクラスを書き足すだけでOK
* `DomainLogic` のコンストラクタに情報 `data` を渡しておくことで、ロジックが追加的な情報を使った振る舞いを持てるようになる（しかもそれを `domain_service` は知らなくてよい）
:::

というメリットを両立させることができました。
オブジェクト間の依存関係を書くとこんな感じ:

```mermaid
graph BT;
    domain_service-->DomainLogic
    DomainLogic1,2-->DomainLogic
    application-->domain_service
    application-->DomainLogic1,2
```

`DomainLogic`や`domain_service`が、具体クラスである `DomainLogic1` `DomainLogic2` の追加変更の影響を受けない様子がわかります。

これでだいぶ変更に強い構造になりましたが、その代償として

:::note alert
登場するモジュールの数や役割が増え、ソースコードが追いにくくなる
:::

というデメリットがでてきてしまいます。
なんでもかんでもここまで抽象化するのはやりすぎかなと思います、構造の意図を新メンバーに理解してもらうためにドキュメント化するなど労力も大きくなっていきます。


## まとめ
登場した5つのパターンのメリデメを整理します:

|  | 機能拡張に対する<br>ドメイン層の安定性 | ドメインロジックが<br>使える情報 | コードの素朴さ |
| --- | :---: | :---: | :---: |
| 1.引数で切り替え | ❌❌ | ❌ | ✅ |
| 2.関数を切り替え | ❌ | ❌ | ✅ |
| 3.関数オブジェクト渡し | ✅ | ❌ | ✅ |
| 4.関数クラスを導入 | ❌ | ✅ | ❌ |
| 5.抽象関数クラスを継承 | ✅ | ✅ | ❌❌ |

拡張性を得る代わりに理解しやすさが犠牲になるトレードオフであることがわかります。
開発チームのフェーズやドメインの性質によって、上記のパターンを使い分けていこうと思います。