TOP / CODING / APP / LINK / NOTE / MAIL

※ これはATL7.0をもとに書いています

WTL/ATLのメッセージマップ実現のしくみ

BEGIN_MSG_MAP(_EX) ~ END_MSG_MAPとそれに関連するマクロ群の単なる使い方を説明しても仕方ないわけで (そのような解説を期待していた人は別のサイトに飛んでください)、 その背後にあるしくみを私なりに紐解いてみようと思う。 この部分はウィンドウをC++クラスにしようと設計を考えた人なら少なからず悩んだ部分だろうから、 興味を引かれている人も多いだろう。ということで、挑戦してみた。 説明が間違っている可能性がないとは言えないので、 ご自分で裏をとって&見つけたら教えていただきたい。

以下の説明文はWTL/ATLによく見られる「テンプレートによる多態」 と私が呼んでいる(一般的にそう呼ばれているかどうかは知らない)テクニック

class Derived : public Base<Derived> { ... };
についての理解を前提としている。わからない方はまずこれを習得してから読んでほしい。 どっかのサイトかソース見ればわかるでしょう。 WTL/ATLのウィンドウ実装系クラスの使い方すらわからない方は、 先にこっちで勉強。 また、私はCWindowImplRootを継承したクラスをCWindowImpl系と呼んでいるが、その中心であろう CWindowImplRoot - CWindowImplBaseT - CWindowImplの筋を基本として説明している。 CDialogImplでもCFrameWindowImplでも似たような方法で実現しているので、一通りの説明で問題ないだろう。 これら亜種(?)の違うところだけまた別に解説するかもしれない。

ProcessWindowMessage()

さて、BEGIN_MSG_MAPの正体はProcessWindowMessage()メソッドである。 そんなことはマクロの定義部分さえ見れば誰でもわかる。 このメソッドはCMessageMapというクラスに純粋仮想関数として定義されていて (CMessageMapはこのメソッドのためのクラスだ)、 CWindowImplRootがこれを継承している。 しかしこのクラス内ではこのメソッドの実装はされていない(その下でもずっと実装されない)ので、 CWindowImpl系クラスを継承して自分のクラスを作ったときに BEGIN_MSG_MAPやDECLARE_EMPTY_MSG_MAPを使ってProcessWindowMessage()を実装しなければコンパイラに怒られる。 もちろんマクロ使わずに自分で書いてもよいが。

問題なのは、どうやってこのメソッドまでメッセージが流れてくるか、である。

CWndClassInfo

私も何度かミスったことがあるが、 CWindowImplで作ったウィンドウはCreate()メソッドで生成しなければならない。 なぜなら、Create()内にメッセージマップを可能にするようなタネがあるからだ。 ウィンドウクラスの登録もその一つ(ミソではないけど)。 WTL/ATLではWNDCLASSEXも表面的には見えないようになっているが、 DECLARE_WND_CLASS系のマクロでクラス定義していることになる。 このマクロは実際はGetWndClassInfo()という静的メソッドを実装するものだが、これは CWndClassInfoクラスのインスタンス(いわば静的メンバ変数)をインラインで書くための技で、 CWndClassInfo型の静的変数への参照を返すだけである。 このCWndClassInfoクラスが少々拡張されたWNDCLASSEXと言ってよいので、 DECLARE_WND_CLASS系マクロはウィンドウクラスを定義していると言えるだろう。 このマクロの変種(パラメータが増えたやつ)はCWndClassInfoのメンバや CWndClassInfoに含まれるWNDCLASSEXのメンバを設定するためのものである。

ここで定義されるウィンドウクラスのクラス名はDECLARE_WND_CLASSマクロに渡した名前である。 CWindowImpl内でDECLARE_WND_CLASS(NULL)とされているので、 自前クラスでDECLARE_WND_CLASS系を書かなくてもエラーにはならない。その際どうやって クラス名を決めているかというと、一連のクラス登録ルーチンの途中で自動的に(アドレスから)生成する (気になる人はFormatWindowClassName()というメソッドを参照)。

このCWndClassInfo(_ATL_WNDCLASSINFO[A|W]のtypedef)にはRegister()というメソッドがあって、 CWindowImplなどはCreate()内でこれを呼ぶ。 このRegister()にはサブクラス化機能のためにもとのプロシージャを取得する(m_pfnSuperWindowProcへ) という効能もあるのだが(通常はm_pfnSuperWindowProcはDefWindowProc()APIだ)、 やはりウィンドウクラスの登録がメインの役割と言える※1。 ウィンドウクラスが登録されてなければウィンドウを生成できないのは当たり前なんだが、 GetWndClassName()という静的メソッドの存在を知っていてコイツでCreateWindowEx() してやろうとしてコケた人はどれくらいいるか? ・・・まああんまりいないだろうな・・・。だが、だからといって、 自前でGetWndClassInfo().Register()すればCreateWindowEx()できるかというと、 ムリ。そんなことしてもProcessWindowMessage()は待ちぼうけである(というより、落ちると思うけど)。

