なぜ参照カウント方式なのか
COM とは?についてはほかの記事に任せるとして、COM のオブジェクトは「参照カウント」によるライフサイクルの管理を行っています。カウントの増減のタイミングを知る前に、なぜ参照カウント方式が COM で使われるのかを論理的に見ていきましょう。
以下の多重継承の例を考えてみましょう。
void TryMeow(void) {
ICat* pcat = NULL; // ICat : IAnimal
IAnimal* panimal = NULL; // IAnimal : IObject
pcat = CreateCat("Shilo");
if (pcat) {
panimal = (IAnimal*)pcat->Dynamic_Cast("IAnimal");
if (!panimal)
pcat->Meow();
pcat->Delete();
else {
panimal->Sleep();
panimal->Delete();
}
}
}
この例では、オブジェクトは最初 IAnimal インターフェースを通じてインスタンス化されているにもかかわらず、最終的には ICat インターフェースを介して Delete メソッドを呼び出しています。
C++ の多重継承の仕組みによると、IAnimal から派生したすべてのインターフェースの Delete はメモリ上で同一メソッドを指すため、これは理論上問題にはなりません。しかし、オブジェクトの使用側(アプリケーション)では一つのインスタンスにつき必ず一回だけ Delete を呼び出す必要があるので、オブジェクトのインスタンスとインターフェースを 1:1 で対応させるほうが安全です。
これによるアプリケーション側の負担を減らすためには、ライブラリ側で安全なリソース管理の手法を提供する必要があります。しかも、そもそも COM は「実装上の詳細を隠蔽」する(言語非依存・コンパイラー/ABI非依存など)ことを目的として設計されているプログラミングモデルにもかかわらず、Delete でメモリを解放できるということは、「ヒープ上にメモリが確保されている」という実装上の詳細を意図せずして公開していることにもなっています。
これの解決策の一つに、各オブジェクトが参照カウントを維持し、インターフェースポインタが複製されたときにカウントを増やし、インターフェースポインタが破棄されたときにカウントを減らす(0 になればオブジェクト自体を破棄・解放する)ことです。
IObject インターフェースを以下のように変更しましょう。
class IObject {
public:
virtual void *Dynamic_Cast(const char* pszType) = NULL;
virtual void ClonePtr(void) = NULL;
virtual void DestroyPtr(void) = NULL;
};
インターフェースは規約ですので、このインターフェースから派生するすべてのインターフェースを使用する場合は、必ず 2 つの規則に沿う必要があります。
- インターフェースポインタを複製するときは、
ClonePtrを呼ぶこと - インターフェースポインタが不要になったときは、
DestroyPtrを呼ぶこと
class Cat : public ICat, public IObject {
int m_refCount;
public:
Cat(const char *pszName) : m_refCount(0) {}
void ClonePtr(void) {
++m_refCount;
}
void DestroyPtr(void) {
if (--m_refCount == 0)
delete this;
}
};
また、参照カウントによるライフサイクルの実装に合わせるため、あともう 2 ヵ所変更する必要があります。
ICat* CreateCat(const char *pszName) {
ICat *pfsResult = new Cat(pszName);
if (pfsResult)
pfsResult->ClonePtr();
return pfsResult;
}
void *Cat::Dynamic_Cast(const char *pszType) {
void *pvResult = 0;
if (strcmp(pszType, "ICat") == 0)
pvResult = static_cast<ICat*>(this);
else if (strcmp(pszType, "IAnimal") == 0)
pvResult = static_cast<IAnimal*>(this);
else if (strcmp(pszType, "IObject") == 0)
pvResult = static_cast<ICat*>(this); // TODO: 説明要
else
return 0;
((IObject*)pvResult)->ClonePtr(); // ポインタの複製
return pvResult;
}
最後に、アプリケーション側では代わりに以下のようなコードを書く必要があります。
void TryMeow(void) {
ICat* pcat = NULL;
IAnimal* panimal = NULL;
pcat = CreateCat("Shilo");
if (pcat) {
panimal = (IAnimal*)pcat->Dynamic_Cast("IAnimal");
if (!panimal)
pcat->Meow();
pcat->DestroyPtr(); // 参照カウントをデクリメント
else {
panimal->Sleep();
panimal->DestroyPtr(); // 参照カウントをデクリメント(ここで参照カウントが 0 になるのでオブジェクト自体破棄される)
}
}
}
これにより、各インスタンスのポインタの自身のライフタイム管理は独立して行うことになり、アプリケーション側がポインタを追跡して開発者が正しいリソース管理を行うための煩雑なコードを書く必要がなくなりました。それに、一つの実装クラスが複数のインターフェースを非常に一貫した方法で公開できるようになります。併せて、ライブラリ側で新しい機能を既存のクラスに追加(新しいインターフェースの実装)して公開したとしても、アプリケーション側に影響が全くないようになりました。
これが COM における IUnknown(IObject)と AddRef(ClonePtr)、 Release(DestroyPtr)になります。
推奨される呼び出しタイミング
COM の仕様には非常に明確な 3 つの規則が記載されています。
規則 1
非 NULL のインターフェースポインターをあるメモリ位置から別のメモリ位置にコピーするとき、追加の参照が行われたことをオブジェクトへ通知するために AddRef を呼び出す。
- 非 NULL のインターフェースポインターをローカル変数に書き込むとき
- 呼び出し先が、非 NULL のインターフェースポインターをメソッドまたは関数の
[out]または[in, out]パラメータに渡すとき - 呼び出し先が、関数の戻り値として非ヌルのインターフェースポインターを返すとき
- 非 NULL のインターフェースポインターをオブジェクトのデータメンバーに書き込むとき
規則 2
非 NULL のインターフェースポインターを含むメモリ位置を上書きする前に、その参照が破棄されることをオブジェクトへ通知するために Release を呼び出す。
- 非 NULL のローカル変数やデータメンバーを上書きする前
- 非 NULL のローカル変数のスコープを離れる前
- 呼び出し先が、初期値が非 NULL であるメソッドまたは関数の
[in, out]パラメータを上書きするとき。なお、[out]パラメータは入力時に NULL であることが前提であり、呼び出し先がReleaseを呼ぶべきではない - 非 NULL のデータメンバーを上書きする前
- 非 NULL のインターフェースポインターをデータメンバーとして保持しているオブジェクトのデストラクターを抜ける前
規則 3
AddRef と Release の呼び出しが冗長になる場合などの特殊なケースでは、これらのメソッドの呼び出しは省略できる。
- 呼び出し元が非 NULL のインターフェースポインターを
[in]パラメータを介して関数やメソッドに渡す場合 - これらのメソッドの呼び出しの最適化より省略する場合のほうが効率的である場合
それぞれ例とともに見ていきましょう。
void GetObject([out] IUnknown **ppUnk);
void UseObject([in] IUnknown *pUnk);
という関数があるとして、
void GetAndUse(/* [out] */ IUnknown** ppUnkOut) {
IUnknown* pUnk1 = 0, *pUnk2 = 0;
*ppUnkOut = 0; // 2.3
// オブジェクト 1 と 2 を取得
GetObject(&pUnk1); // 1.2
GetObject(&pUnk2); // 1.2
// pUnk2 を最初のオブジェクトに設定
if (pUnk2) pUnk2->Release(); // 3.1
if (pUnk2 = pUnk1) pUnk2->AddRef(); // 1.1
// 他の関数に pUnk2 を渡す
UseObject(pUnk2); // 3.1
// ppUnkOut パラメータで pUnk2 を呼び出し元に返す
if (*ppUnkOut != pUnk2) (*ppUnkOut)->AddRef(); // 1.2
// クリーンアップ
if (pUnk1) pUnk1->Release(); // 2.2
if (pUnk2) pUnk2->Release(); // 2.2
}
おわりに
COM に関しては『Essential COM(1999 年 / Don Box 著)』が一番わかりやすく説明してくれています。わかりずらいですが、一度理解すれば、他にも理解が深まっていくんじゃないでしょうか。COM ABI は DirectX や WinRT ABI にも使われていますし、まだまだ現役の技術です。COM に関する記事はこれからも出し行きますので、よろしくお願いします!