はじめに
Strategyパターンは、オブジェクト指向プログラマーが全員が知っておくべき、強力なツールです。
うまく活用すると、プログラムはより柔軟になり、新しい機能を追加するために既存のコードを変更する必要がなくなるので、より変化に強くなります。
この記事では、Strategyパターンとは何か、どのような構造なのか、そして今すぐあなたのコードで使う方法を紹介します。
Strategyパターン
概要
Strategyパターンでは、同じインターフェイスを実装する交換可能な「アルゴリズム」をいくつか定義して、プログラム実行時に適切なアルゴリズムを選択します。
ここでいう「アルゴリズム」は「複数あるやり方の中の一つのやり方」という意味です。例えば、ファイルをアップロードする機能に例えると、S3 にアップロードするか、Google Cloud Storage にアップロードするか、あるいはローカルファイルシステムの /mnt
ディレクトリーに入れるか、それぞれの方法が「アルゴリズム」になるわけです。
実際にアルゴリズムを使うのは「コンテキスト」と呼ばれるクラスです。コンテキストのコンストラクターにアルゴリズムオブジェクトを渡して、インスタンス変数に設定します。このように「コンテキストがアルゴリズムを持つ」関係を コンポジション と言います。
プログラム実行時に、コンテキストがアルゴリズムのメソッドを呼ぶことで、一部の処理を委託します。
class Context:
def __init__(self, strategy):
self.strategy = strategy
def upload(self):
self.strategy.upload()
コンテキストは、どのアルゴリズムを持っているかは分からないので入れ替えることができるわけです。
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つのメンバーで構成されています。
-
Strategy
- アルゴリズムが実装する共通のインターフェイス
-
ConcreteStrategy
- Strategy インターフェイスを実装するクラス(例:RandomForest や LinearRegression)
-
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 やローカルファイルシステムの「ストレージ基盤」を入れ替えています。
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
に出力したかったらどうしますか?
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)
こうなります。
パターンが増えるたびに、クラスも増えます。GoogleCloudStorage、AzureBlob、GoogleDrive、HDFSの追加など想像したくないですね!どんどん可読性が悪くなってメンテナンスしにくくなっていきます。
継承に頼りすぎると、クラスの役割が増えてしまい、プログラムの柔軟性が下がる
Strategyパターンを使うことで、アルゴリズムとコンテキスト(このコード例でいう Job
クラス)を切り離して管理するため、それぞれの役割が明確になります。そして、アルゴリズムを交換できるので、コードを変更しなくても、多様なパターンに対応できるようになります。
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 のインターフェイスで、Zip
や Gzip
がそのインターフェイスを実装する ConcreteStrategy(つまりアルゴリズム)です。
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
にする必要はありません。Job
や S3Client
など、圧縮アルゴリズムを使いたいクラスのことなので、自由に決めてください。
コンテキストに、Compression
タイプのオブジェクトを渡して、インスタンス変数に設定します。コンテキストが Compression
オブジェクトを持つ関係を「コンポジション」と言います。
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 クラスを変えます。
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(アルゴリズム)を実装します。
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 など自由に変えられるわけです。
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 を決めます。
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パターンの概要と使い方を紹介しました。これからご自身の開発で使ってみてください。
<!-- 役に立つにキマットル -->
デザインパターンについてもっと知りたい方は、以下の本をおすすめします。