ホーム < ゲームつくろー! < オブジェクト指向設計編 < オブジェクト指向の根幹部分、オブジェクトの生成と初期化は誰がする?
その9 オブジェクト指向の根幹部分、オブジェクトの生成と初期化は誰がする?
クラスのオブジェクトには、STLのstringのように生成時にすぐ使える物と、ゲームのシーンクラスのように特殊な初期化作業が必要な物があります。生成時空っぽなオブジェクトは、有効なメンバ変数を投入すると初めて使い物になります。初期値を与える、これは一見簡単な事に感じてしまいます。しかし、プログラムにあるゲーム固有の値を焼き込むわけにはいきません。となると、データは外部に置く事になります。では、その外部のデータはどうやって読み込むのか?これは設計が複雑になればなるほど難しくなってくるんです。
この章では、オブジェクト指向設計で避けて通れない「クラスのインスタンスを生成・初期化する」という点にスポットを当ててみたいと思います。
@ 2種類の初期化
オブジェクトの初期化には、コンストラクタによる初期化と、初期化メソッドによる初期化があります。
クラスのインスタンスを生成した瞬間、コンストラクタによる最初の初期化が行われます。クラスの基本ですね。コンストラクタではクラスで致命的なエラーが出ないようメンバ変数に最低限の値を与えます。例えば整数をゼロに初期化するとか、ポインタにNULLを入れたりコンポジションにNULLオブジェクトを代入したりなどします。生成の成功や失敗を直接返すことができず、また仮想関数化できないコンストラクタでは、具体的なコンポジションオブジェクトを生成したり、保持しているメンバ変数を関連付けるなどの複雑な初期化は避けるべきです。
コンストラクタを抜けた後、エラーは出ないが空っぽであるインスタンスを完成させるには、幾つかのメンバ変数設定メソッドを呼び出した後に初期化メソッドを呼び出します。初期化メソッドの目的は、インスタンスを完全に使える状態にする事です。初期化メソッドがtureを返せば、インスタンスの機能がフルに有効になります。クラスが複雑になるほど、2段階初期化が頻繁に使われるようになるはずです。
さて、この2種類の初期化を自作パソコンでなぞらえるなら、前者は必要なパーツの種類をチェックする状態(パーツ自体はまだ無い)、後者は実際にパーツを買って来て、それを組み合わせてケーブルを全部繋いでパソコンとして動く状態にする事です。大変なのは後者でして、これをしっかり固める必要があります。以後、この自作パソコンを引き合いに出して色々と考えていきます。
A 自作パソコンの購入パーツのリストはどうやって手に入れるのか?
パソコンを作る(=初期化メソッドを呼び出す)には、何はともあれ購入すべきパーツのリストが必要になります。これは1枚の紙切れに名前が列挙されているイメージです。このパーツリストの紙切れの所在はどこなのか?大きく3つ考えられます。
1つは、パソコンオブジェクトにすでに刻まれている状態です。コンストラクタや初期化メソッド内に紙切れが焼き込まれているわけです。一般に、この方法はクラスの機能を限定してしまうため、あまり用いられません。
2つ目は、パソコンを作る人が生まれた時から持っている状態です。おぎゃーっと誕生して、ふと右手を開くと「HDDドライブ160GB \14,000」という紙が握られています(笑)。つまり、パソコンの使用者クラスに値が埋め込まれているということです。これは、実質1つ目と状況が同じです。購入するパーツを変えためには、生まれる前に紙に書いてあるパーツ名を書き換える必要が出てきます。この時元のクラス変更は怖いので、派生して再コンパイルすることになります。それで対処することもままありますが、あまり良い状態とは言えませんね。
3つ目は、ちょっと状況が違います。生まれた時にやっぱり紙を握っているのですが、そこには「購入するリストは山の上の木の根元にある」という場所が示されている状態です。とりあえずそこに行って見ると、木の根元にまた紙切れが落ちていて、そこに「HDD300GB \20,000」と書いてあったので、そのパーツを購入します。
2つ目と3つ目の違う点は、握っている紙にパーツが書いてあるか場所が書いてあるかです。これはC言語のポインタと実体の関係とそっくり同じなんです。前者は実体、そして後者はポインタです。「購入するリストは山の上の木の根元にある」というのは、実体のある場所を指し示すポインタのような存在になっているので、ポインタの先を取り替えると、別のパーツを指示できるわけです。これだとプログラムを再コンパイルしなくても、ゲームの状態を変えることができます。
もう薄々感づいていると思いますが、「山の上の木の根元」というのは外部ファイルの位置を指します。結論としては、初期化のデータは外において、その位置であるファイル名をプログラム側で持つというのが、購入パーツのリストを手に入れる素直な方法です。ただ、どのクラスにファイル名を書き込むかは、しっかり考える必要があります。
B 自作パソコンの使用者と製作者を分ける〜ファクトリクラスの必要性
パソコンの使用者がパソコンを作るというのは、もっともに感じます…まぁ、現実は大分に少数派になってしまいましたが。要は、あるクラスを使いたいクラスが生成責任を持つのは普通の事だと言いたいわけです。しかし、複雑な自作パソコンクラスを初期化するために、パソコンのパーツリストが記述されたファイル名を使用者クラスに焼き込むのはちょっと待って欲しいところなんです。使用者クラスの真の目的はパソコンを作ることじゃありません。パソコンを使うことにあります。にもかかわらず、生成に手間をかけ、あまつさえそのための固定的なファイル名をクラスの内部に書き込むというのは、生成に対して仕事を傾け過ぎている気がします。
コストの高い生成は生成の専門家にまかせてしまって、使用者は与えられたパソコンを使用する事だけに集中するとしたらどうでしょう。随分パソコンが使いやすくなります。そういうオブジェクト生成の専門家クラスを「ファクトリクラス」と言います。使用者はファクトリクラスに対して「パソコンを1台作ってください」と希望します。ファクトリクラスは空っぽのパソコンオブジェクトを自ら作り、外部ファイルからパーツの情報を取得し、パソコンオブジェクトを初期化して使用者に渡します。使用者は、そのパソコンを使うだけになります。
ファクトリクラスは生成専門なので、その初期化データがある外部ファイルの名前を持って特段問題はありません。別のパソコンを作りたいのであれば、外部ファイルを入れ替えればいいんです。対応しきれなくなってきたら、ファクトリクラスを派生させる手もあります(再コンパイルを要しますが)。利点として、固有の外部ファイル名を使用者側に焼き込まなくて良いため、使用者クラスが汚れません。その代わりゲームに特化した派生ファクトリクラスは汚して構いません。大切なのは、ファクトリクラスのインターフェイスを統一しておく事です。例えば次のような実装になるでしょうか:
ファクトリクラスの使用例 bool CMyClass::Init()
{
// 使用するパソコンを作ってもらう
IMyPCFactory *pPCFactory;
CreateFactory( MY_PC_FACTORY, &pPCFactory );
IMyPC *pPC;
pPCFactory->Create( &pPC );
if( pPC == NULL ){
pPCFactory->Release();
return false;
}
return true;
}
ファクトリクラスの実装例 bool CMyPCFactory::Create( IMyPC **ppPC )
{
// パソコンを生成
CMyPC *pPC = new CMyPC;
// 外部ファイルに定義されている初期化情報を取得
ifstream ifs;
ifs.open(PCINFOFAILENAME); // 固有外部ファイル名(マクロ化しています)
if( !ifs.is_open() ){
// 適当なデフォルト値を与える
pPC->SetHDDSize( 160 ); // 160GB
pPC->SetMemorySize( 1000 ); // 1GB
pPC->Init(); // 初期化実行
*ppPC = pPC;
return true;
}
// 外部ファイルからパソコン情報を抜き出す
DWORD dwHDDSize;
DWORD dwMemorySize;
ifs >> dwHDDSize >> dwMemorySize;
pPC->SetHDDSize( dwHDDSize );
pPC->SetMemorySize( dwMemorySize );
pPC->Init(); // 初期化実行
*ppPC = pPC; // PCを引き渡し
return true;
}
単純なファクトリクラスの使い方ですが、生成部分をファクトリクラスに依存しているため、使用者であるCMyClassクラスはこれ以上変更する必要は無くなります。CMyClassはIMyPCというインターフェイスポインタをファクトリクラスからもらっています。これにより「使用者はパソコンをインターフェイスを通してのみ使用する」というオブジェクト指向の一つの理想形が出来上がっています。
「そもそも、パソコンを外部から与えちゃ駄目なのか?」と思われるかもしれません。これはケースバイケースです。この判断をするには、「外部はCMyClassクラスがパソコンを使用することを知っているか否か」にかかってきます。例えば、市役所に行って住民票を取得する時に、「私が設定したパソコンを使って下さい」と職員に手渡すかどうか、という感覚の判断だと思います。そういう状況でないのなら、パソコンはCMyClassクラスの内部で作るのが理想でしょう。一般に外部からオブジェクトを与える形式を強めると、オブジェクト指向がどんどん構造化プログラムになっていきます。
C 外部ファイルのアーカイブ化とクラス定義の注意
オブジェクトを適切に初期化する外部ファイルがあると、プログラムはゲームを動かす雛形になっていき、細かな調節は全部外部ファイルで行えるようになります。これは、ゲーム製作の1つの理想です。ただ、外部ファイルでゲームバランスを調節できると言う事は、他の人もそれが可能になるという事でもあります。特にDVDやCD-ROMなどの固定メディアから起動するタイプではなくてHDDにインストールするタイプのゲームだと、この改ざんがクリティカルな問題となります。
これを解決する1つの方法は、沢山ある外部ファイルを一まとめにしてしまう事です。そのようなファイルは「アーカイブファイル」と呼ばれます。アーカイブ化すると、多くの場合改ざん防止に一役かいます。良く分からないファイルは素人は触れないからです。ただ、単にファイルをまとめるだけだと画像や音声のファイルは簡単にセパレートできてしまいます。これも、ちょっとした暗号化をする事によって改善は可能です。
ファイル側の防御はそこそこなんとかなるものなのですが、そういう処理を施すと、今度はプログラムの実装側に問題が出てきます。例えば、テクスチャを生成する時にD3DXCreateTextureFromFile関数に画像ファイル名を指定する方法を取っていたとしましょう。この時、暗号化されたファイルのままだと当然読み込みエラーとなりますから、暗号を復号しなければなりません。この時に復号に伴うメモリの圧迫は最近では殆ど問題になりません。問題は、復号の手間、そしてこの段階ですでにファイル名を関数に与える部分が機能しない点です。つまりD3DXCreateTextureFromFile関数をメモリ→テクスチャ生成というD3DXCreateTextureFromFileInMemory関数に切り替えないといけないんです。もしテクスチャの生成部分がプログラム中に散りばめられていたとしたら、暗号化に伴う相当な変更地獄を見ることになります。
では、どうするとスマートか?まず、テクスチャを生成するファクトリクラスをちゃんと設けます。このファクトリクラスにやっぱりファイル名を指定するのですが、同時にファイルが格納されているアーカイブファイル名も指定します(同時に指定しなくてもかまいません)。後はファクトリクラス内部でファイルの中身を解析してテクスチャを作成し、生成依頼者に返します。ファイル名を指定できるので、プログラム開発時には個々のファイルをそのまま読み込む方式にし、リリース時にアーカイブファイル+暗号+メモリから生成という方法に切り替える事が可能です。どちらの方法をとっているかは、テクスチャ生成依頼者からは隠蔽されます。
このように、ファイルとダイレクトに繋がる生成部分はとことんまでプログラムの本筋から分離するように努めると、プログラムはファイルを意識しなくなってきます。ただ、どうしても固定ファイル名は沢山必要になってしまいます。誰が固定ファイルを持ってよいのか、しっかり考える事が大切です。
テクスチャ生成に関して1つ例を示しておきます。
時計クラスを作成する時に、0〜9までの画像ファイルが必要になります。画像データを外部に置いておけば、文字のデザインを自由に変更できます。この時、時計クラスが直接各画像のファイル名を持つのも悪くありませんが、1つ間をおいて時計クラス直属のファクトリクラスを設けるようにします。具体的には次のように宣言します。
時計クラス宣言例 class IClock
{
public:
// 数字テクスチャを登録
virtual bool SetNumberTexture( DWORD Number, ID3DXTexture9 *pTex ) = 0;
};
class CClock : public IClock
{
public:
// 数字テクスチャを登録
virtual bool SetNumberTexture( DWORD Number, ID3DXTexture9 *pTex );
};
#define CLOCKTEXTURLIST "ClockTextureFileList.dat" // テクスチャリストファイル名
class CClockFactory
{
public:
// 時計を生成
bool CreateClock( IClock **pClock )
{
// 時計オブジェクト生成
*pClock = new CClock;
// アーカイブファイルオープン
CArchive Acv( ArchiveFile );
// アーカイブファイル内からテクスチャ名リストファイルを取得
char *p = Acv.GetFileDataPtr( CLOCKTEXTURLIST ); // CLOCLTEXTURELISTは数字テクスチャ名が列挙されたファイル名
// テクスチャファイル名を取得
vector<string> TexList;
GetTextureFileNames( p, &TexList ); // リストからファイル名を取得して配列に格納
// 取得したファイル名を頼りにテクスチャを作成
size_t i;
for(i=0; i<TexList.size(); i++){
IDirect3DTexture9* pTex;
CreateTexture( Acv, TexList[i] ); // アーカイブファイル+テクスチャファイル名
*pClock->SetNumberTexture( i, pTex ); // テクスチャ登録
}
return true;
}
};
大分に適当入っていますが、ClockFactory::CreateClockメソッドの内部では必要なテクスチャをアーカイブファイルに内包されているファイル空作成しています。具たいていなファイルとしては、例えば次のような定義です。
テクスチャファイル名リスト(ClockTextureFileList.dat) Tex0.dds
Tex1.dds
Tex2.dds
Tex3.dds
Tex4.dds
Tex5.dds
Tex6.dds
Tex7.dds
Tex8.dds
Tex9.dds
このファイルフォーマットはCClockFactoryクラスとの取り決めで決めます。この辺を一般化しておくと使い回しができます。
こうすることで、CClockクラスを汚すことなく、実行時にテクスチャの名前が指定できるようになります。もちろん、アーカイブファイル内にこれらの画像データが全てあることが前提です(無くてもエラーにはなりませんが)。デバッグ時には、パスの通ったフォルダにこれらのテクスチャを置いて読み込めるようにCArchiveクラスを工夫します。ややこしいかもしれませんが、こういう細かい設定が、後々のバランス調節などで絶大な威力を発揮するようになります。
生成は最初に行う部分ですから、この章で取り上げた仕組みは実は早い段階で整えておいた方がいいんです。正直とても大変な実装作業にはなりますが、開発が進めば進むほど、この仕組みの不備が痛手になり、後戻りできない状態になってしまいます。地味な部分ではありますが、中大規模ゲームを製作したいと思っている方で生成に関して等閑になってしまっている方は、考慮してみることをお勧めします。