コーディング · デザインパターン
Composite パターンを例でわかりやすく解説
結論
Composite パターンは、オブジェクトを木構造に組み立て、個々のオブジェクトとオブジェクトのグループを同じように扱えるようにする構造パターンです。葉(ファイルなど)と複合体(ファイルや他のフォルダーを含むフォルダーなど)は、たとえば size() のような同じインターフェイスを実装します。複合体は、その操作を子に対して呼び出すことで実装するため、クライアントコードは、ノードの種類を確認せずに木全体を扱えます。
目的
オブジェクトを木構造に組み立てて、部分と全体の階層を表現します。Composite パターンでは、クライアントが個々のオブジェクトとオブジェクトの集合体を一様に扱えます。Gang of Four(GoF)の書籍にある7つの構造パターンの1つです。
解決する問題
どの項目についても合計サイズを表示しなければならないストレージアプリを想像してください。ファイルにはサイズがあります。フォルダーのサイズは中身すべての合計で、フォルダーにはファイルや他のフォルダーを任意の深さまで入れ子にして含められます。
共通のインターフェイスがないと、木をたどるすべてのコードが、「これはファイルかフォルダーか」と尋ねて、それぞれの場合を処理しなければならず、たいていは何度も同じ再帰を書くことになります。ショートカットや圧縮アーカイブのような新しいノードの種類を加えるには、それらのチェックをすべて探し出して更新することになります。Composite パターンは、再帰をノード自身の中に入れます。
構造と登場要素
| 要素 | 役割 | 例では |
|---|---|---|
| Component | 木に含まれるすべてのオブジェクトに共通のインターフェイスを宣言します。 | FsNode |
| Leaf | 子を持たないオブジェクトで、操作を直接実装します。 | FileNode |
| Composite | 子のコンポーネントを保持し、それらに委譲して操作を実装します。 | Folder |
| Client | Componentインターフェイスを通じて任意のノードを扱います。 | report |
TypeScriptでの実装
interface FsNode {
readonly name: string;
size(): number;
print(indent?: string): string[];
}
class FileNode implements FsNode {
constructor(readonly name: string, private bytes: number) {}
size(): number { return this.bytes; }
print(indent = ""): string[] { return [`${indent}${this.name} (${this.bytes})`]; }
}
class Folder implements FsNode {
private children: FsNode[] = [];
constructor(readonly name: string) {}
add(...nodes: FsNode[]): this { this.children.push(...nodes); return this; }
size(): number { return this.children.reduce((sum, c) => sum + c.size(), 0); }
print(indent = ""): string[] {
const lines = [`${indent}${this.name}/ (${this.size()})`];
for (const child of this.children) lines.push(...child.print(indent + " "));
return lines;
}
}
const root = new Folder("project").add(
new FileNode("README.md", 120),
new Folder("src").add(new FileNode("main.ts", 900), new FileNode("util.ts", 300)),
);
// The client treats a single file and a whole tree the same way.
const report = (node: FsNode) => console.log(node.print().join("\n"));
report(root);
// project/ (1320)
// README.md (120)
// src/ (1200)
// main.ts (900)
// util.ts (300)
report(new FileNode("notes.txt", 42)); // notes.txt (42)report はノードの種類を一度も確認しません。サイズ0を報告する Symlink のような新しい種類のノードは、FsNode を実装するだけで済みます。
設計上の判断:透過性と安全性
add() や remove() のような子を管理するメソッドは、どこに置くべきでしょうか。GoFの書籍はそのトレードオフを説明しています。
| 選択 | 意味 | トレードオフ |
|---|---|---|
| 安全性 | 上の例のように、Composite だけが add() と remove() を持ちます。 | 型安全です。ファイルに子を追加することはできません。ただし、クライアントは複合体を持っているときを把握している必要があります。 |
| 透過性 | Component が add() と remove() を宣言し、葉はそれらを例外にするか無視します。 | クライアントからはすべてのノードが同一に見えますが、間違いは実行時にしか現れません。 |
TypeScriptでは、型チェッカーがコンパイル時に間違いを捕捉できるため、通常は安全性を取るアプローチのほうがうまく機能します。
使うべきとき、使わないとき
次の場合はComposite パターンを使います。
- ファイルとフォルダー、コンテナ内のUIウィジェット、メニューとサブメニュー、組織図、図形とグループなど、データが自然に木構造である場合。
- クライアントが、1つのオブジェクトとオブジェクトのグループとの違いを意識せずに済むようにしたい場合。
次の場合は避けます。
- 構造がフラットな場合や、常に1階層の深さしかない場合。単純な配列のほうが簡単です。
- 葉とグループが、まったく異なる操作を必要とする場合。1つのインターフェイスに押し込むと、一部のノードにとっては意味をなさないメソッドができてしまいます。
- 複合体が保持できる子を、特定のノードの型のみに制限する必要がある場合。統一されたインターフェイスのせいで、型システムでそうしたルールを強制するのが難しくなります。
実際の利用例
- DOM:すべての要素とテキストノードは
Nodeです。要素は他のノードを含むことができ、textContentやcloneNode(true)など多くの操作が、単一のノードにも部分木全体にも同じように働きます。 - React、Vue、AngularなどのUIコンポーネントフレームワークは、コンポーネントが他のコンポーネントを描画できる木としてインターフェイスを構築します。
- Java AWTとSwing:
ContainerはComponentのサブクラスで他のコンポーネントを保持するため、ボタンでいっぱいのパネルも、それ自体がコンポーネントです。 - コンパイラの抽象構文木、ゲームエンジンのシーングラフ、入れ子のメニューも、すべて同じ考え方に従っています。
関連するパターン
- Iterator パターン:子の保持方法を公開せずに、複合体のノードをたどります。
- Visitor パターン:ノードのクラスを変更せずに、複合体の木に新しい操作(エクスポート、検証、統計)を追加します。
- Decorator パターン:こちらもコンポーネントのインターフェイスを持ち、オブジェクトを再帰的にラップしますが、Decoratorの子はちょうど1つで振る舞いを追加するのに対し、複合体は多くの子を持ち、それらの結果を結合します。
- Builder パターン:複雑なComposite パターンの木を段階的に構築するのによく使われます。
- Chain of Responsibility パターン:ブラウザーのイベントがDOMを上方向にバブリングするように、複合体の親へのリンクを使って、リクエストを子から親に渡すことがよくあります。
よくある質問
葉と複合体の違いは何ですか?
葉は子を持たず、自分で作業を行います。複合体は子のコンポーネントを保持し、通常は各子に対して同じ操作を呼び出して結果を結合することで作業を行います。
Composite パターンは木構造専用ですか?
木の形をした部分と全体の階層向けに設計されています。サイクルのある一般的なグラフでは、無限再帰を避けるために、訪問済みのノードを記録するなどの追加の配慮が必要です。
Composite パターンとDecorator パターンの違いは何ですか?
どちらも共通のインターフェイスと再帰的な合成を使います。Decoratorは、振る舞いを追加するためにちょうど1つのオブジェクトをラップします。Compositeは、多くの子をグループ化して、1つのものとして扱います。
add() は葉のクラスにも置くべきですか?
完全な透過性が欲しく、葉に子を追加されたときの実行時エラーを受け入れる場合に限ります。型付き言語では、通常、add() を複合体だけに置くほうが安全です。