🎄

TypeScriptでデザインパターンを理解する Part1

2023/11/12に公開2

はじめに

前回はsolid原則について解説しました。
予告通り、Partを4つに分けてTypeScriptでGoFデザインパターン全23種類を解説します。
よく使われるパターンから解説していくので、時間がない方はPart1だけでも読んでいってください。

Template Methodパターン

Templateとは、「型板」、「雛形」という意味です。
Templateを使うメリットは、類似したものを簡単に作ることができたり、具体の内容を変更できることです。
Template Methodとは、親クラスに処理の枠組みを定め、その子クラスで具体的な内容を決めるパターンです。

Template Methodパターンのクラス図とサンプルコード

templateMethod.ts
abstract class AbstractClass {
  TemplateMethod() {
    this.method1();
    this.method2();
    this.method3();
  }

  abstract method1();
  abstract method2();
  abstract method3();
}

class ConcreteClass extends AbstractClass {
  method1() {
    console.log("method1");
  }
  method2() {
    console.log("method2");
  }
  method3() {
    console.log("method3");
  }
}

AbstractClassではTemplateMethodで処理のフローを定義し、method1、method2、method3を子クラスで具体的な実装をするように強制しています。
AbstractClassを継承しているConcreteClassではmethod1、method2、method3の具体的な実装をしています。
AbstractClassを継承しているクラスでは、TemplateMethodを呼び出すことでmethod1、method2、method3の順序で処理を行うという処理フローを固定してその振る舞いだけを変更することが可能です。

Template Methodパターンのメリット・デメリット

メリット

  • 共通の処理を親クラスにまとめることができる
  • 処理全体の流れを変更することなく、子クラスごとに処理の一部を変更することができる

デメリット

  • 処理全体の流れが親クラスによって決められるので、子クラスの拡張が制限される

Template Methodパターンの使い所

  • 処理フローの構造は変えることなく、処理の一部のみを変更したい場合
  • 多少の違いはあるが、ほぼ同一の処理を持つクラスが複数ある場合

Singletonパターン

クラスが1つのインスタンスしか持たないことを保証し、そのインスタンスへアクセスするためのグローバルな方法を提供するパターンです。
このパターンを使うことで、開発者は1度しかnewしてはならないということを意識しなくても良くなります。
しかし、アンチパターンと言われています。その理由は後ほど説明します。

Singletonパターンのクラス図とサンプルコード

※下線を引いているフィールドやメソッドはstaticという意味です。

singleton.ts
class Singleton {
  private static instance: Singleton;

  private constructor() {}

  static getInstance() {
    if (!this.instance) {
      this.instance = new Singleton();
    }

    return this.instance;
  }
}

privateなコンストラクタを定義しているので外部からSingletonクラスをインスタンス化することはできません。
そのため、唯一のインスタンスを得るためのstaticメソッドであるgetInstanceメソッドが定義されています。
staticメソッドはSingletonクラスをインスタンス化することなく使用することができます。
そして、getInstanceメソッドは常に同じインスタンスを返却します。

Singletonパターンのメリット・デメリット

メリット

  • クラスのインスタンスが1つしかないことを保証できる
  • インスタンスが1つなのでメモリ効率が良い
  • クラスからインスタンスが1つしか生成されないので、生成コストが少ない

デメリット

  • インスタンスへのアクセスがグローバルに提供されているので、依存関係が分かりにくくなる
  • Singletonが状態を持つ場合、密結合になり状態を追うことが難しくなる
  • Singletonはモック化が行えないため、単体テストを行うことが難しい
  • マルチスレッドでのSingletonの扱いが難しい

このようにメリットに対してデメリットの方が大きいのでSingletonパターンはアンチパターンと言われています。

Singletonパターンの使い所

  • プログラム内のクラスで、全てのクライアントが使用できるインスタンスを必ず1つに制限したい場合
    • ロギング
    • キャッシュ管理

Adapterパターン

あるクラスのインターフェース(API)を、そのクラスを使う側が求める他のインターフェースに変換するパターンです。
そのため、インターフェースに互換性のないクラス同士を組み合わせることができます。
日本と海外のコンセントの変換アダプターを想像してみてください。
海外のコンセントの形状と日本のコンセントの形状は異なっているので、海外で日本の家電を使うためには、コンセントの変換アダプターを使用しなければなりません。
Adapterパターンも同じように利用する側と利用される側の違いを埋めるために用いられます。

