ステートパターン関連(コンストラクタ、デストラクタ、など: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の種類を選ぶ
次のメンバを解放する場合、delete と delete[] のどちらを使うべきか答えなさい。
問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);
コード:
No Comments