コールバック関数とオブジェクト指向

1. スレッドをC++クラスでラップしたい!

 ワーカースレッドを作る際には、MFCでは普通、AfxBeginThreadにスレッド制御関数へのポインタを渡してスレッドを開始させます。でも、スレッド制御関数に渡せるパラメータは32ビットのポインタ1つだけです。まあ、グローバル変数を使って情報をやり取りすればいいんだけど、それじゃあ、オブジェクト指向っぽくなくてかっこ悪いような気がするし、構造体の中に渡したいパラメータをまとめて、それへのポインタを渡すのもいいけど、もっとかっこいいやり方があります。

 ワーカースレッドについて、必要なもの全てをまとめて、1つのクラスを作ってしまえばいいのです。でも、ここで1つ問題が出てきます。スレッド制御関数もクラスのメンバに加えたいのですが、メンバ関数をスレッド制御関数にすることはできないのです。これは、一般的に言えることですが、C++のクラスのメンバ関数は、Windowsにエクスポートすることはできません。(エクスポートするということは、Windowsからその関数を呼び出してもらうということ。ウインドウプロシージャや、コールバック関数にするということも同様。)同じ関数宣言でも、クラスの宣言の中に持ってきて、メンバ関数にしてしまうと、できなくなります。

 これは、なぜかというと、メンバ関数には、同じ型のクラスの別インスタンスを識別するための、thisポインタという引数が、暗黙のうちに追加されるからです。thisポインタは、特定のインスタンス(実体)へのポインタで、クラスのメンバ変数と仮想関数テーブルを指します。

 それで、Windowsはこのthisポインタの入る引数が分からないので、呼び出すことができません。・・・もっと正確に言うと、メンバ関数へのポインタだけでは、呼び出すべきメンバ関数は分かっても、どのインスタンスのメンバ関数なのか分からないのです。(まあ、関数の型が違うから呼び出せないと言ってしまえばそれまでだけど・・・。)

 それでは、どうすればいいかというと、スレッド制御関数をstaticメンバ関数にすればいいのです。staticメンバ関数にすると、暗黙のthisポインタが引数から消えます。そうすれば、Windowsにエクスポートできるようになるのです。でも、その代わりに、thisポインタがなくなってしまうわけですから、インスタンスを特定することができず、メンバ変数や、メンバ関数にアクセスできなくなります。(ただし、staticメンバ関数とstaticメンバ変数にはアクセスできます。それらは各型のクラスにただ1つしか存在していないので。)

 それでは、本題に入ります。以下のコードを見てください。

class CMyThread
{
private:
	static UINT ThreadProc(LPVOID pParam);	// スレッドの制御関数
	UINT MainThreadProc();
	HANDLE m_hThread;
public:
	CMyThread();
}

CMyThread::CMyThread()
{
	// スレッド開始
	CWinThread *pThread = AfxBeginThread(ThreadProc, this);
	m_hThread = pThread->m_hThread;
}

// 唯一のスレッド制御関数
UINT CMyThread::ThreadProc( LPVOID pParam )
{
	// 特定の CMyThread オブジェクトへのポインタを得ます
	CMyThread *This = reinterpret_cast<CMyThread *>(pParam);
	// 各オブジェクト固有の制御関数を実行
	return This->MainThreadProc();
}

UINT CMyThread::MainThreadProc()
{
	// ここにスレッド制御関数の本体を書く。メンバ変数も使える。
}

 コンストラクタの中でAfxBeginThreadでスレッドを開始していますが、その際に、スレッド制御関数に渡す32Bitパラメータとして、自分自身のクラスへのポインタ(thisポインタ)を渡しています。そして、static宣言されたスレッド制御関数の中で、その自分自身へのポインタを使ってメンバ関数を呼び出しています。これによって、スレッド制御関数をメンバ関数として扱えるようになります。

 つまり、スレッド制御関数に渡すことのできる32Bit値を活用することにより、呼び出すべきメンバ関数のインスタンスを識別するわけです。この手法は、インターバルタイマなどのコールバック関数を使う際にも同様に使えます。

 このように、ハンドル(この場合はスレッドハンドル)をC++のクラスの中に入れて包むことを「ラップする」といいます。

 ところで、エクスポートする関数(コールバック関数)が引数として渡す事のできる32Bit値を持っていない場合はどうでしょうか?この場合の具体的な例として、Windowsのウインドウプロシージャがあります。APIのみでプログラミングした事のある人なら知っているでしょうが、知らない人のために、ウインドウプロシージャについて説明します。

