124
99

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Strategyパターン】すべての開発者が知っておくべき強力なツール

Last updated at Posted at 2022-06-29

はじめに

Strategyパターンは、オブジェクト指向プログラマーが全員が知っておくべき、強力なツールです。

うまく活用すると、プログラムはより柔軟になり、新しい機能を追加するために既存のコードを変更する必要がなくなるので、より変化に強くなります

この記事では、Strategyパターンとは何か、どのような構造なのか、そして今すぐあなたのコードで使う方法を紹介します。

Strategyパターン

概要

Strategyパターンでは、同じインターフェイスを実装する交換可能な「アルゴリズム」をいくつか定義して、プログラム実行時に適切なアルゴリズムを選択します。

ここでいう「アルゴリズム」は「複数あるやり方の中の一つのやり方」という意味です。例えば、ファイルをアップロードする機能に例えると、S3 にアップロードするか、Google Cloud Storage にアップロードするか、あるいはローカルファイルシステムの /mnt ディレクトリーに入れるか、それぞれの方法が「アルゴリズム」になるわけです。

実際にアルゴリズムを使うのは「コンテキスト」と呼ばれるクラスです。コンテキストのコンストラクターにアルゴリズムオブジェクトを渡して、インスタンス変数に設定します。このように「コンテキストがアルゴリズムを持つ」関係を コンポジション と言います。

プログラム実行時に、コンテキストがアルゴリズムのメソッドを呼ぶことで、一部の処理を委託します。

context.py
class Context:
    def __init__(self, strategy):
        self.strategy = strategy

    def upload(self):
        self.strategy.upload()

コンテキストは、どのアルゴリズムを持っているかは分からないので入れ替えることができるわけです。

app.py
if url.startswith("s3://"):
    context = Context(strategy=S3())
elif url.startswith("gs://"):
    context = Context(strategy=GoogleCloudStorage())
else:
    context = Context(strategy=LocalStorage())

このように独立したアルゴリズムをコンテキストに渡すことで、クラスのコードを変えずに拡張できるようになります。拡張しやすくすることで、既存のコードを変更する必要がなくなり、その分のテスト修正やデグレ確認も不要になります。これがStrategyパターンの本質です。

コードを拡張しやすく設計すると、変化に対する適応力が上がる

いつ使うか

Strategyパターンは、以下のような場合に使うことが多いです。

1. いくつかの関連しているアルゴリズムの「やること」が同じで「やり方」だけ違う時

例えば、機械学習モデルを RandomForest で作っても DeepLearning で作っても「モデルを作っている」という事実は変わりません。異なるのは、学習/予測のアルゴリズムだけです。

2. ディスク容量、実行時間、ネットワーク速度のような物理的制限を考慮して実装する時

ネットワークが遅い時は、画像の画質を多少落として送信すると、ファイルサイズが小さくなるのでもっと早く送信できます。Strategyパターンで、動的に「ネットワーク速度」に応じて「画像を送信するアルゴリズム」を入れ替えることができます。

3. メソッドの振る舞いを if/else で分岐して実装している時

if/else の分岐条件をそれぞれ「アルゴリズム」として切り離して実装すると、if/else が少なくなり、実装したアルゴリズムを他のところで再利用できます。

構造

Strategyパターンは、以下の3つのメンバーで構成されています。

  1. Strategy
    • アルゴリズムが実装する共通のインターフェイス
  2. ConcreteStrategy
    • Strategy インターフェイスを実装するクラス(例:RandomForest や LinearRegression)
  3. Context
    • ConcreteStrategy をインスタンス変数として持つクラス(コンポジション)
    • ConcreteStrategy のメソッドを呼ぶことで、一部の処理を委託する

Pythonでの最低限の実装はこんな感じです。

import abc


class Strategy(abc.ABC):
    """
    アルゴリズム(ConcreteStrategy)が実装する共通のインターフェイス
    """
    @abc.abstractmethod
    def operation(self):
        pass


class ConcreteStrategyA(Strategy):
    """
    Strategy インターフェイスを実装するクラス
    """
    def operation(self):
        print("A")


class Context:
    """
    ConcreteStrategy をインスタンス変数として持つクラス
    """
    def __init__(self, strategy: Strategy):
        self.strategy = strategy

    def operation(self):
        # ConcreteStrategy のメソッドを呼ぶことで、一部の処理を委託する
        self.strategy.operation()


context = Context(strategy=ConcreteStrategyA())
context.operation()

