メインコンテンツまでスキップ

Factory Method と Abstract Factory — 生成を関数に寄せる

生成を関数に寄せられる条件と、Abstract Factory が要る条件を分けます。

このパターンが解こうとした問題

GoF はこの 2 つを別のパターンとして収録しています1

  • Factory Method — オブジェクトを生成するための手続きを定義しておき、どのクラスを実際に作るかはサブクラスに決めさせます。
  • Abstract Factory — 互いに関連するオブジェクトの一式を、具象クラスを名指しせずにまとめて作ります。

どちらも動機は同じで、new PngEncoder() のような具体的な生成を、それを使う側のコードから引き剥がすことです。使う側が具象クラスを直接名指ししていると、種類が増えるたびに使う側を書き換えることになります。

ここでも言語側の前提が効いています。GoF が主要な例に用いた C++ では、状態を捕捉した手続きを値として持ち回れませんでした (関数ポインタはありますが、周囲の変数を抱えられません)。生成の手続きを差し替える現実的な手段がクラスの継承とメソッドの上書きだったので、「生成用のクラス階層」という重い道具が要りました。

クラスで素直に書く

画像のサムネイル生成を題材にします。Factory Method をそのまま書くと、生成する側にも階層ができます。

type EncodedImage = {mimeType: string; bytes: Uint8Array};

interface ImageEncoder {
encode(pixels: Uint8Array): EncodedImage;
}

class PngEncoder implements ImageEncoder {
encode(pixels: Uint8Array): EncodedImage {
return {mimeType: 'image/png', bytes: pixels}; // 符号化の中身は省略
}
}

class WebpEncoder implements ImageEncoder {
encode(pixels: Uint8Array): EncodedImage {
return {mimeType: 'image/webp', bytes: pixels}; // 符号化の中身は省略
}
}

// Creator。どのクラスを作るかの決定をサブクラスに委ねる
abstract class ThumbnailJob {
protected abstract createEncoder(): ImageEncoder;

run(pixels: Uint8Array): EncodedImage {
return this.createEncoder().encode(pixels);
}
}

class PngThumbnailJob extends ThumbnailJob {
protected createEncoder(): ImageEncoder {
return new PngEncoder();
}
}

class WebpThumbnailJob extends ThumbnailJob {
protected createEncoder(): ImageEncoder {
return new WebpEncoder();
}
}

エンコーダを 1 つ増やすと、エンコーダのクラスとジョブのクラスが 1 つずつ増えます。形式の数だけ階層が二重に伸びていきます。

言語機能で置き換える

TypeScript では関数が値なので、生成の手続きをそのまま渡せます。上の 2 段構えは、関数型 1 つと関数 3 つに畳めます。

type EncodedImage = {mimeType: string; bytes: Uint8Array};
type ImageFormat = 'png' | 'webp' | 'avif';
type Encode = (pixels: Uint8Array) => EncodedImage;

const encodePng: Encode = (pixels) => ({mimeType: 'image/png', bytes: pixels});
const encodeWebp: Encode = (pixels) => ({mimeType: 'image/webp', bytes: pixels});
const encodeAvif: Encode = (pixels) => ({mimeType: 'image/avif', bytes: pixels});

function selectEncoder(format: ImageFormat): Encode {
switch (format) {
case 'png':
return encodePng;
case 'webp':
return encodeWebp;
case 'avif':
return encodeAvif;
default: {
// 形式を増やしてここを直し忘れると、この行が型エラーになる
const exhaustive: never = format;
return exhaustive;
}
}
}

// 使う側は「どう作るか」を受け取るだけでよい
function makeThumbnail(pixels: Uint8Array, encode: Encode): EncodedImage {
return encode(pixels);
}

default 節の never は、TypeScript ハンドブックが網羅チェックとして紹介している書き方です。

The never type is assignable to every type; however, no type is assignable to never (except never itself). This means you can use narrowing and rely on never turning up to do exhaustive checking in a switch statement.2

形式を増やして selectEncoder を直し忘れると、format に新しい値が残るため never への代入が型エラーになります。クラス階層の版では、ジョブのクラスを作り忘れてもコンパイラは何も言いません。「作り忘れ」を検出する役目を、階層ではなく型が担っているのがこの書き換えの中身です。

