成員函式指標與高效能的C委託(中篇)
類的成員函式和標準的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運算子計算所獲得的資料:
注:
# 表示使用__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