Context のコンストラクターの引数の型が Strategy であって ConcreteStrategyA|B|C ではないことに気付きましたか?Context からアルゴリズムの詳細を隠しています。こうすることで、後からアルゴリズムが増えても Context のコードを修正する必要はありません。

サブクラスで十分ではないか?

もちろん、クラスを継承して振る舞いを一部変えることは可能ですが、Strategyパターンが使う「コンポジション」の方が、コードを変化に強くする効果があります。

実際のコード例で、サブクラスに頼りすぎたときの罠を見てみましょう。以下のコードで、データを input_path から output_path に移す Job クラスを定義しています。Job を継承して S3 やローカルファイルシステムの「ストレージ基盤」を入れ替えています。

subclass_example.py
class Job:
    """
    継承して「get_data」と「save_data」を実装してください
    """
    def __init__(self, input_path, output_path):
        self.input_path = input_path
        self.output_path = output_path

    def run(self):
        data = self.get_data()
        self.save_data(data)

    def get_data(self):
        pass

    def save_data(self, data):
        pass


class S3Job(Job):
    """
    S3にデータを格納/取得する
    """
    def get_data(self):
        return self.s3_bucket.get(self.input_path)

    def save_data(self, data):
        self.s3_bucket.put(data, self.output_path)


class LocalJob(Job):
    """
    ローカルファイルシステムにデータを格納/取得する
    """
    def get_data(self):
        return Path(self.input_path).read_bytes()

    def save_data(self, data):
        Path(self.output_path).write_bytes(data)

ここで、input_data を S3 から取得して、ローカルファイルシステムの output_path に出力したかったらどうしますか?

subclass_example.py
class S3InputLocalOutputJob(Job):
    def get_data(self):
        return self.s3_bucket.get(self.input_path)

    def save_data(self, data):
        Path(self.output_path).write_bytes(data)

こうなります。:sweat_smile:

パターンが増えるたびに、クラスも増えます。GoogleCloudStorage、AzureBlob、GoogleDrive、HDFSの追加など想像したくないですね!どんどん可読性が悪くなってメンテナンスしにくくなっていきます。

継承に頼りすぎると、クラスの役割が増えてしまい、プログラムの柔軟性が下がる

Strategyパターンを使うことで、アルゴリズムとコンテキスト(このコード例でいう Job クラス)を切り離して管理するため、それぞれの役割が明確になります。そして、アルゴリズムを交換できるので、コードを変更しなくても、多様なパターンに対応できるようになります。

strategy_example.py
class Job:
    def __init__(
        self,
        input_storage_strategy,
        input_path,
        output_storage_strategy,
        output_path
    ):
        # Strategyをコンストラクターで渡して、インスタンス変数にします
        self.input_storage_strategy = input_storage_strategy
        self.input_path = input_path
        self.output_storage_strategy = output_storage_strategy
        self.output_path = output_path

    def run(self):
        data = self.get_data()
        self.save_data(data)

    def get_data(self):
        # ロジックをStrategyに委託する
        self.input_storage_strategy.get(self.input_path)

    def save_data(self, data):
        # ロジックをStrategyに委託する
        self.output_storage_strategy.save(data, self.input_path)

実装例

さて、Strategyパターンとは何か、どのような構造なのかが分かったところで、今すぐ使う方法をいくつかのコード例で見てみましょう。

このセクションでは、Strategyパターンを使う2つのコード例を紹介します。両方の例では、基本的に同じことを(クラス名だけ変えて)繰り返していることに気付いてほしいです(クラス図がすべて同じように見える!)

データを圧縮する

データを圧縮することは、メールで送信するファイルのサイズを小さくしたり、ストレージを最適化できたりと、一般的な要件であることは言うまでもないと思います。ただ、同じ圧縮処理をいろんなところで書くことは非効率で、要件が変わったら、それだけ多くの修正が必要になってきます。

以下のクラス図のように、圧縮処理をStrategyパターンで「アルゴリズム化」して、再利用できるようにしましょう。

Compression が Strategy のインターフェイスで、ZipGzip がそのインターフェイスを実装する ConcreteStrategy(つまりアルゴリズム)です。

strategies.py
class Compression(ABC):
    @abstractmethod
    def compress(self, data):
        pass


class Zip(Compression):
    def compress(self, data):
        # ...


class Gzip(Compression):
    def compress(self, data):
        # ...


class Bzip2(Compression):
    def compress(self, data):
        # ...


class NoCompression(Compression):
    """
    圧縮を行わない NULL オブジェクト(デフォルト値に良い)
    """
    pass

