Template Method — 継承とフックの注入
継承による骨組み固定と、関数注入による合成を比較します。
このパターンが解こうとした問題
GoF は Template Method を、アルゴリズムの骨組みを抽象クラスとして定め、具体的な振る舞いをサブクラスに実装させるパターンとして収録しています1。
似た手順の処理が複数あるとき、共通部分をコピーして回ると、手順の変更が全箇所に波及します。Template Method は手順の順序を親クラスに 1 か所だけ書き、変わる部分を抽象メソッドとして開けておきます。
Strategy との違いは、差し替えの粒度です。Strategy はアルゴリズムまるごとを差し替えます。Template Method は骨組みを固定したまま、穴の部分だけを差し替えます。
クラスで素直に書く
取り込み処理を題材にします。読み込み・検証・集計という手順は共通で、解析の仕方は形式ごとに必ず違い、検証の仕方は形式によって変えたいことがあるとします。
type Row = Record<string, string>;
type ImportSummary = {inserted: number; skipped: number};
abstract class ImportJob {
// 手順を固定する。ここがテンプレートメソッド
run(raw: string): ImportSummary {
const rows = this.parse(raw);
const valid = rows.filter((row) => this.isValid(row));
return {inserted: valid.length, skipped: rows.length - valid.length};
}
// 必ず埋める穴
protected abstract parse(raw: string): Row[];
// 埋めても埋めなくてもよい穴 (フック)
protected isValid(_row: Row): boolean {
return true;
}
}
// 解析は説明用の簡略版。引用符・CRLF・列数の不一致は扱っていない
class CsvImportJob extends ImportJob {
protected parse(raw: string): Row[] {
const [header, ...lines] = raw.trim().split('\n');
const keys = header.split(',');
return lines.map((line) =>
Object.fromEntries(line.split(',').map((value, index) => [keys[index], value])),
);
}
}
class JsonImportJob extends ImportJob {
protected parse(raw: string): Row[] {
return JSON.parse(raw) as Row[];
}
protected override isValid(row: Row): boolean {
return row.id !== undefined;
}
}
const summary = new CsvImportJob().run('id,name\n1,alice\n2,bob');
手順は run の 4 行だけに書かれていて、形式が増えてもここは変わりません。パターンとして正しく機能しています。
言語機能で置き換える
穴を埋める手段はメソッドだけではありません。関数を受け取っても同じことができます。埋めなくてよい穴は省略可能なプロパティで表せます。
type Row = Record<string, string>;
type ImportSummary = {inserted: number; skipped: number};
type ImportHooks = {
parse: (raw: string) => Row[];
isValid?: (row: Row) => boolean;
};
function runImport(raw: string, hooks: ImportHooks): ImportSummary {
const rows = hooks.parse(raw);
const isValid = hooks.isValid ?? (() => true);
const valid = rows.filter(isValid);
return {inserted: valid.length, skipped: rows.length - valid.length};
}
const csvHooks: ImportHooks = {
parse: (raw) => {
const [header, ...lines] = raw.trim().split('\n');
const keys = header.split(',');
return lines.map((line) =>
Object.fromEntries(line.split(',').map((value, index) => [keys[index], value])),
);
},
};
const jsonHooks: ImportHooks = {
parse: (raw) => JSON.parse(raw) as Row[],
isValid: (row) => row.id !== undefined,
};
const summary = runImport('id,name\n1,alice\n2,bob', csvHooks);
「必ず埋める穴」は必須のプロパティ、「埋めなくてよい穴」は省略可能なプロパティとして、型の上に穴の一覧が並びます。クラスの版では、どれが abstract でどれがフックかを、親クラスの本体を読んで判断していました。
もう 1 つの違いは、継承の連鎖が起きないことです。クラスの版で CsvImportJob をさらに継承した派生が現れると、isValid がどこで上書きされているかを追うのに階層をたどることになります。オブジェクトを渡す形では、渡した場所を見れば済みます。
本連載の判断
判定: 条件付き。穴の数と、穴どうしが状態を共有するかで決まります。
- 穴が 1〜3 個で、穴どうしが独立しているならフックの注入 — 上の書き換えがそのまま当てはまります。型に穴の一覧が現れ、継承の階層も生じません。
- 穴が多い、または穴どうしが途中の状態を共有するなら抽象クラス — 継承では
protectedのフィールドを通じて、穴の実装が共通の状態に触れます。フックの注入で同じことをするには、状態を引数で回すか、閉包に閉じ込めることになります。**穴が 5 個を超えて、その半分が同じ中間データを必要とするなら、抽象クラスのほうが素直に書けます。**この目安は本ガイドの判断で、計測に基づくものではありません。
判断のとき見るのは「継承を避けたいか」ではなく、**「穴の実装どうしが同じデータを見る必要があるか」**です。見る必要がないなら、渡すだけで足ります。
この判定が覆る条件
フックの注入には、呼ばれる順序が型に現れないという弱点があります。parse の次に isValid が呼ばれることは runImport の中身を読まないとわかりません。抽象クラスでも事情は同じですが、フックがオブジェクトとして 1 か所に並ぶぶん、順序があるように見えにくくなります。
穴どうしに順序の依存がある場合 (前の穴が返した値を次の穴が受け取るなど) は、その受け渡しを型で表すほうが安全です。そこまでいくと Template Method ではなく、段階を並べた処理の合成として書くことになります。
既存記事との関係
本サイトには Template Method に相当する構造が複数ありますが、いずれもパターン名を出していません。本章は題材を変えたうえで、パターンの側から扱っています。
- クラスと継承 に、抽象クラスが共通処理を持ち可変部分を抽象メソッドに切り出す例があります。抽象クラスの文法そのものはそちらが正本です。
- クラスとインターフェース にも抽象クラスと派生クラスの例があります。
抽象クラスとインターフェースの使い分けの比較表も クラスと継承 にあります。本章では繰り返していません。