はじめに
前回の「デザインパターン②」に続き、今回は残りの7パターンをまとめました。デザインパターン①で6個、②で10個を扱ったので、今回の7個で GoFの23パターンがすべて出揃います。
このシリーズでは、先に SOLID原則、デザインパターン①(テンプレートメソッド・シングルトン・アダプター・イテレータ・ファクトリーメソッド・ファサード)、デザインパターン②(プロトタイプ・ビルダー・アブストラクトファクトリー・ストラテジー・ステート・責任の連鎖・コンポジット・デコレーター・プロキシ・ブリッジ)を解説しています。今回のパターンは①②のパターンと構造が似ているものも多いので、あわせて読むと違いがはっきりします。
今回扱うのは、オブザーバー・メディエーター・メメント・ビジター・コマンド・インタープリター・フライウェイト の7個です。内訳は振る舞いが6個、構造が1個で、②と同じくGoFの分類でグループ分けして進めます。各パターンは「概要 → 構成要素・オブジェクト指向的要素 → メリット・デメリット → 使いどころと実装」の順で解説します。
正直なところ、この7個は①②のパターンに比べて登場頻度が低く、クラス図だけ眺めても手応えが出にくいものが揃っています。そこで「何を分離/共有/集約しているのか」を一言で押さえることを軸に、TypeScriptで書くときにつまずく言語固有の事情(protected constructor の扱い、組み込み型との名前衝突、getMonth() のゼロ埋め)も一緒に拾っていきます。
この記事で分かること
- 残る7パターンを、たとえ・図・TypeScriptコードでイメージできる
- オブザーバーとメディエーター、メメントとコマンドのように「似ているが目的が違う」パターンを見分けられる
- ダブルディスパッチという仕組みと、ビジターが「操作の追加は簡単・構造の追加は困難」になる理由が分かる
- 共有していい情報(intrinsic)と共有してはいけない情報(extrinsic)の区別がつく
- TypeScript特有の落とし穴(パッケージ公開範囲がない、組み込みの
Fileとの衝突など)を回避できる
振る舞いに関するパターン
1. オブザーバー(Observer)
概要
オブザーバー(observer)は「観察者」という意味です。オブザーバーパターンは、観察対象のオブジェクトの状態が変化したときに、複数の観察者へ通知を行うパターンです。GoFの分類では「振る舞いに関するパターン」です。
ただし実際の構造は、観察者が能動的に見張るというより観察対象側が通知を投げる形になっています。そのためこのパターンは パブリッシュ・サブスクライブ(Publish-Subscribe)パターンと呼ばれることもあります。
構成要素とオブジェクト指向的要素
観測者側と観測対象側、2つのクラス階層から成り立ちます。
- Observer:観測者の抽象クラス/インターフェース。通知を受け取るAPIを定義する。リスナー、ハンドラー、サブスクライバーとも呼ばれる
- ConcreteObserver:Observer を継承/実装し、通知から現在の状態を受け取って状態に応じた処理を行う
- Subject:観測対象の抽象クラス/インターフェース。Observer のリストをフィールドに持ち、観測者の追加・削除・通知の共通メソッドと、具体処理用の抽象メソッドを定義する
- ConcreteSubject:Subject を継承/実装し、具体的な処理を実装する
継承とポリモーフィズムを利用したパターンです。Subject は Observer の共通APIを通じて通知するだけで、「誰に届くのか」を具体的には知りません。そのため観測者クラスの追加・削除・切り替えが自由になります。
メリット・デメリット
メリットは、Subject に観測者の追加・削除メソッドがあるため実行時に観測者を変更できること、観測者・観測対象がともに抽象に依存しているため両者の結びつきが弱まり、観測対象の追加や修正が容易になることです。
デメリットは、通知の順番に依存した実装にすると予期せぬバグが発生する点です。観測者A・B・Cが登録されているとき、A→B→Cの順で通知が届いてもC→B→Aで届いても正しく動くように書く必要があります。順番に依存すると、登録順を変えただけで壊れる再現しにくいバグになります。
使いどころと実装例
状態変化に応じた処理を行いたい場合(新規投稿やコメントに反応してポップアップを出す、未読バッジを更新する)や、オブジェクトを一時的に監視したい場合に有効です。ここでは人気商品の入荷通知を実装します。
interface Observer {
update(name: string): void;
}
class StoreObserver implements Observer {
update(name: string): void {
console.log(`${name}が入荷されました。仕入れが可能です`);
}
}
class PersonalObserver implements Observer {
update(name: string): void {
console.log(`${name}が入荷されました。購入が可能です`);
}
}
abstract class ItemSubject {
private observers: Observer[] = [];
constructor(private name: string) {}
attach(observer: Observer): void {
this.observers.push(observer);
}
detach(observer: Observer): void {
this.observers = this.observers.filter((obs) => obs !== observer);
}
notify(): void {
this.observers.forEach((obs) => obs.update(this.name));
}
// 再入荷時の処理は子クラスに任せる
abstract restock(): void;
}
class TvGameSubject extends ItemSubject {
private inStock = false;
isInStock(): boolean {
return this.inStock;
}
restock(): void {
console.log("テレビゲームの入荷");
this.inStock = true;
this.notify(); // 親クラスの共通メソッドで観測者全員に通知
}
}
// 使い方:通知先を登録してから再入荷させる
const tvGame = new TvGameSubject("新RPGゲーム");
tvGame.attach(new StoreObserver());
tvGame.attach(new PersonalObserver());
tvGame.restock();
// テレビゲームの入荷
// 新RPGゲームが入荷されました。仕入れが可能です
// 新RPGゲームが入荷されました。購入が可能です
同じ通知を受けても、店舗は「仕入れのため」、個人は「購入して使うため」に受け取っています。restock() は在庫フラグを立てて notify() を呼ぶだけで、通知先が誰かを一切知らない点がポイントです。
Angularで使われている RxJS は、このオブザーバーパターンを土台にしたライブラリです。ただし現在のAngularは同期的な状態管理向けに Signals も備えており、「状態はSignals、時間軸のある非同期ストリームはRxJS」という住み分けが主流になっています。RxJSが不要になったわけではありませんが、かつてのように何でもRxJSで書く前提ではなくなっている点は押さえておくとよいと思います(この住み分けはバージョンで変わり得るので、最新は公式ドキュメントで確認してください)。
2. メディエーター(Mediator)
概要
メディエーター(mediator)は「仲介者」という意味です。メディエーターパターンは、関連しあうオブジェクト間のやり取りを仲介者に集約し、オブジェクト同士が直接やり取りすることを制限するパターンです。やり取りを制限することで、オブジェクト同士の結びつきを弱めることを目的としています。GoFの分類では「振る舞いに関するパターン」です。
イメージしやすいのはアパートの大家・住人・仲介業者の関係です。大家と住人は仲介業者を介すことで、互いの顔を知らなくても契約を結べます。住人同士の隣人トラブルも、当事者が直接ぶつかるのではなく仲介業者を通すことで穏便に収まります。
構成要素とオブジェクト指向的要素
- Colleague:「同僚」の意味。仲介者とやり取りする抽象クラス/インターフェース。内部に仲介者オブジェクトを保持し、それを通じて他の Colleague とやり取りする
- ConcreteColleague:Colleague を継承/実装し、仲介者と通信する具体的な処理を実装する
- Mediator:仲介者の抽象クラス/インターフェース。Colleague と通信して調整するAPIを定義する
- ConcreteMediator:Mediator を継承/実装し、仲介の具体的な処理を実装する
カプセル化を利用したパターンです。Colleague は Mediator を介してのみ別の Colleague とやり取りできるため、Colleague 間のやり取りや関係性そのものが隠蔽されます。左の図のように全員を直結すると参加者が増えるほど線の数が急増しますが、右のように仲介者を立てれば各オブジェクトは仲介者だけを知っていればよくなります。
メリット・デメリット
メリットは、オブジェクト同士のやり取りをメディエーターに集約することで理解しやすく保守しやすい設計になること、Colleague 間の結びつきが弱まって修正しやすく再利用性も高まることです。関連するオブジェクト数が増えたときほど効果が大きくなります。
デメリットは、メディエーターがすべてのオブジェクトに結合した「ゴッドクラス」になりうる点です。調整ロジックが増えてきたら、役割ごとにメディエーターを分割することを検討してください。
使いどころと実装例
多くのオブジェクト同士が直接つながっている場合に有効です。ここでは複数ユーザーがチャットでメッセージをやり取りする例を実装します。ユーザーオブジェクト同士を直接つなぐと非常に複雑になるので、チャットルームという仲介者を立てて、送信はそこを介してのみ行います。
interface Mediator {
registerUser(user: User): void;
sendMessage(message: string, sender: User): void;
}
class ChatRoom implements Mediator {
private members: User[] = [];
registerUser(user: User): void {
this.members.push(user);
}
sendMessage(message: string, sender: User): void {
this.members.forEach((member) => {
// 送信者本人には配信しない
if (member !== sender) {
member.receive(message);
}
});
}
}
abstract class User {
constructor(
protected mediator: Mediator,
protected name: string,
) {}
abstract send(message: string): void;
abstract receive(message: string): void;
}
class ChatUser extends User {
send(message: string): void {
console.log(`${this.name} → メッセージ送信:${message}`);
// 宛先は知らない。仲介者に渡すだけ
this.mediator.sendMessage(`${message}(from ${this.name})`, this);
}
receive(message: string): void {
console.log(`${this.name} → メッセージ受信:${message}`);
}
}
// 使い方:全員をチャットルームに登録してから送信する
const chatRoom = new ChatRoom();
const yamada = new ChatUser(chatRoom, "山田");
const suzuki = new ChatUser(chatRoom, "鈴木");
const tanaka = new ChatUser(chatRoom, "田中");
chatRoom.registerUser(yamada);
chatRoom.registerUser(suzuki);
chatRoom.registerUser(tanaka);
yamada.send("こんにちは"); // 鈴木・田中に届く
tanaka.send("はじめまして"); // 山田・鈴木に届く
注目してほしいのは、ChatUser に他のユーザーに関する記述が一切ないことです。宛先の管理はすべて ChatRoom の責務なので、ユーザークラスは自分の名前と参加中のチャットルームだけを知っていればよくなり、シンプルに保てます。
なお Mediator が User を参照し、User が Mediator を参照する相互参照になりますが、TypeScriptの型宣言は記述順に依存しないので、両方を書き終えればエラーは消えます。
3. メメント(Memento)
概要
メメント(memento)は「記念品・形見・思い出」という意味です。メメントパターンは、オブジェクトの状態を保存しておき、状態が変化したあとでもその時点に戻せるようにするパターンです。いわゆる Ctrl + Z(Cmd + Z)のUNDO機能を実現します。GoFの分類では「振る舞いに関するパターン」です。
このパターンでは、状態を生成するクラスと、その履歴を管理するクラスを分離します。
構成要素とオブジェクト指向的要素
- Originator:保存対象となるクラス。内部状態を保存するメソッドとリストアするメソッドを持つ
- Memento:Originator の状態を保存するための記憶用クラス。記憶したオブジェクトの完全性を保証するため、Originator 以外からはインスタンス生成・状態変更ができないようにする
- Caretaker:内部に Memento を保持し、いつバックアップ/リストアするのかを管理するクラス
このパターンはカプセル化の破壊を防ぐためのパターンです。メメントを使わずに内部状態の保存・復元を行おうとすると、内部状態を外部からアクセスできるように公開する必要が出てきます。しかし不用意に公開すると安全性が下がり、そのクラスの内部情報に依存したクラスがプログラム中に点在して、変更が行いづらくなってしまいます。これをカプセル化の破壊といいます。
メメントパターンでは、保存対象である Originator 自身が保存操作を行うので、内部状態を非公開のままスナップショットを作成できます。
本来GoFのメメントは、Javaのパッケージプライベートのような「パッケージ単位の公開範囲」を前提に、Originator と Memento を同じパッケージに置くことで安全性を担保します。TypeScriptにはパッケージ単位のアクセス権がないため、ここではGoFの構造から少し変えて、メメントを親・Originator を子とする継承関係にし、メメントのコンストラクタを protected にする構成にしています。protected なコンストラクタは外部からは呼べませんが、それを継承したクラスの本体からは呼べるため、生成元を Originator だけに限定できます。
メリット・デメリット
メリットは、カプセル化を破壊せずにオブジェクトのスナップショットの作成・復元ができること、スナップショットの管理を Caretaker に任せることで Originator をシンプルに保てることです。
デメリットは、スナップショットであるメメントを大量に作成するとメモリ使用量が増加すること、そして実装する言語の仕様(パッケージ公開範囲の有無など)によって構成の変更が必要になることです。
使いどころと実装例
オブジェクトの状態を復元するためにスナップショットを作りたい場合に有効です。メモ帳などのUNDO機能が必要なアプリケーション、ゲームのセーブ機能などが典型例です。ここでは簡易的なメモ帳でUNDOを実装します。
class Memento {
// インスタンス生成時刻=スナップショットを作成した時刻
private date: string = new Date().toLocaleString("ja-JP");
// protected なので外部から new できない
protected constructor(protected memo: string) {}
getMemo(): string {
return this.memo;
}
getInfo(): string {
return `${this.date} / ${this.memo}`;
}
}
class NotePad extends Memento {
constructor(memo: string) {
super(memo);
}
addMemo(memo: string): void {
this.memo = memo;
}
save(): Memento {
console.log("メモを保存しました");
// 継承先なので protected なコンストラクタを呼べる
return new Memento(this.memo);
}
restore(memento: Memento): void {
this.memo = memento.getMemo();
}
}
class Caretaker {
constructor(
private notepad: NotePad,
private mementos: Memento[] = [],
) {}
backup(): void {
this.mementos.push(this.notepad.save());
}
undo(): void {
const memento = this.mementos.pop();
if (!memento) return; // 履歴が空なら何もしない
this.notepad.restore(memento);
}
showHistory(): void {
this.mementos.forEach((memento) => console.log(memento.getInfo()));
}
}
// 使い方:編集のたびにバックアップを取り、あとから巻き戻す
const notepad = new NotePad("First メモ");
const caretaker = new Caretaker(notepad);
caretaker.backup(); // First メモ の状態を保存
notepad.addMemo("Second メモ");
caretaker.backup();
notepad.addMemo("Third メモ");
caretaker.backup();
console.log(notepad.getMemo()); // Third メモ
caretaker.undo();
console.log(notepad.getMemo()); // Third メモ(直前の保存と同じ状態)
caretaker.undo();
console.log(notepad.getMemo()); // Second メモ
caretaker.undo();
console.log(notepad.getMemo()); // First メモ
caretaker.undo();
console.log(notepad.getMemo()); // First メモ(履歴が空なので復元されない)
caretaker.showHistory(); // 何も表示されない
save() の中で new Memento(...) が書けているのがこの構成のキモです。メメントを継承した NotePad のクラス本体からは protected なコンストラクタを呼べるので、スナップショットの生成経路が save() 一本に絞られます。
実行結果の読み方に少しコツがあります。1回目のUNDOでは、直前に取ったスナップショットが「Third メモ」なので状態は変わりません。2回目で Second メモ、3回目で First メモに戻り、4回目はスナップショットが残っていないので復元は行われません。最後の showHistory() で何も表示されないのは、pop() で取り出した分だけ履歴が消費されているためです。
4. ビジター(Visitor)
概要
ビジター(visitor)は「訪問者」という意味です。ビジターパターンは、データ構造を表すクラスと、それに対する操作を行うクラスを分離するパターンです。操作を行うクラスがデータ構造を訪問して順に処理していくことから、この名前が付けられています。データ構造側には操作を記述せず、代わりに訪問者を受け入れるためのメソッドを用意します。GoFの分類では「振る舞いに関するパターン」です。
構成要素とオブジェクト指向的要素
操作を行うクラス階層と、データ構造を表すクラス階層から構成されます。
| 役割 | この例のクラス | 説明 |
|---|---|---|
| Visitor | Visitor |
訪問者の抽象クラス/インターフェース。渡されたデータ構造を操作するAPIを定義する |
| ConcreteVisitor |
ListVisitor / CountVisitor
|
渡されたデータ構造の型に合わせた処理を実装する |
| Element | Entry |
データ構造の抽象クラス/インターフェース。Visitor を受け入れるAPIを定義する |
| ConcreteElement |
Group / Employee
|
Visitor を受け入れる具体的な処理を実装する |
このパターンはダブルディスパッチというテクニックを利用しています。訪問者とそれを受け入れる側が、お互いのメソッドを呼び合う構造です。
つまり、受け入れ側(Element)の具体的な型と、訪問者(Visitor)の具体的な型の組み合わせで、実行される処理が決まります。このように処理を実行するオブジェクトの実行時の型と引数の型をもとに動的に処理を変えることをダブルディスパッチといいます。
たとえ話としては訪問販売が近いです。受け入れる側が各家庭、訪問する側が保険や新聞などを売る販売員です。販売員が家に入れてもらうのが accept()、その家庭の事情を聞いておすすめの商品を売るのが visit() にあたります。どの販売員がどの家庭に行くかで、売られるものが変わるわけです。
メリット・デメリット
メリットは、データ構造と操作を分離して共通の操作を1カ所にまとめられること、同じような操作を複数のオブジェクトに書かずに済み再利用性が高まること、そしてデータ構造と操作が分離されているためオープンクローズドの原則に違反せず新しい操作(Visitor)を追加できることです。
デメリットは、操作とは逆に新しいデータ構造の追加が困難になる点です。訪問者側は訪問先ごとの処理を実装しているので、Element を1つ増やすとすべての Visitor で修正が必要になります。この非対称性がビジターの性格を決めていて、データ構造を固定して操作を変化させたい場合に有効なパターンだといえます。
使いどころと実装例
ツリー構造のような複雑なオブジェクト構造の全要素に何らかの操作を実行したい場合(ディレクトリツリー、組織階層、DOMツリー)に使われます。ここでは組織階層に対して、一覧表示とカウントという2つの操作を行います。
なお、ビジターには「1つの visit() の中で分岐して訪問先ごとの処理を書く方法」と「訪問先ごとに別メソッドを定義する方法」がありますが、ここでは前者を採ります。
abstract class Entry {
constructor(
private code: string,
private name: string,
) {}
getCode(): string {
return this.code;
}
getName(): string {
return this.name;
}
abstract getChildren(): Entry[];
abstract accept(visitor: Visitor): void;
}
class Group extends Entry {
private entries: Entry[] = [];
add(entry: Entry): void {
this.entries.push(entry);
}
getChildren(): Entry[] {
return this.entries;
}
accept(visitor: Visitor): void {
visitor.visit(this); // 自分自身を訪問者に渡す
}
}
class Employee extends Entry {
getChildren(): Entry[] {
return []; // 社員は子要素を持たない
}
accept(visitor: Visitor): void {
visitor.visit(this);
}
}
interface Visitor {
visit(entry: Entry): void;
}
class ListVisitor implements Visitor {
visit(entry: Entry): void {
// 引数の具体的な型は、訪問した先のクラスの型になる
if (entry instanceof Group) {
console.log(`${entry.getCode()} ${entry.getName()}`);
} else {
console.log(` ${entry.getCode()} ${entry.getName()}`); // 社員はインデント
}
// 子要素があれば、そこにも訪問しに行く
entry.getChildren().forEach((child) => child.accept(this));
}
}
class CountVisitor implements Visitor {
private groupCount = 0;
private employeeCount = 0;
visit(entry: Entry): void {
if (entry instanceof Group) {
this.groupCount++;
} else {
this.employeeCount++;
}
entry.getChildren().forEach((child) => child.accept(this));
}
getGroupCount(): number {
return this.groupCount;
}
getEmployeeCount(): number {
return this.employeeCount;
}
}
// 使い方:ツリーを組み立て、訪問者を差し替えて操作する
const rootEntry = new Group("01", "本社");
rootEntry.add(new Employee("0101", "社長"));
const group1 = new Group("10", "神奈川支部");
group1.add(new Employee("1001", "支部長"));
const group2 = new Group("11", "横浜営業所");
group2.add(new Employee("1101", "営業部長"));
group2.add(new Employee("1102", "山田"));
group2.add(new Employee("1103", "鈴木"));
group2.add(new Employee("1104", "田中"));
group1.add(group2);
rootEntry.add(group1);
const listVisitor = new ListVisitor();
const countVisitor = new CountVisitor();
rootEntry.accept(listVisitor);
rootEntry.accept(countVisitor);
console.log(`グループ数:${countVisitor.getGroupCount()}`); // 3
console.log(`社員数:${countVisitor.getEmployeeCount()}`); // 6
visit() の最後で子要素の accept() を呼ぶことで、ツリー構造を再帰的にたどれるようになります。この「たどり方」を訪問者側に書いているので、データ構造側は自分の子要素を返せるだけで済みます。同じツリーに対して訪問者を差し替えるだけで「一覧表示」と「カウント」という別の操作が実行できているのが、ビジターの効果です。
出力はこうなります。
01 本社
0101 社長
10 神奈川支部
1001 支部長
11 横浜営業所
1101 営業部長
1102 山田
1103 鈴木
1104 田中
グループ数:3
社員数:6
なおこの ListVisitor は社員だけを固定幅でインデントする実装なので、階層が深くなっても字下げは増えません。深さに応じたインデントにしたい場合は、訪問時に深さを持ち回る仕組みを足すことになります。
パート②のコンポジットパターンも同じツリー構造を扱いましたが、あちらは操作をデータ構造側(Entry)に持たせる設計でした。操作の種類を増やしたいならビジター、要素を同一視して扱いたいだけならコンポジット、という使い分けになります。
5. コマンド(Command)
概要
コマンド(command)は「命令」という意味です。コマンドパターンは、命令をメソッドではなく独立したクラスとして表現するパターンです。命令の送り手と受け手を分離することを目的としており、命令クラスを切り替えることでさまざまな処理を実現できます。GoFの分類では「振る舞いに関するパターン」です。
イメージとしてはテレビのリモコンが分かりやすいと思います。リモコンにあるボタン一つ一つを独立したクラスとして表し、電源のオン・オフやチャンネル移動、音量調整といった処理を実現します。このときリモコンが命令の送り手で、テレビが命令の受け取り手です。
構成要素とオブジェクト指向的要素
- Command:命令を表すインターフェース。命令を出すための共通APIを定義する
- ConcreteCommand:Command を実装する子クラス。具体的な命令を実装し、内部に受け取り手のオブジェクトを保持する
- Invoker:命令の送り手。命令オブジェクトを保持し、いつ命令を実行するかの責務を担う
- Receiver:命令の受け取り手。命令をどのように実行するかを知っている。Receiver になるための制約はなく、任意のクラスがなれる
「いつ実行するか」と「どう実行するか」を別のクラスに分けている点が、このパターンの読みどころです。ポリモーフィズムを利用しており、Invoker は内部にコマンドを Command 型で保持するので、ConcreteCommand の追加・削除・切り替えが可能になっています。
メリット・デメリット
メリットは、既存のコードを修正することなく命令の追加・拡張ができること、命令が独立したクラスになるので再利用性が高まること、そして命令をキューにまとめておいて非同期に実行できることです。
デメリットは、命令がシンプルで再利用も考えなくてよい場合、コードが余計に複雑になる点です。
使いどころと実装例
複数の命令をひとまとめにして実行したい場合、命令をキューに入れて遅延実行したい場合、命令単位でUNDO・REDOを実現したい場合に有効です。ここではファイル操作をキューに入れてまとめて実行します。
// Receiver:命令をどう実行するかを知っている
class TargetFile {
constructor(private name: string) {}
open(): void {
console.log(`${this.name} が開かれました`);
}
compress(): void {
console.log(`${this.name} が圧縮されました`);
}
close(): void {
console.log(`${this.name} が閉じられました`);
}
}
interface Command {
execute(): void;
}
class OpenCommand implements Command {
constructor(private file: TargetFile) {}
execute(): void {
this.file.open();
}
}
class CompressCommand implements Command {
constructor(private file: TargetFile) {}
execute(): void {
this.file.compress();
}
}
class CloseCommand implements Command {
constructor(private file: TargetFile) {}
execute(): void {
this.file.close();
}
}
// Invoker:いつ実行するかを決める
class CommandQueue {
private commands: Command[] = [];
addCommand(command: Command): void {
this.commands.push(command);
}
executeCommand(): void {
this.commands.forEach((command) => command.execute());
}
}
// 使い方:命令を貯めてから、まとめて実行する
const file = new TargetFile("command.ts");
const queue = new CommandQueue();
queue.addCommand(new OpenCommand(file));
queue.addCommand(new CompressCommand(file));
queue.addCommand(new CloseCommand(file));
queue.executeCommand(); // 追加した順に open → compress → close
各コマンドは受け手を1つ抱えて execute() でそのメソッドを呼ぶだけです。ここが薄いほど、あとで命令を組み替えるのが楽になります。今回は登録の直後に実行していますが、登録だけしておいて実行は任意のタイミングに遅らせられるのがコマンドパターンの強みです。
UNDO・REDOを実現したい場合は、Command インターフェースに undo() を追加し、実行済みコマンドをスタックに積んでいく構成にします。3番のメメントとは別のアプローチでUNDOが実装できるわけです。状態そのものを保存するのがメメント、操作の逆操作を持たせるのがコマンド、と整理すると使い分けやすくなります。
6. インタープリター(Interpreter)
概要
インタープリター(interpreter)は「通訳者」という意味です。インタープリターパターンは、構文の解析を行い、その結果を利用して処理を行うパターンです。解析を行うための規則を1つ1つクラスとして表現します。GoFの分類では「振る舞いに関するパターン」です。
規則がツリー構造を持っていても扱えるのが特徴です。一般的なプログラミング言語では、コードの文字を意味のある単位に分解する字句解析と、その結果が文法に従っているかをチェックする構文解析のステップがあり、これらの結果はツリー構造で表せます。このツリー構造を構文木と呼びます。
構成要素とオブジェクト指向的要素
- Context:解析を行いたい値や、解析に必要な情報を提供するクラス
- AbstractExpression:規則を表す抽象クラス/インターフェース。解析を行うための共通APIを定義する
- NonterminalExpression:AbstractExpression を継承/実装する子クラス。内部に AbstractExpression 型のオブジェクトを保持し、規則の親子関係を表現する
- TerminalExpression:同じく AbstractExpression を継承/実装する子クラス。子要素を持たず、自身が末端の規則を表す
ポリモーフィズムを利用したパターンです。クライアントも NonterminalExpression も AbstractExpression の共通APIだけを使うため、具体的な実装を意識せずに済み、規則の追加・削除・切り替えが可能になります。
なお、NonterminalExpression が同じ型の子要素を保持してツリーを作る部分は、パート②で扱ったコンポジットパターンとまったく同じ構造です。ツリーを扱うパターンとして両者は骨格を共有していて、インタープリターはそこに「解析」という意味づけを与えたものと捉えると理解しやすくなります。
メリット・デメリット
メリットは、既存のコードを修正することなく規則の追加・拡張ができることです。
デメリットは、シンプルな規則を実現する場合でもクラス数が増え、コードがより複雑になる可能性がある点です。
使いどころと実装例
特定の文法で記述された内容を解析して処理したい場合に有効です。正規表現、SQLの解析、プログラミング言語自体の開発などが具体例です。ここでは、TypeScriptに組み込まれている Date 型の日付を、YYYY・MM・DD を使った任意フォーマットの文字列に変換します。
class Context {
constructor(
public expression: string,
public date: Date,
) {
this.validate(expression);
}
private validate(expression: string): void {
// 条件:大文字の YYYY・MM・DD をすべて含み、区切り文字込みで10文字
if (
expression.length !== 10 ||
!/^(?=.*YYYY)(?=.*MM)(?=.*DD)/.test(expression)
) {
throw new Error(`${expression} は不正です`);
}
}
}
interface AbstractExpression {
interpret(context: Context): Context;
}
class YearExpression implements AbstractExpression {
private child: AbstractExpression | null = null;
setChild(child: AbstractExpression): void {
this.child = child;
}
interpret(context: Context): Context {
const year = context.date.getFullYear();
context.expression = context.expression.replace("YYYY", year.toString());
// 子要素があれば、その解析も続けて呼び出す(末端まで再帰)
if (this.child) {
this.child.interpret(context);
}
return context;
}
}
class MonthExpression implements AbstractExpression {
private child: AbstractExpression | null = null;
setChild(child: AbstractExpression): void {
this.child = child;
}
interpret(context: Context): Context {
const month = context.date.getMonth() + 1; // getMonth() は0始まり
context.expression = context.expression.replace(
"MM",
month.toString().padStart(2, "0"),
);
if (this.child) {
this.child.interpret(context);
}
return context;
}
}
class DayExpression implements AbstractExpression {
private child: AbstractExpression | null = null;
setChild(child: AbstractExpression): void {
this.child = child;
}
interpret(context: Context): Context {
const day = context.date.getDate();
context.expression = context.expression.replace(
"DD",
day.toString().padStart(2, "0"),
);
if (this.child) {
this.child.interpret(context);
}
return context;
}
}
// 使い方:アメリカ式の「月/日/年」で表示する
const context = new Context("MM/DD/YYYY", new Date());
const yearExpression = new YearExpression();
const monthExpression = new MonthExpression();
const dayExpression = new DayExpression();
// 年 → 月 → 日 の順で解析されるようにツリーを組む
monthExpression.setChild(dayExpression);
yearExpression.setChild(monthExpression);
const result = yearExpression.interpret(context);
console.log(result.expression); // 例:07/30/2026
3つの規則クラスはどれも child を null で初期化しています。今回のケースはどの順で解析しても結果が同じなので、setChild() で子をセットすれば NonterminalExpression、セットしなければ TerminalExpression として振る舞う、という兼用の作りにできます。interpret() という共通APIだけでツリーの上位から末端まで処理が流れていく点が、このパターンの気持ちよさです。
構造に関するパターン
7. フライウェイト(Flyweight)
概要
フライウェイト(flyweight)はボクシングの「フライ級」、つまり体重が軽い階級のことです。フライウェイトパターンは、インスタンス化されたオブジェクトを効率よく共有することで、生成されるオブジェクトやリソースの消費を抑えるパターンです。使用するメモリの量を軽くするため、この名前が付けられています。GoFの分類では「構造に関するパターン」です。
このパターンで最も大事なのは、環境や状況によって変化しない情報のみを共有するという原則です。
- 誕生日:いつ誰が参照しても変わらない → 共有してよい
- 年齢:計算するタイミングによって変わる → 共有に向かない
この「場所や状況に依存しない情報」を intrinsic(イントリンシック=内在的)な情報、逆に「使われ方次第で変化する情報」を extrinsic(エクストリンシック=外在的)な情報と呼びます。フライウェイトが共有するのは intrinsic な情報だけで、extrinsic な情報は共有オブジェクトに持たせず、操作のたびに引数で渡すのが定石です。
構成要素とオブジェクト指向的要素
- Flyweight:共有されるオブジェクト。フィールドには intrinsic な情報のみを持たせる
- FlyweightFactory:Flyweight の工場。内部に生成済みインスタンスを保持し、要求されたときに既にインスタンスがあればそれを返し、なければ新たに生成する
このパターンは、他のパターンとは異なり特段オブジェクト指向的な要素はありません。継承やポリモーフィズムではなく、「インスタンスを1つにまとめて指し示す」という持ち方の工夫そのものがパターンの中身です。
メリット・デメリット
メリットは、1度生成したインスタンスを共有するので毎回生成するよりもオブジェクトの数を大幅に抑えられること、それによってメモリ使用量と生成コストが下がり、システムのパフォーマンス向上も期待できることです。
デメリットは、コードが複雑化して理解しづらくなること、そして共有されているオブジェクトが変更されると、それを使用する複数の箇所に影響するため想定外のバグを生みやすいことです。だからこそ共有する情報は intrinsic なものに限り、readonly を付ける、ミュータブルなセッターを用意しないといった防御をしておくと安全です。
使いどころと実装例
同一のオブジェクトを大量に使用する必要がある場合や、インスタンス生成によるリソース消費を抑えたい場合に有効です。逆に生成数が少ないなら、素直に new した方が読みやすくなります。
ここでは、LINEなどで使う1文字のスタンプで単語を作る例を実装します。簡単にするため色などの装飾は考えず、どの文字かを表すフィールドだけを持たせます。つまり文字さえ同じであれば、同じスタンプとして共有されます。
class Stamp {
constructor(private char: string) {}
print(): void {
console.log(this.char);
}
}
class StampFactory {
private pool: { [key: string]: Stamp } = {};
getStamp(char: string): Stamp {
const stamp = this.pool[char];
if (stamp) {
return stamp; // すでに同じ文字のスタンプがあれば、それを返す
}
const newStamp = new Stamp(char);
this.pool[char] = newStamp; // 作成したスタンプをプールに登録
return newStamp;
}
getPool(): { [key: string]: Stamp } {
return this.pool;
}
}
// 使い方:回文の「しんぶんし」を作る
const factory = new StampFactory();
const stamp1 = factory.getStamp("し");
const stamp2 = factory.getStamp("ん");
const stamp3 = factory.getStamp("ぶ");
const stamp4 = factory.getStamp("ん"); // 2回目:再利用される
const stamp5 = factory.getStamp("し"); // 2回目:再利用される
stamp1.print();
stamp2.print();
stamp3.print();
stamp4.print();
stamp5.print();
console.log(factory.getPool()); // し・ん・ぶ の3つだけ
ポイントは getStamp() の中だけです。プールを引いて、あればそれを返し、なければ作ってプールに登録してから返します。呼び出し側は「新規なのか使い回しなのか」を意識する必要がありません。
出力は「し・ん・ぶ・ん・し」の5行になりますが、getPool() に入っているスタンプは3つだけです。し と ん は2回登場しますが、2回目は新しく作られず1回目のインスタンスが使い回されているためです。5回の要求に対して生成は3回、これがフライウェイトの効果です。
まとめ:パターン早見表とチェックリスト
パート③で扱った7パターンを振り返ります。
| パターン | GoF分類 | 主なOO要素 | ひとことで |
|---|---|---|---|
| Observer | 振る舞い | 継承/ポリモーフィズム | 状態変化を複数の観測者に通知 |
| Mediator | 振る舞い | カプセル化 | やり取りを仲介者に集約して結合を弱める |
| Memento | 振る舞い | カプセル化(の破壊を防ぐ) | 内部状態を隠したままスナップショット |
| Visitor | 振る舞い | ダブルディスパッチ | データ構造と操作を分離 |
| Command | 振る舞い | ポリモーフィズム | 命令を独立したクラスとして表現 |
| Interpreter | 振る舞い | ポリモーフィズム | 規則をクラスにして構文を解析 |
| Flyweight | 構造 | (特になし) | インスタンスを共有してリソース節約 |
設計で迷ったときのチェックリストとして、次の観点が役立ちます。
- 状態変化を複数のオブジェクトに知らせたい → Observer(通知順に依存しない実装にする)
- 多数のオブジェクトが直接つながって複雑化している → Mediator(ゴッドクラス化に注意)
- UNDOやセーブのために状態を丸ごと戻したい → Memento
- 命令の追加・遅延実行・組み合わせをしたい → Command(逆操作でのUNDOも可)
- 固定した木構造に、操作をいろいろ追加したい → Visitor(構造の追加は困難)
- 独自の文法を解析して処理したい → Interpreter(既存APIで足りないか先に確認)
- 同じオブジェクトを大量に使う/生成コストを削りたい → Flyweight(共有するのは intrinsic な情報だけ)
-
getMonth()は0始まり、桁揃えはpadStart(2, "0")を使えているか -
クラス名が組み込みの型(
Fileなど)と衝突していないか - そもそも、そのパターンは本当に必要か(小規模なら過剰設計かも)を一度立ち止まって考える
これでパート①②③を通じて、GoFの23パターンがすべて出揃いました。今回の7つは登場頻度が高いとは言えませんが、「何を分離し、何を共有しているのか」という視点で見ると、いずれも普段のコードで無意識にやっている工夫の名前付きバージョンだと分かってきます。まずは手元のエディタで各パターンのコードを動かして、出力の違いを目で確認してみてください。