Skip to main content

ステートパターン関連(コンストラクタ、デストラクタ、など:6/23日)

C++授業資料

AI・Stateパターンにつなげる仮想関数、純粋仮想関数、コンストラクタ、デストラクタ

学生用:解説+ワーク


1. 今日のテーマ

ゲームAIでStateパターンを使うと、次のようなクラス構成になります。

Enemy
  ↓ 現在の状態を持つ

EnemyStateBase
  ↑
  ├ PatrolState
  ├ ChaseState
  ├ AttackState
  └ SearchState

ここで重要になるC++の機能が次の4つです。

仮想関数
純粋仮想関数
コンストラクタ
デストラクタ

特に、Stateパターンでは、

EnemyStateBase* state_;

のように、基底クラスのポインタで派生クラスを扱うことがあります。

このとき、デストラクタに virtual が必要になる場面があります。


2. 今日のゴール

今日のゴールは、次の内容を理解することです。

1. 仮想関数とは何か
2. 純粋仮想関数とは何か
3. コンストラクタとデストラクタが呼ばれる順番
4. 継承時の生成と破棄の順番
5. 動的確保したメンバをどこでdeleteするか
6. 基底クラスのデストラクタにvirtualを付けるべき場面
7. Stateパターンでなぜvirtualデストラクタが重要なのか

3. Stateパターンでよく出る形

Stateパターンでは、次のような基底クラスを作ります。

class Enemy;

class EnemyStateBase
{
public:
    virtual ~EnemyStateBase() = default;

    virtual void Update(Enemy& enemy) = 0;
};

そして、状態ごとのクラスを作ります。

class PatrolState : public EnemyStateBase
{
public:
    void Update(Enemy& enemy) override
    {
        // 巡回中の処理
    }
};

ここで出てくる重要な言葉が、

virtual
= 0
override

です。


4. 仮想関数とは何か

仮想関数とは、

基底クラスのポインタや参照から呼んでも、
実際の派生クラスの関数が呼ばれるようにする仕組み

です。


4.1 virtualなしの例

#include <iostream>

class StateBase
{
public:
    void Update()
    {
        std::cout << "StateBase::Update\n";
    }
};

class PatrolState : public StateBase
{
public:
    void Update()
    {
        std::cout << "PatrolState::Update\n";
    }
};

int main()
{
    StateBase* state = new PatrolState();

    state->Update();

    delete state;
}

この場合、Update()virtual が付いていないため、

StateBase::Update

が呼ばれます。

state の中身は PatrolState なのに、呼び出し側の型が StateBase* なので、StateBase::Update() が呼ばれてしまいます。


4.2 virtualありの例

#include <iostream>

class StateBase
{
public:
    virtual void Update()
    {
        std::cout << "StateBase::Update\n";
    }
};

class PatrolState : public StateBase
{
public:
    void Update() override
    {
        std::cout << "PatrolState::Update\n";
    }
};

int main()
{
    StateBase* state = new PatrolState();

    state->Update();

    delete state;
}

この場合は、

PatrolState::Update

が呼ばれます。

virtual を付けることで、

ポインタの型ではなく、
実際に入っているオブジェクトの型に合わせて関数を呼ぶ

ことができます。


5. Stateパターンでvirtualが必要な理由

Stateパターンでは、Enemyが現在の状態を基底クラスのポインタで持つことがあります。

EnemyStateBase* state_;

しかし、実際に入っているのは、派生クラスです。

state_ = new PatrolState();

この状態で、

state_->Update(enemy);

と呼んだときに、PatrolState::Update() が呼ばれてほしいです。

そのため、基底クラス側で Update()virtual にします。

virtual void Update(Enemy& enemy) = 0;

6. 純粋仮想関数とは何か

純粋仮想関数とは、

派生クラスで必ず中身を作らせる仮想関数

です。

書き方は、関数の最後に = 0 を付けます。

virtual void Update(Enemy& enemy) = 0;

これは、

EnemyStateBaseにはUpdateの中身を書かない
派生クラスで必ずUpdateを作る

という意味です。


7. 純粋仮想関数を持つクラスは直接作れない

次のクラスは、純粋仮想関数を持っています。

class EnemyStateBase
{
public:
    virtual void Update() = 0;
};

このクラスは直接作れません。

EnemyStateBase base;  // エラー

なぜなら、Update() の中身がないからです。


7.1 正しい使い方

class PatrolState : public EnemyStateBase
{
public:
    void Update() override
    {
        // 巡回処理
    }
};

EnemyStateBase* state = new PatrolState();

EnemyStateBase は直接使うのではなく、

共通の型として使う

と考えると分かりやすいです。


8. overrideとは何か

override は、

基底クラスのvirtual関数を上書きしていることを明示するもの

です。

