DLL の中のC++クラスの安全なエクスポート

最初に DLL とは?

Windows には Dynamic Link Library ( 以下 DLL ) という、プロセス開始時・起動中に動的にライブラリをリンクする機能があります。このDLL を使うことで、

UNIX系にも似たような動的リンクライブラリがありますが、UNIX系のはコンパイラ側での対応であり、C言語のソースコードで互換性を保っているUNIXのアプリケーションで、動的にバイナリをリンクしにくい性質があるため、それほど使える機能ではないでしょう。

逆に DLL の欠点

とりあえず Microsoft では DLL のバージョン問題と C++ でクラスのエクスポートが安全にできない問題を解決するため COM を利用しています。Component Object Model というのが正規な名前です。

今回は COM まではやりませんが、概念は近いものがあります。

DLL について

DLL のリンクの方法は大きく分けて2通りあります。

  DLLをロードするタイミング スタティック
ライブラリ
ヘッダー
ファイル
暗黙的リンク プロセス開始時に一緒にロードされる。 必要 必要
明示的リンク プロセス起動中いつでもロード・アンロード可能。ロードする場合はLoadLibrary API を使用する。アンロードするときは FreeLibrary APIを使用する。 いらない 必要

暗黙的リンクの方が、プログラムは簡単になります。ただし、プロセスを開始するときに必要な DLL が見つからなかった場合、「XXX.DLLが見つかりませんでした。」と、ダイアログが出てきてアプリケーションが終了されます。

実を言うとこれが問題で、Windows でバージョンをチェックするAPIがあるのですが、なんと古い Win 95 は標準でDLLが含まれておらず、暗黙的リンクすると起動する前に蹴落とされてしまいます。そのため、このバージョンチェックAPIを明示的リンクして呼び出さなければならないという問題が発生します。

明示的リンクはいちいち LoadLibrary をしなければならないのですが、プログラム次第ではアプリケーションを中断しないでDLLの交換をできるので、使い方次第ではかなり便利です。

あと暗黙的リンクではプロセス起動時にいっぺんにたくさんのDLLを読み込むとロードに時間がかかるということで、すぐ使わないDLLは後からロードしようという機能が Win98以降につきました。これをするとDLL内の関数が初めて利用される時にそのDLLをロードするということが可能になります。

DLL と EXE

DLL は EXE からプロセスのロードプログラムの部分を抜いただけのものに限りなく近いです。このため、EXEの中の関数をエクスポート関数とすることもできます。LoadLibrary を使って EXE ファイルをロードすることも不可能ではありません。

ロードされる方法も似ていてファイルトマッピングされます。32bitアドレッシングに最適化しながらファイルトマッピングされます。要するに、ファイルのままマッピングされません。

DLL は C++ 非対応?

残念ながら胸を張って C++ に完全対応しているというものではありません。なぜ、曖昧な答え方をいうかというと普通にスタティックリンクライブラリと同じように DLL を利用することは可能だからです。

ただし、この方法では「スタティックライブラリ」と同じなのでせっかくDLLを使っているのにDLLの機能を使いません。そもそもDLLでコンパイルせずに始めからスタティックライブラリでコンパイルすれば良い話なので、この手のケースは考えないでください♪

これからDLLとして「動的なリンク」を考えて設計します。まずは DLL をエクスポートすることを考えます。

エクスポートする関数の名前の問題

DLLを一度 C++ でコンパイルしたことがある方なら経験されていると思いますが、なにも考えずにコンパイルしてしまうと、関数の名前が変なシンボルにされてしまう現象です。C では関数のオーバーロードができないため、関数名は1つしか存在しないのに対し C++ は関数名のオーバーロードができてしまうため、同じ関数の名前がいっぱいあることになります。

// C++
void foo ( int );
void foo ( string );
int foo( int, string );

// 戻り値の変更だけではNGしますが、引数の型と数が異なればいくらでもOK
/* C */
void foo( int );
void foo( struct damy * ); /* オーバーロードはNG */

DLLでは引数・戻り値を宣言してないため、関数の名前でのみ区別をつけなければならないので関数名に修飾子をつけて、関数名の衝突をしないようにしています。

この問題は DEF ファイルにエクスポートする関数の名前を明記するか extern 'C' を使い部分的に C言語でコンパイルさせるようにするとうまくいきます。

エクスポート クラス の問題

