見出し画像

入れ子クラスの特権を利用したクラス内状態遷移

クラスの中にはメンバ変数とメソッドを定義出来ます。でもそれだけじゃなくクラスの中に構造体もクラスも定義出来ます。このクラスの中で定義したクラスや構造体の事を「入れ子クラス(構造体)」などと呼びます:

class Hero {
    public void update();   // Heroの更新メソッド

private:
    // 状態クラス(入れ子クラス)
    class State {
    public:
        virtual void update( Hero &hero );
    };

private:
    void setHP( int hp );    // HPを設定

    int hp_;
    int level_;
    State *state_;   // 状態オブジェクト
};

上の宣言でHeroクラスの中にStateクラスが組み込まれています。これが入れ子クラスです。このStateクラスはprivateスコープなのでHeroしか知らない秘匿クラスになっています。

入れ子クラスには素晴らしい特権が備わっています。それは親クラス(上だとHeroクラス)のメンバ変数やメソッドに自由にアクセスできるんです。Heroクラス内にあるhp_やlevel_、setHPメソッドはprivateなので外からは一切触れません。しかしStateクラスの中であればダイレクトに触れます:

void Hero::State::update( Hero &hero ) {
    hero.setHP( 100 );   // Access OK
    hero.level_++;       // Access OK
}
 
int main() {
    Hero hero;
    hero.setHP( 200 );   // Can't access!
    hero.level_++;       // Can't access!
}

このことから、入れ子クラスは親のパラメータを色々いじって親の状態を変化させるサブモジュールのような役目を担う事が出来ます。この特権を利用するのが「入れ子クラスによるクラス内状態遷移」です。

派生入れ子クラスで一つの状態を表現します

入れ子クラスによるクラス内状態遷移は、まずベースとなる基底クラスを作ります:

#include <memory>

class Hero {
private:
    // 状態基底クラス
    class State {
    public:
        virtual void update( Hero &parent ) = 0;
    };

    std::shared_ptr< State > state_;
};

このStateクラスの派生クラスは多態性より全てState*に代入可能です。なのでState*メンバ変数を設けても良いのですが、上ではshared_ptrで管理するようにしています。これは派生クラスをnewする事を想定しています。この辺りの「状態クラスの存在管理」は検討対象の1つですが、ここでは上のスタイルで行く事にします。Stateクラスにあるupdateメソッドが毎フレームガンガン回す状態メソッドになりますが、State自体は抽象的な存在なので純粋仮想メソッドになっています。

ヒーローは「待機」と「歩く」と「走る」状態があるとしましょう。それぞれの入れ子クラスを追加します:

class Hero {
...
    class Idle : public State {
        virtual void update( Hero &parent );
    };
    class Walk : public State {
        virtual void update( Hero &parent );
    };
    class Run : public State {
        virtual void update( Hero &parent );
    };
}

一番最初は待機(Idle)状態だとして、HeroのコンストラクタでIdleオブジェクトを作成してstate_に代入します:

class Hero {
public:
    Hero() : state_( new Idle ) {}
...
};

最後にHero::updateメソッドの中でstate_のupdateメソッドを呼びまくれば準備OK:

void Hero::update() {
    if ( state_ != nullptr ) {
        state_->update( *this );
    }
}

これでIdle::updateメソッドが毎フレーム呼ばれ続ける状態になります。

状態遷移はstate_を直に差し替え

待機状態から何か方向キーを入力した時にそちらへ歩き出す状態遷移を記述してみます:

void Hero::Idle::update( Hero &parent ) {
    // キー入力があった時にその方向ベクトルが返る
    Float2 dir = getDirection();
    if ( dir.isZero() == false ) {
        parent.state_ = std::shared_ptr< State >( new Walk( dir ) );
    }
}

方向入力があった時にstate_をWalkオブジェクトに差し替えています。分かり良いですよね。後は好きなように遷移を広げていけば良いだけです。

親のメンバ変数が汚れないメリット!

この方法のメリットは各状態をクラス単位にパッケージ化する事で、その状態を維持するのに使うメンバ変数やオブジェクトをそのクラス内だけにとどめて置ける点にあります。メソッドポインタを使った状態遷移ではそれが出来ないため、すべて親クラスにメンバ変数を持たせる必要がありました。これは遷移が複雑だと酷い事になります。実際フェード等の時間制御系の遷移を書いてみると、そのありがたみが良く分かりますよ(^-^)

オブジェクトを作るべきか、使い回すべきか

上の実装、状態遷移はしてくれますが、遷移の度にnewが走ります。ここに難色を示す人は少なくないと思います。昨今のnewはCPUが爆速になったのとアルゴリズムの発展で十分に速いのですが、オブジェクトを何万も出すような場面だと流石に馬鹿になりません。C#だとガベコレが走りますしね。その場合、状態オブジェクトは先に用意してプールし使い回した方がローコストになります。ただし、使い回すには「再初期化」を徹底しなければならないのと、使い回すオブジェクトの変数を用意する必要があります。switch~case文の時に出て来た列挙型メンドクサイ問題と同じ問題がぶり返すんです。ここがちょっとねぇ…。

派生はやっぱり問題に…

この方法実にお手軽なのですが、派生が絡むと色々問題が噴出します。まず、HeroExクラス内でWalkの派生クラスWalkExを作った時、WalkEx::updateメソッド内では渡されるHeroオブジェクトのpublicメンバやメソッド以外触れなくなります。なんと特権剥奪!Heroクラスにとって兄弟(HeroEx)の子供(WalkEx)は別人というわけです。その為この方法の利点があまり活かせなくなります。

親の派生クラスで状態遷移の一部を変更したり差し替えるというのも難しい問題です。理由は元クラスの入れ子クラスが遷移先のクラスをnewで直に指定しているから。これをサポートするには状態遷移をちゃんとフレームワーク化しなければなりません。

入れ子クラスによる状態遷移はこういう理由から、派生が激しく起こらないようなクラスでささっと利用するのが無難です。適切な場面で上手に使っていきましょう(^-^)
さて次回はもっとガチな方法である「専用クラスによる状態遷移」です。

この記事が気に入ったらサポートをしてみませんか?