概要
很遗憾, 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 中的