class PatrolState : public EnemyStateBase
{
public:
    void Update(Enemy& enemy) override
    {
    }
};

もし関数名や引数を間違えると、コンパイラが教えてくれます。


8.1 overrideがあるとミスに気づける

class PatrolState : public EnemyStateBase
{
public:
    void Updata(Enemy& enemy) override
    {
    }
};

Update と書くべきところを Updata と間違えています。

override が付いているので、コンパイルエラーになり、ミスに気づけます。


9. コンストラクタとは何か

コンストラクタは、

オブジェクトが作られたときに自動で呼ばれる関数

です。

主に、初期化に使います。

class Enemy
{
private:
    int hp_;

public:
    Enemy()
        : hp_(100)
    {
    }
};

この例では、Enemy が作られたときに hp_100 で初期化されます。


10. デストラクタとは何か

デストラクタは、

オブジェクトが消えるときに自動で呼ばれる関数

です。

主に、後片付けに使います。

class Enemy
{
public:
    ~Enemy()
    {
        // 後片付け
    }
};

11. デストラクタが特に重要になる場面

デストラクタが特に重要になるのは、クラスの中で new したメンバを持っている場合です。

class Enemy
{
private:
    int* hp_;

public:
    Enemy()
    {
        hp_ = new int(100);
    }

    ~Enemy()
    {
        delete hp_;
    }
};

このように、コンストラクタで new したものは、デストラクタで delete します。


12. new/deleteの基本ルール

基本ルールは次の通りです。

new したものは delete する
new[] したものは delete[] する

12.1 1個だけ確保した場合

int* p = new int;
delete p;

12.2 配列を確保した場合

int* array = new int[10];
delete[] array;

new[] したものを delete してはいけません。

new したものを delete[] してもいけません。


13. メンバに動的変数を持つ例

次のようなクラスを考えます。

class Texture
{
private:
    int* pixels_;

public:
    Texture()
    {
        pixels_ = new int[256];
    }

    ~Texture()
    {
        delete[] pixels_;
    }
};

このクラスでは、pixels_new[] しているので、デストラクタで delete[] しています。


14. Stateクラスが動的メンバを持つ場合

Stateパターンでも、Stateが動的メンバを持つことがあります。

class SearchState : public EnemyStateBase
{
private:
    float* searchTimer_;

public:
    SearchState()
    {
        searchTimer_ = new float(0.0f);
    }

    ~SearchState()
    {
        delete searchTimer_;
    }

    void Update(Enemy& enemy) override
    {
    }
};

この場合、SearchState のデストラクタが呼ばれなければ、searchTimer_ が解放されません。

ここが重要です。


15. 継承時のコンストラクタの呼ばれる順番

継承している場合、コンストラクタは次の順番で呼ばれます。

基底クラスのコンストラクタ
↓
派生クラスのコンストラクタ

15.1 例

#include <iostream>

class Base
{
public:
    Base()
    {
        std::cout << "Base コンストラクタ\n";
    }
};

class Derived : public Base
{
public:
    Derived()
    {
        std::cout << "Derived コンストラクタ\n";
    }
};

int main()
{
    Derived d;
}

出力は次の順番です。

Base コンストラクタ
Derived コンストラクタ

16. 継承時のデストラクタの呼ばれる順番

デストラクタは、コンストラクタと逆順に呼ばれます。

派生クラスのデストラクタ
↓
基底クラスのデストラクタ

16.1 例

#include <iostream>

class Base
{
public:
    ~Base()
    {
        std::cout << "Base デストラクタ\n";
    }
};

class Derived : public Base
{
public:
    ~Derived()
    {
        std::cout << "Derived デストラクタ\n";
    }
};

int main()
{
    Derived d;
}

出力は次の順番です。

Derived デストラクタ
Base デストラクタ

17. なぜデストラクタは逆順なのか

作るときは、

土台を作る
↓
その上に派生部分を作る

という順番です。

消すときは逆に、

派生部分を片付ける
↓
土台を片付ける

という順番です。


18. 多段継承の場合の順番

多段継承とは、次のような形です。

class GameObject
{
};

class Enemy : public GameObject
{
};

class BossEnemy : public Enemy
{
};

この場合、BossEnemy を作ると、コンストラクタは次の順番です。

GameObject コンストラクタ
↓
Enemy コンストラクタ
↓
BossEnemy コンストラクタ

デストラクタは逆順です。

BossEnemy デストラクタ
↓
Enemy デストラクタ
↓
GameObject デストラクタ

19. virtualデストラクタが必要な理由

次のコードを見てください。

class EnemyStateBase
{
public:
    ~EnemyStateBase()
    {
    }

    virtual void Update() = 0;
};

class SearchState : public EnemyStateBase
{
private:
    float* timer_;

public:
    SearchState()
    {
        timer_ = new float(0.0f);
    }

