🐝

【TS】今さら聞けないステートパターン

2020/11/12に公開

はじめに

今回はステートパターン(State Pattern)について解説します。
ポリモーフィズム(多態性)を利用して、状態ごとの振る舞いを表現するデザインパターンです。

今回はステートパターンとは?

有名どころでTECHSCOREさんの解説があります。

以下、引用です。

状態によって、動作のパターンが変わることがよくあります。 例えば、「機嫌のいい状態」「機嫌が悪い状態」の2つの状態があるお母さんにいくつか頼みごとをすることを考えます。 機嫌のいい状態のお母さんに「お小遣い頂戴」「おやつ頂戴」などのお願いをした場合、 「はいはい」といってお小遣いをくれたり、おやつを出してくれたりするでしょう。 しかし、機嫌の悪い状態のお母さんにこれらのお願いをしても聞き入れてくれないかもしれません。 お母さんは状態によって、振る舞いが変わるわけです。

State パターンとは、このような、状態の変化に応じて振る舞いが変わるような場合に威力を発揮するパターンです

例題

以下のような「犬」クラスを想定します。

  • 状態として「空腹」と「満腹」がある
  • 「一緒に遊ぶ」関数を持ち、「満腹」の時はご機嫌なため遊ぶが、「空腹」の時はご飯をくれといって怒る
  • 「ご飯を食べる」関数を持ち、「空腹」の時は食べるが、「満腹」の時は食べない

ステートパターンなし

例題の内容を特に何のデザインパターンも意識せずにコーディングすると以下のようになるかなと思います。


class Dog {
  // 状態
  private _status: 'Hunger' | 'Full'
  
  constructor(status: 'Hunger' | 'Full' ) {
    this.setStatus(status);
  }
  
  public setStatus(status: 'Hunger' | 'Full') {
    this._status = status;
  }
  
  // 一緒に遊ぶ
  public withPlay(): void {
    switch(this._status) {
      case 'Hunger':
        console.log("お腹がすいたからご飯をくれ!");
        break;
      case 'Full':
        console.log("遊ぶ!");
        break;
    }
  }
  
  // ご飯を食べる
  public eat(): void {
    switch(this._status) {
      case 'Hunger':
        console.log("やった!ご飯だ!");
        break;
      case 'Full':
        console.log("もうお腹いっぱいだよ・・・");
        break;
    }
  }
}

各関数で状態をswitch文で判定して、適した処理を実行しています。
状態が2種類のためさほど困りませんが、ここから「さらに状態を追加」した場合や「状態が関わる関数が増えた」場合には都度分岐処理を見直したりしなければなりません。

ステートパターンで実装

そこで登場するのがステートパターンです。
ステートパターンでは、「状態に関連した関数」をインターフェースとして定義しておいて、各状態でそれらを実装する手法です。
状態を持つ側(例でいうところのDogクラス)は状態を付け替えるだけです。

// 胃袋の状態IF
interface StomachState {
  withPlay():void;
  eat():void;
}

// 空腹状態
class HungerState implements StomachState {  
  withPlay = () => console.log("お腹がすいたからご飯をくれ!");
  eat = () => console.log("やった!ご飯だ!");
}

// 満腹状態
class FullState implements StomachState {  
  withPlay = () => console.log("遊ぶ!");
  eat = () => console.log("もうお腹いっぱいだよ・・・");
}

class Dog {
  private _state: StomachState;
  constructor(state: StomachState) {
    this.setStatus(state);
  }
  
  public setStatus(state: StomachState) {
    this._state = state;
  }

  // withPlayとeatはそれぞれ_state側で定義されているため
  // その中身を意識せず実行するだけ
  withPlay = () => this._state.withPlay(); 
  eat = () => this._state.eat();
}

ポイントは「Dog内では自身の状態を意識しなくなった」ことと「状態の変更や追加時に、他状態の処理に影響を及ぼさない」ことです。
「胃袋の状態」としてHungerStateFullStateStomachStateを実装しているため、仮にここに「腹八分目」などの状態が加わった場合は新たにStomachStateを実装すればよいだけです。
これは、うっかりFullStateStomachStateの挙動が変わってしまうような不具合が起こらないことを意味します(switchでゴリゴリ書いていた場合は、書き漏らしや意図しない箇所の変更が発生する余地がありました)。

ストラテジパターンとの違い

今回はTypescriptでデザインパターンの一つである「ステートパターン」について紹介しました。
ステートパターンはよく 「ストラテジパターン」 と似ているとされます。
というのもそれぞれのパターンを導入した場合の最終系のコードがほぼ同じだからです。

以下、私見になります。
ステートパターンが 「状態による分岐をポリモーフィズムで解決」 するアプローチを取っているのに対して、ストラテジパターンが 「アルゴリズムの切り出しをポリモーフィズムで解決」 するアプローチを取っています。
ポイントは「着目した点が違うだけで、解決方法としてポリモーフィズムを用いているという点は同じ」ということです。
従って 「起点となる思想は異なるがコードレベルに落とし込んだ場合の見た目は同じ」 ということになります。

ストラテジパターンについては、以下の記事でまとめています。

Discussion