セーブ・ロードの自動化 その2(ポインタの扱い)
「セーブ・ロードの自動化 その1(メンバ変数の保存)」では、セーブロードの自動化の基本部分を作成し、クラスごとに指定したメンバ変数を自動的にセーブロードできるようにしました。ただ、現段階では値をそのまま保存するので、ポインタまでも数値として保存してしまいます。ポインタはその場限りのメモリのアドレスであるため、保存することに意味はありません。そこで、本節ではポインタによるオブジェクト間の関係をも解決するセーブロードについて詳しく実装していきます。
(尚、以下は書籍「Game Programing Gems 3」内で記載されているセーブロード法を参照しています)
@ ポインタのセーブロードの概要
その1でも触れた内容をおさらいします。
保存時に、ポインタの先にあるオブジェクトに番号を付け、オブジェクトの代わりにその番号を保存します。もちろん、ポインタ先のオブジェクトも保存する必要があります。
ロード時には保存したオブジェクトを全て復活させ、次に番号を元に作成したオブジェクトのポインタを戻します。
オブジェクト(へのポインタ)をCSaveManagerクラス(その1をご覧下さい)のAddSaveObj関数で登録すると、登録順に要素番号がつくことになります。次のような状態です。
要素番号 オブジェクトのあるアドレス(ポインタ) 0 1023 1 4326 2 10000 3 8542
CSaveManager::Save関数でセーブを開始すると、要素番号の最初からセーブされていきます。今、要素番号0番のオブジェクトがポインタ変数を保持していて、それがアドレス10000を指しているとしましょう。上の表で分かるように、それは登録されたオブジェクトの要素番号2番に相当します。よって、0番のオブジェクトをセーブするときには、自分が指しているアドレスの変わりに要素番号2番を保存します。
ロード時には上の表の順にオブジェクトは復活していきますが、アドレスは異なります。
要素番号 オブジェクトが作成されたアドレス(ポインタ) 0 3456 1 213 2 99999 3 1201
機械的に復活させた直後、0番のオブジェクトはアドレス2を指すメンバ変数を持ちます。もちろんそのままにする訳にはいきません。復活時のアドレス(ポインタ)はリストの要素番号そのものを指すので、そこに保存されているアドレス(上表だと99999)を0番のポインタ変数に戻します。これで、全てのポインタが正しく復活するわけです。
まずはこの機構を確立してしまいましょう。
A ポインタデータタイプの追加
保存するデータのオフセット位置を示すDATARECORD構造体のtypeには、そのデータのタイプを示すマクロ定数が登録されます。現在、通常のメンバ変数としてTYPE_LOCALというマクロ定数を定義していますが、ここで新しく「TYPE_PTR」を作成しましょう。
#define TYPE_LOCAL (1)
#define TYPE_PTR (2)
各クラスで定義するDATARECORD構造体の配列を作成しやすくするために、その1ではDATA_LOCAL( CLASSNAME, MEMBERNAME )というマクロを設定しました。ポインタ変数についても同様のマクロDATA_PTR( CLASSNAME, MEMBERNAME)というマクロを定義します。
#define DATA_PTR( CLASSNAME, MEMBERNAME ) \
{\
TYPE_PTR, \
( (__int64)&((CLASSNAME*)0)->MEMBERNAME ), \
sizeof( int ) \
}
DATARECORD::sizeにはポインタの大きさが入るのですが、これは整数(要素番号)として保存されるのでsizeof(int)としています。
B CSaveManager::Write関数の変更
1つのオブジェクトの内容をセーブするCSaveManager::Write関数を変更して、ポインタから要素番号を検索し、その番号を保存する機構を追加します。
// データ書き込み
int CSaveManager::Write( DATARECORD* list, CSaveObjBase *pobj )
{
---中略---
while(list->type != TYPE_END)
{
// データの位置を算出
char *pos = (char*)pobj + list->offset;
// データタイプとサイズの書き込み
fs.write( (char*)&list->type, sizeof(char) ); // データタイプ
fs.write( (char*)&list->size, sizeof(int) ); // データサイズ
switch(list->type)
{
case TYPE_LOCAL: // 通常変数
fs.write( pos, list->size ); // データ本体
break;
case TYPE_PTR: // ポインタ変数
void *p = (void*)*(__int64*)pos;
int Elem = ElemFromPtr( p ); // ポインタを要素番号に変換
fs.write( (char*)&Elem, list->size ); // データ本体
break;
}
// 次のリストへ
list++;
}
---中略---
}
引数のpobjオブジェクトが保持する変数のアドレスはposに算出されるようにしています。次にその位置にあるデータをサイズ分保存する作業が続きます。TYPE_LOCAL(ローカルメンバ変数)の時にはこれでよいのですが、TYPE_PTR(ポインタメンバ変数)の時にはポインタをCSaveManagerの持つリストの要素番号に変換しなければなりません。そこでポインタを与えると要素番号を返してくれるElemFromPtr関数を新設します。
ちょっとわかりにくいのは、TYPE_PTRの場合分けにある変数pのキャストでしょうか。これはElemFromPtr関数にグローバルなポインタを渡すために行っています。まずposの指す先に保存すべきポインタの値が格納されています。それは*posと間接参照演算子を用いると取り出せるのですが、posはchar*型として定義されているので、そのままだと1バイト分しか値を取り出せません。
*pos -> 1バイトの数値
ポインタは32bitOSであるWindowsなどでは4バイトですし、今後のOSでは64ビット(8バイト)になるはずです。int型へのポインタに変換して良いのですが、コンパイラが危険なキャストとしてwarningを出します。そこでOSのポインタと同じ大きさにするために__int64型というのを使用します。これだと、OSに合わせた大きさにしてくれます。
*(__int64*)pos -> OSに合わせた数値(4バイト、8バイト、etc...)
取得した整数はポインタを整数に変換したものなので、これを再びポインタに戻します。
(void*)*(__int64*)pos -> 汎用ポインタ
C ポインタを要素番号に変換
Bでポインタを要素番号に変換するElemFromPtr関数を使います。この関数は例えば次のように実装されます。
int CSaveManager::ElemFromPtr( void* pos )
{
list< CSaveObjBase* >::iterator it;
int elem = 0;
for(it = m_ObjList.begin(); it!=m_ObjList.end(); it++)
{
void* objptr = (void*)(*it);
if(objptr == pos)
return elem;
elem++;
}
return NO_ELEM;
}
オブジェクトポインタのリストを線形検索しています。(void*)(*it)としてポインタを汎用ポインタ型に変換した後に引数のポインタと比べ、同じであればその要素番号elemを返します。同じオブジェクトが無い場合はNO_ELEMという値を返すことにしました。これは、
#define NO_ELEM -1
と定義しています。線形検索に抵抗があるならば、この関数をオーバーライドさせて効率的な検索に変更すればよいでしょう。
これでポインタのセーブについての実装ができました。
D ポインタのロード
ロード時にはセーブと逆の作業を行います。すでにオブジェクトをファクトリ関数(CSaveManager::CreateObj関数)で作成したりデータをファイルからオブジェクトに流し込む基盤は出来上がってるので、その辺りの拡張は比較的簡単です。兎にも角にもまずデータをオブジェクトに流し込んでしまいまして、ポインタの接続は各オブジェクトのCSaveData::ConnectPtr関数で行うことにしましょう。CSaveManager::Load関数の中身を見てみます。
// ロード
int CSaveManager::Load( string filepath )
{
// セーブファイルオープン
fs.open( filepath.c_str(), ios_base::in | ios_base::binary );
if( !fs.is_open() )
return NO_SAVEFILE;
while( !fs.eof() )
{
// データの読み込み
// クラスIDの取得
int ClassID;
fs.read( (char*)&ClassID, sizeof(int) );
// 読み込み時にエラーが起きた場合はロードを終了
if(fs.fail() != 0) break;
// 指定クラスIDのオブジェクトを生成して保持
CSaveObjBase *pobj = CreateObj( ClassID );
AddSaveData( pobj );
// 生成オブジェクトが無い場合はデータを飛ばす
if(!pobj){ LoadSkip(); continue; }
// オブジェクトをロード
pobj->Load( this );
}
list< CSaveObjBase* >::iterator it;
for(it = m_ObjList.begin(); it != m_ObjList.end(); it++)
{
// オブジェクトのポインタをつなげる
if(*it)
(*it)->ConnectPtr( this );
}
fs.close();
return LOAD_OK; // ロード完了
}
太文字の部分を追加するだけです。ロードで復活した全てのオブジェクトのConnectPtr関数を呼び出します。次がCSaveObjBase::ConnectPtr関数の中身です。
CSaveObjBase.cpp int CSaveObjBase::ConnectPtr( CSaveManager *mgr )
{
return mgr->ConnectPtr( GetDataRecord(), this );
};
CSaveManagerクラス内にもConnectPtr関数を設けます。これはWrite関数やRead関数と同じような使われ方をします。宣言部はこうなります。
SaveManager.h class CSaveManager
{
public:
--- 中略 ---
int Write( DATARECORD* list, CSaveObjBase *pobj );
int Read( DATARECORD* list, CSaveObjBase *pobj );
int ConnectPtr( DATARECORD* list, CSaveObjBase *pobj ); // オブジェクトのポインタを結合
};
実装をする段階で、ある欠点に気がつきました。繋ぐべきオブジェクトは要素番号で指定されているのですが、現在のリスト管理だと要素に飛ぶのに時間がかかってしまいます。よって、オブジェクトの管理をリストから配列に変更します。具体的にはCManager::m_ObjListをlist型からvector型に変更します。この変更で発生する些細なコンパイルエラーを修正し、改めてConnectPtr関数を実装します。
SaveManager.cpp int CSaveManager::ConnectPtr ( DATARECORD* list, CSaveObjBase *pobj )
{
// データタイプがポインタだった時には指定されている
// 配列要素のオブジェクトポインタを変数に流し込む
while( list->type != TYPE_END )
{
if( list->type == TYPE_PTR )
{
// 変数のアドレスを算出
int *pos = (int*)((char*)pobj + list->offset);
// 変数に格納されている要素番号をポインタに変換
int ObjID = *pos;
if(ObjID != NO_ELEM)
{
*pos = (int)(m_ObjList[ObjID]);
}
else
{
*pos = NULL;
}
}
list++;
}
return CONNECT_OK;
};
posにはポインタ変数へのアドレスが計算されます。このアドレスには要素番号が仮に格納されています。ObjIDにその値を一端格納しておいて、それがNO_ELEM(=-1)でなければ*posにアドレスを直接流し込みます。これでちゃんとアドレスが格納されます。
E まだまだ機能拡張します
さて、これでポインタの結合もサポートするセーブロードの自動化ができました。実際もうすでにどれだけ複雑にオブジェクトがポインタを通して結合されているシステムの塊があったとしても、それを復元する事ができます。もちろん、
・ ポインタが指すオブジェクトがCSaveObjBaseから派生されており、
・ セーブ時に必要なすべてのオブジェクトがCSaveManagerに登録され
・ ファクトリクラスが正しくオブジェクトを生成できる
事が前提となります。
もしこれからゲームを作るのならば、面倒を避けるためになるべくCSaveObjBaseクラスから派生させるべきです。 ただ、例えばDirectXで定義されている構造体やSTLなど、CSaveObjBaseクラスを親に持たせる事ができない既存クラスについては、CSaveObjBaseクラスのコンポーネントにするなど取り扱いに工夫が必要になります。
その1つが、文字列のような不定長の配列です。スマートポインタやCOMコンポーネントにも対処できなければなりませんし、CSaveObjBaseクラスから派生を続けたオブジェクトのセーブロードも出来るようになりたいところです。まだまだ機能拡張の目はあります。
この中で一番楽なのは文字列などの不定長配列の扱いです。そこで次の章では文字列、もう少し一般化して「メモリブロック」のセーブロードについて考えてみましょう。