Adapterパターンのクラス図とサンプルコード

Adapterパターンには、2つの実装方法があります。

1つ目は、継承を使用した例です。
JSONデータをCSVに変換する例(継承を使用した例)
※紫色のオブジェクトはクラスを表しており、緑のオブジェクトはインターフェースを表しています。

ClientがCSVを扱うシステムで、Targetに対してgetCsvDataメソッドを呼び出しています。
しかし、使用したいライブラリであるNewLibraryはJSON形式でデータを返却します。
そのため、ClientとNewLibraryには互換性がありません。
そこで、JsonToCsvAdapterというアダプターをClientとNewLibraryの間に置き、NewLibraryのgetJsonDataを使用してJSONデータを取得し、CSVデータに変換して値を返却します。

Clientクラス

  • あるクラスの機能を利用する側のクラス

Targetインターフェース

  • クライアントが必要とする機能のAPIを定義するインターフェース

NewLibraryクラス

  • 利用される側のクラス
  • クライアントと互換性がない

JsonToCsvAdapterクラス

  • Targetを実装するクラス
  • NewLibraryを継承する子クラス
  • TargetのAPIを実装して、NewLibraryの機能をクライアントが利用できるようにする
adapterInheritance.ts
interface Target {
  getCsvData(): string;
}

class NewLibrary {
  getJsonData() {
    return [
      {
        "1": "apple",
        "2": "orange",
      },
      {
        "1": "太郎",
        "2": "二郎",
      },
    ];
  }
}

class JsonToCsvAdapter extends NewLibrary implements Target {
  getCsvData(): string {
    const jsonData = this.getJsonData();
    const header = Object.keys(jsonData[0]).join(",") + "\n";
    const body = jsonData.map((data) => {
      return Object.keys(data)
        .map((key) => data[key])
        .join(",");
    }).join('\n');
    return header + body;
  }
}

2つ目は、委譲を使用した例です。

JsonToCsvAdapterクラスにNewLibraryのインスタンスをフィールドとして持たせ、そのフィールドのメソッドを呼び出すことでTargetインターフェースの定義を満たす方法です。

この場合、JsonToCsvAdapterとNewLibraryは集約関係にあります。
また、あるメソッドの処理を自分のクラスで実装するのではなく、別のクラスのインスタンスに任せることを委譲と呼びます。
ここでは、NewLibraryクラスのインスタンスに委譲しています。

adapterDelegation.ts
interface Target {
  getCsvData(): string;
}

class NewLibrary {
  getJsonData() {
    return [
      {
        "1": "apple",
        "2": "orange",
      },
      {
        "1": "太郎",
        "2": "二郎",
      },
    ];
  }
}

class JsonToCsvAdapter implements Target {
  constructor(private adaptee: NewLibrary) {}

  getCsvData(): string {
    const jsonData = this.adaptee.getJsonData();
    const header = Object.keys(jsonData[0]).join(",") + "\n";
    const body = jsonData
      .map((data) => {
        return Object.keys(data)
          .map((key) => data[key])
          .join(",");
      })
      .join("\n");
    return header + body;
  }
}

このように2つのやり方があるとどちらで実装をする方が良いのか迷うかもしれません。
その場合は、2つ目の委譲を使用する方が良いと思います。
継承を使用する場合は、リスコフの置換原則に違反しないように注意するなど気をつけなければならない点があり、委譲を使用する方がトラブルが少ないからです。
※リスコフの置換原則などのsolid原則の説明についてはこちら↓
https://zenn.dev/takahiro0404/articles/6c6a82c8607dc0

Adapterパターンのメリット・デメリット

メリット

  • 既存のクラス(Adaptee)を修正しないので再テストが不要になる
  • 変換のためのコードをビジネスロジックと分離できるので単一責任の原則に違反しない
  • インターフェースを介してアダプタと連携するのでオープンクローズドの原則に違反しない

デメリット

  • インターフェースやクラスが増えるので、小さなシステムではAdapteeを直接修正した方が良い場合もある

