インターフェイスのコピー問題と解放作業を解決するCom_ptrクラス
DirectXはCOMに従っていますので「参照カウンタ方式」によるオブジェクトの解放を行う必要があります。
参照カウンタ方式とは実体を指しているポインタの数を保存することで各ポインタの先に実体がある事を保証する方式です。下の図をご覧下さい。
図の「実体」とはメモリに確保されているデータそのものです。COMではそれぞれのポインタが実体を指す時に、「私はあなたを指してますよ」と宣言します。これはCOMインターフェイス全てが持っているAddRef関数によって行います。AddRef関数が呼ばれると、実体は自分の持つ参照カウンタを1つ増加させます。上の図では参照カウンタ「3」がセットされます。次に下の図のようにあるポインタが指すのをやめた時には「私はもう指していませんよ」と実体に告げます。これはRelease関数で行います。Release関数が呼ばれると、実体は参照カウンタを1つ減らします。もし、参照カウンタが0になったら、自分を指しているポインタが何もなくなったので、実体は自動的に自分自身をメモリから削除します。これが「参照カウンタ方式」と呼ばれるオブジェクトの管理方法です。
ポインタで怖いのは指している先に何も無い状態でその実体へアクセスする事です。そういうポインタを「ダングリングポインタ」と呼びます。参照カウンタ方式では、参照カウンタが0でなければポインタの先には必ず実体があります。
DirectXの参照カウンタ方式は、仕組みがあるだけで「カウンタの増減はプログラマが行う」必要があります。ですから、これを間違うと大変な事になります。Release関数を呼び忘れるとメモリリークが発生し、AddRef関数を呼び忘れるとダングリングポインタが発生します。DirectXを扱う限り、これらの呼び出しは必ず行う必要があります。
AddRef関数を呼び出すタイミングは簡単で、「ポインタをコピーした時」に呼び出します。Release関数もそれ程難しくなく「ポインタで実体を参照する必要がなくなった時」に呼び出します。
さて、こういう間違いが起こりそうな事はクラスで自動管理したくなる物です。例えばこういうクラスを作ります。
class CTexture
{
private:
IDirect3DTexture9* m_pTexture;
public:
CTexture(){
m_pTexture = NULL;
}
virtual ~CTexture(){
if(m_pTexture)
m_pTexture->Release(); // 削除
}
BOOL CreateTexture(char* filename);
};
このクラスはテクスチャインターフェイスへのポインタをメンバ変数に持ち、CreateTexture関数で取得したインターフェイスポインタをそこに格納します。そして、CTextureオブジェクト自体がいらなくなった時点で、デストラクタ内でインターフェイスのRelease関数を呼び、参照カウンタを1つ減らします。こうすると、取得と削除が対となり、プログラマは解放のし忘れを避ける事が出来ます。
良さそうに思えるのですが、これが全然ダメなんです。
このクラスが単体で動作し、このポインタを誰にも一切渡さないのであればうまくいきます。しかし、例えば、
CTexture Me, You;
Me.CreateTexture("c://tex.png");
You = Me;
という風にオブジェクトのコピーを行うと、Me.m_pTextureとYou.m_pTextureが同じポインタになってしまいます。この状態でMeで確保されたテクスチャインターフェイスの参照カウンタは「1」であることに注意してください。MeとYouがいらなくなると、それぞれのデストラクタが呼ばれます。つまり参照カウンタが2回減らされるので、削除された後にまた削除しようとする危険行為が行われます。大抵の場合、メモリ保護違反となりアプリケーションが強制終了されます。
このCTextureクラスではリリースの事は考慮していてもAddRef関数の事について考えていないので、こういうエラーが発生するのです。
上の問題はCTextureオブジェクトのコピー時に発生するので、コピーをした時にAddRef関数を正しく呼び出せば良くなります。
C++言語におけるクラスインスタンスのコピーには「明示的なコピー」と「暗黙的なコピー」の2種類があります。明示的なコピーとは「=演算子」を用いてオブジェクトをコピーする事で、上の例が正にそれです。一方暗黙的なコピーとは、例えば関数の引数にオブジェクトを渡した時に行われる「値コピー」がそれに相当します。これは=演算子ではなく、「コピーコンストラクタ」によってコピーが行われます。
AddRef関数による参照カウンタの増加は、この明示的なコピーと暗黙的なコピーの両方で実装する必要があります。それに対応したCTextureクラスの例を下に挙げます。
class CTexture
{
private:
IDirect3DTexture9* m_pTexture;
public:
CTexture(){m_pTexture = NULL;}
// コピーコンストラクタ(暗黙的コピー)
CTexture(const CTexture& src){
src.m_pTexture->AddRef();
m_pTexture = src.m_pTexture;
}
// =演算子のオーバーロード(明示的コピー)
void operator =(const CTexture& src){
if(src.m_pTexture)
src.m_pTexture->AddRef();
if(m_pTexture)
m_pTexture->Release();
m_pTexture = src.m_pTexture;
}
virtual ~CTexture(){
if(m_pTexture)
m_pTexture->Release(); // 削除
}
BOOL CreateTexture(char* filename);
};
それぞれのコピーに対応してAddRef関数がちゃんと呼ばれるので、安心してデストラクタでRelease関数を呼び出せます。
「めでたしめでたし」と言いたいところなのですが、実は上の実装は「うまくないんです」。
と言うのは、CTextureクラスの派生クラスで新しいインターフェイスポインタをメンバ関数に持たせた時、=演算子でもう一度上のようなAddRefとReleaseの作業をしなければならないのです。それだけではありません。これから作ろうとする全てのクラスで同じ作業が繰り返されます。これは非常に面倒でしかもミスも犯しやすいですよね。
ではどうしたら良いのか?非常に悩むところであります。実は、この手の「ポインタ保持+デストラクタで削除」という形式の問題を解決するとっておきの方法がもう世の中にはあります。それは「スマートポインタ」です。
「スマートポインタ」とはポインタを扱うテンプレートクラスの1つで、保持しているポインタ先の実体の削除を参照カウンタ方式によって行います。テンプレートなのでありとあらゆるオブジェクトに対して参照カウンタが使え、ポインタの先にある実体が保証されます。スマートポインタ同士のコピーが行われた時には、参照カウンタを自動的に1つ増やすため、ダングリングポインタが発生しない仕組みになっています。また、デストラクタで参照カウンタが1つ減り、もし0になったらオブジェクトを削除します。
スマートポインタの実装については、ここでは取り上げません。検索サイトで検索すればいくらでも出てきますし、参照文献「Windowsプロフェッショナルゲームプログラミング」にすばらしい実装が掲載されています。
ここで取り上げたいのは、スマートポインタの考え方を用いて、DirectX用のスマートポインタを作る事です。スマートポインタは「テンプレートで定義した型のnew演算子でメモリを確保し、スマートポインタオブジェクトに登録して、デストラクタでそれをdeleteする」という仕組みになっています。一方、DirectXの全てのインターフェイスオブジェクトは、それ専用の生成関数で作成しRelease関数で解放します。なんだか動きが似ていますよね。ただ、スマートポインタが「全ての型に適用可能」なのに対して、DirectXは「COMインターフェイスのみ」と制限が掛かっています。
そこでいきなりですが下のソースをご覧下さい。
template <class T>
class Com_ptr
{
private:
T* m_pPtr;
public:
// コンストラクタ
Com_ptr(T* pPtr = NULL){
m_pPtr = pPtr;
}
// デストラクタ
virtual ~Com_ptr
{
m_pPtr->Release(); // 削除?
}
}
なんでもないテンプレートクラスですが、デストラクタに注目してください。T型のクラスのRelease関数を呼び出しています。T型という不特定な型に対してRelease関数という特定の関数を呼び出しています。「型が分からないのにこんな事は可能なのか?」と思うかもしれませんが、これはコンパイルできます。なぜなら、テンプレートクラスはコンパイル時に型が決定されるからです。つまり、このテンプレートクラスへはメンバ関数にRelease関数を持つクラスを型として指定しないとコンパイルエラーとなります。このように、テンプレートクラスは内部で特定の関数を与えると、扱える型を限定できるのです。
スマートポインタはテンプレートクラスで、この方法だと「COM専用テンプレート」を作成できる。つまり、2つを合わせれば、「COM専用のスマートポインタ」が出来ます!
@ COM専用スマートポインタ「Com_ptrクラス」の構築
ここからCom_ptrテンプレートクラスの実装について、少しずつ話を進めていきましょう。
まず、メンバ変数にはT型のインターフェイスへのポインタであるm_pInterfaceを設けます。
T* m_pInterface;
T型はCOMと仮定していますので、ポインタの先にはもう参照カウンタがあります。よって、Com_ptrクラスの中にはあえて参照カウンタを定義しません。
次にコンストラクタです。デフォルトコンストラクタでは、インターフェイスポインタを受け取るようにします。ポインタをダイレクトに受け取った時は「そのRelease権限をCom_ptrに移譲する」とします。ただし、あげた側にも削除権限を持ちたい(共有したい)場合もありますので、一応フラグで判断するようにします。デフォルトコンストラクタはこんな感じです。
explicit Com_ptr(T* pInterface=NULL, BOOL addition=FALSE){
// 共有の場合はインターフェイスの参照カウンタを増加
if(pInterface && addition)
pInterface->Release();
// インターフェイスポインタをコピー
m_pInterface = pInterface;
}
「explicit」というのが付いています。これは「暗黙の型変換を禁止する」宣言です。つまり、引数の型以外で暗黙的な型変換のあるデフォルトコンストラクタによる初期化を禁止します。こうすると、例えば、
void Func(Com_ptr<IDirect3DTexture9> tex);
という関数に対して、
IDirect3DTexture9* tmp = NULL;
Func(tmp);
という代入が禁止されます。この関数の代入が許可されると、関数から抜けた時に関数内部のローカルCom_ptr変数のデストラクタによって参照カウンタが1つ減らされますので、参照カウンタに間違いが起こります。explicit宣言は重要です。
次にコピーコンストラクタです。暗黙のコピーが行われた場合の処理を書きます。コピーコンストラクタが呼ばれる場合、自分自身はまだ初期化されていませんが、コピー元は少なくともインターフェイスへのポインタを持っている可能性があります。持っている時はポインタを共有するのでAddRef関数を呼んであげます(NULLの場合はしない)。
Com_ptr(const Com_ptr& src)
{
// コピー元の参照カウンタを増加
if(src.m_pInterface)
src.m_pInterface->AddReff();
// コピー
m_pInterface = src.m_pInterface.
}
暗黙的なコピーはこれで大丈夫です。次に明示的なコピーを行う=演算子をオーバーロードします。=はオブジェクトが作成された後に呼ばれるため、コピー先にすでにインターフェイスポインタが保持されている場合があります。その時はその参照カウンタを減らして、新しいポインタを保持します。
Com_ptr& operator=(const Com_ptr& src)
{
// コピー元の参照カウンタを増加
if(src.m_pInterface)
src.pInterface->AddRef();
// 自分が保持しているインターフェイスの参照カウンタを減少
if(m_pInterface)
m_pInterface->Release();
// コピー
m_pInterface = src.m_pInterface;
return (*this);
}
内部に保持しているポインタにアクセスするために、ポインタを取得する関数を設けます。この時、m_pInterfaceと&m_pInterfaceの両方を取得できるようにすると便利です。
// ポインタをそのまま取得
T* GetPtr(){return m_pInterface;
T** GetPtrPtr(){ return &m_pInterface;}
// インターフェイス生成関数へ渡す専用
T** ToCreator(){
// 自分のインターフェイスを変更する事が前提
if(m_pInterface)
m_pInterface->Release();
return &m_pInterfacr;
}
ToCreater関数は、インターフェイス取得関数へ直接ポインタへのアドレスを渡してインターフェイスポインタを取得する場合などに重宝します。この場合、インターフェイスを変える前提での仕様となるので、保持していたインターフェイスの参照カウンタが減らされます。これらの関数の使い方を間違わないようにしなければなりません。
デストラクタは簡単で、保持しているインターフェイスの参照カウンタを1つ減らします。
~Com_ptr(){
if(m_pInterface)
m_pInterface->Release();
}
後は比較演算子があると便利です。これはインターフェイスの有無をチェックする時に使用します。
BOOL operator ==(int val){
if(val == (int)m_pInterface)
return TRUE;
return FALSE;
}
BOOL operator !=(int val){
if(val != (int)m_pInterface)
return TRUE;
return FALSE;
}
以上のCom_ptrクラスの完全実装はこちらです。
A Com_ptrクラスの使用例
Com_ptrクラスの具体的な使用方法を挙げます。
このクラスは他のクラスのメンバ変数として使用する事を前提としています。今、CTextureクラスの内部にこのクラスが定義されているとしましょう。
class CTexture
{
private:
Com_ptr<IDirect3DTexture9> m_pTexture;
public:
BOOL CreateTexture(Cmp_ptr<IDirect3DDevice9> Dev, char* filename);
}
CreateTexture関数の内部ではヘルパー関数であるD3DXCreteTextureFromFile関数によってテクスチャが作成されます。
D3DXCreteTextureFromFile(
Dev.GetPtr(),
filename,
m_pTexture.ToCreator();
}
デバイスへのポインタは通常のCom_ptr::GetPtr関数で「ポインタを貸す」ことになるのでDev.GetPtr()としますが、テクスチャインターフェイスへのポインタはインターフェイスを取得する事になるので、Com_ptr::ToCreator関数を用います。
テクスチャインターフェイスへのポインタ取得に失敗した場合、ポインタにはNULLが返るので、内部では何も保持していない状態になります。一方、正しく生成された場合は参照カウンタが1となり、内部にもテクスチャへのポインタが格納されます。ToCreator関数は便利です。
テクスチャクラスのコピーは通常通りに行えます。
CTexture A, B;
A.CreateTexture(Dev, "tex.png");
B = A;
この場合、明示的なコピーのため=演算子が呼ばれtex.pngから作成されたテクスチャインターフェイスの参照カウンタが2になります。
Com_ptrクラスに対して標準テンプレートライブラリも通常のクラスオブジェクトを使うのと同じ用に使えます。
vector<Com_ptr<IDirect3DTexture9> > m_pTextureAry;
list<Com_ptr<IDirect3DTexture9> > m_pTextureList;
型指定の時に「>>」と連続させないようにして下さい。連続させるとシフト演算子と間違われてコンパイルエラーになります。
このようにCom_ptrはインターフェイスの取り扱いを随分と楽にしてくれます。
実装は少し面倒な感じでしたが、使い方はとっても簡単です。本当は間接参照演算子などもオーバーロードしたかったのですが、使い方に面倒が生じるのであえて行いませんでした。実装が面倒と言う方はこちらの完全実装をコピペすれば、直ぐに使えるようになります。
Com_ptrを使用する事で、DirectXクラス間でのコピーが容易に行えるようになります。しかも、参照カウンタを用いた解放作業というわずらわしくて間違えやすい作業から完全解放されるのです。Com_ptrの実装では、デバッグ時にインターフェイスの使用状況をテキストファイルに出力するようにしました。これを見ると内部で参照カウンタが増減して、最後にはきっちり無くなっている事が良くわかります(出力例はこちら)。
ただし、間違った使い方をすると、当然の事ながらエラーとなってしまいます。例えば、通常の方法でインターフェイスポインタを取得して、それを2つのCom_ptrに渡してはいけません。参照カウンタが1なのに、2つのCom_ptrがそれぞれデストラクタで参照カウンタを減らしてしまうため、エラーとなります。これを防ぐには2つ目のCom_ptrのデフォルトコンストラクタの第2引数にTRUEを与えてポインタを渡すか、Com_ptr::ToCreator関数を使用してインターフェイス取得時にポインタをダイレクトに与えます。
Com_ptr::GetPtr関数でポインタ変数を得て、それを別のCom_ptr関数に代入してもいけません。同様のエラーとなります。
このクラスを使用すると、ゲーム作成が驚くほど楽になると思いますし、メモリの大幅節約にもなります(インタフェイスを共有するため)。積極的に活用していきたいです。
B 追加補足:CUnknownの実装について(2006. 12. 7)
Com_ptrクラスは何もDirectXのインターフェイスにだけしか使えないというわけではありません。オリジナルのインターフェイスをIUnknownから派生させれば、同様の使い方ができるようになります。ただ、その場合DirectXが用意しているCUnknownを用いるか、独自でCUnknownを実装する必要があります。DirectXで用意しているCUnknownはちょっとだけ使いにくいところがありますので、ここは独自に実装してみる事にしましょう。これは「極めて簡単」です。
まずは実装を公開致します。以下のプログラムは私が実際に使用しているCUnknownです:
#pragma once
#include <unknwn.h> // Windows標準IUnknownヘッダー
// CUnknownクラス
interface CUnknown : public IUnknown
{
protected:
ULONG m_dwRef;
protected:
CUnknown(){
// 生成時は参照カウント1
m_dwRef = 1;
}
virtual ~CUnknown(){}
public:
// 公開関数
// インターフェイス要求
virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppv)
{
return S_FALSE;
};
// 参照カウンタ増加
virtual ULONG STDMETHODCALLTYPE AddRef() {
return ++m_dwRef;
}
// 参照カウンタ減少
virtual ULONG STDMETHODCALLTYPE Release()
{
// 参照カウントが0になったら自動消去
if(--m_dwRef == 0)
{
delete this;
return 0;
}
return m_dwRef;
}
};
このクラスはコピペすればすぐに使えます。COMは仕様ですから、仕様に沿ってプログラムを自分で実装してもちゃんと動きます。
独自のCUnknownクラスを実装する場合、Releaseメソッドの中で1つ注意する事があります。Releseメソッドは減少後の参照カウンタ数を返す必要があります。ですから「return m_dwRef」としているのですが、delete thisと自分自身を削除した後にreturn m_dwRefとしてはいけません。実は、そうしても大抵の場合はうまく動くのですが、例えば次のようなプログラムを実装すると、駄目な時があります:
typedef Com_ptr< MyClass > ComMyClass;
int main()
{
list<ComMyClass> List;
for(int i=0; i<100000; i++)
{
MyClass *p = new MyComClass;
ComMyClass cpTmp = p; // Comポインタに登録
List.push_back( cpTmp ); // リストに登録
}
return 0;
}
このプログラムは、私が大いにはまったバグを再現するために作ったものです。自分が作成したCOMクラスを100000個リストに登録して終了するという、極めて単純で、そして良く使われる形態のプログラムです。Releaseメソッドでdelete thisした後にreturn m_dwRefとした場合、このプログラムを動かすとメモリ保護違反が発生する事があります。「事がある」という曖昧な表現にしたのは、COMクラスを1000個ぐらい生成した程度ではこのエラーが出ないんです!私、これで4日間デバッグ地獄に合い、死ぬかと思いました(T_T)。
なぜ100000個でエラーになるか、その確かな理由は通常のデバッグでは追跡できませんでしたが、delete thisした後の消去済みオブジェクトがm_dwRefというメンバ変数を返すという一瞬の矛盾がエラーを起こしているようです。数が少ない場合、削除したての位置を指すポインタが生きているので、変な値を返しますがエラーにはなりません。しかし数が多い時にはdelete
thisとオブジェクトを消去した瞬間にthisが指すポインタが不定になる時があり、その場合に触ってはいけないメモリブロックが指されてしまうので、エラーになると想像しています。要は、消してしまってすでに無い物を参照して返してはいけないという、基本的な所ではまっていたわけです。このバグを見つけた後、delete
thisの後ろをreturn 0と定数を返すように変更したところ、エラーは出なくなりました(見つけた時は泣きましたよ、ホントに(T_T))。retrun
0と定数を返す場合、関数(関数はコンパイル時に静的に決定するメモリブロックです)に埋め込まれた値なので、問題はありません。
こういうのはとてもマニアックな話しですが、こんな所でも極めて厄介なバグの温床になりえるという例です。実装の際ははくれぐれもご注意下さい。上のプログラムをコピペして頂ければ間違いありません。
このクラスを基底にして、独自のCOMインターフェイスを作れば、DirectXと全く同じ使用方法で自分のライブラリを拡張していけますので、DirectXのゲーム製作をされる方は是非お試しください(^-^)。