Composite — 再帰的な判別可能ユニオン
クラス階層と再帰的なユニオン型のどちらで木を表すかを比較します。
このパターンが解こうとした問題
GoF は Composite を、複数の似たオブジェクトを合成して 1 つのオブジェクトのように扱えるようにするパターンとして収録しています1。
木構造を扱うコードは、放っておくと「これは葉か、枝か」の分岐だらけになります。Composite は葉と枝に同じ形を持たせることで、扱う側から分岐を消します。ファイルとディレクトリを同じ FsNode として扱えれば、合計サイズを求める処理は再帰 1 本で書けます。
クラスで素直に書く
葉と枝の共通の親を作り、双方に同じ操作を実装します。
abstract class FsNode {
constructor(readonly name: string) {}
abstract totalSize(): number;
}
class FileLeaf extends FsNode {
constructor(
name: string,
private readonly bytes: number,
) {
super(name);
}
totalSize(): number {
return this.bytes;
}
}
class DirectoryNode extends FsNode {
private readonly children: FsNode[] = [];
add(child: FsNode): this {
this.children.push(child);
return this;
}
totalSize(): number {
return this.children.reduce((sum, child) => sum + child.totalSize(), 0);
}
}
const tree = new DirectoryNode('docs')
.add(new FileLeaf('index.md', 1200))
.add(new DirectoryNode('guide').add(new FileLeaf('intro.md', 800)));
const size = tree.totalSize();
呼び出し側は tree.totalSize() と書くだけで、中身がファイル 1 つでも入れ子の階層でも同じです。ここは GoF のねらいどおりです。
言語機能で置き換える
TypeScript の型エイリアスは自分自身を参照できます。葉と枝を判別用のプロパティで区別すれば、木そのものを型で書けます。
type FsNode =
| {kind: 'file'; name: string; bytes: number}
| {kind: 'directory'; name: string; children: FsNode[]};
function totalSize(node: FsNode): number {
switch (node.kind) {
case 'file':
return node.bytes;
case 'directory':
return node.children.reduce((sum, child) => sum + totalSize(child), 0);
default: {
// 種類を増やしてここを直し忘れると、この行が型エラーになる
const exhaustive: never = node;
return exhaustive;
}
}
}
const tree: FsNode = {
kind: 'directory',
name: 'docs',
children: [
{kind: 'file', name: 'index.md', bytes: 1200},
{
kind: 'directory',
name: 'guide',
children: [{kind: 'file', name: 'intro.md', bytes: 800}],
},
],
};
const size = totalSize(tree);
case 'directory' の中で node.children が読めるのは、kind による絞り込みが効いているからです。default の never は網羅チェックで、これはハンドブックが紹介している書き方にあたります2。
2 つの版の違いは、分岐が消えたかどうかではありません。クラスの版では分岐がメソッドの選択という形で存在していて、ユニオンの版では switch として目に見える形で存在しています。分岐の総量は変わりません。
変わったのは、データと操作が分かれたことです。
FsNodeは素のオブジェクトなので、JSON.parseの結果をそのまま当てはめられます。クラスの版では、読み込んだデータからインスタンスを組み立て直す処理が別に要ります。ただしJSON.parseの戻り値はanyなので、当てはめられるのは形が合っている前提の話です。外部から来るデータには実行時の検証が別途要ります。- 操作を足すとき、ユニオンの版は関数を 1 つ書くだけで型に触りません。クラスの版は
FsNodeに抽象メソッドを足し、すべての派生クラスに実装を足すことになります。 - 逆に、種類を足すときは立場が入れ替わります。ユニオンの版は既存の関数すべてが型エラーになり、順に直すことになります。クラスの版はクラスを 1 つ足すだけで済みます。
本連載の判断
判定: 条件付き。何が増えるかで決まります。
- 操作のほうが増えるならユニオン型 — 木の種類は決まっていて、集計・検索・変換・描画のように処理を足していく形です。関数を足すだけで既存の型に触りません。
- 種類のほうが増えるならクラス階層 — 扱うノードの種類が今後も増え、操作は固定に近い形です。クラスを足すだけで既存のクラスに触りません。
- 外部データを読み込むならユニオン型 — 木が JSON や API 応答から来る場合、素のオブジェクトのまま扱えることの利点が大きくなります。
アプリケーションのコードでは、操作が増えて種類が固定という状況のほうがよく起きます。既定はユニオン型に置き、種類が頻繁に増える見込みがあるときだけクラス階層を検討する、という順序が実務では扱いやすくなります。
この判定が覆る条件
ユニオン型の弱点は、種類を足すときの影響範囲が事前に読めないことです。FsNode を扱う関数が 30 個あれば、種類を 1 つ足した瞬間に、そのうち網羅チェックを書いてある関数すべてでコンパイルが止まります。変更のたびに広い範囲を触ることになるので、この規模ではクラス階層のほうが変更が局所に収まります。
注意すべきなのは、止まるのは網羅チェックを書いた箇所だけという点です。if (node.kind === 'file') { ... } else { /* ディレクトリ前提 */ } のように意図的に非網羅で書いた分岐は、種類が増えても静かに通ります。安全側に倒れるのは switch と never を使ったところに限られます。
もう 1 つ、ノードごとに固有の状態と、その状態を守る不変条件がある場合はクラスが向きます。素のオブジェクトは誰でも書き換えられるので、「ディレクトリの children は必ずソート済み」のような不変条件を型だけでは守れません。readonly を付けても、それは代入を止めるだけで配列の中身の順序までは保証しません。
既存記事との関係
- 条件分岐 が判別可能ユニオンと網羅チェックの基本を扱っています。本章はそれを再帰的な構造に当てはめた例で、基礎の説明は繰り返していません。
- クラスと継承 が抽象クラスと
implementsの使い分けを扱っています。本章のクラス版はその形をそのまま使っています。
なお、本章で使った再帰的な型エイリアスを扱った記事は、本サイトにはまだありません。書き方は本章の例で完結しています。