The deep inside of Windows 〜 Windowsの深淵


Lesson 3.DLL側のクラスを呼び出す  '01/12/05

C++のクラスを、独立させ、外部に出してしまいたいという願望はプログラマならば誰にでも存在するでしょう。クラスを一種の部品と考えれば、再利用したいし、他の誰かに使ってもらいたい。あるいは、他の誰かのクラスを使いたい。そう思うのは自然の摂理と言えましょう。

DLL側で用意されたクラスを、インポートしてくる手段は無いのでしょうか?DLLをクラス対応にしたのがCOM(ActiveX)なのでしょうか?確かに、その認識はあまり外れてはいないのかも知れません。しかし、COMは、レジストリを侵食します。あまっさえ屑のようなレジストリ構造をしているWindows、レジストリサイズの制限もありますし、こういう状況下では、レジストリを使うCOMは避けようと考えたほうが無難です。

それでは、DLLを使って、DLL側で用意されたクラスをインポートしてきて使う手段について考えてみましょう。

方法としては、C++のvirtual関数を使います。virtualな関数は、仮想関数テーブル(以下、そのテーブルへのポインタのことをvtableと表記)に登録されていくことはご存知の通り。多重継承をしているときは、そのインスタンスには、当然、vtableは、この多重継承したクラス分だけあります(そうしないと、アップキャストできない) この部分、ご存知ないかたのために、少し詳しく説明します。

たとえば、AというクラスとBというクラスから多重継承しているCというクラスのインスタンスのメモリイメージは、普通、Aのvtable、Aのメンバ変数、Bのvtable、Bのメンバ変数という順番で並びます。だから、CのポインタをBのポインタにアップキャストする場合は、Cのメモリイメージにおける、先頭からBのvtableまでのアドレスの差(オフセット)を加算してやります。逆に、ダウンキャストするときは、これを引いてやります。多重継承時のvtableの解決はこういう仕組みになっています。

しかし、単一継承の場合は、何度継承を繰り返したところで、vtableは一つです。普通、それは先頭にあります。

もう、わかる人にはわかったかも知れませんが、クラスをインポートするためには、そのクラスのメンバ関数すべてのアドレス(メンバ関数テーブル)を一発で渡してやる手段さえあれば良いわけで、それはすなわち、vtableではないのか、ということです。(ただし、上の原理により、基本的に、単一継承のときしか使えませんが)

つまり、すべてのメンバ関数をvirtualにしておけば、vtableを渡してやることで、メンバ関数にアクセスすることは出来るようになります。具体的には、まずインターフェースクラスを用意します。インターフェースクラスとは、すべての関数がvirtualである関数です。ついでに言えばC++の場合、仮想デストラクタも用意しておかなければなりません。これが無いと、ここから派生させたクラスのデストラクタが正しく呼び出されないからです。

class IHoge {
public:
 virtual void fHoge1() = 0;
 virtual void fHoge2() = 0;
 virtual ~IHoge();
};

DLL側では、IHogeを継承してCHogeクラスを作成し、上の各メンバを実装しておきます。Main側(DLLを使う側。以下、Mainというのは、そういう意味で使うものとします)では、CHogeのインスタンスのアドレスさえ得られれば(CHogeのポインタである必要は無い。欲しいのはアドレス)、IHoge*にキャストして、上のメンバ関数にアクセスすることが出来ます。多重継承しているときは、この限りではありません。

では、このCHogeの生成と解体は、誰がどうやってやれば良いのでしょうか。まず、生成ですが、これは、DLL側で、生成子を用意しておき、Main側からはそれを呼び出す、というのが妥当でしょう。単純に考えれば、IHogeに

IHoge* CreateInstance( ) { return new CHoge; }

というCreatorがあれば良いように思います。ところが、これをvirtualにしても、結局、IHogeのポインタが無いため、呼び出すことが出来ないわけです。そこでこの関数はstaticにして、これを::GetProcAddressで、受け渡しするということは考えられます。まあ、悪くはありません。

ところで、IHoge*を使い終わったとき、解体はどうやって行なうのでしょうか?ご存知ない方も結構おられるでしょうが、DLL側で生成されたものを、Main側では、deleteできません。

なぜかというと、newとdeleteには、さまざまな実装があるわけです。そして、newとdeleteはペアにして使わなければなりません。しかし、Main側のnewとdeleteは、DLL側のnewとdeleteと同一の実装とは限りません。それは、それをコンパイルしたコンパイラ依存、あるいは、コンパイラの気分次第とも言えます。つまり、DLL側でnewしたものは、DLL側のdeleteによって解体しなければならないのです。これは、大前提です。

つまり、

class IObject {
public:
 virtual void DeleteInstance() = 0;
 virtual ~IObject(){ }
};

というような、IObjectクラスからIHogeを派生させ、CHogeのDeleteInstance(IHoge* p)では、 delete this; するような実装にして、Main側でIHoge*が不要になったときは、このDeleteInstance( )を呼び出してオブジェクトは自滅するべきです。別にIObjectのようなインターフェースクラスを作る必要は無いのですが、消滅は必ずIObject::DeleteInstanceの呼び出しで行なえるようになっているほうが、あとあとすっきりします。

ここまではそんなに難しくないと思います。実際、COMの実装も、これに近いものがあります。

まあ、引数の受け渡しについて、VC++でコンパイルしたものはBCBでうまく受け取れるのか、だとか、そういった問題も無いではないですが、ここでは詳しく述べません。興味のある人は、__cdecl コンパイラ オプションについてMSDN等で調べてみてください。実際、COMでうまく異種のコンパイラ間で関数が呼び出せているのですから、技術的には出来るのです。