※1 ちなみにRegister()は_AtlWinModuleにATOMを追加するので、 登録したクラスは終了時にすべてUnregisterClass()されます(成功すれば)。

CWndProcThunk

CWindowImpl::Create()は最後にCWindowImplBaseT::Create()を呼ぶ。 このCWindowImplBaseT::Create()内ではCreateWindowEx()APIがコールされるが、 その前に_AtlWinModule(CAtlWinModule型のグローバル変数)のAddCreateWndData()が呼ばれる。 ここがミソ(の出発点)。 CWindowImplBaseT::Create()ではこのAddCreateWndData()にm_thunkメンバ変数のcdメンバ(m_thunk.cd) とthisポインタ(引数はvoid*で宣言されているので後でキャストされる)を渡す。 m_thunkはCWndProcThunk型で、CWindowImplRootでメンバとして定義されているのだが、これがミソの中心(変な日本語)。 このCWndProcThunkクラスは先ほどAddCreateWndData()に渡すと言った_AtlCreateWndData型のcdと、 CStdCallThunk型のthunkという2つのメンバ変数で構成されている※2。 メンバ関数はInit()とGetWNDPROC()の2つ。

m_thunk.cdの型である_AtlCreateWndDataは、 _AtlWinModuleのメンバの一つであるm_pCreateWndListという一方向リンクリストの要素(エントリー)の型である。 リンクリストを実現するm_pNextポインタ以外にvoid* m_pThisとDWORD m_dwThreadIDというメンバがあるが、 m_pThisのほうはAddCreateWndData()に渡したthisポインタがそのまま入り、 m_dwThreadIDのほうはGetCurrentThreadId()APIで取得したスレッドIDが入る。 m_thunk.cdのこれらのメンバ変数をセットし、 リンクリストにつなげる(先頭に挿入する)のがAddCreateWndData()の役目である。

さて、だからthisをグローバルに登録してどうなるんだ、と首をかしげる人もいるだろう。 ここから佳境である。

※2 ATL7.0、7.1ではunionにはなっていない。

StartWindowProc()

CWindowImplBaseTにはStartWindowProc()とWindowProc()という2つの静的メソッドがある (他にも静的メソッドはあるけど)。 これらのどちらか、あるいは両方が、ウィンドウプロシージャであろうことは想像に難くない。 静的メソッドは普通のメソッドと違って通常の関数と同じに扱える (thisがいらない)のでこれを利用してメッセージマップを設計しようと考える人が普通である。 しかし単にそれだけでは(静的メソッドをウィンドウプロシージャとして登録しただけでは) メンバ変数にアクセスできない(登録は可能)。これでは意味がない。 アクセスするためにはthisを渡すだけでなく、メンバ関数内でメッセージを処理しなければならない。 StartWindowProc()とWindowProc()のうち、メンバ関数ProcessWindowMessage()へメッセージを流すのは、 WindowProc()のほうである。それではStartWindowProc()は何のためにあるのか。 というわけで、StartWindowProc()の役割を説明しよう。

実はStartWindowProc()は1インスタンスにつき一度しか呼ばれない。 すなわち、プロシージャの本体ではない。では何なのかというと、本当のプロシージャへの中継役である。 だが、最初に呼ばれるのはコイツである。つまり、ウィンドウクラス登録の際、 StartWindowProc()がウィンドウプロシージャとしてセットされる。 DECLARE_WND_CLASS系マクロ内で堂々とプロシージャのところにStartWindowProcと書いてあるのだから。 そして初めてプロシージャがコールバックされるときに、StartWindowProc()の中の処理が行われ、 その処理の途中でウィンドウプロシージャは別のものに置き換えられるのである (単純にSetWindowLongPtr()APIを使って書き換えるだけ)。 よってその後二度とそのインスタンスにはStartWindowProc()は関わらない。

ではウィンドウプロシージャの置き換え以外には何をやっているのか。 StartWindowProc()では最初に_AtlWinModuleのExtractCreateWndData()というメソッドを呼ぶ。 これは_AtlWinModuleのリンクリストに登録したthisポインタを取ってくるメソッドである。 ExtractCreateWndData()内ではリストの先頭からしらみつぶし※3 でスレッドIDが一致するものを探す。 一致したエントリーのm_pThisをreturnし、さらにリストからそのエントリーを削除する。 これで先ほどスレッドIDをリンクリストに登録した意味がおわかりいただけただろう。 返ってくるのはvoid*だが、StartWindowProc()は型を知っているので問題なくキャストされる(自分の所属するクラスだ)。

