コーディング · デザインパターン
Prototype パターンを例でわかりやすく解説
結論
Prototype パターンは、new でゼロから作る代わりに、既存のインスタンス(プロトタイプ)をコピーして新しいオブジェクトを作る生成パターンです。各プロトタイプが clone() メソッドを実装するので、クライアントコードは具象クラスを知らなくてもオブジェクトを複製できます。設定にコストがかかるオブジェクトや、プリセットのバリエーションが多いオブジェクトに便利です。主な落とし穴はシャローコピーで、入れ子のオブジェクトもコピーしないと、複製と元のオブジェクトがそれらを共有してしまいます。
目的
プロトタイプとなるインスタンスを使って作成するオブジェクトの種類を指定し、そのプロトタイプをコピーして新しいオブジェクトを作ります。Prototype パターンは、Gang of Four(GoF)の書籍にある5つの生成パターンの1つです。
解決する問題
プリセットの図形パレットを持つ図表エディターを想像してください。赤い警告ボックス、青いメモ、緑の「完了」バッジといったプリセットで、それぞれにサイズ、色、フォント、スタイルルールのリストがあります。ユーザーがプリセットをキャンバスにドラッグしたとき、エディターは、まさにその設定から始まる、新しい独立した図形を必要とします。
このパターンがなければ、エディターはプリセットごとに別々の生成関数を用意し、すべての具象図形クラスを知っていなければなりません。さらに悪いことに、ユーザーが実行時に定義したプリセットは、設定を事前に知っているコードがないため、そもそも作成できません。Prototype パターンなら、エディターは単に preset.clone() を呼ぶだけです。
構造と登場要素
| 要素 | 役割 | 例では |
|---|---|---|
| Prototype | 自分自身を複製するためのインターフェイスを宣言します。 | clone() を持つ Shape |
| ConcretePrototype | 自身の状態をコピーして clone() を実装します。 | Box |
| Client | プロトタイプに自身を複製させて、新しいオブジェクトを作ります。 | エディターのコード |
| プロトタイプレジストリ(省略可能) | 名前付きのプロトタイプを保存し、クライアントが検索できるようにします。 | palette マップ |
TypeScriptでの実装
clone() は入れ子のオブジェクトや配列を含むすべてのフィールドをコピーするので、複製は元のオブジェクトから独立します。
interface Shape {
clone(): Shape;
describe(): string;
}
class Box implements Shape {
constructor(
public width: number,
public height: number,
public style: { fill: string; border: string },
public tags: string[],
) {}
clone(): Box {
// copy nested objects too, so the clone shares nothing mutable
return new Box(this.width, this.height, { ...this.style }, [...this.tags]);
}
describe(): string {
return `${this.width}x${this.height} ${this.style.fill} [${this.tags.join(", ")}]`;
}
}
// A registry of named prototypes, which could also be filled at run time.
const palette = new Map<string, Shape>([
["warning", new Box(120, 40, { fill: "red", border: "black" }, ["alert"])],
["note", new Box(160, 80, { fill: "blue", border: "none" }, [])],
]);
const a = palette.get("warning")!.clone() as Box;
a.width = 200;
a.tags.push("urgent");
console.log(a.describe()); // 200x40 red [alert, urgent]
console.log(palette.get("warning")!.describe()); // 120x40 red [alert]エディターは Box の名前を一度も出しません。新しいプリセットは、レジストリの新しい項目であり、新しいクラスや新しい生成関数ではありません。
シャローコピーとディープコピー
Prototype パターンで最もよくあるバグは、複製が入れ子のオブジェクトを元のオブジェクトと共有してしまうことです。スプレッド構文によるコピーはシャローコピーで、最上位の階層だけをコピーします。
const original = { name: "note", style: { fill: "blue" } };
const shallow = { ...original };
shallow.style.fill = "green";
console.log(original.style.fill); // green (shared object was changed)
const deep = structuredClone(original);
deep.style.fill = "yellow";
console.log(original.style.fill); // green (unchanged)structuredClone() は最近のブラウザーとNode.jsに組み込まれており、入れ子のオブジェクト、配列、Map、Set、Date を含むプレーンなデータをディープコピーします。ここで重要な制限が2つあります。
- クラスのインスタンスはプレーンなオブジェクトとして戻ります。コピーはプロトタイプを失うため、
describe()のようなメソッドがなくなります。 - 関数とDOMノードは複製できません。
structuredClone()はDataCloneErrorを投げます。
使うべきとき、使わないとき
次の場合はPrototype パターンを使います。
- オブジェクトのセットアップにコストがかかり(ファイルから読み込む、計算する、多くのステップで設定する)、似たコピーが多数必要な場合。
- ユーザーが作成するプリセットやテンプレートのように、バリエーションの集合が実行時に定義される場合。
- 具象クラスに依存せず、並行したファクトリの階層も作らずに、オブジェクトを作りたい場合。
次の場合は避けます。
- オブジェクトの作成が安価で単純な場合。コンストラクタやファクトリ関数を呼ぶほうが明快です。
- 開いているファイル、ソケット、一意のIDなど、複製すべきでないリソースをオブジェクトが保持している場合。これらの複製には特別な扱いが必要です。
- オブジェクトに循環参照や複雑な所有関係があり、正しいディープコピーが難しい場合。
実際の利用例
- JavaScript自体がプロトタイプベースです。
Object.create(proto)は、既存のオブジェクトを継承する新しいオブジェクトを作ります。これは関連はありますがこのパターンと同一ではありません。新しいオブジェクトがプロトタイプをコピーするのではなく、プロトタイプに委譲するためです。 - DOM:
node.cloneNode(true)は要素とその子をディープコピーします。繰り返し使うUIの断片を量産する一般的な方法です。 - Java:クラスは
Cloneableインターフェイスを実装してObject.clone()をオーバーライドできますが、多くのJava開発者はコピーコンストラクタを好みます。 - ゲームエンジンやエディターでは、あらかじめ設定したテンプレート(プレハブとも呼ばれます)から新しいオブジェクトを生成するのが一般的です。
関連するパターン
- Abstract Factory パターン:ファクトリがプロトタイプの集合を保持し、それを複製して製品を作れます。ファミリーごとにファクトリのサブクラスが不要になります。
- Factory Method パターン:サブクラスを通じてオブジェクトを作ります。Prototype パターンは、インスタンスをコピーすることでそのサブクラスを不要にします。
- Composite パターンとDecorator パターン:これらを多用する設計は、Prototype パターンの恩恵を受けることがよくあります。設定済みの構造を再構築するより、コピーするほうが簡単だからです。
- Memento パターン:こちらもオブジェクトの状態をコピーしますが、後で復元するためであり、新しい独立したオブジェクトを作るためではありません。
よくある質問
Prototype パターンはJavaScriptのプロトタイプと同じですか?
いいえ。JavaScriptのプロトタイプは継承のための言語の仕組みで、オブジェクトは存在しないプロパティをプロトタイプに委譲します。Prototype パターンは、オブジェクトの独立したコピーを作ります。
clone() はディープコピーとシャローコピーのどちらにすべきですか?
変更可能な入れ子のデータについては、複製と元のオブジェクトが影響し合わないように、通常はディープコピーにします。不変の値や、共有する読み取り専用のリソースは、安全に共有したままで構いません。
structuredClone() を使って clone() を実装できますか?
プレーンなデータオブジェクトに限ります。クラスのインスタンスの場合、structuredClone() はメソッドのないプレーンなオブジェクトを返すので、コピーのロジックを自分で書くか、コンストラクタでインスタンスを再構築します。
Prototype パターンがファクトリより優れているのはどんなときですか?
バリエーションが多い、実行時に設定される、またはセットアップにコストがかかる場合です。そのときは、既製のインスタンスを複製するほうが、バリエーションごとに生成関数を書くより簡単です。