C++ ならやっぱりクラスをエクスポートしたいと思うはずです。先に述べた通りDLLはライブラリと同じようにも使えるので、そのままではまったく問題なく使えます。一番、エクスポートするクラスで注意しなければならないのは「DLLの中のクラスを変更する」行為が一番危険です。これはクラスの差分サイズの変更で微妙なバッファーオーバーランやメモリリークぐらいしかしないため、検出できないことが多いです。そこで、DLLの中でクラスを作る場合に起こりうるバグの説明をしてみようと思います。

簡単に下のようなエクスポート用のクラスを作ります。

// ヘッダー名; Export.h
class Export
{
   public:
      Expert() : m_iData(0) {}
      int   m_iData;
};

上のコードを含むプロジェクトを a.DLL という名前の DLL をコンパイルします。これをスタティックリンクして(*コードを簡潔にするためにエクスポート・インポートの修飾子を省いています)

// a.EXE の中のコード
#include "Export.h"

void foo()
{
   Export dllClass;

   // 処理

}

という感じで、Export クラスを含んでコンパイルして作成したアプリケーションを a.EXE という名前にします。

ここまで問題なくアプリケーションが動作します。が、ちょっと問題があり Export.h の Export クラスを以下のように修正しました。それで void Run(); 関数と m_iExpand の追加を行いました。

// ヘッダー名; Export.h
// ver 1.0a
class Export
{
   public:
      Expert() : m_iData(0) {}
      void Run();
      int   m_iData;
      int   m_iExpand;
};

たまたま DLL の中のクラスの変更だったため、このままリビルドし、a.EXE で利用されていた a.EXE ファイルを上書きしました。

ここからが重要です。このまま a.EXE ファイルを実行し foo() 関数が呼ばれると、a.EXE がビルドされた時点の Export.h ファイルの Export クラスのサイズしかスタック上にメモリを確保されません。

  a.DLL a.EXE
変更前 sizeof( Export ) sizeof( Export )
変更後 sizeof( Export(b) ) sizeof( Export )

* Export(b) は修正版のクラスのつもり

要するに、上の図のようにアプリ側ではDLLのクラスのサイズが変わっても、割り当てられるクラスのサイズが変わりません。既にコンパイルした時にメモリのサイズが決定されているため、このような問題が発生します。書き直すと

int size = 10;
int array [size];      // gcc では通りますが、他のコンパイラは通りません。

と、書くことが不可能なようにスタックのサイズは後からの変更は不可能です。

コンパイラはクラスのサイズを決定する場合ヘッダーファイルを見ます。このため、ヘッダーファイルのクラスの宣言を変更するたびにアプリ側のプロジェクトでも、新しいヘッダーなどをコピーし、リビルドが 余儀なくされます。

エクスポートクラスの問題解決

せっかく DLL にクラスを入れても上のように毎回インプリメントする度に EXE 側もリビルドしていたら大変です。そこで EXE のリビルドが不要な DLL のクラスの設計方法を紹介します。

まず DLL のプロジェクトに2つのクラスを追加します。一つは純粋仮想関数をもつ抽象クラス。もう一つはその抽象クラスを派生クラスです。

// ヘッダー名; Export .h // ヘッダー名; InnerExport.h
class Export
{
   public:
      Export ()=0;
      virtual Export ()=0;
      void Process()=0;
}
class InnerExport: public Export
{
   public:
      InnerExport() : m_iData(0) {}
      void Process(){
         // 何らかの処理
      }
      int   m_iData;
}

上のような感じで Export のクラスを抽象クラスにして、その派生クラスで InnerExportを作ります。実際にエクスポートされるクラスは 抽象クラスの Export の方になります。

ただ、次にアプリ側のプロジェクトで Export クラスのインスタンスを作るのが不可能な事に気がつくと思います。それはExport クラスが「抽象クラス」だからです。このためデザインパターンで Template Method と呼ばれる手法をとることになります。ですから Export  のインスタンスを作るには Export クラスを派生する InnerExport のインスタンスをキャストすれば良いのです。では、このインスタンスをどうやってアプリ側へ渡すのかというと

// DLL 内のソースファイル
extern 'C' {
   Export* getExport()
   {
      return new InnerExport;
   }
}

と、関数をDLLのエクスポート関数として作成します。こうすれば、アプリ側でインスタンスを受けることが出来ます。