※3 とは言っても、AddCreateWndData()からCreateWindowEx()の中でプロシージャがコールバックされるまでのタイムラグなので、 リストはそんなに長くない(たいてい1エントリー)だろうけど。

CStdCallThunk

thisを取得した後、プロシージャ(StartWindowProc())に渡されたHWNDでthis->m_hWndをセットし、 次にthis->m_thunkのInit()メソッドが呼ばれる。これは同じ引数をもつm_thunk.thunkのInit()を呼ぶだけ。 ここで今まで触れられなかったもう一つのメンバ、m_thunk.thunkが活躍するのである。 m_thunk.thunkはCStdCallThunk型だと言ったが、その実体は様々である。 すなわち、ターゲットのプラットフォームを識別するマクロによって実装は切り換えられる。 Init()にはthisとthis->GetWindowProc()の2つが引数として渡される。 GetWindowProc()は静的メソッドWindowProc()のアドレスを返すものである ※4。 これを受けて、Init()はメモリ上(CStdCallThunkの中) にインストラクションコード(マシン語)を作る※5。 この辺の詳細はアセンブリ言語がわかっていないとどうしようもないので、 ここではC++オンリーの説明ということで詳細は省く(気になる人はこっちを読んで)。 要するに、インストラクションコードの内容は「第一引数をthisポインタで上書きし、 所定の位置(Init()に渡されたプロシージャのアドレス)へジャンプ」である。 これでm_thunk.thunkは「HWNDをthisに変えてWindowProc()を呼び出す関数」になったと考えればよい。 HWNDがなくなってしまうと思われるかもしれないが、 Init()の前にm_hWndをセットしていることを思い出していただきたい。 m_thunkのもう一つのメソッドGetWNDPROC()は、 このインストラクションコード(m_thunk.thunk)へのポインタをWNDPROC型として返すものであり、 このメソッドで得られたWNDPROCをウィンドウプロシージャとしてセットするのが次の工程である (再確認。それまではStartWindowProc()がプロシージャにセットされている)。 ようやくStartWindowProc()の最後だが、 この取得したWNDPROCを直接実行し、その戻り値をStartWindowProc()の戻り値としてreturnする。 つまり、1回目のコールバックの時からWindowProc()は実行されるのである(HWNDをthisにした上で)。

※4 実はこれは仮想メンバ関数で、CMDIFrameWindowImplでこれが利用されてたりするのだが、 この話は別の機会に。
※5 x86以外のプラットフォームの場合、 CStdCallThunkはそれぞれ固有の_stdcallthunk構造体がtypedefされている。 x86の場合も同様にインストラクションコードの構築用に_stdcallthunk構造体が定義されており、 これを使ってはいるのだが、CStdCallThunkはCDynamicStdCallThunkというクラスのtypedefであり、 これは_stdcallthunkへのポインタをメンバに持っていて、 HeapAlloc()APIでヒープを確保してその上にインストラクションコードを構築する。

WindowProc()

さて最後の仕上げだ。先ほど言ったようにHWNDはthisで上書きされているので、 まずはWindowProc()の最初でHWNDをキャストしてthisを得る。 次にm_pCurrentMsgをセットする。これは_ATL_MSG構造体型(MSG構造体の拡張) でCWindowImplRootでメンバとして定義されているのだが、 GetCurrentMessage()メソッドで現在処理中のメッセージを取得できるようにするためのものである。 その後、this->ProcessWindowMessage()がメッセージマップID:0でコールされる。 HWNDにはthis->m_hWndを。 これでようやく、BEGIN_MSG_MAP ~ END_MSG_MAPに処理が移る。 チェーンなりリフレクションなり好きなことをしてくれ。 ProcessWindowMessage()の後は、m_pCurrentMsgをもとの値に戻し、 ProcessWindowMessage()から返ってきたLRESULTを返して終わり。

ProcessWindowMessage()でメッセージがハンドルされなかった場合は、 this->DefWindowProc(uMsg, wParam, lParam)をコールする。 メッセージがWM_NCDESTROYのときは加えて必要ならばサブクラス化の解除が行われる。

ATL7以降はm_dwStateというCWindowImplRootのメンバを使うようになったようだ。 これはフラグ用だが今のところWINSTATE_DESTROYEDしか定義されていないみたい。 WINSTATE_DESTROYEDはウィンドウが破棄されたことを示し、 このフラグが立っていると(かつ他に処理中のメッセージがないと)WindowProc()は最後にm_hWndをNULLにし、OnFinalMessage()を呼ぶ。 このフラグはWM_NCDESTROYでDefWindowProc()が呼ばれた後に立てられるが、 WM_NCDESTROYでデフォルト処理を切る(ProcessWindowMessage()でTRUEを返す)と立たないので、 注意したい。