2. ウインドウプロシージャの呼び出し方

 Windowsでは、メッセージを受け取るイベントハンドラ関数を、OSにコールバック呼び出ししてもらいます。そのコールバック関数のことを、ウインドウプロシージャと呼びます。(ここで、なぜいちいちコールバック関数にするのかというと、別に、メインスレッドの実行に割り込んで実行されるからという訳ではありません。第一、ウインドウプロシージャは、コールバック呼び出しとは言っても、アプリ側がDispatchMessageというAPIを実行したときに呼び出されるものです。なぜエクスポートするのかというと、メッセージキューを通さずにメッセージハンドラを呼び出すためです。例えば、PostMessageはメッセージキューにメッセージをポストして、メッセージループからの実行を待つタイプですが、SendMessageは、その中で、ウインドウプロシージャを直接実行します。このようなことをするために、いちいちプロシージャのアドレスをOSに渡します。でも、よく考えるとキューを通さずに呼び出す場合にはウインドウプロシージャを直接実行すればよく、特にこのような事をする必要もありません。X Window Systemでは、プロシージャのアドレスをOSに渡すということはしないで、XGetNextEventでイベントをとってきて、それぞれのWindowに対するディスパッチはアプリ側でします。)

 ここで、ウインドウプロシージャは、各ウインドウについて別々のものを指定できるようになっています。(正確には、各ウインドウクラスについて)

 ウインドウプロシージャは、以下の形式です。

LRESULT CALLBACK WndProc( HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam )

 最初の引数は、ウインドウハンドルです。2番目の引数はメッセージを識別する整数で、残りの2つの引数は、メッセージによって使われ方が違います。見ての通り、ユーザーが自由に使っていい引数はありません。しかし、VisualC++(MFC)やBorlandC++Builder(VCL)では、メッセージのハンドラは、普通メンバ関数として実装されます。なので、そのメンバ関数のthisポインタに相当する引数を、なんとかして手に入れないと、呼び出す事はできません。しかし、ウインドウプロシージャには自由に使える引数がない・・・どうしたらいいのでしょうか?ここで、VisualC++とBorlandC++Builderでは、異なった解決方法をとっています。

(1) MFCライブラリ(Microsoft Visual C++)の場合

 MFCでは、全てに共通のウインドウプロシージャとして、AfxWndProcという関数で全てのメッセージを受け取ります。MFCでは、スレッドごとに、ウインドウのウインドウハンドルと、そのラッパオブジェクトの対応表(CHandleMap)を持っています。新たにウインドウが作られると、そのテーブルにウインドウハンドルと、そのラッパオブジェクトの対が追加されます。AfxWndProcでは、届いたメッセージのウインドウハンドルから、そのテーブルを参照して、そのラッパオブジェクトへのポインタを得て、それを使ってメンバ関数を呼び出します。

 MFCソースのWinhand_.hを見ると、コメントに以上話したような事が書いてあります。

(2) VCLライブラリ(Borland C++ Builder)の場合

 VCLでは、オブジェクトインスタンスという機能を用いてこの問題を解決しています。関数を呼び出す際には、その引数をスタックにプッシュしてからその関数のエントリーポイントにジャンプ(正確にはコール)しますが、コールバック関数からメンバ関数が呼び出せないのは、C++オブジェクトへのポインタ(thisポインタ)をスタックにプッシュしていないためです。このことを踏まえて、VCLでは次のようにしてメンバ関数を呼び出しています。まず、コールバックから飛んできた段階で、直接メンバ関数へはジャンプできないので、いったん別の位置にエントリーポイントを設けて、そこにジャンプさせます。次に、スタックにラッパオブジェクト(VCLオブジェクト)へのポインタをプッシュします。この段階で、スタックの内容はメンバ関数を呼び出せるようになっているので、そのままメンバ関数にジャンプします。(このときはコールでない。)ここで、スタックにラッパオブジェクトへのポインタをプッシュするコードのことを、オブジェクトインスタンスと呼びます。

 以上で、「スタックにラッパオブジェクトへのポインタをプッシュする」と書きましたが、正確にはこの表現は間違いです。VCLでのメッセージハンドラは、__fastcallというC++ Builderに独自の拡張キーワードで修飾されています。__fastcallで修飾されたメソッドは、引数をレジスタ渡しするようになります。関数の最初の 3 つの引数は,レジスタに収まるサイズであれば(左から右の順で)EAX,EBX,EDX で渡されます。よって、この場合も、「スタックにラッパオブジェクトへのポインタをプッシュする」のではなく、「EAXレジスタにラッパオブジェクトへのポインタを代入する」というのが正確な表現です。

 VCLでは、オブジェクトインスタンスを実行時に動的に生成します。これはなぜかというと、ラッパオブジェクトへのポインタをオブジェクトインスタンスのコードの中に埋め込む必要があるのですが、ラッパオブジェクトは実行時に生成されるので、ポインタも実行時にしか確定しないためです。つまり、そのウインドウに特化されたウインドウプロシージャを動的に生成しているとも見ることができます。具体的には、実行・読み書き可能なメモリを確保して、その中に、ポインタをプッシュしてメンバ関数へジャンプするためのアセンブラのコードを、プログラムが埋め込んでいきます。(ソースはforms.pas)

 オブジェクトインスタンスを生成する関数は以下の宣言です。

