無知とは罪である。
かつてWindowsプログラム始めたころ、
CreatePenでペンを作成してDeleteObjectを呼ばないプログラムを平気な顔をして書いていたものです。
フレーム毎にそんな事をしていたものだから、あっという間にリソースを食いつぶしてしまい、
挙句の果てにはウインドウ右上のボタン類の表示が数字に化けてびっくりした事があります。
さて、今回のお話はC++でDLLを書くというネタ。
以前CでDLLを書いた事があったので、余裕をかましていたらひどい目にあいました。
そんなわけで参考文献、これとか、これとか、これを読みながら覚書を書いておきます。
ただし、まだ知らない事があるかもしれないので信用しきってはいけません。
無知とは恐ろしい事なのです。
1. メモリ管理
メモリの確保、解放に関して注意すべきことがあります。
簡単な事で、「DLL内で確保したメモリはDLL内で解放しなければならない」ということです。
newやmallocのルーチンがアプリケーションとDLLで異なっている可能性があるからです。
コンパイラが違えば当然ですし、リリースビルドとデバッグビルドでもリンクされる
ランタイムが異なるので危険です。
2. 命名規則
C++の関数名はコンパイラによって勝手に書き換えられます。
例えばhogehogeクラスのmoemoe関数はVCを使うと
?moemoe@hogehoge@@UAE_NXZといった感じになります。
name manglingというらしいんですが、この作業によってC++のオーバーロードの実装なんかが可能になっています。
で、この名前の変換はコンパイラ毎に異なっています。
モジュール定義ファイル(.def)を使う事で名前の変換を抑制できますが、 .defが各コンパイラ間で使いまわせるのか謎です。
さらに関数が増えるとそれだけ関数名の指定をしなければいけないので大変です。
とはいえ、自作のプログラムの場合DLLもそれを使うプログラムもVCだからと割り切ってしまうなら、
__declspec(dllexport)を使うことで.defがいらなくなります。
// DLL.h
class __declspec(dllexport) Test{ // DLLをビルドするときはdllexport
//class __declspec(dllimport) Test{ // DLLを使用するときはdllimport
public:
Func();
};
// DLL.cpp
#include "DLL.h"
Test::Func()
{
}
// DLLTest.cpp
#include "DLL.h"
#pragma comment( lib, "DLL.lib" )
void main()
{
Test a;
a.Func();
}
こんな感じでしょうか。
DLL1.hでdllexportとdllimportを使い分けなければならないのがちょっとめんどくさいですが、
#ifdef DLLEXPORT
#define DLLEXPorIMP __declspec(dllexport)
#else
#define DLLEXPorIMP __declspec(dllimport)
#endif
ってな風にマクロでごまかすのも手かもしれません。
あとはDLL側のビルドオプションで/D "DLLEXPORT"を指定すればOK。
ちなみに手を抜いてこの切り替えをしないでヘッダを__declspec(dllexport)のままにして DLLTest.cppをコンパイルすると
exeとlibができて再度関数がエクスポートされるという不思議な事になります。まぁ、それでも動くみたいですが。
クラス宣言に__declspec(dllexport)みたいな妙なものが付くのが嫌な場合は真面目に.defを書く事になります。詳しくはこちら。
簡単に説明すると
一度目のコンパイル時にmapファイルを作って、そこに書かれているname mangling後の関数名を
defに書き写し、そのdefをプロジェクトに組み込んで再度コンパイルといった流れです。
3. DLLのバージョンアップ
DLL化してうれしい事の一つに、アプリケーションを一切変更することなく、新しいDLLに差し替えるだけで性能を上げることができるという事があります。
それに伴ってクラスの定義を変更して
class Test{
public:
int x[30000];
Func();
};
とかヘッダの構成を変えてしまうと危険です。
なぜなら、古いヘッダと新しいヘッダではsizeof(Test)が異なり、配列Xを使った瞬間、
古いヘッダを前提としたアプリケーションではメモリアクセス違反を引き起こしてしまいます。
MFCのDLLがMFC42.DLLみたいにバージョンごとにファイル名を変えているのはこのためです。
もっとも、これでは古いアプリケーションでは古いDLLを使う事になるので、バージョンアップの恩恵を受ける事ができません。
4. DLLのバージョンアップその2
__declspec(dllexport)を使うと.defファイルやname manglingのことを気にしなくていい代わりに、
エクスポート序数をこちらの思い通りに制御できません。
序数というのは.defで
EXPORTS
Func @1
と書く時の@に続く数のことですが、関数名とこの数を常に同じになるようにしておけば、
DLLのバージョンを上げてもアプリケーション側の変更は必要ありません。
つまり__declspec(dllexport)を利用するとDLLの変更の度にアプリケーションを再コンパイルする必要が出てくるわけです。
5. インターフェースクラス
さすがに、クラス定義を変更できないのはつらいので、実装クラスの上にインターフェースクラスをかぶせて誤魔化します。
// IDLL.h
class Test; // 実装クラスの名前を導入
class __declspec(dllexport) ITest{ // DLLをビルドするときはdllexport
//class __declspec(dllimport) ITest{ // DLLを使用するときはdllimport
Test *pTest;
public:
ITest();
~ITest();
Func();
};
// IDLL.cpp
#include "IDLL.h"
ITest::ITest()
{
pTest = new Test();
}
ITest::ITest()
{
delete pTest;
}
Test::Func()
{
pTest->Func();
}
ITestのクラス定義でclass Test;として実装クラスの名前を導入するというテクニックは知らなかったですね。
知らなくてもTest *pTest;をvoid *p;として(Test*)p->Func();といったvoidポインタのキャストでなんとかなるんですが。
とにかく、このようなラッパーを用意する事で実装は隠蔽されるので、安心してDLLのバージョンアップが出来ます。
しかし、Testクラスの関数が増えるたびにITestクラスの関数を増やしていかなければならないのは大変なので、
インターフェースクラスを純粋仮想関数にしてこれを実装クラスに継承させます。
// IDLL.h
class __declspec(dllexport) ITest{ // DLLをビルドするときはdllexport
//class __declspec(dllimport) ITest{ // DLLを使用するときはdllimport
public:
virtual Func() = 0;
};
// DLL.h
class Test : public ITest{
public:
Func();
};
こうすることでIDLL.cppが不要になって手間がかかりません。
ついでに仮想関数にアクセスするためのテーブル(vtbl)の構造がWindows上のコンパイラなら同じらしいので、
コンパイラ間の差異を吸収することが出来ます。
と良い事づくめに思えますが、インターフェースクラスが純粋仮想関数になってしまったのでクラスのインスタンスを作る事が出来ません。
よってDLL.cppに
ITest __declspec(dllexport) *CreateInstance() {
return new Test();
}
というインスタンス生成用の関数を用意します。
さらにDLLで確保したメモリはDLLで解放しなければならないので
Test::Delete()
{
delete this;
}
と、自爆用の関数を追加します。
アプリケーション側は
#include "IDLL.h"
#pragma comment( lib, "DLL.lib" )
ITest *CreateInstance();
void main()
{
ITest* pTest = CreateInstance();
pTest->Func();
pTest->Delete();
}
こんな感じで使います。
ちなみに.libを生成する事でCreateInstanceをそのまま使う事ができますが、代わりにDLLを任意の場所に置くことができません。
私の知る限りでは.libを使ったDLLの暗黙的リンクを行うとDLLをsystemディレクトリや、実行ファイルのある場所におかなければなりません。
せっかくならどこにDLLを置いても使えるようにしたいので、.libが不要なLoadLibraryによるDLLの明示的リンクを行いましょう。
その場合、CreateInstanceの名前がname manglingされると鬱陶しいので.defもあわせて作る必要があります。
あと注意しなくてはならないのは、IDLL.hを必ずDLLをビルドしたときのものとアプリからDLL使うときのものとで一致させなければならない事です。
仮想関数を追加するのはもちろん、宣言する順番を入れ替えるのも不可です。
IDLL.hはvtblの構造そのものなので、このフォーマットが崩れたら最後、まともに動作しません。
DLLを作ってる最中に色々試行錯誤していると、ついうっかりIDLL.hを揃えるのを忘れてしまう事があるので気をつけましょう。
そんなこんなで、それなりな感じのDLLのフレームワークはこちら。
いやはや、DLL内のクラスを使うのがこんなに大変だとは思いませんでした。
02/07/09 追記 DLLのバージョンアップその2を追加。
|