// a.EXE の中のコード
#include "Export.h"

void foo()
{
   Export* dllClass = getExport();  // 新しいインスタンスを受け取る

   // 処理
 
   // 処理終了
   delete dllClass;
}

と、いう具合に修正します。

さて、これでうまくいきそうです。が、しかし new のサイズが違うのだから delete する時のサイズも異なるのでは?という疑問もわくと思います。実はその通りで、この場合だとアプリ側は Export のサイズしか知らない問題が発生します。ヘッダーすら定義されていない InnerExport クラスのサイズがわかるはずもありません。

なので、自分で自分を消すメンバ関数を足しておきます。

// ヘッダー名; Export .h // ヘッダー名; InnerExport.h
class Export
{
   public:
      void Destroy()=0;
}
class InnerExport: public Export
{
   public:
      void Destroy(){
         delete this;
      }
}

これで Destroy() メンバ関数を呼び出すことで、『確実』にインスタンスを破棄することができます。プロジェクト毎で new /delete がオーバーロードされていないとは言い切りづらいので delete よりは安全のハズです。

ここまで、クラス設計は終わりです。次にDLLの中のクラスを修正する場合は

// ヘッダー名; InnerExport.h
class InnerExport: public Export
{
   public:
      int   m_iExpand;
      void Run(){};
}

と、いう具合に派生クラスを訂正しても Export には変更はないので、DLLのみを上書きし安全にDLLをバージョンアップすることが可能になります。

エクスポート関数の戻り値と引数の型

先ほど、クラスのバージョン違いで問題がでることを述べました。戻り値・引数の型でクラスを使うことも同じ状況になります。なるべく、標準の型を利用するのが一番安全です。

ちなみに C++ にとって STL の string は標準の型になってしまうのかもしれませんが、配列を DLL とアプリ間で交換する場合 STL や MFC を利用するのはとても便利ですが、1つだけ問題があります。DLL とアプリ間でソースコード上の互換性ではなく「バイナリコード」上で型に互換性がなければ不具合の原因となります。

STL はC++のコンパイラには標準に搭載されていますが、どのコンパイラも全く同じテンプレートクラスを使っていません。STLが複雑なので、コンパイラ毎にテンプレートを展開する作業が違うため、STLでのバイナリコードの互換性はまずないと思って良いでしょう。

あと MFC の CString も同じです。 Visual C++ 6 の CString と Visual C++ .net の CString は名前こそ同じですが、.net になり CString がテンプレートクラスになったことが大きな違いがあります。このため、同じ名前であっても全く互換性がないことに気をつけれなければなりません。

一番良いの int, char, などの基本の型を使うのが一番安全です。

エクスポートクラスをちょっと見直す

Export クラスは見事にメソッドしか定義されていないクラスです。ちょうど COM のインターフェースにそっくりな感じじゃないですか?DirectX なんかは IDirectDrawSurface とか IDirectDrawSurface7 とかいっぱい同じ名前のバージョンがあります。COM では新しい抽象クラスを定義し、前のバージョンの抽象クラスには手を出さないようにしています。これが下位互換を保つ仕組みになっています。COMのインターフェースで設計されている場合、比較的バージョン管理されているので、上の問題は起こりにくいのですがちょっと設計するのが難しいのが難点です。

あと、DLL の説明で書き忘れた点がありました。UNIX系の動的リンクと違う点で、DLLをロードすると、新しいスレッドが走り出します。このため、他のプロセスからロードされるたびに新しいメッセージが通知されるため、ロードされたプロセスの管理も行うことが可能です。まぁあまり使いませんが、プロセス間通信を行うときは便利かもしれません。自分と同じDLLを読み込んでいるアプリケーションにメッセージをブロードキャストできたりすると便利でしょうしね。

最後に、C++の場合 delete をするためのメソッドも作るべきでしょう。最初に C++ クラスの設計で後のことを考えて設計をするなら new 演算子をオーバーロードしたのなら delete 演算子もオーバーロードすべきでしょう。インスタンスをエクスポート関数経由で受けとったポインタがnew か new[] の配列の中のポインタ先なのか判別できないのに delete をするべきではありまえん。そのため、配列でもあっても安全に破棄できる破棄メソッドを用意すると利用するユーザーも破棄メソッドを使えばオブジェクトを破棄できることがわかるので、安全ではないかと思います。