Adapterパターンの使い所

  • 既存のクラスを使用したいが、そのインターフェースが利用したい側のコードと互換性がない場合
    • 十分にテストされて実績のあるクラスを直接修正せずに利用したい場合
    • adapteeクラスのソースコードが手に入らない場合

Iteratorパターン

Iteratorとは「繰り返すもの」という意味を持つ英単語です。
コレクションの内部構造を利用者に意識させることなく、その要素に順番にアクセスする方法を提供するパターンです。
※コレクションとは、配列や連想配列などのデータをまとめて格納したもの

Iteratorパターンのクラス図とサンプルコード

Iteratorインターフェース

  • コレクションを探索するために必要な操作を定義する

ConcreteIteratorクラス

  • Iteratorインターフェースを実装
  • 探索を行うコレクションをフィールドにもつ
  • ConcreteIteratorの実装によって探索の内容を変更することができる

Aggregateインターフェース

  • 探索を行うコレクションを表す
  • Iteratorを生成するメソッドを定義

ConcreteAggregateクラス

  • Aggregateインターフェースを実装
  • ConcreteIteratorクラスの新しいインスタンスを返却する

病院の待合室で患者が順番に呼ばれる例を考えてみましょう。

Iterator.ts
class Patient {
    constructor(public id: number, public name: string) {}
}

interface IIterator {
    hasNext(): boolean;
    next();
}

interface Aggregate {
    getIterator(): IIterator;
}

class WaitingRoom implements Aggregate {
    private patients: Patient[] = [];

    getPatients(): Patient[] {
        return this.patients;
    }

    getCount(): number {
        return this.patients.length;
    }

    checkIn(patient: Patient) {
        this.patients.push(patient);
    }

    getIterator(): IIterator {
        return new WaitingRoomIterator(this);
    }
}

class WaitingRoomIterator implements IIterator {
    private position: number = 0;

    constructor(private aggregate: WaitingRoom) {}

    hasNext(): boolean {
        return this.position < this.aggregate.getCount();
    }

    next() {
        if (!this.hasNext()) {
            console.log("患者がいません");
            return;
        }

        const patient = this.aggregate.getPatients()[this.position];
        this.position++;
        return patient;
    }
}

WaitingRoom(待合室)クラスがConcreteAggregateで、WaitingRoomIterator(待合室イテレータ)クラスがConcreteIteratorです。
WaitingRoomクラスでは、患者のリストを返却するgetPatientsメソッド、患者の人数をカウントするgetCountメソッド、受付をするcheckInメソッド、待合室イテレータの新しいインスタンスを返却するgetIteratorメソッドが定義されています。
WaitingRoomIteratorは、WaitingRoom型のaggregateと今何番目までの患者を呼んだかを管理するpositionをフィールドに持ち、IIteratorインターフェースを実装しています。
nextメソッドを呼ぶことでnextメソッドのロジックに従って次の患者を呼ぶことができます。

Iteratorパターンのメリット・デメリット

メリット

  • イテレータのnextメソッドを呼び出すことで次の要素を取得できるので利用者はコレクションの構造を把握しなくてよくなる
  • 既存のコードに修正を加えることなく、新しいコレクションやイテレータを追加できるのでオープンクローズドの原則に違反しない
  • コレクションとイテレータの実装を分離できるのでコレクションの実装が複雑化しない

デメリット

  • 少なくとも4つのクラスを作成することになるので、単純なコレクションの場合には使用しない方が良いこともある

Iteratorパターンの使い所

  • コレクションの構造が複雑で、利用者からその複雑さを隠したい場合
      - イテレータのメソッドを呼び出すだけで要素を取得することが可能
  • 探索のための方法を複数追加したい場合
    • オープンクローズドの原則に違反することなく、探索のためのロジックを追加できる

最後に

今回は、Template Method、Singleton、Adapter、Iteratorの4つのパターンについて説明しました。私もまだデザインパターンに関しては勉強中なので、説明やソースコードの指摘やミスがあればコメントいただけると幸いです。

Discussion

JboyHashimotoJboyHashimoto

たまによく聞く、シングルトンとAdapterパターンについての解説がわかりやすかったです!
良い記事をありがとうございます🎉