http://www.adintr.com/mytranslate/fastdelegate.html
概要
很遗憾, C++ 标准中没能提供面向对象的函数指针. 面向对象的函数指针也被称为闭包(closures) 或委托(delegates), 在类似的语言中已经体现出了它的价值. 在 Delphi(Object Pascal) 中, 他们是 VCL (Borland's Visual Component Library, 宝蓝可视化组件) 的基础. 最近的 C# 让委托的概念更为流行, 这也成为 C# 成功的因素之一. 在许多程序中, 委托可以简化由松耦合对象组成的高级设计模式(观察者模式, 策略模式, 状态模式)的使用. 毫无疑问, 委托在 C++ 中是非常有用的.
C++中没有委托, 只提供成员函数指针. 在非必要的情况下, 大多数程序员都不愿意使用成员函数指针. 它们语法复杂(比如 ->*
和.*
操作符), 难以理解, 别且大多数情况下都有更好的代替办法. 更为讽刺的是: 编译器实现委托比实现成员函数指针要简单得多!
本文将为你揭开成员函数指针的神秘面纱. 学习完成员函数指针的语法和特性之后, 我会详细解释常见的编译器是如何实现成员函数指针的. 之后我们会看到编译器该如何来实现高效的委托, 最终, 利用上面关于成员函数指针的知识, 我会实现一个在大多数编译器上都高效的委托. 比如, 在 Visual C++(6.0, .NET 和 .NET 2003) 调用一个单目标的委托只会产生两行汇编代码.
函数指针
让我们从函数指针开始. 在 C/C++ 中, 假如有一个函数带一个int
参数和一个char *
参数, 返回值为float
, 那么一个名为my_func_ptr
的指向这个函数的函数指针声明如下:
float (*my_func_ptr)(int, char *); // 为了便于理解, 强烈建议使用 typedef. // 否则在使用函数指针作为参数时代码会难以阅读和理解. // 使用 typedef 后的声明如下: typedef float (*MyFuncPtrType)(int, char *); MyFuncPtrType my_func_ptr;
需要注意的是, 函数参数不同, 其指针的类型也不同. 在 MSVC(Microsoft Visual C++ 系列编译器) 中, 调用方式(__cdecl
,__stdcall
, 和 __fastcall
)不同, 其指针类型也不相同. 让函数指针指向一个函数 float some_func(int, char *)
的代码如下:
my_func_ptr = some_func;
通过函数指针调用其指向的函数方法如下:
(*my_func_ptr)(7, "Arbitrary String");
函数指针之间可以互相转换, 但不能被转换成数据指针 void *
. 还有一些其它不重要的操作这里就不再累述了. 函数指针可以被设置成 0 来标识空指针. 所有的比较操作符(==
, !=
, <
, >
, <=
, >=
) 对函数指针都有效, 你也可以通过把函数指针隐式转换成 bool
或使用==0
来测试空指针. 更为有趣的是, 你还可以把函数指针作为非类型的模板参数来使用. 这与使用类型的模板参数, 整数的非类型模板参数本质上都是不一样的. 它会按照名字来实例化, 而不是类型或值. 所有编译器都支持基于名字的模板参数, 甚至有些编译器还支持偏特化.
在 C 中, 函数指针通常用来作为 qsort
这种库函数的参数, Windows API 函数的回调参数等等. 当然, 函数指针还有许多其它的应用. 函数指针的实现非常简单: 它们只"代码地址(code pointers)": 它存储了汇编代码的开始地址. 函数指针有各种不同的类型只是为了在调用的时候做语法检查, 保证以正确的方式进行调用.
成员函数指针
在 C++ 程序中, 大多数的函数都是成员函数, 是类的一部分. 你不能用普通的函数指针来指向成员函数, 必须使用成员函数指针. 一个指向 SomeClass
类的, 参数同上的成员函数指针声明如下:
float (SomeClass::*my_memfunc_ptr)(int, char *); // 对常量的成员函数, 声明如下: float (SomeClass::*my_const_memfunc_ptr)(int, char *) const;
注意这里使用了一个特殊的操作符 (::*
), 而且 SomeClass
也是声明的一部分. 成员函数指针有一个可怕的限制: 它们只能指向固定的一个类的成员函数. 对每一种参数组合, 每一个类型的 const 版本或非 const 版本, 以及每一个不同的类, 其成员函数指针的类型都是不同的. 在 MSVC 中, 对每一种调用方式 __cdecl
, __stdcall
, __fastcall
, 以及 __thiscall
. (__thiscall
是默认的调用方式, 有趣的是, 你在文档中无法找到 __thiscall
这个关键词, 但是它经常出现在错误消息中. 如果你显示的使用它, 你会得到一个错误消息, 这个关键词是被保留以便将来使用的.) 成员函数指针依然有不同的类型. 在使用成员函数指针时, 你应该始终使用typedef
以避免混淆.
让函数指针指向 float SomeClass::some_member_func(int, char *)
的代码如下:
my_memfunc_ptr = &SomeClass::some_member_func; // 下面是针对操作符的语法: my_memfunc_ptr = &SomeClass::operator !; // 你没有办法取得构造函数和析构函数的地址
某些编译器 (以 MSVC 6 和 7 为代表) 允许你省略 &
, 显然这并不符合标准, 而且容易引起混乱. 对大多数符合标准的编译器 (比如, GNU G++ 和 MSVC 8 (也叫 VS 2005)) 来说, &
是必须的, 所以, 你应该始终使用它. 在调用成员函数指针时, 你需要提供一个SomeClass
类的对象, 然后使用一个特殊的操作符 ->*
. 这个操作符优先级很低, 你还需要把它放在括号里面.
SomeClass *x = new SomeClass; (x->*my_memfunc_ptr)(6, "Another Arbitrary Parameter"); // 如果对象在栈上, 你也可以使用 .* 操作符. SomeClass y; (y.*my_memfunc_ptr)(15, "Different parameters this time");
不要问我语法的问题 -- 看起来某位 C++ 的设计者特别喜欢这些符号!
C++ 在 C 的基础上添加了三个操作符来支持成员函数指针. ::*
用于指针的声明, ->*
和 .*
用于调用函数指针指向的函数. 看起来 C++ 的设计者们对这个语言中很少使用的部分给予了特别的关注. (虽然我不明白为什么要这么做, 但是你还可以重载 ->*
操作符. 我只知道一种需要重载这个操作符的情况 [参见 Meyers 的文章].)
成员函数指针可以设置为 0. 对同一个类的成员函数指针, 可以进行 ==
和 !=
操作. 所有的成员函数指针都可以和 0 比较来判断是否为空. [2005 年三月更新: 并不是所有编译器都这样, 在 Metrowerks MWCC 中, 指向类的第一个虚函数的成员函数指针是和 0 相等的!] 和普通函数指针不同, 对大小进行比较的操作符 (<
, >
, <=
, >=
) 是不可用的. 和普通函数指针一样, 成员函数指针也可以作为非类型的模板参数, 不过好像支持的编译器还不多.
成员函数指针的特点
成员函数指针的某些地方显得很奇怪. 首先, 成员函数指针不能指向一个静态成员函数. 指向静态成员函数需要使用普通函数指针("成员函数指针"这个名字显得有些不恰当: 它们实际上应该叫做"非静态成员函数指针"). 其次, 在处理继承的类时它的行为很奇怪. 例如: 下面的代码在注释完整的时候是可以在 MSVC 上编译的:
class SomeClass { public: virtual void some_member_func(int x, char *p) { printf("In SomeClass"); }; }; class DerivedClass : public SomeClass { public: // 如果你取消下面这行注释, 在 * 的位置将会编译失败! // virtual void some_member_func(int x, char *p) { printf("In DerivedClass"); }; }; int main() { // 为 SomeClass 声明函数指针 typedef void (SomeClass::*SomeClassMFP)(int, char *); SomeClassMFP my_memfunc_ptr; my_memfunc_ptr = &DerivedClass::some_member_func; // ---- (*) }
很奇怪, &DerivedClass::some_member_func
是类 SomeClass
的一个成员函数指针, 而不是 DerivedClass
的! (某些编译器有一些细微的差别: 比如, 对 Digital Mars C++ 来说, &DerivedClass::some_member_func
在这种情况下是未定义的.) 但是, 如果 DerivedClass
重写 some_member_func
, 上面的代码就不能编译了, 因为&DerivedClass::some_member_func
已经变成 DerivedClass
的成员函数指针了!
成员函数指针之间的转换是一个相当灰暗的领域. 在 C++ 标准化的过程中, 对这种转换有过激烈的争论: 是否允许将成员函数指针转换为它的基类或派生类的成员函数指针? 还是直接允许在不相干的两个类之间转换? 在标准委员会还在为他们的想法纠结时, 不同的编译器已经用他们的实现来对这个问题做了不同的回答. 根据标准 (5.2.10/9 节), 可以使用 reinterpret_cast
在不相关的类的成员函数指针之间进行转换. 对转换后的成员函数指针进行调用的结果是未定义的. 你对转换后的成员函数指针唯一能做的就是再把它们转换回去. 对于这个各种编译器还没有统一标准的问题, 我稍后还会详细讨论.
在某些编译器中, 转换基类和派生类的成员函数指针会发生灵异事件. 涉及多继承时, 如果想用 reinterpret_cast
把派生类的成员函数指针转换为基类的成员函数指针, 是否可以编译通过还要看这些类声明时使用的顺序. 来看这个例子:
class Derived: public Base1, public Base2 // 方法 (a) class Derived2: public Base2, public Base1 // 方法 (b) typedef void (Derived::* Derived_mfp)(); typedef void (Derived2::* Derived2_mfp)(); typedef void (Base1::* Base1mfp) (); typedef void (Base2::* Base2mfp) (); Derived_mfp x;
在方法 (a) 中, static_cast<Base1mfp>(x)
运行正常, 但是 static_cast<Base2mfp>(x)
则会编译失败. 同理, 对方法 (b) 来说,情况刚好相反. 只有把派生类的成员函数指针转换成第一个基类的成员函数指针才是安全的! 你可以测试一下, MSVC 对次会发出警告 C4407, Digital Mars C++ 则会产生错误. 如果用 reinterpret_cast
代替 static_cast
, 这两个编译器都会出错, 只是提示的原因不同. 需要当心的是, 还有一些编译器对这种用法完全接受, 没有任何提示!
标准中还有一条有趣的规则: 你可以在类定义之前声明这个类的成员函数指针. 你甚至还可以调用一个没有完成的类型的成员函数! 这个问题稍后再讨论. 注意, 还有一小部分编译器不能处理这种情况(较早的 MSVC, 较早的 CodePlay, LVMM).
还有一点需要注意一下, 同成员函数指针一样, C++ 标准还提供了成员数据指针. 他们使用相同的操作符, 一部分用法也相同. 成员数据指针在某些 stl::stable_sort
的实现中会用到, 对成员数据指针的其它问题这里就不在涉及了.
成员函数指针的使用
看到这里, 你应该相信成员函数指针的确是有些怪异了. 那么, 它们有何用处呢? 我搜索了网上的大量代码后发现, 成员函数指针的主要用途有两点:
- 作为例子, 向 C++ 新手演示语法, 以及
- 实现委托!
当然成员函数指针还有一些不那么重要的用法, 比如在 STL 和 boost 中作为短小的函数适配器, 让你可以用成员函数来调用标准算法. 这种情况下, 它们只是用于编译, 在编译后的代码中并不会真正出现成员函数指针. 成员函数指针最有趣的应用是用来定义复杂的接口, 用于实现很炫的效果, 但我还没有找到这种例子. 大多数情况下, 成员函数指针能做的都可以用虚函数代替. 虽然如此, 成员函数指针还是在各种基于 MFC 消息映射机制的框架中被广泛使用.
当你使用 MFC 的消息映射宏 (比如, ON_COMMAND
) 时, 你实际上是在填充一个包含消息 ID 和成员函数指针(具体是指CCmdTarget::*
的成员函数指针)的数组. 这就是为什么你想要处理消息的话就得从 CCmdTarget
继承才行. 但是不同的消息处理函数有不同的参数 (例如, OnDraw
的第一个参数为 CDC *
), 所以数组也需要包含不同类型的成员函数指针. MFC 怎么处理这个问题的呢? 它们使用了一个可怕的作弊手段, 把所有可能用到的成员函数指针放到一个巨大的联合(union)中来避免 C++ 的类型检查. (查看afximpl.h 和 cmdtarg.cpp 中的 MessageMapFunctions
联合即可发现这个可怕的事实.) 由于 MFC 的重要性, 所有的编译器都支持这种用法了.
除了编译时的外, 我没有搜索到什么对成员函数指针用得很好的例子. 它的复杂性使得它没能在 C++ 中留下多少足迹. C++ 成员函数指针在设计上的缺陷是无法否认的.
在写这篇文章时, 我意识到了一点: C++ 标准允许你转换成员函数指针, 以及不让你调用转换后的指针都是非常荒谬的. 其荒谬体现在这三点: 首先, 这种转换在许多常用的编译器上都无法工作(这种转换符合标准, 但是不兼容). 其次, 在所有编译器上, 一旦转换成功了, 调用转换后的函数指针的行为和你希望的一模一样, 并不会是什么"未定义行为". (调用是兼容的, 可移植的, 但是不符合标准!) 最后, 允许转换而不允许调用是完全没用的. 但是如果转换和调用都可行, 那么实现高效委托就很简单了. 这对 C++ 语言来说将会带来巨大的好处.
如果你还怀疑这种观点, 请看这个例子. 考虑一个只有如下代码的文件. 这些在 C++ 中都是合法的:
class SomeClass; typedef void (SomeClass::* SomeClassFunction)(void); void Invoke(SomeClass *pClass, SomeClassFunction funcptr) { (pClass->*funcptr)(); };
注意编译器需要在对 SomeClass
类 一无所知 的情况下生成调用成员函数指针的汇编代码. 显然, 除非连接器去做一些复杂的调整, 这部分代码 必须 在不知道实际的类定义的情况下正确的运行. 这直接证明了你可以安全的调用一个从完全不同的类转换过来的成员函数指针.
要解释这个观点的另外一半 (即成员函数指针的转换并不像标准说的那样运行), 我们需要讨论编译器实现成员函数指针的细节. 这也会帮助我们理解为什么成员函数指针在使用上会有那么多限制. 要通过普通的错误消息来获得成员函数指针的准确描述是很困难的, 所以我检查了很多编译器生成的汇编代码. 是该我们动手的时候了.
成员函数指针为什么这么复杂?
类的成员函数和标准的 C 函数有很大的不同. 除了被声明的参数外, 它还有一个隐藏的指向具体对象的 this
参数. 在不同的编译器中, this
可能被当成一个普通的参数处理, 也可能被特别处理(比如, 在 VC++ 中, this
通常使用 ECX
寄存器进行传递, 这和普通的函数参数有本质的不同). 虚函数还要等到 运行时 才能知道该执行哪一个函数. 即使成员函数是一个非虚拟的函数(real function), 在标准 C++ 中你也没有办法让一个普通函数处理得像成员函数一样: 标准中并没有 thiscall
这样的关键字可以保证调用方式正确. 成员函数和普通函数有天壤之别(成员函数来自火星, 普通函数来自金星).
你可能认为, 成员函数指针像普通函数指针一样, 只是保存了函数代码的起始地址. 这么认为你就错了. 在大多数编译器中, 成员函数指针都比普通函数指针占用的空间要大. 更奇怪的是, 在 Visual C++ 中, 一个成员函数指针可能是 4, 8, 12, 或者 16 字节大小, 这和与之相关的类, 以及编译器的设置相关! 成员函数指针比你想象的更为复杂. 但并不总是这样.
让我们回到二十世纪八十年代早期. 在原始的 C++ 编译器 (CFront) 刚被开发出来的时候, 它只支持单继承. 那时的成员函数指针很简单: 它们只是有一个额外的 this
参数做为第一个参数的普通函数指针. 调用虚函数时, 函数指针指向一小块额外的代理指令('thunk' code). (10 月 4 日更新: comp.lang.c++.moderated 讨论组已经确认, CFront 并没有真正使用代理指令, 而是采用了一种更为优雅的方法. 但是, 它应该曾经使用过, 或者看起是使用的这种方法, 这会让下面的讨论简单些. )
CFont 2.0 的发布让这个田园般的世界破碎了. 它引入了模板和多继承. 多继承所带来的副作用是成员函数指针被去掉了. 因为在使用多继承时, 还没有调用函数之前你无法知道该用哪个 this
指针. 举例来说, 假如你有如下的四个类:
class A { public: virtual int Afunc() { return 2; }; }; class B { public: int Bfunc() { return 3; }; }; // C 是单继承, 从 A 派生 class C: public A { public: int Cfunc() { return 4; }; }; // D 使用多继承 class D: public A, public B { public: int Dfunc() { return 5; }; };
试想, 我们为 C
类创建了一个成员函数指针. 在这个例子中, Afunc
和 Cfunc
都是 C
的成员函数, 我们的成员函数指针可以指向Afunc
或者 Cfunc
. 但是 Afunc
需要一个指向 C::A
的 this
指针(简称 Athis
). 而 Cfunc
需要一个指向 C
的 this
指针(简称 Cthis
). 编译器在处理这个问题时耍了一些小花招: 它们把 A 存储在内存中 C 的开始位置. 这意味着 Athis == Cthis
. 我们只需要考虑一个 this
就可以处理所有情况了.
现在假设我们创建了一个 D
类的成员函数指针. 这时, 我们的成员函数指针可以指向 Afunc
, Bfunc
, 或者 Dfunc
. 但是 Afunc
需要一个指向 D::A
的 this
指针, 而 Bfunc
需要一个指向 D::B
的 this
指针. 这次编译器耍的小花招就不灵了. 我们不能把 A
和 B
都放在 D
的开始位置. 所以一个指向 D
的成员函数指针不仅要知道该调用哪个函数, 还需要知道怎么使用 this
指针才行. 如果编译器知道了 A
的大小, 它就可以通过增加偏移量 (delta = sizeof(A)
) 来把 Athis
转换成 Bthis
了.
如果你使用了虚继承 (即虚基类), 情况就更糟了, 理解起来也更困难. 通常来说, 编译器会使用虚函数表 ('vtable') 来存储虚函数, 其中包括函数地址和虚偏移信息(virtual_delta): 把提供的 this
指针转换为函数需要的 this
指针所需要的偏移量.
如果 C++ 用稍微不同的方式来定义成员函数指针, 其实没必要这么复杂的. 在上面的代码中, 允许 A::Afunc
作为 D::Afunc
是产生复杂性的根源, 这通常也不是设计良好的代码风格. 通常, 你应该使用基类作为接口. 如果严格遵循, 那么成员函数指针就成了有特殊调用方式的普通函数指针了. 恕我直言, 允许它们指向重载的函数是一个不幸的错误, 为了这个很少用到的功能, 成员函数指针变得稀奇古怪, 也让制作编译器的人为实现它们而头疼不已.
成员函数指针的实现
那么, 编译器究竟是怎样来实现成员函数指针的呢? 下面是各种编译器对不同的类型使用 sizeof
的结果. 编译器包括 32位, 64位 和 16 位的编译器. 测试的类型有 int
, void *
数据指针, 普通函数指针(比如, 指向静态函数的), 成员函数指针(指向的类包括单继承的, 多继承的, 虚继承的, 或者未知类(比如, 使用前向声明这种).
编译器 | 选项 | int | 数据指针 | 函数指针 | 单继承类 | 多继承类 | 虚继承类 | 未知类 |
---|---|---|---|---|---|---|---|---|
MSVC | 4 | 4 | 4 | 4 | 8 | 12 | 16 | |
MSVC | /vmg | 4 | 4 | 4 | 16# | 16# | 16# | 16 |
MSVC | /vmg /vmm | 4 | 4 | 4 | 8# | 8# | -- | 8 |
Intel_IA32 | 4 | 4 | 4 | 4 | 8 | 12 | 16 | |
Intel_IA32 | /vmg /vmm | 4 | 4 | 4 | 4 | 8 | -- | 8 |
Intel_Itanium | 4 | 8 | 8 | 8 | 12 | 16 | 20 | |
G++ | 4 | 4 | 4 | 8 | 8 | 8 | 8 | |
Comeau | 4 | 4 | 4 | 8 | 8 | 8 | 8 | |
DMC | 4 | 4 | 4 | 4 | 4 | 4 | 4 | |
BCC32 | 4 | 4 | 4 | 12 | 12 | 12 | 12 | |
BCC32 | /Vmd | 4 | 4 | 4 | 4 | 8 | 12 | 12 |
WCL386 | 4 | 4 | 4 | 12 | 12 | 12 | 12 | |
CodeWarrior | 4 | 4 | 4 | 12 | 12 | 12 | 12 | |
XLC | 4 | 8 | 8 | 20 | 20 | 20 | 20 | |
DMC | small | 2 | 2 | 2 | 2 | 2 | 2 | 2 |
medium | 2 | 2 | 4 | 4 | 4 | 4 | 4 | |
WCL | small | 2 | 2 | 2 | 6 | 6 | 6 | 6 |
compact | 2 | 4 | 2 | 6 | 6 | 6 | 6 | |
medium | 2 | 2 | 4 | 8 | 8 | 8 | 8 | |
large | 2 | 4 | 4 | 8 | 8 | 8 | 8 |
注#: 使用 __single
/ __multi
/ __virtual_inheritance
关键字后大小为 4, 8, 或 12 字节.
编译器为 Microsoft Visual C++ 4.0 到 7.1 (.NET 2003), GNU G++ 3.2 (MingW binaries, www.mingw.org), Borland BCB 5.1 (www.borland.com), Open Watcom (WCL) 1.2 (www.openwatcom.org), Digital Mars (DMC) 8.38n (www.digitalmars.com), Intel C++ 8.0 for Windows IA-32, Intel C++ 8.0 for Itanium (www.intel.com), IBM XLC for AIX (Power, PowerPC), Metrowerks Code Warrior 9.1 for Windows (www.metrowerks.com), 以及 Comeau C++ 4.3 (www.comeaucomputing.com). Comeau 的数据在他们所支持的所有 32 位平台(x86, Alpha, SPARC, 等)上测试过. 16 位编译器在 4 种 DOS 配置 (tiny, compact, medium, 和 large) 下测试过. MSVC 在选项 (/vmg) 下也进行了测试. (如果你的编译器不在列表中, 请告知我. 非x86体系下的编译器测试结果有特别的价值.)
很吃惊, 是吧? 看着这张表, 你可以感觉到, 稍不留神你写的代码在某些编译器下就不能运行, 即使它们在某些环境下可以工作良好. 很明显编译器内部的实现各不相同, 实际上, 我觉得没有任何语言的实现会有如此的不同. 这些实现的细节也非常的不雅.
行为良好的编译器
几乎所有的编译器都使用 delta
和 vindex
这两个字段来把传入的 this
指针转换为调用函数所需要的指针 (adjustedthis
). 举例来说, 下面是 Watcom C++ 和 Borland 所使用的技术:
struct BorlandMFP { // Watcom 也这样用 CODEPTR m_func_address; int delta; int vindex; // 没有使用虚继承时为 0 }; if (vindex==0) adjustedthis = this + delta; else adjustedthis = *(this + vindex -1) + delta CALL funcadr
如果使用了虚函数, 函数指针指向一块两个指令的代理(thunk), 它们决定了实际调用的函数. Borland 使用了一种优化: 如果它知道类只用了单继承, 就可以推断 delta
和 vindex
的值为 0, 因此可以跳过这些计算. 需要注意的是, 它只跳过了计算, 并没有改变数据结构.
许多其它编译器也使用这种计算方法, 数据结构相差也不大.
// Metrowerks CodeWarrior 的实现稍有变化. // 在多继承被禁用的嵌入式 C++ 中, 这个结构也是相同的. struct MetrowerksMFP { int delta; int vindex; // 没有使用虚继承时为 -1 CODEPTR func_address; }; // 早期的 SunCC 显然使用了另一种顺序: struct { int vindex; // 没有虚函数时为 0 CODEPTR func_address; // 使用虚函数时为 0 int delta; };
Metrowerks 看起来没有把这些计算内联. 而是提供了一个短小的成员函数调用器 (member function invoker). 这使得代码的大小会小一点, 但是让他们的成员函数指针调用要慢一些.
Digital Mars C++ (原来叫 Zortech C++, 又曾叫 Symantec C++) 使用了不同的优化方式. 单继承类的成员函数指针只是一个函数地址. 对于更复杂的继承, 成员函数指针指向一个代理函数, 代理函数里面先对 this
指针进行调整, 然后再调用实际的函数. 这些代理函数在每次多继承类成员函数指针被调用时都会创建. 这是我所喜欢的简洁实现方式.
struct DigitalMarsMFP { // 为什么其他人不这么做呢? CODEPTR func_address; };
当前版本的 GNU 编译器使用了一种聪明且奇怪的优化方法. 我们已经看到, 使用虚继承的时候必须查找虚函数表 (vtable) 来获得计算 this
指针所需要的 voffset
. 你那么做的时候, 或许也想把函数指针放在虚函数表中. 他们这么做了, 把 m_func_address
和m_vtable_index
组合在了一起. 然后他们利用函数指针必须指向一个地址而虚函数表序号 (vtable index) 总是奇数来区分它们.
// GNU g++ 使用了一种聪明的方法优化空间, IBM's VisualAge 和 XLC 也模仿了这种方法. struct GnuMFP { union { CODEPTR funcadr; // 总是偶数 int vtable_index_2; // = vindex*2+1, 总是奇数 }; int delta; }; adjustedthis = this + delta if (funcadr & 1) CALL (* ( *delta + (vindex+1)/2) + 4) else CALL funcadr
G++ 使用的方法在文档中有详细描述, 也已经被许多的编译厂商所模仿, 包括 IBM's VisualAge 和 XLC 编译器, 新版本的 Open64, Pathscale EKO, 以及 Metrowerks 的 64 位编译器. 低版本的 GCC 也使用了那些常见的简单结构. SGI 已经不再更新 MIPSPro 和 Pro64 编译器了, 苹果古老的 MrCpp 编译器也使用这种方法. (Pro64 编译器现在已经成为开源的 Open64 编译器了).
struct Pro64MFP { short delta; short vindex; union { CODEPTR funcadr; // 如果 vindex==-1 short __delta2; } __funcadr_or_delta2; }; // vindex==0 代表空指针.
那些基于 Edison Design Group 前端的编译器 (Comeau, Portland Group, Greenhills) 使用了一种近似的方法. 它们的计算方法如下 (PGI 32 位编译器):
//使用 EDG 前端的编译器 (Comeau, Portland Group, Greenhills, 等) struct EdisonMFP{ short delta; short vindex; union { CODEPTR funcadr; // vindex=0 时 long vtordisp; // vindex!=0 时 }; }; if (vindex==0) { adjustedthis=this + delta; CALL funcadr; } else { adjustedthis = this+delta + *(*(this+delta+vtordisp) + vindex*8); CALL *(*(this+delta+funcadr)+vindex*8 + 4); };
大多数嵌入式系统的编译器不允许多继承. 因此他们没有这些问题: 一个成员函数指针就是一个有隐藏 'this
' 参数的普通函数指针.
微软 "最小类(Smallest For Class)" 方法的恶心之处
微软的编译器使用和 Borland 的优化方法类似. 他们能高效的处理单继承. 和 Borland 不同的是, 他始终让浪费的空间为 0. 也就是说单继承指针和普通函数指针大小一样, 多继承要大些, 虚继承又更大. 这可以节省空间, 但和标准不兼容, 而且有些古怪的副作用.
首先, 在派生类和基类之间转换成员函数指针会改变其大小! 所以, 转换过程会造成信息丢失. 其次, 当成员函数指针在类定义之前时, 编译器需要判断该为它分配多少空间. 但是, 它做不到, 因为在看到类的定义之前它不知道它的继承关系. 它只能靠猜, 如果在某个编译单元中猜错了, 而在另一个单元中猜对了, 程序运行的时候会莫名其妙的崩溃. 所以微软为他们的编译器增加了一些保留字:__single_inheritance
, __multiple_inheritance
, 和 __virtual_inheritance
. 他们还增加了一个编译器选项开关: /vmg, 这会通过填充 0 的方式让所有的成员函数指针大小一样. 这些处理方法非常恶心.
文档中说 /vmg 选项和在每个类前都声明 __virtual_inheritance
是一样的. 但事实上并不是这样, 对于未知继承方式的类 (unknown_inheritance
) 使用的结构会更大. 在使用前向声明时产生的成员函数指针也是一样. 他们不能使用__virtual_inheritance
指针, 因为他们用了一种非常傻逼的优化方法. 下面是他们使用的算法:
// Microsoft 和 Intel 在不知类定义情况下使用的方式. // Microsoft 在设置了 /vmg 选项后也这样使用 // 在 VC1.5 - VC6 中, 这个结构已经被破坏了! 详见下文. struct MicrosoftUnknownMFP{ FunctionPointer m_func_address; // 安腾处理器 (Itanium) 是 64 位. int m_delta; int m_vtordisp; int m_vtable_index; // 没有虚继承时为 0 }; if (vindex=0) adjustedthis = this + delta else adjustedthis = this + delta + vtordisp + *(*(this + vtordisp) + vindex) CALL funcadr
虚继承中, vtordisp
的值并没有存储在 __virtual_inheritance
指针中! 而是在调用函数时直接硬编码到汇编里面. 但是在处理未完成的类时, 需要知道这些, 所以他们最终使用了两种类型的虚继承指针. 一直到 VC7, 未知继承 (unknown_inheritance) 的 Bug 已经多得无可救药了. vtordisp
和 vindex
的值总是为 0! 结果很恐怖: 从 VC4 到 VC6, /vmg 选项 (没有 /vmm 和 /vms 的情况下) 会导致错误的函数被调用! 非常难于跟踪. 在 VC4 里, IDE 设置 /vmg 选项的输入框是被禁用的. 我猜微软里面的某些人应该知道这个 bug, 但是他们并没有把它列出来. 他们最终在 VC7 中修复了这个问题.
Intel 的计算方法和 MSVC 一样, 但是他们的 /vmg 选项作用完全不同 (它通常是不起效的 - 只对未知继承(unknown_inheritance)有影响). 在他们编译器的官方发布声明中提到, 并没有完全支持虚继承成员指针的转换, 如果你试图去转换, 编译器会发出警告, 编译可能会停止, 也可能产生错误的代码. 这是语言中非常灰暗的角落.
最后来看看 CodePlay. 老版本 Codeplay 的 VectorC 有与 VC6, GNU, Metrowerks 兼容的链接选项. 但是他们使用的方法只是微软那种. 他们像我一样进行了反编译, 但是他们没有检测未知继承 (unknown_inheritance
), 即 vtordisp
的值. 他们计算时私自(错误的)假设 vtordisp=0
, 因此在某些情况下(很难发现)这会调用到错误的函数. 但是 Codeplay 即将发布的 VectorC 2.2.1 已经修复了这个问题. 现在的成员函数指针和 Microsoft, GNU 都是二进制兼容的. 在经过高度优化, 以及对兼容 C++ 标准的大量改进(模板偏特化等)后, 它现在已经成为一个非常优秀的编译器了.
我们学到了什么?
理论上讲, 所有的编译器厂商都得彻底改变它们的技术来适应成员函数指针. 按常规, 这是不太可能的, 这会让很多已有的代码无法工作. MSDN 中有一篇微软发布的很老的文章解释了 Visual C++ 在运行时的实现细节[JanGray]. 这篇文章是 Jan Gray 写的, 他在 1990 年也曾写过微软C++对象模型(MS C++ object model). 虽然文章是 1994 年写的, 但对现在仍然非常有用 - 除了修复一些小 bug, 微软已经十五年没有修改过这篇文章了. 同样的, 除了把寄存器从 16 位替换成了 32 位外, 现在的 Borland 编译器生成的代码和我用过的最早的版本 (Borland C++ 3.0, (1990)) 生成的也没什么差别.
现在, 你对成员函数指针已经了解很多了. 那么, 关键在哪里呢? 我们已经看过了相关的规则. 虽然他们的实现各不相同, 但有些共同点很有用: 不管是什么类, 有什么参数, 汇编代码都需要调用成员函数指针. 有些编译器根据类的继承关系来进行优化, 但是对还没有定义好的类, 这些优化是不可能的. 这个事实可以用来实现委托.
委托
和成员函数指针不同, 不难找到委托的用处. 它可以用于你在 C 程序中使用函数指针的任何地方. 或许最重要的是, 可以用委托轻易的实现改进后的目标/观察者模式[GoF, p. 293]. 观察者模式在 GUI 代码中很常见, 而且我发现在程序的核心部分也非常有效. 委托也可以让策略和状态模式实现得更加优雅.
有个情况需要说明下, 委托不仅比成员函数指针更有用, 而且要简单得多! 因为委托由 .NET 语言提供, 你可能认为一个如此高层次的概念,实现它的汇编代码会很复杂. 事实并不是这样: 委托的调用是一个很底层的概念, 像普通函数调用一样底层和高效. 一个 C++ 委托只需要包含一个 this
指针和普通函数指针. 你在构建委托的时候, 你需要提供函数和调用那个函数的 this
指针. 编译器会在创建委托的时候而不是调用的时候调整 this
指针. 更棒的是, 某些编译器可以在编译的时候就完成所有的事情, 所以创建委托也不会有什么复杂的操作. 在 x86 系统下调用委托的汇编代码应该是这个样子:
mov ecx, [this]
call [pfunc]
但是, 在标准的 C++ 中无法产生这样高效的代码. Borland 为他们的 C++ 编译器增加了一个关键字 (__closure
) 来解决这个问题, 这可以用简洁的语法来生成代码. GNU 编译器也使用了一种语言扩展, 但是和 Borland 不兼容. 如果你使用这些扩展, 你将会依赖于特定的厂商. 如果遵循标准, 仍然可以实现委托, 只是效率就会低一些.
有趣的是, 在 C# 和其他 .NET 语言中, 委托比函数调用 (MSDN) 要慢许多. 我估计是因为垃圾收集机制和 .NET 的安全性造成的. 最近, 微软在 Visual C++ 中增加了统一事件模型 (unified event model), 引入了关键字 __event
, __raise
, __hook
, __unhook
,event_source
和 event_receiver
. 坦白讲, 我觉得这些特性很可怕. 它们完全不符合标准, 语法丑陋, 看起来都不像 C++ 了, 而且产生的代码效率也非常低.
动力: 对高效委托的迫切需求
使用 C++ 标准来实现的委托已经很多了. 他们都使用同样的原理, 主要是利用成员函数指针来实现委托 -- 他们只有单继承时才能运行. 为了避免这个限制, 可以增加一个间接层: 使用模板来为每一个类生成一个"成员函数调用器(member function invoker)". 这种委托保存着 this
指针和一个要调用的函数指针. 这个成员函数调用器需要在堆上进行分配.
使用这种方法的实现有许多, 在 CodeProject 也有好几个. 他们在复杂性, 语法(尤其是和 C# 的近似程度), 以及架构上都不一样. 其中最有影响力的是 boost::function. 最近, 它已经被下一版本的 C++ 标准[Sutter1]接受了. 希望它能被广泛使用.
尽管这些实现很聪明, 但还是不够让人满意. 他们提供了需要的功能, 并试图掩盖潜在的问题: 在语言底层缺乏相应的支持. 让人沮丧的是, 在所有平台上, 成员函数调用器的代码对所有类都是相同的. 更重要的是, 它使用了堆, 对某些程序来说, 这是不能接受的.
我在其中一个工程中模拟了独立的事件. 这个程序的核心是事件分发, 并对调用不同对象的成员函数进行了模拟. 大多数成员函数都很简单: 他们只是更新对象的内部状态, 有时在事件队列中添加事件. 这是使用委托的最佳例子. 但是, 每一个委托都只会调用一次. 最初, 我使用了 boost::function
, 但是我发现运行过程中为委托分配的内存超过了整个程序内存的三分之一. 我要真正的委托! 为不禁大喊, 它应该只有两行汇编代码!
我通常很难称心如意, 但这次很幸运. 我现在的 C++ 代码在多数情况下可以生成理想的汇编代码. 最重要的是, 调用单目标的委托和普通函数调用是一样快的. 这并没用到什么高深的东西, 只是有点遗憾, 在实现的时候有些东西不符合 C++ 标准的规范, 我使用了一些未公开的成员函数指针的知识. 如果你能小心点, 并且不介意使用一点点编译器相关的代码, 高效委托可以在所有编译器上运行.
技巧: 把成员函数指针转换成标准格式
我代码的核心是一个类, 让你可以把各种类指针和成员函数指针转换成一个普通类指针和一个普通成员函数. C++ 并没有普通成员函数 (generic member function) 的说法, 因此我使用一个未定义的 CGenericClass
类的成员函数来代替.
大多数编译器对不同类的成员函数指针都使用相同的处理方式. 对这些, 直接使用 reinterpret_cast<>
来将成员函数指针转换为普通成员函数指针 (generic member function pointer) 就可以了. 实际上, 如果这样不行, 那么编译器就不符合标准了. 对剩下的那些编译器 (Microsoft Visual C++ 和 Intel C++), 我们需要先把多继承, 虚继承类的成员函数指针转换为单继承类的成员函数指针. 这会用到一些灵异的, 可怕的手段. 注意这些的手段只对那些不兼容标准的编译器才是必须的, 而且可以得到非常不错的奖励: 我们得到了理想的代码.
因为我们知道编译器内部怎么存储成员函数指针的, 而且了解怎么调整 this
指针来调用函数, 我们可以在构造委托的时候自己来调整 this
指针. 单继承的不需要调整; 多继承只是一个简单的加法; 虚继承 ... 这就复杂了. 但是它在大多数时候都可以运行, 并且所有事情都是在编译阶段完成的.
我们怎么区分不同的继承类型呢? 官方没有提供方法来判断一个类是否是多继承. 有个不太光彩的做法, 你看看我前面提供的那张表 -- 在 MSVC 里, 不同继承方式的成员函数指针大小是不一样的. 因此, 我们可以使用基于成员函数指针大小的模板特化! 多继承涉及到复杂的计算. 类似的, 未知继承 (unknown_inheritance) (16 字节) 使用的计算方法有一点细微的差别.
对于微软(以及 Intel)的, 丑陋的, 非标准的 12 字节 virtual_inheritance
指针, 需要玩另一个把戏, 这还是 John Dlugosz 的主意. 我们已经了解到, 微软/Intel 成员函数指针的一个重要特性是, 不管其它成员的值是什么, 它始终会调用 CODEPTR
成员. (对其他编译器来说并不一定是这样的, 比如, GCC 中调用虚函数时会从虚函数表中取得函数地址来调用.) Dlugosz 的方法是使用一个假的函数指针, 让它的 codeptr
指向一个检测函数, 这个检测函数返回要使用的 'this
' 指针. 当你调用这个函数时, 编译器会利用内部的vtordisp
值为你计算好一切.
一旦你能将类指针和成员函数指针转换为标准形式, 实现单目标的委托就简单了(虽然很麻烦). 你只需要为不同数量的参数创建模板类就行了.
实现委托的这种非标准转换方式带来的另一个极大的好处就是你可以比较他们是否相等. 大多数现有的委托都不行, 这在一些特定的任务中就很难处理了, 比如实现多播委托[Sutter3].
静态函数的委托
理论上讲, 简单的非成员函数, 或者静态成员函数应该能作为委托的目标. 这可以通过把静态函数转换为成员函数来实现. 我想到有两种方法可以实现, 这两种方法的委托都指向一个称作 "调用器 (invoker)" 的成员函数, 它在里面调用静态函数.
有一种邪恶的办法. 你可以把函数指针存储在存放 this
指针的位置, 在调用器(invoker)函数中, 只需要把 this
指针转换成静态函数指针并调用就行了. 这种做法对普通函数调用完全没有影响. 问题在于这种方法需要在代码指针与数据指针间进行转换. 这在某些代码指针比数据指针大的系统 (DOS 编译器使用 medium 内存模型) 上就无法工作了. 据我所知, 这在所有 32 位和 64 位处理器上都可以工作. 但是这太邪恶了, 我们得找个更好的方法.
更安全的方法是把函数指针存储在委托的一个额外成员中. 委托指向自己的成员函数. 但是, 当拷贝委托时, 这些自引用需要被转换, 而且 =
和 ==
操作符也变得复杂了. 这会让委托增加 4 个字节大小, 也会增加代码的复杂性, 但对调用的速度没有影响.
我实现了这两种方法, 因为他们各有各的优点: 安全的方法保证可以工作, 邪恶的方法产生的汇编代码和编译器可能产生的一样, 如果编译器原生支持委托的话. 邪恶的方法可以通过 #define
(FASTDELEGATE_USESTATICFUNCTIONHACK
) 来启用.
备注: 邪恶的那种方法为什么能运行呢? 如果你仔细检查各种编译器在调用成员函数指针时使用的算法, 你将发现对单继承中非虚函数(即 delta=vtordisp=vindex=0
), 所有编译器都不会去计算该调用什么函数. 所以, 即使传入一个垃圾指针, 也会调用正确的函数. 在那个函数里面, 接收到的 this
指针将是 garbage + delta = garbage
. (换句话说, 传进去的垃圾指针会原封不动的传出来!) 基于这点, 我们可以把这个垃圾指针还原成函数指针. 这对于静态函数调用器 (static function invoker) 是虚函数的情况就无效了.
代码使用方法
源代码中包含了高效委托 (FastDelegate) 的实现, 以及一个展示语法的 demo.cpp 文件. 要在 MSVC 上使用, 先创建一个空的控制台应用程序, 然后把这两个文件加入工程. 要在 GNU 上使用, 在命令行下输入 "g++ demo.cpp" 即可.
高效委托可以在任意的参数组合下运行. 为了在更多的编译器上工作, 你需要在声明委托的时候指明参数的个数. 预定义的参数最多八个, 要增加这个上限需要的代码比较琐碎. 委托使用了 fastdelegate
名字空间, 具体的实现在里面嵌套的 detail
名字空间里面.
Fastdelegate
可以通过构造函数或 bind()
方法来绑定成员函数或静态函数(自由函数). 它们默认为 0 (null
). 他们也可以通过 clear()
设置为 null
. 可以使用 !
操作符或 empty()
来判断是否为 null
.
和其它大多数委托的实现不一样, Fastdelegate
提供了相等比较 (==
, !=
) 操作符. 在内联函数中也可以调用.
这里摘录了部分 FastDelegateDemo.cpp 的代码, 它们展示了大部分可以使用的操作符. CBaseClass
是 CDerivedClass
的虚基类. 这些例子都很简单, 只是为了展示语法而已.
using namespace fastdelegate; int main(void) { // 委托支持 8 个参数上限. // 这是没有参数的情况. // 我们声明一个委托, 并与 SimpleVoidFunction() 绑定 printf("-- FastDelegate demo --\nA no-parameter delegate is declared using FastDelegate0\n\n"); FastDelegate0 noparameterdelegate(&SimpleVoidFunction); noparameterdelegate(); // 调用委托 - 这会调用到 SimpleVoidFunction() 函数 printf("\n-- Examples using two-parameter delegates (int, char *) --\n\n"); typedef FastDelegate2<int, char *> MyDelegate; MyDelegate funclist[10]; // 委托都被初始化为空 CBaseClass a("Base A"); CBaseClass b("Base B"); CDerivedClass d; CDerivedClass c; // 绑定一个简单的成员函数 funclist[0].bind(&a, &CBaseClass::SimpleMemberFunction); // 也可以绑定一个静态函数(自由函数) funclist[1].bind(&SimpleStaticFunction); // 以及静态成员函数 funclist[2].bind(&CBaseClass::StaticMemberFunction); // 和常量成员函数 funclist[3].bind(&a, &CBaseClass::ConstMemberFunction); // 还有虚函数. funclist[4].bind(&b, &CBaseClass::SimpleVirtualFunction); // 你也可以使用 = 操作符. // 对于静态函数, 委托看起来就像普通函数指针. funclist[5] = &CBaseClass::StaticMemberFunction; // 继承类的成员函数指针语法古怪, 应尽量避免. // 你也可以像 .bind() 一样使用全局函数 MakeDelegate(). funclist[6] = MakeDelegate(&d, &CBaseClass::SimpleVirtualFunction); // 最麻烦的是有非虚基类的虚派生类的抽象虚函数 // (an abstract virtual function of a virtually-derived class // with at least one non-virtual base class). // 这是非常极端的情况, 你在真实世界中应该很难遇到, // 但是作为测试的一个极端例子, 这里包含了这种情况. funclist[7].bind(&c, &CDerivedClass::TrickyVirtualFunction); // ...这种情况下, 你应该总是使用基类作为接口. // 下面这行代码使用了同一个函数. funclist[8].bind(&c, &COtherClass::TrickyVirtualFunction); // 你也可以使用构造函数直接绑定 MyDelegate dg(&b, &CBaseClass::SimpleVirtualFunction); char *msg = "Looking for equal delegate"; for (int i=0; i<10; i++) { printf("%d :", i); // 提供的 ==, !=, <=,<,>, 和 >= 操作符可以在内联函数中使用 if (funclist[i]==dg) { msg = "Found equal delegate"; }; // 有好几种方法可以检测空指针 // 你可以使用 if (funclist[i]) // 或者 if (!funclist.empty()) // 或者 if (funclist[i]!=0) // 或者 if (!!funclist[i]) if (funclist[i]) { // 调用生成的高效汇编代码. funclist[i](i, msg); } else { printf("Delegate is empty\n"); }; } };
返回值
1.3 版本的代码增加了处理非 void 返回值类型的能力. 像 std::unary_function
一样, 返回类型是最后一个参数(译注: 指的是声明时的模板参数). 默认为 void
, 这样可以保持向后兼容, 而且意味着大多数时候还可以保持简洁. 我想让它在任何平台上都有完整的功能. 除了 MSVC6, 其它编译器都很好处理. VC6 有两个重大限制:
- 你不能用
void
作为默认模板参数. - 你不能返回
void
.
我使用了两个手段来处理这种情况:
- 我创建了一个
DefaultVoid
的傀儡类. 需要的时候把它转换成void
. - 当需要返回
void
时, 返回const void *
来代替. 这个返回值会放在EAX
寄存器里. 从编译器的观点来看, 没有使用返回值时void
函数和void *
函数是毫无区别的. 最后需要明白, 想调用一个不产生无效代码的函数来把void
转换成void *
是不可能的. 但是, 如果你在构造委托的时候立即就把接收到的函数指针转换掉, 所有事情都会在编译期完成. 也就是说, 你需要转换函数的定义, 而不是返回值本身.
还有一个会破环兼容性的修改: 所有使用 FastDelegate0
的地方必须改成 FastDelegate0<>
. 这个可以通过在你的所有文件中使用全局查找替换来完成, 相信你不会在意. 我觉得这个修改可以让语法更直观: 所有 void
的 FastDelegate
声明现在看起来更像函数声明了, 除了 ()
被替换成 <>
了. 如果这个修改还是让你不爽, 你可以修改头文件: 为 FastDelegate0<>
在newstyle
名字空间内定义一个包装形式: typedef newstyle::FastDelegate0<> FastDelegate0;
. 对MakeDelegate
你也需要做同样的事情.
用委托做函数参数
MakeDelegate
模板可以让你使用 FastDelegate
做为需要函数指针参数的地方. 一种典型的场景是把 FastDelegate
做为类的私有成员, 然后使用一个修改函数来设置它.(就像微软的 __event
.) 例子如下:
// 接受任何原型为: int func(double, double); 的函数 class A { public: typedef FastDelegate2<double, double, int> FunctionA; void setFunction(FunctionA somefunc){ m_HiddenDelegate = somefunc; } private: FunctionA m_HiddenDelegate; }; // 设置委托的语法是: A a; a.setFunction( MakeDelegate(&someClass, &someMember) ); // 成员函数或 a.setFunction( &somefreefunction ); // 静态函数
原生语法和与 boost 的兼容性 (1.4 新增)
Jody Hagins 在最近的 Boost.Function
和 Boost.Signal
版本中扩充了 FastDelegateN 类, 提供了一种漂亮的语法. 在支持偏特化的编译器中, 你可以写 FastDelegate< int (char *, double)>
来代替 FastDelegate2<char *,double, int>
. 做得太漂亮了, Jody! 如果你的代码需要在 VC6, VC7.0, 或 Borland 上编译, 你只得使用旧的, 兼容的语法. 我做了些修改来保证新旧两种语法 100% 等价, 可以互相交换.
Jody 还提供了一个辅助函数, bind
, 可以让为 Boost.Function
和 Boost.Bind
写的代码直接转换成 FastDelegate
. 这让你很快就可以看到如果切换到 FastDelegate
能提高多少性能. 这可以在 "FastDelegateBind.h" 中找到. 假如我们的代码如下:
using boost::bind;
bind(&Foo:func, &foo, _1, _2);
如果你把 "using
" 替换成 using fastdelegate::bind
, 一切仍将照常运行. 警告: bind
的参数会被忽略! 没有实际的绑定操作会执行. 只有在只使用 _1, _2, _3,
等基本占位符参数时这些行为才和 boost::bind
相同. 将来的版本可能会完全支持boost::bind
.
比较操作符 (1.4 新增)
相同类型的 FastDelegate
现在可以使用 <
, >
, <=
, >=
来进行比较了. 成员函数指针不支持这些操作符, 但是他们可以用memcmp()
做简单的二进制比较. 比较的结果没什么意义, 也是编译器相关的, 不过这可以让他们存储在像 std:set
这样的有序容器中了.
DelegateMemento 类 (1.4 新增)
一个新类 DelegateMemento
被加入了, 它允许把不同类型的委托集合在一起. 每个 FastDelegate
类都增加了两个额外的成员:
const DelegateMemento GetMemento() const; void SetMemento(const DelegateMemento mem);
DelegegateMemento
可以被拷贝和比较 (==
, !=
, >
, <
, >=
, <=
), 可以被存储在任何有序或无序的容器里. 可以用来代替 C 中的指针联合(union of function pointers in C). 作为各种不同内容的联合, 你有责任保证使用一致的类型. 举例来说, 如果你从FastDelegate2
取得 DelegateMemento
, 并存储到 FastDelegate3
中, 你的程序可能就会在运行时崩溃. 将来我可能会加入一个调试模式, 并使用 typeid
来保证安全. DelegegateMemento
主要是给其他库用的, 而不是给用户使用的. 一种重要的用途就是窗口消息, 动态的 std::map<MESSAGE, DelegateMemento>
可以替换 MFC 和 WTL 中的静态消息表. 不过, 那是另一个故事了.
隐式转换成 bool (1.5 新增)
你现在可以使用 if (dg) {...}
这种语法 (其中 dg
是高效委托) 来代替 if (!dg.empty())
, if (dg!=0)
还有更丑陋的 if (!!dg)
了. 如果你正在使用以前的代码, 你只需要知道它可以在所有编译器上运行, 原来那些操作符也还可以使用.
它的实现比预想的要难. 仅仅提供 operator bool
是很危险的, 因为它允许你这样写 int a = dg;
而你实际上想要的可能是 int a = dg();
. 解决的办法是使用安全布尔 (Safe Bool idiom)[Karlsson]: 提供一个向私有成员数据指针的转换来代替 bool
. 不幸的是, 安全布尔不支持 if (dg==0)
语法, 而且有些编译器在实现成员数据指针 (咦, 该再写一篇文章?) 时还有 bug, 因此, 我不得不又开始玩鬼把戏了. 有人曾使用过的方法是提供和整数的比较, 并且当整数不等于 0 时触发 ASSERT
. 我使用了一种更麻烦些的方法, 和函数指针进行比较. 和常数 0 比较大小是不支持的(但是和等于 null 的函数指针比较大小是有效的).
许可协议
文章相关的代码使用公开的. 不管什么目的, 你都可以使用它. 坦白的说, 写文章的时间几乎是写代码的十倍. 当然, 我很希望听到有人用这些代码写出了伟大的软件. 最后, 欢迎大家多提意见.
移植性
因为使用的方法不符合标准, 我在许多编译器上小心的做了测试. 可笑的是, 它比许多标准的代码兼容性更好, 因为很多编译器不完全遵循标准. 知道的人多了以后, 它也更安全了. 主要的编译器厂商和一些 C++ 标准委员会成员也知道了这里提到的技术 (经常有编译器的主要开发人员因为这篇文章和我联系的). 编译器厂商不太可能冒险去做让以上代码不能工作的修改. 举例来说, 要支持微软的第一个 64 位编译器, 不需要做任何修改. Codeplay 甚至已经用 FastDelegates 作为他们的 VectorC 编译器的内部测试 (即使不够准确, 也差不多了).
FastDelegate
的实现已经在 Windows, DOS, Solaris, BSD, 和好几种 Linux 上测试过了, 使用过 x86, AMD64, Itanium, SPARC, MIPS, .NET 虚拟机, 和一些嵌入式系统处理器. 下面这些是测试成功的编译器:
- Microsoft Visual C++ 6.0, 7.0 (.NET), 7.1 (.NET 2003) 和 8.0 (2005) Beta (包括 /clr '托管 C++').
- {测试了编译和链接, 检查了汇编代码, 但是没有运行} Microsoft 8.0 Beta 2 for Itanium and for AMD64.
- GNU G++ 2.95, 3.0, 3.1, 3.2 以及 3.3 (Linux, Solaris, 以及 Windows (MingW, DevCpp, Bloodshed)).
- Borland C++ Builder 5.5.1 和 6.1.
- Digital Mars C++ 8.38 (x86, 包括 32-bit 和 16-bit, Windows 和所有内存模型的 DOS).
- Intel C++ for Windows (x86) 8.0 和 8.1.
- Metrowerks CodeWarrior for Windows 9.1 (包括 C++ 和 EC++ 模式).
- CodePlay VectorC 2.2.1 (Windows, Playstation 2). 更早的版本不支持.
- Portland Group PGI Workstation 5.2 for Linux, 32-bit.
- {编译了, 但是没有链接和运行} Comeau C++ 4.3 (x86 NetBSD).
- {编译链接了, 检查过汇编代码, 但是没有运行} Intel C++ 8.0 and 8.1 for Itanium, Intel C++ 8.1 for EM64T/AMD64.
下面是我知道的其它还在使用的编译器的情况:
- Open Watcom WCL: 在加入了成员函数模版后的编译器版本中可用. 核心代码可以在 (成员函数指针间的转换) WCL 1.2 上运行.
- LVMM: 核心代码可以运行, 但是目前编译 bug 太多.
- IBM Visual Age and XLC: 应该可以运行, 因为 IBM 声称它和 GCC 100% 的二进制兼容.
- Pathscale EKO: 应该可以运行, 它也和 GCC 二进制兼容.
- 所有使用 EDG 前端的编译器 (GreenHills, Apogee, WindRiver, 等等.) 也应该可以运行.
- Paradigm C++: 未知, 看起来只是 Borland 早期编译器的一个包装.
- Sun C++: 未知.
- Compaq CXX: 未知.
- HP aCC: 未知.
仍然还有人在抱怨代码不够兼容! (唉).
总结
从解释几行代码开始, 我已经写了几乎一个教程了. 目前我还没发现在流行的那六个编译器上有 bug 或者不兼容的情况. 为了这两行汇编代码的工作还真多!
我希望我已经解释清楚了成员函数指针和委托中的灰色地带. 我们已经看到了由于各种编译器不同的实现所带来的成员函数指针的古怪行为. 相反的, 我们也看到委托并不是什么复杂的高级概念, 它实际上非常简单. 我希望你已经相信它应该是语言的一部分. 有理由相信, 委托将会被编译器直接支持, 当 C++0x 标准发布时将被加入到 C++ 语言中 (去游说标准委员会吧!).
据我所知, 还没有哪个委托的实现比我这个 FastDelegates 更高效或很简单. 说了你别笑话, 大部分代码还是我在哄小女儿睡觉都时用一只手写的. 希望它对你有用.
参考资料
- [GoF] 设计模式("Design Patterns: Elements of Reusable Object-Oriented Software", E. Gamma, R. Helm, R. Johnson, and J. Vlissides).
我在研究这个问题时参考了许多网站, 下面是里面比较有趣的:
- [Boost]. 委托可以通过
boost::function
和boost::bind
的组合来实现.Boost::signals
是最好的事件/消息 (event/messaging) 系统之一. boost 库大多数都要求和标准非常兼容的编译器. - [Loki]. Loki 提供的 'functors' 就是绑定参数的委托. 他们和
boost::function
很相似. 看起来 Loki 最终会和 boost 合并. - [Qt]. Qt 库包含信号/插槽 (Signal/Slot) 机制 (即委托). 要让他工作, 你需要在编译前在你的代码上运行一个特殊的处理程序. 性能很低, 但是可以在对模版支持很差的编译器上运行.
- [Libsigc++]. 一个基于 Qt 的事件系统. 它避免了 Qt 需要特殊处理程序的麻烦, 但是要求所有的目标都继承一个基类 (使用虚继承 -- 靠!).
- [JanGray] MSDN 文章 "Under the Hood", 描述了 Microsoft C/C++ 7 的对象模型. 对随后版本的编译器也适用.
- [Hickey]. 一种古老的委托实现, 避免了内存分配. 需要保证所有的成员函数指针大小相同, 所有不能在 MSVC 运行. 这里有一些关于这份代码的有用的讨论.
- [Haendal]. 专注于函数指针的网站?! 但是没有多少关于成员函数指针的细节.
- [Karlsson]. 安全布尔 (Safe Bool Idiom).
- [Meyers]. Scott Meyer 的关于重载
operator ->*
的文章. 注意经典的智能指针实现 (Loki and boost) 并不麻烦. - [Sutter1]. 函数指针的: 关于
boost::function
应该怎么加入 C++ 标准的讨论. - [Sutter2]. 使用
std::tr1::function
的观察者模式 (需要多播委托). 关于boost::function
局限的讨论, 想让它提供==
操作符. - [Sutter3]. Herb Sutter 的 Guru of the Week, 关于回调的文章.
- [Dlugosz]. 最近的一份委托/闭包实现, 像我的一样, 非常高效, 但是只支持 MSVC7 和 7.1.