In controls.hpp

typedef void __fastcall (__closure *TWndMethod)(Messages::TMessage &Message);

In forms.hpp

extern PACKAGE void * __fastcall MakeObjectInstance(Controls::TWndMethod Method);
extern PACKAGE void __fastcall FreeObjectInstance(void * ObjectInstance);

 使い方は、コールバック関数にしたいメンバ関数をMakeObjectInstanceに引数として渡すと、そのメンバ関数を呼び出すための、新しいエントリーポイント(オブジェクトインスタンス)を返してくるので、そのポインタをコールバック関数のエントリーポイントとしてWindowsにエクスポートするだけです。ここで、オブジェクトインスタンスはメモリを消費するので、使い終わったら、FreeObjectInstanceで、開放しておかなくてはいけません。

 ここで、1つ気になることがあります。MakeObjectInstanceの引数はメンバ関数へのポインタなのですが、それだけで何で、そのメンバ関数を持っているC++オブジェクトへのポインタが分かるのでしょうか?実はこれには、TWndMethodの宣言に秘密があって、__closureというC++ Builder独自のC++の拡張キーワードが、TWndMethodに普通のメンバ関数へのポインタ以上の情報を持たせているのです。

 __closureキーワードで修飾されたメンバ関数へのポインタは、メンバ関数へのポインタと、クラスインスタンスへのポインタがセットになったものになり、合計64ビットの情報を保持します。よって、__closureキーワードのついたポインタそれのみで、特定のクラスインスタンスのメンバ関数を呼び出すことができるのです。

 以上のようなWindowsでのプログラムに関する問題は、WindowsAPIがC++のオブジェクト指向をサポートしていない点にあります。

3. MFCに関して注意すること

 以上で書いたとおり、MFCではスレッドごとに、ウインドウハンドルと、そのウインドウのラッパオブジェクトの対応表(CHandleMap)を持っています。ここで、MSDNのマニュアルにも書いてありますが、対応表を「スレッドごとに」持っているために、MFCのラッパオブジェクトはスレッドセーフではありません。なぜかというと、例えば、あるスレッドで生成されたラッパオブジェクトのメソッドを、別のスレッドから使った場合、もし、そのメソッドの中で、自分自身のスレッドに対してメッセージを投げる動作をしている場合、メッセージがポストされるのは、そのメソッドを呼び出している側のスレッドなので、対応表を「スレッドごとに」しか持っていないため、対応するラッパオブジェクトが見つからず、メッセージは正常にハンドラに届きません。

 ここで、対応表が「スレッドごとに」しかないために、メッセージのハンドラを実行できるのは、そのラッパオブジェクトが生成されたスレッドで動いているメッセージポンプのみということになります。つまり、メッセージハンドラのコードは、そのスレッドからしか実行されないということになります。

 結局メッセージ機構が問題になっているので、メッセージ機構を使っていないメソッドなら他スレッドから呼び出してもいいのかというと、簡単にはいかず、思わぬところでメッセージ機構を使っている場合があります。例えば、CWnd::GetDC()では、戻り値はCDCクラスへのポインタですが、このCDCオブジェクトは”テンポラリ(一時)オブジェクト”といって、一時的に構築されるオブジェクトで、メッセージループがアイドルになった時点で削除されてしまいます。ここで、もしGetDC()を呼び出した側がメッセージポンプを持たないワーカースレッドだった場合、オブジェクトは削除されないで残ってしまうかもしれません。もし、メッセージポンプを持つスレッドで実行した場合でも、どうなるかは分かりません。

 複数のスレッドからラッパオブジェクトにアクセスする場合には、スレッド間でHWNDなどのハンドル渡しをして、各スレッド内でそのハンドルのラッパオブジェクトを構築して、アタッチして使うべきだとMSDNには書いてあります。