あと、少しややこしい問題が2つほどあります。

☆ smart_ptrの受け渡し

ひとつ目は、smart_ptrの受け渡しです。smart_ptrとは、shared_ptrとも呼ばれ、Java風の参照カウントを管理しているポインタです。(詳しくは、天才ゲームプログラマ養成ギプス 第11章をご覧ください)

もう、C++の腐乱臭のするポインタは使いたくないのです。ポインタは、スマートポインタで行いたいのです。ところが、スマートポインタを、上のような仕組みで受け渡しするには、ひとつの問題があるのです。

それは何かといいますと、スマートポインタは、参照カウントが0になった時点で解放されるのですが、いつ参照カウントが0になるのかは、実行時まで判明しないのです。DLL側で0になるか、Main側で0になるか、それはわからないのです。ところが、DLL側でnewしたものは、DLL側でdeleteしなければならないので、Main側でdeleteしたいときは、上述のDeleteInstanceを呼び出さなくてはならないのです。

よって、dll_smart_ptrのようなものを用意して、そいつのdeleteは、delete pではなく、p->DeleteInstance( )によって行なわなければなりません。これで、DLL、Main間でのスマートポインタ問題は一応、解決します。ただ、普通のsmart_ptrからdll_smart_ptrへ変換が出来ないのが、ちょっと不満ですが、それは原理的に出来ないので、目を瞑ることにしましょう。

☆ ObjectCreateManagerの使用

CHogeをMain側から使うときに、DLL側の生成子を呼び出さないといけないことは、上述しました。これが、案外面倒なのです。実際、DLLから他のDLLにあるクラスを呼び出したいこともあるわけで、そういうときに、すべてのObjectの生成を管理する親玉(ObjectCreateManager)のようなものが無いことには、不便で仕方ありません。

ところが、このようなオブジェクトの生成マネージャを使うときに、DLL名とそのDLL内におけるクラスID(通しナンバー)を用いて、

IHoge* iHoge = (IHoge*)o->CreateInstance(DLL_NO,IHOGE_ID);

のような呼び出しになるのです。IHoge*にキャストしているあたりが気持ち悪いし、オブジェクトの生成に関して、かならずこのオブジェクト生成マネージャのポインタが必要になるというのも、なんだかなぁ..という感じです。

まあ、こういうのは表記上の問題ですし、C++のプリプロセッサでも作らないことには、解決しないので、ここで深く議論はしません。ただ、オブジェクト指向スクリプトのようなものを作ったときに、そのスクリプト側から利用するのは楽なわりには、C++から利用するのは非常に疲れると、まあ、そういう状況になったりもします。

とりあえず、DLL側のクラスをインポートする方法の説明は以上で終わりです。本当は、こんな原理的なことより、DLLの読み込みがどうしてあんなに遅いのか、ということについて書きたいのです。ちゅーか、ひとつのDLL読み込みに0.2秒かかったら、30個ぐらいに分割されていると、6秒もかかるのです。なぜこんなに遅いのか詳しい原理はやねうらおの知るところではありませんが、いずれPEヘッダ(PE = Portable Execute)、すなわち実行ファイルの中身について調べ、自前で読み込む実験などしてみたいと思う次第です。今回は、準備不足でどうもすみません。


追加情報('01/12/05)

掲示板のほうで、Phiesさんのほうから情報をいただきましたので、感謝の意を表し、ここに転載させていただく次第です。情報ありがとうございます。

こんにちは、はじめまして。
長くなってしまったので前置きは抜きで(^^;、いきなり本題です。

> DLLの読み込みがどうしてあんなに遅いのか、ということについて書きたいのです。

この件については『 Advanced Windows 改訂第4版』の P.728 「20.7 モジュールの先頭アドレスの調整」で詳しく議論されています。

簡単に要約してお話すると、DLL の再配置による、コード書き換えのオーバーヘッドなのだと思います。

DLL は、コンパイル時に指定されたベースアドレスにうまくロードできれば、高速にロード出来るのだそうです。しかし、当該のアドレスに他のファイル、DLL がコミットされていたり予約されていて使えないときは、別のアドレスにロードしなければなりません。

別のアドレスにロードされると、今度はその DLL の中身にあるコードのうち、絶対アドレスを利用しているものを書き換えなければなりません。
前述の書では、

int g_x;
void func () {
g_x = 5;
}

という例を出しています。
DLL をコンパイルする際、DLL のベースアドレスにデフォルト値である 0x10000000 をそのまま使うと、生成されるコードは

MOV [0x10014540], 5

のようになります。
>もうおわかりと思います……もしこの DLL が 0x10000000 にロードされれば、このコードは問題なく動作します。ですが、そのアドレスが利用不可で、別のアドレスにロードせざるを得ないときは、自分の知らない、何かの値を書き換えてしまうことになります。

そこで、Windows は DLL を、DLL が希望するアドレスとは別の位置にロードするときは、中身のコードをすべてチェックして、上のようなコードを書き換える作業を行うそうなのです。その処理に時間がかかる、ということです。

複数の DLL を用いる場合でも、多くのプログラマはすべての DLL に対して、コンパイラが指定するデフォルト値 0x10000000 を使うため、2個目以降にロードされる DLL はすべて再配置が行われ、結果として遅くなるのだそうです。

以上が、前述の本の当該セクションの要約です。

手作業で用いるすべての DLL の先頭アドレスを調整できれば問題ないのですが、さすがにそういうのは無理なので、この再配置をあらかじめするためのツールが、Platform SDK に付属しています。ReBase.exe といい、使い方については Platform SDK にドキュメントがあるそうですので参考になさってください(簡単な使い方なら、前述の本にもあります)。

戻る