一式をまとめたいとき

Abstract Factory が扱うのは、組み合わせを間違えてはいけない生成物が複数あるときです。エンコーダと拡張子がちぐはぐだと壊れる、といった状況が該当します。この制約は、まとまりを返す関数で表せます。

type EncodedImage = {mimeType: string; bytes: Uint8Array};
// ここでは説明を短くするため 2 形式に絞る (前の例は avif を含む 3 形式)
type ImageFormat = 'png' | 'webp';

type ImageToolkit = {
encode: (pixels: Uint8Array) => EncodedImage;
fileExtension: string;
supportsTransparency: boolean;
};

// Record のキーを文字列リテラル型のユニオンにすると、形式の追加漏れがここで型エラーになる
const toolkits: Record<ImageFormat, ImageToolkit> = {
png: {
encode: (pixels) => ({mimeType: 'image/png', bytes: pixels}),
fileExtension: '.png',
supportsTransparency: true,
},
webp: {
encode: (pixels) => ({mimeType: 'image/webp', bytes: pixels}),
fileExtension: '.webp',
supportsTransparency: true,
},
};

export function selectToolkit(format: ImageFormat): ImageToolkit {
return toolkits[format];
}

一式が 1 つのオブジェクトに閉じているので、png のエンコーダと .webp の拡張子を取り違える書き方が構文として現れません。GoF が抽象クラスの組で保証していた整合性を、オブジェクトの形が保証しています。

本連載の判断

Factory Method の判定: 言語機能で代替できる。 生成の手続きを差し替えたいだけなら、関数を渡すか、種別から関数を引く形で済みます。生成側にクラス階層を作る理由は、TypeScript では通常ありません。

例外は、すでにクラス階層が存在していて、その各サブクラスが自分に対応する部品を作る必要があるときです。この場合は階層を新設するのではなく、既にある階層に生成メソッドを 1 つ足すだけなので、追加コストがほぼありません。

Abstract Factory の判定: 条件付き。 次の 2 つがそろったときだけ意味を持ちます。

  • 生成物が複数あり、それらが同じ系列で揃っていないと壊れる
  • 系列が実行時に決まる (設定・環境・ユーザーの選択などで切り替わる)

生成物が 1 つしかないなら、それは Factory Method の話であって Abstract Factory ではありません。系列が固定なら、そもそも切り替える仕組みが要りません。

この判定が覆る条件

関数に寄せる書き方は、生成のたびに重い準備が要る場合に弱くなります。接続の確立やモデルの読み込みのように、生成物の再利用が前提なら、生成した結果を抱えておく入れ物が要ります。そのときはオブジェクトを返す関数か、クラスを選ぶ判断になります。

また Record で網羅を強制する書き方は、キーの集合が閉じていることが前提です。形式が実行時に増える (プラグインとして後から登録される) 設計なら、Record は使えず、登録簿と既定値の組み合わせになります。

既存記事との関係

「ファクトリ」という語は、本サイトの別の記事でも使われています。指しているものが異なるので、区別を書いておきます。

  • エンティティ の「ファクトリメソッドのパターン」は、コンストラクタを非公開にして create()reconstruct() という名前付きの生成メソッドを用意する書き方を指しています。生成の意図に名前を付けるための idiom で、GoF の Factory Method のようにサブクラスへ決定を委ねる構成ではありません。本章は GoF の側から扱っているので、同じ語でも対象が違います。
  • 命名規則 ② 関数とクラス~Factory を「オブジェクト生成」を表すクラス名のサフィックスとして挙げています。本章の判断に従って関数で書く場合、その関数には create~select~ のような動詞の名前を付けることになります。

判別可能ユニオンと網羅チェックの基本は 条件分岐 で扱っています。本章はそれを生成の文脈で使った例にあたります。

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides『Design Patterns: Elements of Reusable Object-Oriented Software』(Addison-Wesley, 1994)。Factory Method と Abstract Factory はいずれも生成に関するパターンとして収録されています。意図の要約は本ガイドによるもので、原典とは照合していません。同書が C++ と Smalltalk を例に用いていることは二次情報で確認しました (出典: Design Patterns(Wikipedia))。

  2. 出典: Narrowing(TypeScript Handbook)の Exhaustiveness checking。