次にコンテキストを実装します。クラスの名前を Context にする必要はありません。JobS3Client など、圧縮アルゴリズムを使いたいクラスのことなので、自由に決めてください。

コンテキストに、Compression タイプのオブジェクトを渡して、インスタンス変数に設定します。コンテキストが Compression オブジェクトを持つ関係を「コンポジション」と言います。

context.py
from strategies import Compression


class Context:
    def __init__(self, compression: Compression, output_path):
        self.compression = compression
        self.output_path = output_path

    def run(self):
        data = self.get_data()
        compressed = self.compression.compress(data)
        with open(self.output_path) as file:
            file.write(compressed)

    def get_data(self):
        # ...

main.py でユーザーが指定した「圧縮レベル」によって、使う Strategy クラスを変えます。

main.py
import sys

from context import Context
from strategies import Bzip2, Gzip, NoCompression, Zip


if __name__ == "__main__":
    compress_level = int(sys.argv[1])

    compression = NoCompression()

    if compress_level == 1:
        compression = Zip()
    elif compress_level == 2:
        compression = Gzip()
    elif compress_level >= 3:
        compression = Bzip2()

    context = Context(compression)
    context.run()

ストレージ

複数のストレージ基盤を使用したアプリケーションを開発したことはありますか?例えば、開発環境ならローカルファイルシステム、本番環境なら S3 を使うようなアプリケーションです。

コードの再利用をあまり意識しないで実装すると、以下のような if 文が数多くできてしまいます。

path = "path/to/file.csv"
if env == "prod":
    s3_bucket.put(data, path)
else:
    with open(path, "w") as file:
        file.write(data)

ここも Strategy パターンの出番です。以下のクラス図のように、それぞれのストレージクライアントを独立したクラスとして実装しましょう。

まずは、Strategy のインターフェイスと ConcreteStrategy(アルゴリズム)を実装します。

strategies.py
import abc
import io
from urllib.parse import urlparse

import boto3


class Storage(abc.ABC):
    """
    継承して `get` と `put` を実装してください
    """
    @abc.abstractmethod
    def get(self, uri):
        pass

    @abc.abstractmethod
    def put(self, data, uri):
        pass


class S3(Storage):
    def get(self, uri):
        bucket_name, object_name = self._parse_s3_uri(uri)
        file = io.BytesIO()
        boto3.client("s3").download_file(bucket_name, object_name, file)
        data = file.read()
        return data

    def put(self, data, path):
        ...

    def _parse_s3_uri(self, uri):
        parts = urlparse(uri)
        bucket_name = parts.netloc
        object_name = parts.path.lstrip("/")
        return bucket_name, object_name


class GoogleCloudStorage(Storage):
    ...


class LocalStorage(Storage):
    ...

次にコンテキストを実装します。input_path からデータを取得して output_path に保存する簡単なプログラムです。ストレージ Strategy を外から渡しているので、S3、GoogleCloudStorage など自由に変えられるわけです。

context.py
class Context:
    def __init__(
        self,
        input_storage: Storage,
        output_storage: Storage,
        input_path,
        output_path
    ):
        self.input_storage = input_storage
        self.output_storage = output_storage
        self.input_path = input_path
        self.output_path = output_path

    def run(self):
        data = self.get_data()
        self.save_data(data)

    def get_data(self):
        return self.input_storage.get(self.input_path)

    def save_data(self, data):
        self.output_storage.put(data, self.output_path)

プログラムのメインファイルで、ユーザーから「入力URL」と「出力URL」を受け取り、それぞれの URL のスキームによって ConcreteStrategy を決めます。

main.py
from context import Context
from strategies import GoogleCloudStorage, LocalStorage, S3


if __name__ == "__main__":
    input_uri, output_uri = sys.argv[1:3]

    if input_uri.startswith("s3://"):
        input_storage = S3()
    elif input_uri.startswith("gs://"):
        input_storage = GoogleCloudStorage()
    elif input_uri.startswith("file://"):
        input_storage = LocalStorage()
    else:
        raise ValueError()

    # .. output_uri で同じことを行う

    context = Context(input_storage, input_path, output_storage, output_path)
    context.run_program()

終わりに

以上、Strategyパターンの概要と使い方を紹介しました。これからご自身の開発で使ってみてください。
<!-- 役に立つにキマットル -->

デザインパターンについてもっと知りたい方は、以下の本をおすすめします。

124
99
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
124
99

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?