テクニカル ノート 2: 永続オブジェクトのデータ形式
更新 : 2007 年 11 月
ここでは、C++ の永続オブジェクトをサポートする MFC ルーチンと、オブジェクトのデータをファイルに格納するときの形式について説明します。このテクニカル ノートは、マクロ DECLARE_SERIAL および IMPLEMENT_SERIAL を使うクラスだけが対象です。
問題
MFC では、永続データを保存するとき、複数のオブジェクトのデータをファイル内の 1 か所に隣接させて格納します。オブジェクトの Serialize メソッドにより、オブジェクトのデータはコンパクト バイナリ形式に変換されます。
この実装では、CArchive クラス を使用して、すべてのデータが同じ形式で保存されることが保証されます。変換には CArchive オブジェクトが使用されます。このオブジェクトは、作成時点から CArchive::Close が呼び出されるまで持続します。このメソッドは、プログラマが明示的に呼び出すことも、CArchive のあるスコープをプログラムが終了するときに、デストラクタから暗黙的に呼び出すこともできます。
このテクニカル ノートでは、CArchive のメンバ CArchive::ReadObject と CArchive::WriteObject の動作について説明します。これらの関数のコードは Arcobj.cpp にあり、CArchive の主な実装は Arccore.cpp にあります。ユーザー コードでは、ReadObject と WriteObject を直接呼び出すことはしません。代わりに、クラス固有のタイプ セーフな出力ストリーム演算子および入力ストリーム演算子を使用します。これらの演算子は、DECLARE_SERIAL と IMPLEMENT_SERIAL の各マクロによって自動的に生成されます。WriteObject および ReadObject を暗黙的に呼び出す方法を次のコードに示します。
class CMyObject : public CObject
{
DECLARE_SERIAL(CMyObject)
};
IMPLEMENT_SERIAL(CMyObj, CObject, 1)
// example usage (ar is a CArchive&)
CMyObject* pObj;
CArchive& ar;
ar << pObj; // calls ar.WriteObject(pObj)
ar >> pObj; // calls ar.ReadObject(RUNTIME_CLASS(CObj))
オブジェクトを記憶領域に格納する (CArchive::WriteObject)
CArchive::WriteObject メソッドは、オブジェクトの再構築時に使用するヘッダー データを書き込みます。このデータの内容は 2 つの部分、つまりオブジェクトの型とオブジェクトの状態に分けられます。このメソッドには、書き込むオブジェクトの ID を維持する役割もあります。これにより、オブジェクトを指すポインタ (循環ポインタなど) の数に関係なく、単一のコピーだけが保存されます。
オブジェクトの保存 (出力) および復元 (入力) には複数の "記号定数" を使います。記号定数は値であり、バイナリ形式で格納され、以下の情報をアーカイブに提供します。プリフィックス "w" は 16 ビット値であることを示します。
タグ |
説明 |
---|---|
wNullTag |
NULL オブジェクト ポインタとして使用します (0)。 |
wNewClassTag |
次のクラス記述がこのアーカイブ コンテキストで初めてであることを示します (-1)。 |
wOldClassTag |
読み込むオブジェクトのクラスがこのコンテキストに既にあったことを示します (0x8000)。 |
オブジェクトを格納するとき、アーカイブには CMapPtrToPtr (m_pStoreMap) が保持されます。これによって、格納するオブジェクトが 32 ビットの永続識別子 (PID) にマップされます。PID は一意なオブジェクトにそれぞれ付与されます。アーカイブのコンテキストに保存される一意なクラス名にもそれぞれ付与されます。これらの PID は 1 から順に付与されます。これらの PID の有効範囲は、アーカイブのスコープ内に限られます。特に、レコード番号などの他の ID 項目と混同しないように注意してください。
CArchive クラスの PID は 32 ビットですが、0x7FFE 以下の場合は 16 ビット値で書き出されます。大型の PID は 0x7FFF として書き出され、その後に 32 ビット PID が続きます。これにより、レガシ プロジェクトとの下位互換性が保持されています。
オブジェクトをアーカイブに保存するように要求すると (通常はグローバル出力ストリーム演算子を使用)、CObject ポインタが NULL かどうかのチェックが行われます。ポインタが NULL の場合は、wNullTag がアーカイブ ストリームに挿入されます。
ポインタが NULL ではなく、シリアル化できる場合 (DECLARE_SERIAL クラス)、コードは m_pStoreMap をチェックして、このオブジェクトが既に保存されているかどうかを調べます。保存済みの場合は、このオブジェクトに関連付けられている 32 ビット PID がアーカイブ ストリームに挿入されます。
未保存のオブジェクトの場合は、2 とおりの可能性を考慮する必要があります。つまり、オブジェクトとその正確な型 (クラス) が両方ともこのアーカイブ コンテキストに初めて出現した場合と、このオブジェクトと同じ型が以前に出現している場合です。既に出現した型かどうかを判定するために、コードは m_pStoreMap を照会して、保存するオブジェクトに関連付けられた CRuntimeClass オブジェクトに一致する CRuntimeClass オブジェクトを調べます。一致が見つかると、WriteObject は、wOldClassTag とこのインデックスのビットごとの OR をとったタグを挿入します。このアーカイブ コンテキストに初めて出現した CRuntimeClass の場合、WriteObject はこのクラスに新しい PID を割り当て、wNewClassTag 値に続けてアーカイブに挿入します。
次に、CRuntimeClass::Store メソッドを使用して、このクラスの記述子をアーカイブに挿入します。CRuntimeClass::Store は、クラスのスキーマ番号 (下記参照) と ASCII テキスト名を挿入します。ただし、ASCII テキスト名を使用すると、アプリケーション間でアーカイブの一意性が保証されません。そこで、データ ファイルの破損を防ぐために、ファイルにタグを付けてください。クラス情報が挿入されると、アーカイブではオブジェクトを m_pStoreMap に設定してから、Serialize メソッドを呼び出してクラス固有のデータを挿入します。オブジェクトを m_pStoreMap に設定してから Serialize を呼び出すと、記憶領域にオブジェクトのコピーが複数保存されるのを回避できます。
最初の呼び出し側 (通常はオブジェクト ネットワークのルート) に戻るときは、CArchive::Close を呼び出す必要があります。別の CFile 操作を実行する場合は、アーカイブの破損を防ぐために、CArchive の Flush メソッドを呼び出す必要があります。
メモ : |
---|
この実装では、ハードウェア上アーカイブ コンテキストあたりの最大インデックス数が 0x3FFFFFFE に制限されています。この上限値は、単一アーカイブに保存できる一意なオブジェクトおよびクラスの最大数です。ただし、単一ディスク ファイルに保存できるアーカイブ コンテキストの数は無制限です。 |
記憶領域からオブジェクトを読み込む (CArchive::ReadObject)
オブジェクトの読み込み (入力) では CArchive::ReadObject メソッドが使用され、WriteObject の逆の処理が行われます。WriteObject と同じように、ReadObject もユーザー コードからは直接呼び出しません。ユーザー コードでは、タイプ セーフな入力ストリーム演算子を呼び出す必要があります。この演算子により、適切な CRuntimeClass で ReadObject が呼び出されます。これにより、入力ストリーム演算における型の整合性が保証されます。
WriteObject は 1 から始まる昇順の PID を割り当てたので (0 は NULL オブジェクト用に定義済み)、ReadObject では配列を使用してアーカイブ コンテキストの状態を保守できます。PID を記憶領域から読み取るとき、その PID が m_pLoadArray の現在の上限より大きいと、ReadObject は新しいオブジェクト (またはクラス記述) が出現したと認識します。
スキーマ番号
クラスの IMPLEMENT_SERIAL メソッドが検出されると、そのクラスにスキーマ番号が割り当てられます。この番号は、クラスの実装 "バージョン" を示します。スキーマはクラスの実装を表すものであり、特定のオブジェクトのこれまでの永続回数を示すものではありません。オブジェクトの永続回数は通常、オブジェクト バージョンと言います。
同じクラスの複数の実装を長期にわたって保守する場合は、オブジェクトの Serialize メソッドの実装を改訂するたびに、スキーマ番号をインクリメントします。これにより、以前のバージョンの実装で格納したオブジェクトを読み込むコードの作成が可能になります。
CArchive::ReadObject メソッドは、永続記憶領域にあるスキーマ番号がメモリ内のクラス記述のスキーマ番号と違うことを検出すると、CArchiveException をスローします。これは回復困難な例外です。
この例外がスローされるのを避けるために、VERSIONABLE_SCHEMA とスキーマ バージョンをビットごとの OR で組み合わせて使用できます。VERSIONABLE_SCHEMA を使用すると、コードの Serialize 関数で CArchive::GetObjectSchema からの戻り値を調べて適切な処理を行うことができます。
Serialize 関数を直接呼び出す
多くの場合、WriteObject と ReadObject による全般的なオブジェクト アーカイブ方式では、不要なオーバーヘッドが発生します。これは、データを CDocument にシリアル化する場合に共通する問題です。このような場合は、入力ストリーム演算子も出力ストリーム演算子も使用せず、CDocument の Serialize メソッドを直接呼び出します。文書の内容はさらに汎用的なオブジェクト アーカイブ方式を使うこともできます。
Serialize を直接呼び出すと、以下の利点と不具合があります。
オブジェクトをシリアル化する前または後に、アーカイブに補足バイトが追加されません。したがって、保存データを小型化できるばかりでなく、どのファイル形式でも処理できる Serialize ルーチンを実装できます。
他の目的のためにより汎用的なオブジェクト アーカイブ方式が必要な場合以外は、WriteObject および ReadObject と関連コレクションがアプリケーションにリンクされないように MFC が微調整されます。
コードでは、以前のスキーマ番号から復元する必要はありません。スキーマ番号、ファイル形式バージョン番号など、データ ファイルの先頭で使用されるあらゆる識別番号のエンコードは、文書のシリアル化コードで行う必要があります。
Serialize を直接呼び出してシリアル化するオブジェクトでは、CArchive::GetObjectSchema を使用できません。使用する場合は、不明なバージョンを示す戻り値 (UINT)-1 を処理する必要があります。
Serialize は直接文書に対して呼び出されるので、通常文書のサブオブジェクトではその親文書への参照をアーカイブできません。サブオブジェクトにはそれぞれのコンテナ オブジェクトへのポインタを明示的に渡すか、バック ポインタがアーカイブされる前に CArchive::MapObject 関数を使用して CDocument ポインタを PID に割り当てる必要があります。
前に述べたように、Serialize を直接呼び出すときは、バージョン情報とクラス情報を自分でエンコードして、後で形式を変更しても以前のファイルとの下位互換性が維持されるようにする必要があります。CArchive::SerializeClass 関数を明示的に呼び出してから、オブジェクトを直接シリアル化することも、基本クラスを呼び出すこともできます。