成員函式指標與高效能的C++委託
Member Function Pointers and the Fastest Possible C++ Delegates
撰文:Don Clugston
翻譯:周翔
引子
標準C++中沒有真正的面向物件的函式指標。這一點對C++來說是不幸的,因為面向物件的指標(也叫做“閉包(closure)”或“委託(delegate)”)在一些語言中已經證明了它寶貴的價值。在Delphi (Object Pascal)中,面向物件的函式指標是Borland視覺化組建庫(VCL,Visual Component Library)的基礎。而在目前,C#使“委託”的概念日趨流行,這也正顯示出C#這種語言的成功。在很多應用程式中,“委託”簡化了鬆耦合物件的設計模式[GoF]。這種特性無疑在標準C++中也會產生很大的作用。
很遺憾,C++中沒有“委託”,它只提供了成員函式指標(member function pointers)。很多程式設計師從沒有用過函式指標,這是有特定的原因的。因為函式指標自身有很多奇怪的語法規則(比如“->*”和“.*”操作符),而且很難找到它們的準確含義,並且你會找到更好的辦法以避免使用函式指標。更具有諷刺意味的是:事實上,編譯器的編寫者如果實現“委託”的話會比他費勁地實現成員函式指標要容易地多!
在這篇文章中,我要揭開成員函式指標那“神祕的蓋子”。在扼要地重述成員函式指標的語法和特性之後,我會向讀者解釋成員函式指標在一些常用的編譯器中是怎樣實現的,然後我會向大家展示編譯器怎樣有效地實現“委託”。最後我會利用這些精深的知識向你展示在C++編譯器上實現優化而可靠的“委託”的技術。比如,在Visual C++(6.0, .NET, and .NET 2003)中對單一目標委託(single-target delegate)的呼叫,編譯器僅僅生成兩行彙編程式碼!
函式指標
下面我們複習一下函式指標。在C和C++語言中,一個命名為my_func_ptr的函式指標指向一個以一個int和一個char*為引數的函式,這個函式返回一個浮點值,宣告如下:
float (*my_func_ptr)(int, char *);
為了便於理解,我強烈推薦你使用typedef關鍵字。如果不這樣的話,當函式指標作為一個函式的引數傳遞的時候,程式會變得晦澀難懂。這樣的話,宣告應如下所示:
typedef float (*MyFuncPtrType)(int, char *);
MyFuncPtrType my_func_ptr;
應注意,對每一個函式的引數組合,函式指標的型別應該是不同的。在Microsoft Visual C++(以下稱MSVC)中,對三種不同的呼叫方式有不同的型別:__cdecl, __stdcall, 和__fastcall。如果你的函式指標指向一個型如float some_func(int, char *)的函式,這樣做就可以了:
my_func_ptr = some_func;
當你想呼叫它所指向的函式時,你可以這樣寫:
(*my_func_ptr)(7, "Arbitrary String");
你可以將一種型別的函式指標轉換成另一種函式指標型別,但你不可以將一個函式指標指向一個void *型的資料指標。其他的轉換操作就不用詳敘了。一個函式指標可以被設定為0來表明它是一個空指標。所有的比較運算子(==, !=, <, >, <=, >=)都可以使用,可以使用“==
在C語言中,函式指標通常用來像qsort一樣將函式作為引數,或者作為Windows系統函式的回撥函式等等。函式指標還有很多其他的應用。函式指標的實現很簡單:它們只是“程式碼指標(code pointer)”,它們體現在組合語言中是用來儲存子程式程式碼的首地址。而這種函式指標的存在只是為了保證使用了正確的呼叫規範。
成員函式指標
在C++程式中,很多函式是成員函式,即這些函式是某個類中的一部分。你不可以像一個普通的函式指標那樣指向一個成員函式,正確的做法應該是,你必須使用一個成員函式指標。一個成員函式的指標指向類中的一個成員函式,並和以前有相同的引數,宣告如下:
float (SomeClass::*my_memfunc_ptr)(int, char *);
對於使用const關鍵字修飾的成員函式,宣告如下:
float (SomeClass::*my_const_memfunc_ptr)(int, char *) const;
注意使用了特殊的運算子(::*),而“SomeClass”是宣告中的一部分。成員函式指標有一個可怕的限制:它們只能指向一個特定的類中的成員函式。對每一種引數的組合,需要有不同的成員函式指標型別,而且對每種使用const修飾的函式和不同類中的函式,也要有不同的函式指標型別。在MSVC中,對下面這四種呼叫方式都有一種不同的呼叫型別:
__cdecl, __stdcall, __fastcall, 和 __thiscall。
(__thiscall是預設的方式,有趣的是,在任何官方文件中從沒有對__thiscall關鍵字的詳細描述,但是它經常在錯誤資訊中出現。如果你顯式地使用它,你會看到“它被保留作為以後使用(it is reserved for future use)”的錯誤提示。)
如果你使用了成員函式指標,你最好使用typedef以防止混淆。將函式指標指向型如float SomeClass::some_member_func(int, char *)的函式,你可以這樣寫:
my_memfunc_ptr = &SomeClass::some_member_func;
很多編譯器(比如MSVC)會讓你去掉“&”,而其他一些編譯器(比如GNU G++)則需要新增“&”,所以在手寫程式的時候我建議把它添上。若要呼叫成員函式指標,你需要先建立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增加了三種特殊運算子來支援成員指標。“::*”用於指標的宣告,而“->*”和“.*”用來呼叫指標指向的函式。這樣看起來對一個語言模糊而又很少使用的部分的過分關注是多餘的。(你當然可以過載“->*”這些運算子,但這不是本文所要涉及的範圍。)
一個成員函式指標可以被設定成0,並可以使用“==”和“!=”比較運算子,但只能限定在同一個類中的成員函式的指標之間進行這樣的比較。任何成員函式指標都可以和0做比較以判斷它是否為空。與函式指標不同,不等運算子(<, >, <=, >=)對成員函式指標是不可用的。
成員函式指標的怪異之處
成員函式指標有時表現得很奇怪。
首先,你不可以用一個成員函式指標指向一個靜態成員函式,你必須使用普通的函式指標才行(在這裡“成員函式指標”會產生誤解,它實際上應該是“非靜態成員函式指標”才對)。
其次,當使用類的繼承時,會出現一些比較奇怪的情況。比如,下面的程式碼在MSVC下會編譯成功(注意程式碼註釋):
#include “stdio.h”
class SomeClass {
public:
virtual void some_member_func(int x, char *p) {
printf("In SomeClass"); };
};
class DerivedClass : public SomeClass {
public:
// 如果你把下一行的註釋銷掉,帶有 line (*)的那一行會出現錯誤
// 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; // ---- line (*)
return 0;
}
奇怪的是,&DerivedClass::some_member_func是一個SomeClass類的成員函式指標,而不是DerivedClass類的成員函式指標!(一些編譯器稍微有些不同:比如,對於Digital Mars C++,在上面的例子中,&DerivedClass::some_member_func會被認為沒有定義。)但是,如果在DerivedClass類中重寫(override)了some_member_func函式,程式碼就無法通過編譯,因為現在的&DerivedClass::some_member_func已成為DerivedClass類中的成員函式指標!
成員函式指標之間的型別轉換是一個討論起來非常模糊的話題。在C++的標準化的過程中,在涉及繼承的類的成員函式指標時,對於將成員函式指標轉化為基類的成員函式指標還是轉化為子類成員函式指標的問題和是否可以將一個類的成員函式指標轉化為另一個不相關的類的成員函式指標的問題,人們曾有過很激烈的爭論。然而不幸的是,在標準委員會做出決定之前,不同的編譯器生產商已經根據自己對這些問題的不同的回答實現了自己的編譯器。根據標準(第
在一些編譯器中,在基類和子類的成員函式指標之間的轉換時常有怪事發生。當涉及到多重繼承時,使用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,這兩個編譯器都會發生錯誤,但是兩種編譯器對此有著不同的原因。但是一些編譯器對此細節置之不理,大家可要小心了!
標準C++中另一條有趣的規則是:你可以在類定義之前宣告它的成員函式指標。這對一些編譯器會有一些無法預料的副作用。我待會討論這個問題,現在你只要知道要儘可能得避免這種情況就是了。
值得注意的是,就像成員函式指標,標準C++中同樣提供了成員資料指標(member data pointer)。它們具有相同的操作符,而且有一些實現原則也是相同的。它們用在stl::stable_sort的一些實現方案中,而對此很多其他的應用我就不再提及了。
成員函式指標的使用
現在你可能會覺得成員函式指標是有些奇異。但它可以用來做什麼呢?對此我在網上做了非常廣泛的調查。最後我總結出使用成員函式指標的兩點原因:
- 用來做例子給C++初學者看,幫助它們學習語法;或者
- 為了實現“委託(delegate)”!
成員函式指標在STL和Boost庫的單行函式介面卡(one-line function adaptor)中的使用是微不足道的,而且允許你將成員函式和標準演算法混合使用。但是它們最重要的應用是在不同型別的應用程式框架中,比如它們形成了MFC訊息系統的核心。
當你使用MFC的訊息對映巨集(比如ON_COMMAND)時,你會組裝一個包含訊息ID和成員函式指標(型如:CCmdTarget::*成員函式指標)的序列。這是MFC類必須繼承CCmdTarget才可以處理訊息的原因之一。但是,各種不同的訊息處理函式具有不同的引數列表(比如OnDraw處理函式的第一個引數的型別為CDC *),所以序列中必須包含各種不同型別的成員函式指標。
MFC是怎樣做到這一點的呢?MFC利用了一個可怕的編譯器漏洞(hack),它將所有可能出現的成員函式指標放到一個龐大的聯合(union)中,從而避免了通常需要進行的C++型別匹配檢查。(看一下afximpl.h和cmdtarg.cpp中名為MessageMapFunctions的union,你就會發現這一恐怖的事實。)
因為MFC有如此重要的一部分程式碼,所以事實是,所有的編譯器都為這個漏洞開了綠燈。(但是,在後面我們會看到,如果一些類用到了多重繼承,這個漏洞在MSVC中就不會起作用,這正是在使用MFC時只能必須使用單一繼承的原因。)
在boost::function中有類似的漏洞(但不是太嚴重)。看起來如果你想做任何有關成員函式指標的比較有趣的事,你就必須做好與這個語言的漏洞進行挑戰的準備。要是你想否定C++的成員函式指標設計有缺陷的觀點,看來是很難的。
在寫這篇文章中,我有一點需要指明:“允許成員函式指標之間進行轉換(cast),而不允許在轉換完成後呼叫其中的函式”,把這個規則納入C++的標準中是可笑的。
首先,很多流行的編譯器對這種轉換不支援(所以,轉換是標準要求的,但不是可移植的)。
其次,所有的編譯器,如果轉換成功,呼叫轉換後的成員函式指標時仍然可以實現你預期的功能:那編譯器就沒有所謂的“undefined behavior(未定義的行為)”這類錯誤出現的必要了(呼叫(Invocation)是可行的,但這不是標準!)。
第三,允許轉換而不允許呼叫是完全沒有用處的,只有轉換和呼叫都可行,才能方便而有效地實現委託,從而使這種語言受益。
為了讓你確信這一具有爭議的論斷,考慮一下在一個檔案中只有下面的一段程式碼,這段程式碼是合法的:
class SomeClass;
typedef void (SomeClass::* SomeClassFunction)(void);
void Invoke(SomeClass *pClass, SomeClassFunction funcptr) {(pClass->*funcptr)(); };
注意到編譯器必須生成彙編程式碼來呼叫成員函式指標,其實編譯器對SomeClass類一無所知。顯然,除非連結器進行了一些極端精細的優化措施,否則程式碼會忽視類的實際定義而能夠正確地執行。而這造成的直接後果是,你可以“安全地”呼叫從完全不同的其他類中轉換過來的成員函式指標。
為解釋我的斷言的另一半——轉換並不能按照標準所說的方式進行,我需要在細節上討論編譯器是怎樣實現成員函式指標的。我同時會解釋為什麼使用成員函式指標的規則具有如此嚴格的限制。獲得詳細論述成員函式指標的文件不是太容易,並且大家對錯誤的言論已經習以為常了,所以,我仔細檢查了一系列編譯器生成的彙編程式碼……
成員函式指標——為什麼那麼複雜?
類的成員函式和標準的C函式有一些不同。與被顯式宣告的引數相似,類的成員函式有一個隱藏的引數this,它指向一個類的例項。根據不同的編譯器,this或者被看作內部的一個正常的引數,或者會被特別對待(比如,在VC++中,this一般通過ECX暫存器來傳遞,而普通的成員函式的引數被直接壓在堆疊中)。this作為引數和其他普通的引數有著本質的不同,即使一個成員函式受一個普通函式的支配,在標準C++中也沒有理由使這個成員函式和其他的普通函式(ordinary function)的行為相同,因為沒有thiscall關鍵字來保證它使用像普通引數一樣正常的呼叫規則。成員函式是一回事,普通函式是另外一回事(Member functions are from Mars, ordinary functions are from Venus)。
你可能會猜測,一個成員函式指標和一個普通函式指標一樣,只是一個程式碼指標。然而這種猜測也許是錯誤的。在大多數編譯器中,一個成員函式指標要比一個普通的函式指標要大許多。更奇怪的是,在Visual C++中,一個成員函式指標可以是4、8、12甚至16個位元組長,這取決於它所相關的類的性質,同時也取決於編譯器使用了怎樣的編譯設定!成員函式指標比你想象中的要複雜得多,但也不總是這樣。
讓我們回到二十世紀80年代初期,那時,最古老的C++編譯器CFront剛剛開發完成,那時C++語言只能實現單一繼承,而且成員函式指標剛被引入,它們很簡單:它們就像普通的函式指標,只是附加了額外的this作為它們的第一個引數,你可以將一個成員函式指標轉化成一個普通的函式指標,並使你能夠對這個額外新增的引數產生足夠的重視。
這個田園般的世界隨著CFront 2.0的問世被擊得粉碎。它引入了模版和多重繼承,多重繼承所帶來的破壞造成了成員函式指標的改變。問題在於,隨著多重繼承,呼叫之前你不知道使用哪一個父類的this指標,比如,你有4個類定義如下:
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需要一個this指標指向C::A(後面我叫它Athis),而Cfunc需要一個this指標指向C(後面我叫它Cthis)。編譯器的設計者們為了處理這種情況使用了一個把戲(trick):他們保證了A類在物理上儲存在C類的頭部(即C類的起始地址也就是一個A類的一個例項的起始地址),這意味著Athis == Cthis。我們只需擔心一個this指標就夠了,並且對於目前這種情況,所有的問題處理得還可以。
現在,假如我們建立一個D類的成員函式指標。在這種情況下,我們的成員函式指標可以指向Afunc、Bfunc或Dfunc。但是Afunc需要一個this指標指向D::A,而Bfunc需要一個this指標指向D::B。這時,這個把戲就不管用了,我們不可以把A類和B類都放在D類的頭部。所以,D類的一個成員函式指標不僅要說明要指明呼叫的是哪一個函式,還要指明使用哪一個this指標。編譯器知道A類佔用的空間有多大,所以它可以對Athis增加一個delta = sizeof(A)偏移量就可以將Athis指標轉換為Bthis指標。
如果你使用虛擬繼承(virtual inheritance),比如虛基類,情況會變得更糟,你可以不必為搞懂這是為什麼太傷腦筋。就舉個例子來說吧,編譯器使用虛擬函式表(virtual function table——“vtable”)來儲存每一個虛擬函式、函式的地址和virtual_delta:將當前的this指標轉換為實際函式需要的this指標時所要增加的位移量。
綜上所述,為了支援一般形式的成員函式指標,你需要至少三條資訊:函式的地址,需要增加到this指標上的delta位移量,和一個虛擬函式表中的索引。對於MSVC來說,你需要第四條資訊:虛擬函式表(vtable)的地址。
成員函式指標的實現
那麼,編譯器是怎樣實現成員函式指標的呢?這裡是對不同的32、64和16位的編譯器,對各種不同的資料型別(有int、void*資料指標、程式碼指標(比如指向靜態函式的指標)、在單一(single-)繼承、多重(multiple-)繼承、虛擬(virtual-)繼承和未知型別(unknown)的繼承下的類的成員函式指標)使用sizeof運算子計算所獲得的資料:
編譯器 |
選項 |
int |
DataPtr |
CodePtr |
Single |
Multi |
Virtual |
Unknown |
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 |
12 |
|
Intel_IA32 |
/vmg /vmm |
4 |
4 |
4 |
4 |
8 |
-- |
8 |
Intel_Itanium |
4 |
8 |
8 |
8 |
12 |
20 |
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 |
DMC |
medium |
2 |
2 |
4 |
4 |
4 |
4 |
4 |
WCL |
small |
2 |
2 |
2 |
6 |
6 |
6 |
6 |
WCL |
compact |
2 |
4 |
2 |
6 |
6 |
6 |
6 |
WCL |
medium |
2 |
2 |
4 |
8 |
8 |
8 |
8 |
WCL |
large |
2 |
4 |
4 |
8 |
8 |
8 |
8 |
注: # 表示使用__single/__multi/__virtual_inheritance關鍵字的時候代表4、8或12。 這些編譯器是Microsoft Visual C++ 4.0 to 7.1 (.NET 2003), GNU G++ 3.2 (MingW binaries, http://www.mingw.org/), Borland BCB 5.1 (http://www.borland.com/), Open Watcom (WCL) 1.2 (http://www.openwatcom.org/), Digital Mars (DMC) 8.38n (http://www.digitalmars.com/), Intel C++ 8.0 for Windows IA-32, Intel C++ 8.0 for Itanium, (http://www.intel.com/), IBM XLC for AIX (Power, PowerPC), Metrowerks Code Warrior 9.1 for Windows (http://www.metrowerks.com/), 和 Comeau C++ 4.3 (http://www.comeaucomputing.com/). Comeau的資料是在它支援的32位平臺(x86, Alpha, SPARC等)上得出的。16位的編譯器的資料在四種DOS配置(tiny, compact, medium, 和 large)下測試得出,用來顯示各種不同程式碼和資料指標的大小。MSVC在/vmg的選項下進行了測試,用來顯示“成員指標的全部特性”。(如果你擁有在列表中沒有出現的編譯器,請告知我。非x86處理機下的編譯器測試結果有獨特的價值。) |
看著表中的資料,你是不是覺得很驚奇?你可以清楚地看到編寫一段在一些環境中可以執行而在另一些編譯器中不能執行的程式碼是很容易的。不同的編譯器之間,它們的內部實現顯然是有很大差別的;事實上,我認為編譯器在實現語言的其他特性上並沒有這樣明顯的差別。對實現的細節進行研究你會發現一些奇怪的問題。
一般,編譯器採取最差的,而且一直使用最普通的形式。比如對於下面這個結構:
// Borland (預設設定) 和Watcom C++.
struct {
FunctionPointer m_func_address;
int m_delta;
int m_vtable_index; //如果不是虛擬繼承,這個值為0。
};
// Metrowerks CodeWarrior使用了稍微有些不同的方式。
//即使在不允許多重繼承的Embedded C++的模式下,它也使用這樣的結構!
struct {
int m_delta;
int m_vtable_index; // 如果不是虛擬繼承,這個值為-1。
FunctionPointer m_func_address;
};
// 一個早期的SunCC版本顯然使用了另一種規則:
struct {
int m_vtable_index; //如果是一個非虛擬函式(non-virtual function),這個值為0。
FunctionPointer m_func_address; //如果是一個虛擬函式(virtual function),這個值為0。
int m_delta;
};
//下面是微軟的編譯器在未知繼承型別的情況下或者使用/vmg選項時使用的方法:
struct {
FunctionPointer m_func_address;
int m_delta;
int m_vtordisp;
int m_vtable_index; // 如果不是虛擬繼承,這個值為0
};
// AIX (PowerPC)上IBM的XLC編譯器:
struct {
FunctionPointer m_func_address; // 對PowerPC來說是64位
int m_vtable_index;
int m_delta;
int m_vtordisp;
};
// GNU g++使用了一個機靈的方法來進行空間優化
struct {
union {
FunctionPointer m_func_address; // 其值總是4的倍數
int m_vtable_index_2; // 其值被2除的結果總是奇數
};
int m_delta;
};
對於幾乎所有的編譯器,delta和vindex用來調整傳遞給函式的this指標,比如Borland的計算方法是:
adjustedthis = *(this + vindex -1) + delta // 如果vindex!=0
adjustedthis = this + delta // 如果vindex=0
(其中,“*”是提取該地址中的數值,adjustedthis是調整後的this指標——譯者注)
Borland使用了一個優化方法:如果這個類是單一繼承的,編譯器就會知道delta和vindex的值是0,所以它就可以跳過上面的計算方法。
GNU編譯器使用了一個奇怪的優化方法。可以清楚地看到,對於多重繼承來說,你必須檢視vtable(虛擬函式表)以獲得voffset(虛擬函式偏移地址)來計算this指標。當你做這些事情的時候,你可能也把函式指標儲存在vtable中。通過這些工作,編譯器將m_func_address和m_vtable_index合二為一(即放在一個union中),編譯器區別這兩個變數的方法是使函式指標(m_func_address)的值除以2後結果為偶數,而虛擬函式表索引(m_vtable_index_2)除以2後結果為奇數。它們的計算方法是:
adjustedthis = this + delta
if (funcadr & 1) //如果是奇數
call (* ( *delta + (vindex+1)/2) + 4)
else //如果是偶數
call funcadr
(其中, funcadr是函式地址除以2得出的結果。——譯者注)
Inter的Itanium編譯器(但不是它們的x86編譯器)對虛擬繼承(virtual inheritance)的情況也使用了unknown_inheritance結構,所以,一個虛擬繼承的指標有20位元組大小,而不是想象中的16位元組。
// Itanium,unknown 和 virtual inheritance下的情況.
struct {
FunctionPointer m_func_address; //對Itanium來說是64位
int m_delta;
int m_vtable_index;
int m_vtordisp;
};
我不能保證Comeau C++使用的是和GNU相同的技術,也不能保證它們是否使用short代替int使這種虛擬函式指標的結構的大小縮小至8個位元組。最近釋出的Comeau C++版本為了相容微軟的編譯器也使用了微軟的編譯器關鍵字(我想它也只是忽略這些關鍵字而不對它們進行實質的相關處理罷了)。
Digital Mars編譯器(即最初的Zortech C++到後來的Symantec C++)使用了一種不同的優化方法。對單一繼承類來說,一個成員函式指標僅僅是這個函式的地址。但涉及到更復雜的繼承時,這個成員函式指標指向一個形式轉換函式(thunk function),這個函式可以實現對this指標的必要調整並可用來呼叫實際的成員函式。每當涉及到多重繼承的時候,每一個成員函式的指標都會有這樣一個形式轉換函式,這對函式呼叫來說是非常有效的。但是這意味著,當使用多重繼承的時候,子類的成員函式指標向基類成員函式指標的轉換就會不起作用了。可見,這種編譯器對編譯程式碼的要求比其他的編譯器要嚴格得多。
很多嵌入式系統的編譯器不允許多重繼承。這樣,這些編譯器就避免了可能出現的問題:一個成員函式指標就是一個帶有隱藏this指標引數的普通函式指標。
微軟"smallest for class"方法的問題
微軟的編譯器使用了和Borland相似的優化方法。它們都使單一繼承的情況具有最優的效率。但不像Borland,微軟在預設條件下成員函式指標省略了值為0 的指標入口(entry),我稱這種技術為“smallest for class”方法:對單一繼承類來說,一個成員函式指標僅儲存了函式的地址(m_func_address),所以它有4位元組長。而對於多重繼承類來說,由於用到了偏移地址(m_delta),所以它有8位元組長。對虛擬繼承,會用到12個位元組。這種方法確實節省空間,但也有其它的問題。
首先,將一個成員函式指標在子類和基類之間進行轉化會改變指標的大小!因此,資訊是會丟失的。其次,當一個成員函式指標在它的類定義之前宣告的時候,編譯器必須算出要分配給這個指標多少空間,但是這樣做是不安全的,因為在定義之前編譯器不可能知道這個類的繼承方式。對Intel C++和早期的微軟編譯器來說,編譯器僅僅對指標的大小進行猜測,一旦在原始檔中猜測錯誤,你的程式會在執行時莫名其妙地崩潰。所以,微軟的編譯器中增加了一些保留字:__single_inheritance, __multiple_inheritance,和 __virtual_inheritance,並增設了一些編譯器開關(compiler switch),如/vmg,讓所有的成員函式指標有相同的大小,而對原本個頭小的成員函式指標的空餘部分用0填充。Borland編譯器也增加了一些編譯器開關,但沒有增加新的關鍵字。Intel的編譯器可以識別Microsoft增加的那些關鍵字,但它在能夠找到類的定義的情況下會對這些關鍵字不做處理。
對於MSVC來說,編譯器需要知道類的vtable在哪兒;通常就會有一個this指標的偏移量(vtordisp),這個值對所有這個類中的成員函式來說是不變的,但對每個類來說會是不同的。對於MSVC,經調整過的this指標是在原this指標的基礎上經過下面的計算得出的:
if (vindex=0) //如果不是虛擬繼承(_virtual_inheritance)
adjustedthis = this + delta
else //如果是
adjustedthis = this + delta + vtordisp + *(*(this + vtordisp) + vindex)
在虛擬繼承的情況下,vtordisp的值並不儲存在__virtual_inheritance指標中,而是在發現函式呼叫的程式碼時,編譯器才將其相應的彙編程式碼“嵌”進去。但是對於未知型別的繼承,編譯器需要儘可能地通過讀程式碼確定它的繼承型別,所以,編譯器將虛擬繼承指標(virtual inheritance pointer)分為兩類(__virtual_inheritance和__unknown_inheritance)。
理論上,所有的編譯器設計者應該在MFP(成員函式指標)的實現上有所變革和突破。但在實際上,這是行不通的,因為這使現在編寫的大量程式碼都需要改變。微軟曾發表了一篇非常古老的文章(http://msdn.microsoft.com/archive/en-us/dnarvc/html/jangrayhood.asp)來解釋Visual C++運作的實現細節。這篇文章是Jan Gray寫的,他曾在1990年設計了Microsoft C++的物件模型。儘管這篇文章發表於1994年,但這篇文章仍然很重要——這意味著C++的物件模型在長達15年的時間裡(1990年到2004年)沒有絲毫改變。
現在,我想你對成員函式指標的事情已經知道得太多了。要點是什麼?我已為你建立了一個規則。雖然各種編譯器的在這方面的實現方法有很大的不同,但是也有一些有用的共同點:不管對哪種形式的類,呼叫一個成員函式指標生成的組合語言程式碼是完全相同的。有一種特例是使用了“smallest for class”技術的非標準的編譯器,即使是這種情況,差別也是很微小的。這個事實可以讓我們繼續探索怎樣去建立高效能的委託(delegate)。
委託(delegate)
和成員函式指標不同,你不難發現委託的用處。最重要的,使用委託可以很容易地實現一個Subject/Observer設計模式的改進版[GoF, p. 293]。Observer(觀察者)模式顯然在GUI中有很多的應用,但我發現它對應用程式核心的設計也有很大的作用。委託也可用來實現策略(Strategy)[GoF, p. 315]和狀態(State)[GoF, p. 305]模式。
現在,我來說明一個事實,委託和成員函式指標相比並不僅僅是好用,而且比成員函式指標簡單得多!既然所有的.NET語言都實現了委託,你可能會猜想如此高層的概念在彙編程式碼中並不好實現。但事實並不是這樣:委託的實現確實是一個底層的概念,而且就像普通的函式呼叫一樣簡單(並且很高效)。一個C++委託只需要包含一個this指標和一個簡單的函式指標就夠了。當你建立一個委託時,你提供這個委託一個this指標,並向它指明需要呼叫哪一個函式。編譯器可以在建立委託時計算出調整this指標需要的偏移量。這樣在使用委託的時候,編譯器就什麼事情都不用做了。這一點更好的是,編譯器可以在編譯時就可以完成全部這些工作,這樣的話,委託的處理對編譯器來說可以說是微不足道的工作了。在x86系統下將委託處理成的彙編程式碼就應該是這麼簡單:
mov ecx, [this]
call [pfunc]
但是,在標準C++中卻不能生成如此高效的程式碼。 Borland為了解決委託的問題在它的C++編譯器中加入了一個新的關鍵字(__closure),用來通過簡潔的語法生成優化的程式碼。GNU編譯器也對語言進行了擴充套件,但和Borland的編譯器不相容。如果你使用了這兩種語言擴充套件中的一種,你就會限制自己只使用一個廠家的編譯器。而如果你仍然遵循標準C++的規則,你仍然可以實現委託,但實現的委託就不會是那麼高效了。
有趣的是,在C#和其他.NET語言中,執行一個委託的時間要比一個函式呼叫慢8倍(參見http://msdn.microsoft.com/library/en-us/dndotnet/html/fastmanagedcode.asp)。我猜測這可能是垃圾收集和.NET安全檢查的需要。最近,微軟將“統一事件模型(unified event model)”加入到Visual C++中,隨著這個模型的加入,增加了__event、 __raise、__hook、__unhook、event_source和event_receiver等一些關鍵字。坦白地說,我對加入的這些特性很反感,因為這是完全不符合標準的,這些語法是醜陋的,因為它們使這種C++不像C++,並且會生成一堆執行效率極低的程式碼。
解決這個問題的推動力:對高效委託(fast delegate)的迫切需求
使用標準C++實現委託有一個過度臃腫的症狀。大多數的實現方法使用的是同一種思路。這些方法的基本觀點是將成員函式指標看成委託��但這樣的指標只能被一個單獨的類使用。為了避免這種侷限,你需要間接地使用另一種思路:你可以使用模版為每一個類建立一個“成員函式呼叫器(member function invoker)”。委託包含了this指標和一個指向呼叫器(invoker)的指標,並且需要在堆上為成員函式呼叫器分配空間。
對於這種方案已經有很多種實現,包括在CodeProject上的實現方案。各種實現在複雜性上、語法(比如,有的和C#的語法很接近)上、一般性上有所不同。最具權威的一個實現是boost::function。最近,它已經被採用作為下一個釋出的C++標準版本中的一部分[Sutter1]。希望它能夠被廣泛地使用。
就像傳統的委託實現方法一樣,我同樣發覺這種方法並不十分另人滿意。雖然它提供了大家所期望的功能,但是會混淆一個潛在的問題:人們缺乏對一個語言的底層的構造。 “成員函式呼叫器”的程式碼對幾乎所有的類都是一樣的,在所有平臺上都出現這種情況是令人沮喪的。畢竟,堆被用上了。但在一些應用場合下,這種新的方法仍然無法被接受。
我做的一個專案是離散事件模擬器,它的核心是一個事件排程程式,用來呼叫被模擬的物件的成員函式。大多數成員函式非常簡單:它們只改變物件的內部狀態,有時在事件佇列(event queue)中新增將來要發生的事件,在這種情況下最適合使用委託。但是,每一個委託只被呼叫(invoked)一次。一開始,我使用了boost::function,但我發現程式執行時,給委託所分配的記憶體空間佔用了整個程式空間的三分之一還要多!“我要真正的委託!”我在內心呼喊著,“真正的委託只需要僅僅兩行彙編指令啊!”
我並不能總是能夠得到我想要的,但後來我很幸運。我在這兒展示的程式碼(程式碼下載連結見譯者注)幾乎在所有編譯環境中都產生了優化的彙編程式碼。最重要的是,呼叫一個含有單個目標的委託(single-target delegate)的速度幾乎同調用一個普通函式一樣快。實現這樣的程式碼並沒有用到什麼高深的東西,唯一的遺憾就是,為了實現目標,我的程式碼和標準C++的規則有些偏離。我使用了一些有關成員函式指標的未公開知識才使它能夠這樣工作。如果你很細心,而且不在意在少數情況下的一些編譯器相關(compiler-specific)的程式碼,那麼高效能的委託機制在任何C++編譯器下都是可行的。
訣竅:將任何型別的成員函式指標轉化為一個標準的形式
我的程式碼的核心是一個能夠將任何類的指標和任何成員函式指標分別轉換為一個通用類的指標和一個通用成員函式的指標的類。由於C++沒有“通用成員函式(generic member function)”的型別,所以我把所有型別的成員函式都轉化為一個在程式碼中未定義的CGenericClass類的成員函式。
大多數編譯器對所有的成員函式指標平等地對待,不管他們屬於哪個類。所以對這些編譯器來說,可以使用reinterpret_cast將一個特定的成員函式指標轉化為一個通用成員函式指標。事實上,假如編譯器不可以,那麼這個編譯器是不符合標準的。對於一些接近標準(almost-compliant)的編譯器,比如Digital Mars,成員函式指標的reinterpret_cast轉換一般會涉及到一些額外的特殊程式碼,當進行轉化的成員函式的類之間沒有任何關聯時,編譯器會出錯。對這些編譯器,我們使用一個名為horrible_cast的行內函數(在函式中使用了一個union來避免C++的型別檢查)。使用這種方法看來是不可避免的��boost::function也用到了這種方法。
對於其他的一些編譯器(如Visual C++, Intel C++和Borland C++),我們必須將多重(multiple-)繼承和虛擬(virtual-)繼承類的成員函式指標轉化為單一(single-)繼承類的函式指標。為了實現這個目的,我巧妙地使用了模板並利用了一個奇妙的戲法。注意,這個戲法的使用是因為這些編譯器並不是完全符合標準的,但是使用這個戲法得到了回報:它使這些編譯器產生了優化的程式碼。
既然我們知道編譯器是怎樣在內部儲存成員函式指標的,並且我們知道在問題中應該怎樣為成員函式指標調整this指標,我們的程式碼在設定委託時可以自己調整this指標。對單一繼承類的函式指標,則不需要進行調整;對多重繼承,則只需要一次加法就可完成調整;對虛擬繼承...就有些麻煩了。但是這樣做是管用的,並且在大多數情況下,所有的工作都在編譯時完成!
這是最後一個訣竅。我們怎樣區分不同的繼承型別?並沒有官方的方法來讓我們區分一個類是多重繼承的還是其他型別的繼承。但是有一種巧妙的方法,你可以檢視我在前面給出了一個列表(見中篇)——對MSVC,每種繼承方式產生的成員函式指標的大小是不同的。所以,我們可以基於成員函式指標的大小使用模版!比如對多重繼承型別來說,這只是個簡單的計算。而在確定unknown_inheritance(16位元組)型別的時候,也會採用類似的計算方法。
對於微軟和英特爾的編譯器中採用不標準12位元組的虛擬繼承型別的指標的情況,我引發了一個編譯時錯誤(compile-time error),因為需要一個特定的執行環境(workaround)。如果你在MSVC中使用虛擬繼承,要在宣告類之前使用FASTDELEGATEDECLARE巨集。而這個類必須使用unknown_inheritance(未知繼承型別)指標(這相當於一個假定的__unknown_inheritance關鍵字)。例如:
FASTDELEGATEDECLARE(CDerivedClass)
class CDerivedClass : virtual public CBaseClass1, virtual public CBaseClass2 {
// : (etc)
};
這個巨集和一些常數的宣告是在一個隱藏的名稱空間中實現的,這樣在其他編譯器中使用時也是安全的。MSVC(7.0或更新版本)的另一種方法是在工程中使用/vmg編譯器選項。而Inter的編譯器對/vmg編譯器選項不起作用,所以你必須在虛擬繼承類中使用巨集。我的這個程式碼是因為編譯器的bug才可以正確執行,你可以檢視程式碼來了解更多細節。而在遵從標準的編譯器中不需要注意這麼多,況且在任何情況下都不會妨礙FASTDELEGATEDECLARE巨集的使用。
一旦你將類的物件指標和成員函式指標轉化為標準形式,實現單一目標的委託(single-target delegate)就比較容易了(雖然做起來感覺冗長乏味)。你只要為每一種具有不同引數的函式製作相應的模板類就行了。實現其他型別的委託的程式碼也大都與此相似,只是對引數稍做修改罷了。
這種用非標準方式轉換實現的委託還有一個好處,就是委託物件之間可以用等式比較。目前實現的大多數委託無法做到這一點,這使這些委託不能勝任一些特定的任務,比如實現多播委託(multi-cast delegates) [Sutter3]。
靜態函式作為委託目標(delegate target)
理論上,一個簡單的非成員函式(non-member function),或者一個靜態成員函式(static member function)可以被作為委託目標(delegate target)。這可以通過將靜態函式轉換為一個成員函式來實現。我有兩種方法實現這一點,兩種方法都是通過使委託指向呼叫這個靜態函式的“呼叫器(invoker)”的成員函式的方法來實現的。
第一種方法使用了一個邪惡的方法(evil method)。你可以儲存函式指標而不是this指標,這樣當呼叫“呼叫器”的函式時,它將this指標轉化為一個靜態函式指標,並呼叫這個靜態函式。問題是這只是一個戲法,它需要在程式碼指標和資料指標之間進行轉換。在一個系統中程式碼指標的大小比資料指標大時(比如DOS下的編譯器使用medium記憶體模式時),這個方法就不管用了。它在目前我知道的所有32位和64位處理器上是管用的。但是因為這種方法還是不太好,所以仍需要改進。
另一種是一個比較安全的方法(safe method),它是將函式指標作為委託的一個附加成員。委託指向自己的成員函式。當委託被複制的時候,這些自引用(self-reference)必須被轉換,而且使“=”和“==”運算子的操作變得複雜。這使委託的大小增至4個位元組,並增加了程式碼的複雜性,但這並不影響委託的呼叫速度。
我已經實現了上述兩種方法,兩者都有各自的優點:安全的方法保證了執行的可靠性,而邪惡的方法在支援委託的編譯器下也可能會產生與此相同的彙編程式碼。此外,安全的方法可避免我以前討論的在MSVC中使用多重繼承和虛擬繼承時所出現的問題。我在程式碼中給出的是“安全的方法”的程式碼,但是在我給出的程式碼中“邪惡的方法”會通過下面的程式碼生效:
#define (FASTDELEGATE_USESTATICFUNCTIONHACK)
多目標委託(multiple-target delegate)及其擴充套件
使用委託的人可能會想使委託呼叫多個目標函式,這就是多目標委託(multiple-target delegate),也稱作多播委託(multi-cast delegate)。實現這種委託不會降低單一目標委託(single-target delegate)的呼叫效率,這在現實中是可行的。你只需要為一個委託的第二個目標和後來的更多目標在堆上分配空間就可以了,這意味著需要在委託類中新增一個數據指標,用來指向由該委託的目標函式組成的單鏈表的頭部節點。如果委託只有一個目標函式,將這個目標像以前介紹的方法一樣儲存在委託中就行了。如果一個委託有多個目標函式,那麼這些目標都儲存在空間動態分配的連結串列中,如果要呼叫函式,委託使用一個指標指向一個連結串列中的目標(成員函式指標)。這樣的話,如果委託中只有一個目標,函式呼叫儲存單元的個數為1;如果有n(n>0)個目標,則函式呼叫儲存單元的個數為n+1(因為這時函式指標儲存在連結串列中,會多出一個連結串列頭,所以要再加一——譯者注),我認為這樣做最合理。
由多播委託引出了一些問題。怎樣處理返回值?(是將所有返回值型別捆綁在一起,還是忽略一部分?)如果把同一個目標在一個委託中添加了兩次那會發生什麼?(是呼叫同一個目標兩次,還是隻呼叫一次,還是作為一個錯誤處理?)如果你想在委託中刪除一個不在其中的目標應該怎麼辦?(是不管它,還是丟擲一個異常?)
最重要的問題是在使用委託時會出現無限迴圈的情況,比如,A委託呼叫一段程式碼,而在這段程式碼中呼叫B委託,而在B委託呼叫的一段程式碼中又會呼叫A委託。很多事件(event)和訊號跟蹤(signal-slot)系統會有一定的方案來處理這種問題。
為了結束我的這篇文章,我的多播委託的實現方案就需要大家等待了。這可以借鑑其他實現中的方法——允許非空返回型別,允許型別的隱式轉換,並使用更簡捷的語法結構。如果我有足夠的興趣我會把程式碼寫出來。如果能把我實現的委託和目前流行的某一個事件處理系統結合起來那會是最好不過的事情了(有自願者嗎?)。
本文程式碼的使用
原始碼包括了FastDelegate的實現(FastDelegate.h)和一個demo .cpp的檔案用來展示使用FastDelegate的語法。對於使用MSVC的讀者,你可以建立一個空的控制檯應用程式(Console Application)的工程,再把這兩個檔案新增進去就好了,對於GNU的使用者,在命令列輸入“gcc demo.cpp”就可以了。
FastDelegate可以在任何引數組合下執行,我建議你在儘可能多的編譯器下嘗試,你在宣告委託的時候必須指明引數的個數。在這個程式中最多可以使用8個引數,若想進行擴充也是很容易的。程式碼使用了fastdelegate名稱空間,在fastdelegate名稱空間中有一個名為detail的內部名稱空間。
Fastdelegate使用建構函式或bind()可以繫結一個成員函式或一個靜態(全域性)函式,在預設情況下,繫結的值為0(空函式)。可以使用“!”操作符判定它是一個空值。
不像用其他方法實現的委託,這個委託支援等式運算子(==, !=)。
下面是FastDelegateDemo.cpp的節選,它展示了大多數允許的操作。CBaseClass是CDerivedClass的虛基類。你可以根據這個程式碼寫出更精彩的程式碼,下面的程式碼只是說明使用FastDelegate的語法:
using namespace fastdelegate;
int main(void)
{
printf("-- FastDelegate demo -- A no-parameter
delegate is declared using FastDelegate0 ");
FastDelegate0 noparameterdelegate(&SimpleVoidFunction);
noparameterdelegate();
//呼叫委託,這一句呼叫SimpleVoidFunction()
printf(" -- Examples using two-parameter delegates (int, char *) -- ");
typedef FastDelegate2 MyDelegate;
MyDelegate funclist[12]; // 委託初始化,其目標為空
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);
// 繫結const型的成員函式
funclist[3].bind(&a, &CBaseClass::ConstMemberFunction);
// 繫結虛擬成員函式
funclist[4].bind(&b, &CBaseClass::SimpleVirtualFunction);
// 你可以使用”=”來賦值
funclist[5] = MyDelegate(&CBaseClass::StaticMemberFunction);
funclist[6].bind(&d, &CBaseClass::SimpleVirtualFunction);
//最麻煩的情況是繫結一個抽象虛擬函式(abstract virtual function)
funclist[7].bind(&c, &CDerivedClass::SimpleDerivedFunction);
funclist[8].bind(&c, &COtherClass::TrickyVirtualFunction);
funclist[9] = MakeDelegate(&c, &CDerivedClass::SimpleDerivedFunction);
// 你也可以使用建構函式來繫結
MyDelegate dg(&b, &CBaseClass::SimpleVirtualFunction);
char *msg = "Looking for equal delegate";
for (int i=0; i<12; i++) {
printf("%d :", i);
// 可以使用”==”
if (funclist[i]==dg) { msg = "Found equal delegate"; };
//可以使用”!”來判應一個空委託
if (!funclist[i]) {
printf("Delegate is empty ");
} else {
// 呼叫生成的經過優化的彙編程式碼
funclist[i](i, msg);
};
}
};
因為我的程式碼利用了C++標準中沒有定義的行為,所以我很小心地在很多編譯器中做了測試。具有諷刺意味的是,它比許多所謂標準的程式碼更具有可移植性,因為幾乎所有的編譯器都不是完全符合標準的。目前,核心程式碼已成功通過了下列編譯器的測試:
- Microsoft Visual C++ 6.0, 7.0 (.NET) and 7.1 (.NET 2003) (including /clr 'managed C++'),
- GNU G++ 3.2 (MingW binaries),
- Borland C++ Builder
對於Comeau C++ 4.3 (x86, SPARC, Alpha, Macintosh),能夠成功通過編譯,但不能連結和執行。對於Intel C++ 8.0 for Itanium能夠成功通過編譯和連結,但不能執行。
此外,我已對程式碼在MSVC 1.5 和4.0,Open Watcom WCL 1.2上的執行情況進行了測試,由於這些編譯器不支援成員函式模版,所以對這些編譯器,程式碼不能編譯成功。對於嵌入式系統不支援模版的限制,需要對程式碼進行大範圍的修改。(這一段是在剛剛更新的原文中新增的——譯者注)
而最終的FastDelegate並沒有進行全面地測試,一個原因是,我有一些使用的編譯器的評估版過期了,另一個原因是——我的女兒出生了!如果有足夠的興趣,我會讓程式碼在更多編譯器中通過測試。(這一段在剛剛更新的原文中被刪去了,因為作者目前幾乎完成了全部測試。——譯者注)
總結
為了解釋一小段程式碼,我就得為這個語言中具有爭議的一部分寫這麼一篇長長的指南。為了兩行彙編程式碼,就要做如此麻煩的工作。唉~!
我希望我已經澄清了有關成員函式指標和委託的誤解。我們可以看到為了實現成員函式指標,各種編譯器有著千差萬別的方法。我們還可以看到,與流行的觀點不同,委託並不複雜,並不是高層結構,事實上它很簡單。我希望它能夠成為這個語言(標準C++)中的一部分,而且我們有理由相信目前已被一些編譯器支援的委託,在不久的將來會加入到標準C++的新的版本中(去遊說標準委員會!)。
據我所知,以前實現的委託都沒有像我在這裡為大家展示的FastDelegate一樣有如此高的效能。我希望我