LoginSignup
18
31

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

Last updated at Posted at 2023-11-20

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

前提

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

  • ドメイン層.py
    • domain_logic 関数
    • domain_service 関数
  • アプリケーション層.py
    • application 関数
ドメイン層.py
def domain_logic(input: DomainInput) -> DomainOutput:
    # この中にドメイン固有のロジック

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

依存関係を図にするとこんな感じ:

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

  • domain_logic は将来に渡ってバリエーションを増やして機能拡張できるよう対応したい。
  • ドメイン層.py は中核的なモジュールで様々な場所から依存されているため、破壊的な変更を加えたくない。変更を加えるとしたら新しい関数やオブジェクトを書き足すだけにしたい(cf. 開放・閉鎖原則 - Wikipedia

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

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

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

ドメイン層.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)
アプリケーション層.py
def application():
    domain_service_output = domain_service(domain_service_input)

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

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

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

2. 関数自体を切り替え

ドメイン層.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()
アプリケーション層.py
def application():
    domain_service_output = domain_service(domain_service_input)

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

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

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

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

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

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

ここで、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) # 渡された関数オブジェクトを使う
アプリケーション層.py
def application():
    if ...:
        domain_logic = domain_logic1
    elif ...:
        domain_logic = domain_logic2
    domain_service_output = domain_service(domain_service_input, domain_logic)  # 関数オブジェクトを渡す

これで

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

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

※これは一般的に "ストラテジーパターン" とか "依存性の注入"と呼ばれるようです。

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

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

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

4. 関数クラスを導入

そこで、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__)
アプリケーション層.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 関数はそのインスタンスだという位置づけにしました。

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

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

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

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

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

最後に、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)
ドメイン具体層.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が使える
アプリケーション層.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にはインターフェースがないので代わりに抽象クラスが使われるのが一般的です。

これによって

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

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

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

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

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

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

まとめ

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

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

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

18
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
31