Strategy — 関数が第一級であること
関数で足りる場合と、オブジェクトとして束ねる価値がある場合を分けます。
このパターンが解こうとした問題
GoF は Strategy を、一群のアルゴリズムから 1 つを実行時に選べるようにするパターンとして収録しています1。
if や switch でアルゴリズムを選ぶコードは、種類が増えるたびに同じ場所を書き換えることになります。Strategy はアルゴリズムを別々のオブジェクトに切り出し、選択を「どのオブジェクトを渡すか」に変えます。
これも言語側の前提が効いているパターンです。アルゴリズムそのものを値として渡せない言語では、アルゴリズムを持ち回るための入れ物が要り、その入れ物がクラスでした。
クラスで素直に書く
ファイルの圧縮方式の切り替えを題材にします。
interface CompressionCodec {
compress(input: Uint8Array): Uint8Array;
}
class GzipCodec implements CompressionCodec {
compress(input: Uint8Array): Uint8Array {
return input; // 圧縮の中身は省略
}
}
class BrotliCodec implements CompressionCodec {
compress(input: Uint8Array): Uint8Array {
return input; // 圧縮の中身は省略
}
}
class AssetWriter {
constructor(private readonly codec: CompressionCodec) {}
write(input: Uint8Array): Uint8Array {
return this.codec.compress(input);
}
}
const writer = new AssetWriter(new BrotliCodec());
AssetWriter は圧縮方式を知りません。方式を足すときも AssetWriter には触れません。設計としては正しく動いています。
ただし GzipCodec を見ると、クラスの中身は compress 1 つだけです。状態を持たないクラスは、関数を 1 つ包んだだけの入れ物になっています。
言語機能で置き換える
TypeScript では関数そのものを値として渡せます。ハンドブックはこう書いています。
Functions are the basic building block of any application, whether they're local functions, imported from another module, or methods on a class. They're also values, and just like other values, TypeScript has many ways to describe how functions can be called.2
関数の型は矢印で書けます。
type Compress = (input: Uint8Array) => Uint8Array;
const gzip: Compress = (input) => input; // 圧縮の中身は省略
const brotli: Compress = (input) => input; // 圧縮の中身は省略
function writeAsset(input: Uint8Array, compress: Compress): Uint8Array {
return compress(input);
}
const output = writeAsset(new Uint8Array([1, 2, 3]), brotli);
interface も 2 つのクラスもなくなり、残ったのは型 1 つと関数 2 つです。差し替えられるという性質は保たれています。
writeAsset の引数を見れば、この関数が圧縮方式を外から受け取ることがわかります。クラスの版では、それを知るにはコンストラクタを見る必要がありました。依存が呼び出しの形に現れるのは、関数に寄せたときの副産物です。
戦略が状態や複数の操作を持つとき
圧縮と展開が対で必要なら、話が変わります。圧縮と展開を別々の関数として持ち回ると、対応しない組み合わせを渡せてしまいます。1 つの値にまとめておけば、その値を渡すかぎり対応が保たれます。
type Codec = {
compress: (input: Uint8Array) => Uint8Array;
decompress: (input: Uint8Array) => Uint8Array;
contentEncoding: string;
};
const gzipCodec: Codec = {
compress: (input) => input,
decompress: (input) => input,
contentEncoding: 'gzip',
};
function writeAsset(input: Uint8Array, codec: Codec): {body: Uint8Array; encoding: string} {
return {body: codec.compress(input), encoding: codec.contentEncoding};
}
// 保存したものを読み戻す。圧縮と展開に同じ codec が使われることが型で決まる
function readAsset(body: Uint8Array, codec: Codec): Uint8Array {
return codec.decompress(body);
}
const asset = writeAsset(new Uint8Array([1, 2, 3]), gzipCodec);
const restored = readAsset(asset.body, gzipCodec);
まとまりをオブジェクトにすると、圧縮と展開が別々の方式になる書き方が現れません。ここで使っているのは素のオブジェクトで、クラスではありません。
本連載の判断
判定: 条件付き。束ねる必要があるかで決まります。
- 操作が 1 つで状態を持たないなら関数 — 引数として渡すだけです。型は関数型 1 つ、実装は関数です。
interfaceもクラスも書きません。 - 操作が複数あり、それらが揃っている必要があるならオブジェクト — 上の
Codecの形です。まとまりの整合を型が保証します。 - 戦略自身が状態を持つならクロージャかクラス — 呼び出しをまたいで持ち回るもの (接続、統計の蓄積、内部バッファ) がある場合です。状態を閉じ込めた関数を返すファクトリ関数でも表せるので、クラスは選択肢の 1 つにすぎません。
3 つのどれでも、AssetWriter のような受け取る側は変わりません。選択肢は戦略の側の形だけです。
「クラスにするか関数にするか」ではなく、**「束ねる必要があるか」「持ち回る状態があるか」**の 2 つを順に見るのが、判断の道筋として扱いやすくなります。
この判定が覆る条件
関数に寄せると、戦略に名前が付かないことがあります。writeAsset(input, (x) => x) のように無名関数を直接書けてしまうので、ログやエラーメッセージで「どの戦略だったか」を出せません。戦略の識別が運用上必要なら、名前を持つオブジェクトに寄せる理由になります。
もう 1 つ、戦略の集合を設定ファイルや外部入力から選ぶ場合は、文字列から戦略への対応表が要ります。この対応表は Factory Method で扱った Record の形になり、Strategy と Factory の議論が合流します。
既存記事との関係
本サイトには Strategy に相当する実例が複数ありますが、いずれもパターン名を出していません。本章はそれらと同じ構図を、パターンの側から扱っています。既存記事とは題材を変えてあります。
- SOLID 原則 の開放閉鎖の原則の例が、文字列による分岐をインターフェースと複数実装に置き換える構図です。原則の説明が主題で、パターン名は出てきません。
- クラスとインターフェース にも、振る舞いをコンストラクタで注入する例があります。
- 条件分岐 が、
switchをオブジェクトの対応表に置き換える書き方を扱っています。本章の「文字列から戦略を引く」話は、そこで扱われている手法と同じ形です。
原則としての「なぜそうするか」は上記の記事が扱い、本章は「TypeScript でどの形を選ぶか」を扱う、という分担になります。