    ~SearchState()
    {
        delete timer_;
    }

    void Update() override
    {
    }
};

この状態で、次のように使います。

EnemyStateBase* state = new SearchState();

delete state;

このとき、基底クラスのデストラクタが virtual ではないと、SearchState のデストラクタが正しく呼ばれない可能性があります。

つまり、

~SearchState()
{
    delete timer_;
}

が呼ばれず、timer_ が解放されない危険があります。


20. 正しい形:基底クラスのデストラクタをvirtualにする

基底クラスのポインタで派生クラスを消すなら、基底クラスのデストラクタを virtual にします。

class EnemyStateBase
{
public:
    virtual ~EnemyStateBase()
    {
    }

    virtual void Update() = 0;
};

または、

class EnemyStateBase
{
public:
    virtual ~EnemyStateBase() = default;

    virtual void Update() = 0;
};

これで、

EnemyStateBase* state = new SearchState();
delete state;

としたときに、次の順番で呼ばれます。

SearchState デストラクタ
↓
EnemyStateBase デストラクタ

21. virtualデストラクタを付けるべき場面

次の条件に当てはまる場合は、基底クラスのデストラクタに virtual を付けます。

1. 継承して使う基底クラスである
2. 基底クラスのポインタで派生クラスを扱う
3. delete される可能性がある

Stateパターンの基底クラスは、まさにこれに当てはまります。

EnemyStateBase* state = new PatrolState();
delete state;

そのため、

virtual ~EnemyStateBase() = default;

が必要です。


22. virtualデストラクタが不要な場面

次のような場合は、必ずしも virtual デストラクタは必要ありません。

継承して使わないクラス
基底クラスのポインタでdeleteしないクラス
値としてしか使わないクラス

ただし、ゲームエンジンやAIの基底クラスのように、

継承前提
ポリモーフィズム前提
基底ポインタで扱う前提

の場合は、基本的に virtual デストラクタを付ける、と覚えると安全です。


23. Stateパターンでのまとめ

Stateパターンでは、次のような設計になります。

class EnemyStateBase
{
public:
    virtual ~EnemyStateBase() = default;
    virtual void Update(Enemy& enemy) = 0;
};

この設計には意味があります。

virtual Update
→ 基底ポインタから派生StateのUpdateを呼ぶため

= 0
→ 各StateにUpdateを必ず作らせるため

virtual デストラクタ
→ 基底ポインタで派生Stateをdeleteしても安全にするため

24. Update中に自分自身をdeleteしない

Stateパターンでは、Stateの Update() 中に状態を切り替えたくなることがあります。

危険な形:

void Enemy::ChangeState(EnemyStateBase* nextState)
{
    delete state_;
    state_ = nextState;
}

この場合、PatrolState::Update() の中で ChangeState() を呼ぶと、実行中の PatrolState 自身を消してしまいます。

PatrolState::Update 実行中
↓
enemy.ChangeState(new ChaseState())
↓
delete state_
↓
今実行中のPatrolStateが消える

そのため、状態変更はすぐに行わず、

次のStateを予約する
↓
Updateが終わる
↓
古いStateをdeleteする

という形にします。


25. 安全な状態切り替え

class Enemy
{
private:
    EnemyStateBase* state_ = nullptr;
    EnemyStateBase* nextState_ = nullptr;

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

        ApplyStateChange();
    }

    void ChangeState(EnemyStateBase* nextState)
    {
        delete nextState_;
        nextState_ = nextState;
    }

    void ApplyStateChange()
    {
        if (nextState_ == nullptr)
        {
            return;
        }

        delete state_;
        state_ = nextState_;
        nextState_ = nullptr;
    }
};

この形なら、Stateの Update() が終わった後で、古いStateを消します。


26. 解説まとめ

重要なことは次の通りです。

基底ポインタで派生クラスを扱うなら、関数はvirtualにする
派生クラスで必ず作らせたい関数は純粋仮想関数にする
newしたメンバはデストラクタでdeleteする
コンストラクタは基底→派生の順番
デストラクタは派生→基底の順番
基底ポインタでdeleteするなら、基底クラスのデストラクタはvirtualにする
StateのUpdate中に自分自身をdeleteしない

ここからワーク


ワーク1:用語確認

次の用語を、自分の言葉で説明しなさい。

用語 説明
仮想関数
純粋仮想関数
コンストラクタ
デストラクタ
virtualデストラクタ

ワーク2:出力順を答える

次のコードを読んで、出力順を書きなさい。

#include <iostream>

class Base
{
public:
    Base()
    {
        std::cout << "Base ctor\n";
    }

    ~Base()
    {
        std::cout << "Base dtor\n";
    }
};

class Derived : public Base
{
public:
    Derived()
    {
        std::cout << "Derived ctor\n";
    }

