Knowledge Notebook
一覧に戻る

GoFデザインパターン「構造パターン」入門:クラスとオブジェクトの複雑な関係を整理する7つの設計手法

オブジェクト指向でのシステム設計において、複雑に絡み合うクラスやオブジェクトの整理に苦労した経験はありませんか。最初はシンプルだった構成も、度重なる機能追加によって、いつの間にか「どこがどう繋がっているか分からない」状態に陥ることがあります。

そうした設計の絡まりを解きほぐし、クラスやオブジェクト同士をクリーンに関連付ける設計手法。それがGoFデザインパターンのうち「構造に関するパターン(Structural Patterns)」です。今回は、このカテゴリーに属する7つの主要なパターンについて、具体的な使い所とJavaScriptでのサンプルコードを交えながら、その価値を紐解いていきます。

1. 構造に関するパターンとは

構造に関するパターンは、クラスやオブジェクトを効率的に組み合わせて、より大きな構造を作るためのアプローチです。単にクラスを大きくするのではなく、役割ごとに分割された小さな部品を疎結合に組み合わせることで、変更に強く見通しの良いシステムを実現します。代表的な7つのパターンを順に見ていきましょう。

2. Adapter(アダプター)

使い所

すでに存在するクラスのインターフェースが、新しく導入するシステムで求められる仕様と一致しない場合に使用します。「互換性のない2つのインターフェースを繋ぐ架け橋」となるパターンです。

使い方とサンプルコード

既存のクラス(適合される側)をラップし、利用側が期待するメソッドに変換して呼び出すクラス(アダプター)を用意します。

// レガシーなセンサー(華氏で温度を返す)
class LegacyFahrenheitSensor {
  getFahrenheitTemp() {
    return 86; // 86°F
  }
}

// 新しいシステムが期待するインターフェースのベース
class CelsiusSensor {
  getCelsiusTemp() {
    throw new Error("Method not implemented.");
  }
}

// アダプターを挟んで新しいインターフェースに適合させる
class TemperatureAdapter extends CelsiusSensor {
  constructor(legacySensor) {
    super();
    this.legacySensor = legacySensor;
  }

  getCelsiusTemp() {
    const fahrenheit = this.legacySensor.getFahrenheitTemp();
    // 華氏から摂氏への計算: (F - 32) * 5/9
    return Math.round((fahrenheit - 32) * 5 / 9);
  }
}

// 利用例
const legacySensor = new LegacyFahrenheitSensor();
const adapter = new TemperatureAdapter(legacySensor);
console.log(`摂氏温度: ${adapter.getCelsiusTemp()}°C`); // 摂氏温度: 30°C

何が便利なのか

古い実績あるコードに手を加えることなく、新しい仕様のインターフェースに適合させることができるため、デバッグ済みのクラスを壊すリスクを完全に排除できます。

3. Bridge(ブリッジ)

使い所

「機能を追加していく階層」と「実装を切り替えていく階層」の2つが同時に複雑化しそうな場合に使用します。この2つの系統を独立して拡張できるように切り離すことで、クラスの爆発的増加を防ぎます。

使い方とサンプルコード

機能側のクラスが実装側のインターフェースをメンバ変数として保持し(橋渡し)、具体的な処理をそちらへ委譲します。

// 実装側のインターフェース
class MessageSender {
  send(text) {}
}

// 具体的な実装1: Eメールでの送信
class EmailSender extends MessageSender {
  send(text) {
    console.log(`[Email] 送信中: ${text}`);
  }
}

// 具体的な実装2: SMSでの送信
class SMSSender extends MessageSender {
  send(text) {
    console.log(`[SMS] 送信中: ${text}`);
  }
}

// 機能側のベース(抽象)
class Message {
  constructor(sender) {
    this.sender = sender;
  }
  send(content) {
    this.sender.send(content);
  }
}

// 機能拡張側のクラス
class UrgentMessage extends Message {
  send(content) {
    super.send(`【至急】${content}`);
  }
}

// 利用例
const emailSender = new EmailSender();
const smsSender = new SMSSender();

const normalMail = new Message(emailSender);
normalMail.send("明日の定例会について"); // [Email] 送信中: 明日の定例会について

const urgentSMS = new UrgentMessage(smsSender);
urgentSMS.send("サーバーエラーが発生しました"); // [SMS] 送信中: 【至急】サーバーエラーが発生しました

何が便利なのか

機能と実装がそれぞれ個別に変化しても、お互いに影響を与えずにクラスを追加・修正できます。例えば、新しい送信手段(Slackなど)を増やす際に、メッセージの種類(通常、至急など)のコードを一切変更する必要がありません。

4. Composite(コンポジット)

使い所

ファイルとフォルダ、組織と社員のように、「容器(全体)」と「中身(部分)」が木構造(ツリー構造)を形成しており、それらを同一のものとして再帰的に扱いたい場合に使用します。

使い方とサンプルコード

フォルダ(Composite)とファイル(Leaf)の双方が共通のインターフェースを持つように設計します。

// 共通のインターフェース
class FileSystemNode {
  constructor(name) {
    this.name = name;
  }
  print(indent = "") {}
}

// 葉オブジェクト(ファイル)
class FileNode extends FileSystemNode {
  print(indent = "") {
    console.log(`${indent}📄 ${this.name}`);
  }
}

// 合成オブジェクト(ディレクトリ)
class DirectoryNode extends FileSystemNode {
  constructor(name) {
    super(name);
    this.children = [];
  }

  add(node) {
    this.children.push(node);
  }

  print(indent = "") {
    console.log(`${indent}📁 ${this.name}`);
    for (const child of this.children) {
      child.print(indent + "  ");
    }
  }
}

// 利用例
const root = new DirectoryNode("root");
const src = new DirectoryNode("src");
const doc = new FileNode("README.md");
const code = new FileNode("app.js");

root.add(src);
root.add(doc);
src.add(code);

root.print();
// 📁 root
//   📁 src
//     📄 app.js
//   📄 README.md

何が便利なのか

利用する側のプログラムは、相手が単一の「ファイル」なのか、複数の要素を含む「フォルダ」なのかを気にする必要がなくなります。どちらに対しても同じメソッドを呼び出すだけで自動的に処理が行われます。

5. Decorator(デコレーター)

使い所

既存のオブジェクトに対して、そのコードを変更することなく、後から「動的に機能を追加したり装飾したりしたい」場合に使用します。継承を使わずに柔軟な機能拡張を行いたいときに威力を発揮します。

使い方とサンプルコード

基本オブジェクトとデコレータークラス群が同じインターフェースを持ち、デコレーターの中にターゲットとなるオブジェクトを包み込む(ネストさせる)構造をとります。

// 基本インターフェース
class Notifier {
  send(message) {}
}

// 基本となる送信処理
class SimpleNotifier extends Notifier {
  send(message) {
    console.log(`標準通知を送信: ${message}`);
  }
}

// デコレーターのベース
class NotifierDecorator extends Notifier {
  constructor(notifier) {
    super();
    this.wrappedNotifier = notifier;
  }
  send(message) {
    this.wrappedNotifier.send(message);
  }
}

// デコレーター具体例1: Slackへの通知機能を追加
class SlackDecorator extends NotifierDecorator {
  send(message) {
    super.send(message);
    this.sendSlack(message);
  }
  sendSlack(message) {
    console.log(`[Slack] ログチャンネルへ投稿: ${message}`);
  }
}

// デコレーター具体例2: ログ出力の処理を追加
class LoggingDecorator extends NotifierDecorator {
  send(message) {
    console.log(`[LOG] 送信要求を受理しました: ${new Date().toLocaleTimeString()}`);
    super.send(message);
  }
}

// 利用例
const basic = new SimpleNotifier();
const withSlack = new SlackDecorator(basic);
const fullyLoaded = new LoggingDecorator(withSlack);

fullyLoaded.send("ディスク容量が残り少ないです");
// [LOG] 送信要求を受理しました: 11:32:08
// 標準通知を送信: ディスク容量が残り少ないです
// [Slack] ログチャンネルへ投稿: ディスク容量が残り少ないです

何が便利なのか

「標準+Slack」「標準+ログ」「標準+Slack+ログ」といった複数の機能の組み合わせを、クラスの多重継承に頼ることなく、実行時にオブジェクトを繋ぎ合わせるだけで自由に構築できます。

6. Facade(ファサード)

使い所

裏側にある多数のクラスやサブシステムが複雑に連携している際、それらを利用する側に対して「非常にシンプルな一つの窓口(インターフェース)」を提供したい場合に使用します。

使い方とサンプルコード

複雑な呼び出し手順をカプセル化したファサードクラスを用意し、利用者はその単一のメソッドのみをコールします。

// 複雑なサブシステムたち
class Inventory {
  check(itemId) {
    console.log(`商品 ${itemId} の在庫を確認しました`);
    return true;
  }
}

class Payment {
  charge(amount) {
    console.log(`${amount}円の決済処理が完了しました`);
    return true;
  }
}

class Shipping {
  arrange(itemId) {
    console.log(`商品 ${itemId} の配送手配を完了しました`);
  }
}

// ファサードが処理を統合してシンプルな窓口を作る
class OrderFacade {
  constructor() {
    this.inventory = new Inventory();
    this.payment = new Payment();
    this.shipping = new Shipping();
  }

  placeOrder(itemId, price) {
    console.log("--- 注文処理(簡易窓口)を開始 ---");
    if (this.inventory.check(itemId)) {
      if (this.payment.charge(price)) {
        this.shipping.arrange(itemId);
        console.log("--- 注文処理が正常完了しました ---");
        return true;
      }
    }
    console.log("--- 注文処理が失敗しました ---");
    return false;
  }
}

// 利用例
const orderSystem = new OrderFacade();
orderSystem.placeOrder("ITEM-404", 3200);

