以前使用Win32 SDK编程时,经常会遇到回调函数。比如,枚举窗口函数EnumWindows API就用到回调函数。回调函数的本质就是将一函数指针传递给API函数(或者其它什么函数),在API函数内部会根据这个函数指针来调用这个函数。委托的实质也类似于回调函数,不过这是OOP中的专用说法而已。回调函数的作用就相当于委托模型中的代理(delegate)。

在BCB中,使用了__closure关键字扩展了标准C++,从而实现了委托:

typedef void __fastcall (__closure *FCallBack)(int);
 class CDelegate
 {
 public:
     void __fastcall Callback(int param)
     {
         ShowMessage("Call Times: "+String(param));
         return;
     }
 }; class CTest
 {
 public:
     FCallBack pcallback;
     CTest()
     {
         CDelegate delegate;
         pcallback=delegate.Callback;
         for(int i=0;i<10;i++){pcallback(i);}
     };
 };

然而,在标准C++中不支持委托,只提供成员函数指针(member function pointers)。这个东西用起来繁琐,写出来的代码猥琐:

class CCallBack
 {
 public:
     void Callback(int param)
     {
         printf("Call Times: %d\n",param);
         return;
     }
 }; typedef void (CCallBack::*FCallback)(int);
 class CTest
 {
 public:
     FCallback pcallback;
     CTest()
     {
         CCallBack cb;
         pcallback=&(CCallBack::Callback);             //比较:delegate.Callback;
         for(int i=0;i<10;i++){(cb.*pcallback)(i);}    //比较:pcallback(i);
     }
 };

使用"__closure"关键字,没有对象类型的限制;而使用成员函数指针必须在声明时就确定对象类型。换句话说,成员函数指针不支持透明调用。其实,实现成员函数的透明调用的汇编代码十分简单。来看看BCB的"__closure"是如何实现的。先来猜猜下面代码用BCB编译后的运行结果:

#include <stdio.h>
 void (__closure *p)(int);
 int main(int argc, char* argv[])
 {
     printf("%d\n",sizeof(p));
     return 0;
 }

该代码的功能是打印一个“闭包(closure)指针”的大小。结果是什么?是8。出乎意料么?看来这并不是一个普通的指针。那多出来的4字节究竟储存了什么?在解释这个问题前,先要说说对象成员函数是如何调用的。众所周知,普通函数调用,如__stdcall,__cdecl等,是先将参数逐一压入堆栈,然后使用call指令条转到目标函数中执行。而C++中调用对象成员函数采用__thiscall调用方式,先将参数逐一压入堆栈,然后将对象的this指针放入ecx寄存器,最后在调用call指令。所以调用一个对象的成员函数不但要知道成员函数的地址,还要知道对象的地址。描述闭包指针内存布局的C伪代码如下:

//执行闭包指针的赋值语句时会填充这个结构
 typedef struct{
     void *ptr_this;    //存放对象的this指针
     void *ptr_mfunc;   //存放成员函数的地址
 }PTR_CLOSURE;

知道了原理,就可以用标准C++模拟这个实现。只需要用两个变量来分别保存代理对象的地址和代理对象回调函数的地址就可以了。然后用内嵌汇编代码来模拟__thiscall调用。不过在实际操作中碰到了一点问题:编译器死活也不让我得到成员函数的地址,似乎只可以将成员函数地址赋值给成员函数函数指针。无论是用(void*)强制转换,还是xxxx_cast转换,都无法将成员函数地址赋值给一个void*类型的变量。最后,不得不采取了一些非常常规段才达到目的:

#include <stdio.h>
 class CDelegate
 {
 public:
     void Callback(int param)    //回调函数
     {
         printf("this:0x%08X – param:%d\n",this,param);
         return;
     }
 }; class CTest
 {
 private:
     void *pObjs;        //指向代理对象的指针
     void *pCallback;    //指向代理对象回调函数的指针
     void Delegate(int param){
         void* tmp1=pObjs;
         void* tmp2=pCallback;
         __asm{
             push ecx        ;保存this指针
             push param      ;压入回调函数的参数
             mov  ecx,tmp1   ;放入pObjs的this指针
             call tmp2       ;调用CDelegate的成员函数
             //add  esp,4    (*注)
             pop  ecx        ;恢复this指针
         }
     };
 public:
     CTest()
     {
         pObjs=new CDelegate;
         typedef void (CDelegate::*FCallback)(int);
         (FCallback&)pCallback=&CDelegate::Callback;    //利用引用来强制类型转换
         for(int i=0;i<10;i++){
             Delegate(i);    //试试效果
         }
     };
 }; CTest test;
 int main()
 {
     return(0);
 }

(*注):在John Robbins的《应用程序调试技术》一书中,将__thiscall调用描述为“调用者平衡堆栈”,而我实际调试下来却发现是“调用对象平衡堆栈”。所以不需要"add  esp,4"操作。

用上面的代码能实现高效的委托,但是也存在一个致命的弱点——它不支持多重继承的代理类。也就是说,CDelegate中的Callback成员函数必须是由该类自己实现,或是从别的类中单一继承的。究竟为什么会产生这个问题还必须从C++的对象模型说起。

对于VC编译器,一个对象的所占内存是由虚函数表指针和非静态成员变量组成的。成员函数通过对象的this指针加上偏移量来访问成员变量。当一个类被继承时,编译器会将基类和派生类所占的内存空间合并。问题就此产生了:基类的成员函数并不知道他已经被继承,仍旧使用原来的偏移量。当一个类被单一继承时,编译器童过将基类对象放在派生类对象的首部来解决这个问题。这么一来,基类的成员函数仍旧能够使用原来的偏移量来访问基类对象的成员变量。但如果是多重继承的话,必然会有一个基类对象的偏移量会发生改变。例如:

class A{
     int ma;
 };
 class B{
     int mb;
     void funcb();
 };
 void B::funcb(){mb=1;}
 class C:public A,public B{
     … …
 };

在这种情况下,编译器只能将A放在C的首部,而B只能跟在A的后面了。这么一来,派生类C的成员函数funcb就不能通过原来的this指针加偏移量来访问成员变量mb了。如果有如下调用:

C cobj;
cobj->funcb();

编译器会自动修正this指针,使得传递的this指针指向cobj中正好是B对象的位置。也就是说,此时在funcb内部的this指针不指向cobj的首地址,而是指向cobj的内部,准确的说是cobj首地址偏移一个sizeof(A)的位置。不单单是多重继承,虚继承也会碰到类似的问题。

对于BCB的闭包指针,编译器会修正存放在PTR_CLOSURE结构中的this指针。但是在我们实现的代码中,this指针只是死板地指向对象的首地址。如果我们在写代码的时候去手工修正它显然是愚蠢的。我们只好保证代理类不是多重继承或是虚继承的,至少要保证用于回调成员函数不是从别的基类中多重继承来的。毕竟,MFC中的类也不支持多重继承的,那就将究着用吧。