最後に

メッセージマップの実現にあたって要求されるのは、 (クラスではなく)個々のインスタンス(C++オブジェクト)にメッセージをディスパッチすることであり、 それは実質的なメッセージ処理関数であるインスタンスのメソッドをどう呼べばよいかという問題に換言することができるだろう。 つまり、静的な関数であるウィンドウプロシージャにおいてthisをどうやって取得するかに尽きる。 そのために、ATLはここまで込み入った実装をしているのである。 プロシージャの入れ替えなどはサブクラス化でよくやることなので、たいして珍しくもない。

もっと簡単な実現方法(API&C++でのthisの取得方法)はないものか? すぐに思いつくのは、map(コレクション)型のグローバル変数(あるいは静的メンバ変数)にHWNDをキーとしてthisを登録し、 毎回それを検索するという方法である。「グローバル変数を使ってはいけない」という制限がないなら (事実、ATLもグローバル変数を使っているし)、これでも構わないような気がする。 しかし、この方法では、ウィンドウが増えるほど検索のコストが大きくなるのは明白である。

ならば、SetWindowLongPtr()でGWLP_USERDATAにthisをセットしてはどうか。 GWLP_USERDATAは使えなくなるが、プログラマにそういう制限をつければこれでもいけると思う。 あるいは拡張ウィンドウメモリでもよい。 GetWindowLongPtr()がどれくらい時間がかかるのか知らないが、 そんなに遅くなさそうな気はするので(勝手な思い込みだが)、これでOKじゃないか。 あるいはSetProp()でも(こちらのほうが遅いと思うけど)。 おそらくATLの設計者はそのような制限を付けたくなかったのだろうと思う。 制限うんぬん以前にATLの方法が一番速いだろうから反論の余地はないけど。

いやはや、少なくとも私は思いつかないね、いや、思いついても実行しないね、こんな手段。

_stdcallthunkのインストラクションコード

※5にあるように、 _stdcallthunk構造体は各プラットフォーム向けに用意されているのだが、 ここではx86用のコードに絞って話をしよう。 以下、提示されているコードはATLソース中のものと完全に同一でないが、 内容は同じである。

 DWORD   m_mov;
 DWORD   m_this;
 BYTE    m_jmp;
 DWORD   m_relproc;
_stdcallthunkにはこのようにメンバ変数が定義されている。 #pragma pack(push,1)~#pragma pack(pop)されているので、パディングはない。Init()は
 void Init(DWORD_PTR proc, void* pThis)
 {
   m_mov = 0x042444C7;
   m_this = PtrToUlong(pThis);
   m_jmp = 0xE9;
   m_relproc = DWORD( (INT_PTR)proc - ((INT_PTR)this+sizeof(_stdcallthunk)) );
   FlushInstructionCache(GetCurrentProcess(), this, sizeof(_stdcallthunk));
 }
このようになっている。さて、Init()5行目のFlushInstructionCache() ※6はいいとして、 4行目まででメンバ変数にどのような値がセットされるのか。

1byte毎に見ていくと、最初のm_movの部分は

C7 44 24 04
である。

次のm_thisの4bytesにはpThis(Init()に渡されたthisポインタ)の値が入っている。

次のm_jmpは1byteで値は

E9
である。

最後のm_relprocの4bytesは4行目の計算結果が入っている。 これはこの構造体の終端(m_relprocの次)からproc(Init()に渡されたプロシージャへのアドレス) までの相対アドレスである。

これらをアセンブリ言語に直すと、

mov dword ptr [esp + 4h], ****   ; ****はm_thisの値
jmp @@@@                         ; @@@@はm_relprocの値
となる。[esp + 4h]はスタック上の第一引数のあるはずの場所で、そこの4bytesに****がコピーされる。 もしこのコードがウィンドウプロシージャとしてコールバックされたなら、 コードが実行される前に引数たちはスタックに積まれており、 それをmovで上書きすることになるのだ。 そして、@@@@の分だけ先へ、すなわちWindowProc()へとジャンプする。 結局、第一引数すなわちHWNDをm_thisの値にすりかえてWindowProc()がコールバックされたような挙動になる。

これが_stdcallthunkの真相。また、※5に書いたようにx86では必ずヒープ上にこのコードが展開される。

※6 FlushInstructionCache()というAPIが使われる数少ないチャンスだな。

first written: 2004.Mar.14

added: 2004.Mar.15


to the Top of this page