何が便利なのか

利用する側のプログラムが、裏側の複雑な仕組み(データベース、外部API、依存関係)を一切意識せずに済むため、開発ミスが減り、システムの結合度を大きく下げることができます。

7. Flyweight(フライウェイト)

使い所

酷似したオブジェクトを大量に生成する必要があり、そのままではメモリが枯渇してしまうような場合に使用します。オブジェクト間で共有可能なデータを抽出し、メモリ使用量を最小限に抑えます。

使い方とサンプルコード

オブジェクトが持つ状態を、「共有できる共通データ(本質的状態)」と「共有できない固有データ(非本質的状態)」に分離し、共通部分はファクトリを通じて同一のインスタンスを参照させます。

// 本質的な状態(共有するスタイルデータ)
class CharacterStyle {
  constructor(font, size, color) {
    this.font = font;
    this.size = size;
    this.color = color;
  }
}

// 共有インスタンスを管理するファクトリ
class StyleFactory {
  constructor() {
    this.styles = {};
  }

  getStyle(font, size, color) {
    const key = `${font}_${size}_${color}`;
    if (!this.styles[key]) {
      this.styles[key] = new CharacterStyle(font, size, color);
      console.log(`[新規作成] スタイルをメモリにキャッシュ: ${key}`);
    }
    return this.styles[key];
  }
}

// 個別の状態(文字と位置)
class FormattedCharacter {
  constructor(char, style) {
    this.char = char;
    this.style = style; // 共有インスタンスを参照
  }

  display(position) {
    console.log(`文字: '${this.char}' (位置: ${position}) - スタイル: ${this.style.font}, ${this.style.size}px`);
  }
}

// 利用例
const factory = new StyleFactory();

const redStyle = factory.getStyle("Arial", 12, "red");
const redStyleDuplicate = factory.getStyle("Arial", 12, "red"); // キャッシュから返される

const documentText = [];
documentText.push(new FormattedCharacter("A", redStyle));
documentText.push(new FormattedCharacter("B", redStyleDuplicate));

documentText[0].display(0);
documentText[1].display(1);
console.log(`同じスタイルインスタンスを共有しているか: ${redStyle === redStyleDuplicate}`); // true

何が便利なのか

文字情報やゲームのオブジェクト(無数の雑草や弾丸など)を数万個と生成する状況において、不必要なインスタンス複製を防ぎ、メモリ消費量を劇的に節約することができます。

8. Proxy(プロキシ)

使い所

本物のオブジェクトへのアクセスが発生する前に、割り込んで処理を行いたい場合に使用します。「セキュリティのためのアクセス制限」「重いオブジェクトの遅延初期化(Lazy loading)」「APIのキャッシュ処理」などに便利です。

使い方とサンプルコード

本物と同じインターフェースを持つプロキシクラスを用意し、プロキシ内で本物に対するアクセス前後の制御を行います。

// 本物のAPIサービス
class RealWeatherService {
  async fetchForecast(city) {
    console.log(`[API] 外部天気APIへの通信が発生しました: ${city}`);
    return `晴れのち曇り (${city})`;
  }
}

// 代理(プロキシ)としてキャッシュを中継する
class CachedWeatherProxy {
  constructor(realService) {
    this.realService = realService;
    this.cache = {};
  }

  async fetchForecast(city) {
    if (this.cache[city]) {
      console.log(`[Proxy] キャッシュデータを発見。API通信をバイパスします: ${city}`);
      return this.cache[city];
    }

    const result = await this.realService.fetchForecast(city);
    this.cache[city] = result;
    return result;
  }
}

// 利用例
(async () => {
  const service = new RealWeatherService();
  const proxy = new CachedWeatherProxy(service);

  // 1回目のリクエスト(APIが呼ばれる)
  console.log(await proxy.fetchForecast("Tokyo"));
  
  // 2回目のリクエスト(APIは呼ばれずキャッシュが返る)
  console.log(await proxy.fetchForecast("Tokyo"));
})();

何が便利なのか

利用する側のコードを全く変えずに、プロキシを途中に挟むだけで、セキュリティ制御や通信コストの削減といった付加価値を後から容易に追加できます。

なお、オブジェクトの生成処理を柔軟にする仕組みについては「GoF生成に関するデザインパターン5選」、オブジェクト間の動的な連携やアルゴリズムについては「GoF振る舞いに関するデザインパターン11選」で解説しています。

9. まとめ:全体の調和をとる「構造」の知恵

構造に関する7つのパターンは、すべて「クラス同士をどのように綺麗に繋ぎ合わせるか」という関係性の課題に対する解答です。それぞれのパターンが、異なる切り口で複雑さをカプセル化し、システム全体の調和をもたらします。

機能を追加するだけでなく、今ある構成をどのように繋げば将来にわたって保守しやすくなるのか。そんな疑問に突き当たった際には、これらの構造パターンを設計の道具箱から取り出してみるのがおすすめです。