    ~Derived()
    {
        std::cout << "Derived dtor\n";
    }
};

int main()
{
    Derived d;
}

出力順:






ワーク3:多段継承の出力順

次のコードを読んで、出力順を書きなさい。

#include <iostream>

class GameObject
{
public:
    GameObject()
    {
        std::cout << "GameObject ctor\n";
    }

    ~GameObject()
    {
        std::cout << "GameObject dtor\n";
    }
};

class Enemy : public GameObject
{
public:
    Enemy()
    {
        std::cout << "Enemy ctor\n";
    }

    ~Enemy()
    {
        std::cout << "Enemy dtor\n";
    }
};

class BossEnemy : public Enemy
{
public:
    BossEnemy()
    {
        std::cout << "BossEnemy ctor\n";
    }

    ~BossEnemy()
    {
        std::cout << "BossEnemy dtor\n";
    }
};

int main()
{
    BossEnemy boss;
}

出力順:








ワーク4:virtualが必要か判断する

次のクラスでは、基底クラスのデストラクタに virtual が必要ですか。

class EnemyStateBase
{
public:
    ~EnemyStateBase()
    {
    }

    virtual void Update() = 0;
};

class PatrolState : public EnemyStateBase
{
public:
    void Update() override
    {
    }
};

int main()
{
    EnemyStateBase* state = new PatrolState();
    delete state;
}

答え:

必要 / 不要

理由:





ワーク5:問題点を見つける

次のコードの問題点を書きなさい。

class EnemyStateBase
{
public:
    ~EnemyStateBase()
    {
    }

    virtual void Update() = 0;
};

class SearchState : public EnemyStateBase
{
private:
    float* timer_;

public:
    SearchState()
    {
        timer_ = new float(0.0f);
    }

    ~SearchState()
    {
        delete timer_;
    }

    void Update() override
    {
    }
};

int main()
{
    EnemyStateBase* state = new SearchState();
    delete state;
}

問題点:




修正案:





ワーク6:純粋仮想関数を完成させる

次の空欄を埋めて、Stateの基底クラスを完成させなさい。

class Enemy;

class EnemyStateBase
{
public:
    __________ ~EnemyStateBase() = default;

    __________ void Update(Enemy& enemy) = 0;
};

ワーク7:派生Stateを完成させる

次の PatrolState を完成させなさい。

class PatrolState : public EnemyStateBase
{
public:
    void __________(Enemy& enemy) __________
    {
        enemy.Patrol();

        if (enemy.CheckCanSeePlayer())
        {
            enemy.ChangeState(new ChaseState());
        }
    }
};

ワーク8:deleteの種類を選ぶ

次のメンバを解放する場合、deletedelete[] のどちらを使うべきか答えなさい。


問1

int* hp_ = new int(100);

解放:



問2

int* pixels_ = new int[256];

解放:



問3

float* timer_ = new float(0.0f);

解放:



ワーク9:State切り替えの危険を説明する

次のコードは危険です。なぜ危険なのか説明しなさい。

void Enemy::ChangeState(EnemyStateBase* nextState)
{
    delete state_;
    state_ = nextState;
}

ヒント:

この関数がStateのUpdate中に呼ばれたらどうなるか?

説明:





ワーク10:安全なState切り替えを完成させる

次の空欄を埋めなさい。

class Enemy
{
private:
    EnemyStateBase* state_ = nullptr;
    EnemyStateBase* nextState_ = nullptr;

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

        ____________________;
    }

    void ChangeState(EnemyStateBase* nextState)
    {
        delete nextState_;
        nextState_ = nextState;
    }

    void ApplyStateChange()
    {
        if (nextState_ == nullptr)
        {
            return;
        }

        delete __________;
        state_ = __________;
        nextState_ = __________;
    }
};

ワーク11:Stateパターンでvirtualデストラクタが必要な理由

次の文章の空欄を埋めなさい。

Stateパターンでは、Enemyが現在のStateを
__________ クラスのポインタで持つ。

しかし、実際に入っているのは
PatrolState や SearchState などの __________ クラスである。

そのため、基底クラスのポインタで delete しても
派生クラスのデストラクタが呼ばれるように、
基底クラスのデストラクタには __________ を付ける。

ワーク12:ミニ設計問題

次のStateを持つAIを考えます。

PatrolState
ChaseState
AttackState

次の条件で遷移します。

PatrolState中にプレイヤーを見つけたらChaseState
ChaseState中に攻撃範囲に入ったらAttackState
AttackState中にプレイヤーを見失ったらPatrolState

AttackState のクラスを書きなさい。

使ってよいEnemyの関数:

void Attack();
bool CheckCanSeePlayer();
void ChangeState(EnemyStateBase* nextState);

コード: