ゲーム開発においてバグの8割は初期化ミスであると考えている。
ここでは、初期化を可能な限りミスしないためのクラスの基礎設計について説明する。
##必要なメソッド
クラスを作る際に以下のメソッドを必ず定義する。
- コンストラクタ(C++11からはなくてもよい)
- デストラクタ
- Initialize
- Finalize
クラスを作る際に以下のメンバを定義する。
- InitializeParam構造体を保持する変数
- bool型の初期化済み判定変数
それぞれの役割について説明をする。
###コンストラクタ
メンバ変数を初期化する。
C++11からは変数定義時に初期化できるため、デフォルトコンストラクタしか必要ない場合は
意図的に定義する必要はない。
###デストラクタ
Finalizeメソッドを呼び出す
###Initialize
実質的な初期化はこのメソッド内で行う。
初期化に必要なパラメータも基本的にInitializeメソッドに渡す。
引数が3つ以上になる場合は、InitializeParamのような初期化構造体を作って渡したほうがよい
メソッドの頭でFinalizeメソッドを呼び出す
###Finalize
肝となるメソッド。
変数を定義したら、即座にFinalizeに終了処理を記述する。
このメソッドが呼ばれたら、如何なる状態からでも正常に終了出来るようにする。
また、2度以上呼ばれた場合でも正常に動作するようにする。
##作成例
/*!
*@brief 例題クラス
*/
class Hoge
{
public :
/*!
* @brief 初期化構造体
*/
struct InitializeParam {
size_t size;
size_t value;
};
/*!
* @brief コンストラクタ
*/
Hoge() = default;
/*!
* @brief デストラクタ
*/
~Hoge() {
Finalize();
}
/*!
* @brief 初期化を行います。
*
* @param param 初期化構造体を設定します。
*
* @return 正常に終了した場合は true を返します。
*/
bool Initialize(const InitializeParam& param) {
// 必ずFinalizeを呼んで終了させる
Finalize();
init_param_ = param;
// バッファを初期化する
buffer_ = new char[init_param_.size];
// 初期化済みとする
initialized = true;
return true;
}
/*!
* @brief 終了化を行います。
*/
void Finalize() {
delete[] buffer_;
buffer_ = nullptr;
// 未初期化状態とする
initialized = false;
}
/*!
* @brief 適当なメソッド
*/
void Func() {
// 初期化されていない場合は何もしない
if (!initialized) return ;
// 必要な処理を記載
・・・
}
private :
// 変数を定義、必要な変数を初期化する
InitializeParam init_param_; //!< 初期化構造体
bool initialized = false; //!< 初期化済み判定変数
char* buffer_ = nullptr; //!< バッファ、nullptrで初期化しないと最初のFinalizeでエラーとなる
};
##意図
Initializeメソッドはいかなる場合に呼んでも、必ず初期化されることが重要である。
ゲーム開発中はデバッグも含めて意図的にクラスを初期化状態にする必要があることが多々存在する。
余計な事を考えず、Initializeを呼べば必ず初期化状態になることは、それだけで初期化のミスを減らすことになる。
Finalizeメソッドはいかなる場合に呼んでも、必ず正常に終了されることが重要である。
プログラムは上位の管理クラスにより、想定していないタイミングで解放されることは多々存在する。
デストラクタでFinalizeを呼んでおけば、いかなる場合でも正常に終了することが期待できる。
また、正常に終了することが保証されているが故、Initializeの頭でFinalizeを呼ぶことで
いかなる場合でも正常に終了し、正常に初期化することができるのである。
##(追記)コンストラクタ、デストラクタとは別にInitializeとFinalizeを追加する理由
初期化の重要性と手法を記述したが、コンストラクタとデストラクタで初期化、終了化をしない理由を
書かなかったため、知り合いから突っ込みかが入りました。
処理をあえて分ける必要があるパターンをいくつか記載します。
###事前に確保し、使いまわす場合
例えば、シューティングの弾やエフェクトなどのオブジェクトは出入りが激しいため
毎回newを行うと処理が重くなってしまいます。
事前に配列で複数個確保し、使用する際にInitializeをしたほうが処理が軽くなります。
これはplacement newを使う方法でも回避は可能ですが、デストラクタを明示的に呼ぶ必要があるため
Initialize関数を呼ぶほうが綺麗に書けます。
###例外を排除
コンストラクタで問題が発生した場合は例外が投げられ、呼び出し元でtry catchする必要があります。
C++として、この動作は正しいのですがゲームは速度を求められるので、例外設定を切ることが多々あります。
Initialize関数の戻り値で処理の結果を取得することで例外の発生を抑えることが出来ます。
###クラス間で相互に互いのポインタを保持する場合
相互に保持するとなるとコンストラクタでは渡せない、しかし必須パラメータであるため
Set関数で渡すのは微妙であるという時にInitialize関数で必要なパラメータを渡すということが出来ます。
本内容とは外れますが、上記の相互クラスが存在する場合は片方に管理をさせるべきです。
例えば、AクラスとBクラスが存在してAクラスが管理する場合
Aクラスは、BクラスのSet,Getを呼び出すのに対して、BクラスはAクラスのGetのみしか呼び出さない。
このように、Setは一方のみが呼び出すようにしないと処理が複雑になった際に
何が影響して動作しているかが非常にわかりづらくなってしまいます。