您好,登錄后才能下訂單哦!
本篇文章給大家分享的是有關c++成員函數指針是什么,小編覺得挺實用的,因此分享給大家學習,希望大家閱讀完這篇文章后可以有所收獲,話不多說,跟著小編一起來看看吧。
C++語言支持指向成員函數的指針這一語言機制。就像許多其它C++語言機制一樣,它也是一把雙刃劍,用得好,能夠提高程序的靈活性、可擴展性等等,但是也存在一些不易發現的陷阱,我們在使用它的時候需要格外注意, 尤其是在我們把它和c++其它的語言機制合起來使用的時候更是要倍加當心。
關鍵字:成員函數指針,繼承,虛函數,this指針調整,static_cast
C++成員函數指針(pointer
to member function)的用法和C語言的函數指針有些相似.
下面的代碼說明了成員函數指針的一般用法:
class ClassName {public: int foo(int); }
int (ClassName::*pmf)(int)
ClassName c; //.*的用法,經由對象調用 (c.*pmf)(5); // A
ClassName *pc = &c; //->*的用法,經由對象指針調用 (Pc->*pmf)(6); // B
|
使用typedef可以讓代碼變得略微好看一點:
typedef int (ClassName::*PMF)(int); PMF pmf = &ClassName::foo; |
注意獲取一個成員函數指針的語法要求很嚴格:
1)
不能使用括號:例如&(ClassName::foo)不對。
2)
必須有限定符:例如&foo不對。即使在類ClassName的作用域內也不行。
3)
必須使用取地址符號:例如直接寫ClassName::foo不行。(雖然普通函數指針可以這樣)
所以,必須要這樣寫:&ClassName::foo。
C++成員函數的調用需要至少3個要素:this指針,函數參數(也許為空),函數地址。上面的調用中,->*和.*運算符之前的對象/指針提供了this(和真正使用this并不完全一致,后面會討論),參數在括號內提供,pmf則提供了函數地址。
注意這里成員函數指針已經開始顯示它“異類”的天性了。上面代碼中注釋A和B處兩個表達式,產生了一個在C++里面沒有類型(type)的“東西”(這是C++語言里面唯一的例外,其它任何東西都是有類型的),這就是.*和->*運算符:
(c.*pmf)
(Pc->*pmf)
這兩個運算符求值生成的“東西”我們只知道可以把它拿來當函數調用一樣使喚,別的什么也不能干,甚至都不能把它存在某個地方。就因為這個原因,Andrei Alexandrescu 在他那本著名的《Modern c++ design》里面就說,成員函數指針和這兩個操作符號是“curiously half-baked concept in c++”。(5.9節)
C++里面引入了“引用”(reference)的概念,可是卻不存在“成員函數的引用”,這也是一個特殊的地方。(當然,我們可以使用“成員函數指針”的引用,呵呵)
C++是一種Multi-Paradigm的語言,各種語言機制混合使用也是平常的事。這里我們只提幾種會影響到成員函數指針實現和運行的語言機制。
根據C++語言規定,成員函數指針具有contravariance特性,就是說,基類的成員函數指針可以賦值給繼承類的成員函數指針,C++語言提供了默認的轉換方式,但是反過來不行。
首先要說明,指向虛擬成員函數(virtual function
member)的指針也能正確表現出虛擬函數的特性。舉例說明如下:
class B { public:
virtual int
foo(int) {/* B's
implementation */return 0; } };
class D : public
B { public: virtual
int foo(int) { /* D's implementation */ return
0; } };
int
(B::*pmf)(int) = &B::foo;
D d;
B* pb = &d;
(d.*pmf)(0); //這里執行D::foo
(pb->*pmf)(0); //這里執行D::foo,多態
C++借由虛函數提供了運行時多態特性,虛函數的實現和普通函數有很大的不同。一般編譯器都是采用大家都熟悉的v-table (virtual function table)的方式。所有的虛函數地址存在一個函數表里面,類對象中存儲該函數表的首地址(vptr_point)。運行時根據this指針、虛函數索引和虛函數表指針找到函數調用地址。
3.2 多繼承因為這些不同,所以成員函數指針碰上虛函數的時候,也需要作特殊的處理,才能正確表現出所期望的虛擬性質。
這里扯上多繼承,是因為多繼承的存在導致了成員函數指針的實現的復雜性。這是因為編譯器有時候需要進行”this”指針調整。
舉例說明如下:
class B1{};
class B2{};
class D: public
B1, public B2{}
假設上面三個對象都不涉及到虛函數,D在內存中的典型布局如下圖所示(如果有虛函數則多一個vptr指針, 差別不大)。
現在假設我們經由D對象調用B2的函數,
D d;
d.fun_of_b2();
這里傳給fun_of_b2的this指針不能是&d, 而應該對&d加上一個偏移,得到D內含的B2子對象的首地址處。
成員函數指針的實現必須考慮這種情況。
多繼承總是不那么受歡迎。不過即使是單繼承,上面的情況也會出現。考慮下面的例子:
class B{}; //non-virtual
class
class D :public B{}; //virtual class
假設B是一個普通的類,沒有虛擬成員函數。而D加上了虛擬成員函數。
因為D引入了vptr指針,而一般的實現都將vptr放在對象的開頭,這就導致經由D對象訪問B的成員函數的時候,仍然需要進行this指針的調整。
D d;
d.fun_of_b(); //this 指針也需要調整,否則fun_of_b的行為就會異常
從上面一節我們可以看到,編譯器要實現成員函數指針,有幾個問題是繞不過去的:
1) 函數是不是虛擬函數,這個涉及到虛函數表(__vtbl)的訪問。
2) 函數運行時,需不需要調整this指針,如何調整。這個涉及到C++對象的內存布局。
事實上,成員函數指針必須記住這兩個信息。為什么要記住是否為虛函數就不用解釋了。但是this指針調整為什么要記住呢?因為在.*和->*運算符求值時必須用到。 考慮上面那個多繼承的例子:
int (D::*pmf)(int) = &B2::foo_of_b2; //A
D d;
(d.*pmf)(0);
//B
看看上面的代碼,其實我們在A處知道需要進行this指針調整,也知道該怎么調整。但是這時候this還沒出世呢,還不到調整的時候。到了B處終于有了This指針了,可是又不知道該怎樣調整了。所以pmf必須記住調整方式,到了B處調用的時候,再來進行調整。
Microsoft VC的實現采用的是Microsoft一貫使用的Thunk技術(不知道這個名字怎么來的,不過有趣的是把它反過來拼寫就變成了大牛Knuth的名字,呵呵)。
對于Mircosoft來說,成員函數指針實際上分兩種,一種需要調節this指針,一種不需要調節this指針。
先分清楚那些情況下成員函數指針需要調整this指針,那些情況下不需要。回憶上一節討論的c++對象內存布局的說明,我們可以得出結論如下:
如果一個類對象obj含有一些子對象subobj,這些子對象的首地址&subobj和對象自己的首地址&obj不等的話,就有可能需要調整this指針。因為我們有可能把subobj的函數當成obj自己的函數來使用。
根據這個原則,可以知道下列情況不需要調整this指針:
1)
繼承樹最頂層的類。
2)
單繼承,若所有類都不含有虛擬函數,那么該繼承樹上所有類都不需要調整this指針。
3)
單繼承,若最頂層的類含有虛函數,那么該繼承樹上所有類都不需要調整this指針。
下列情況可能進行this指針調整:
1)
多繼承
2)
單繼承,最頂的base class不含virtual function,但繼承類含虛函數。那么這些繼承類可能需要進行this指針調整。
Microsoft把這兩種情況分得很清楚。所以成員函數的內部表示大致分下面兩種:
struct void* vcall_addr; };
|
struct void* vcall_addr; int }; |
這兩種表示導致成員函數指針的大小可能不一樣,pmf_type1大小為4,pmf_type2大小為8。有興趣的話可以寫一段代碼測試一下。
上面兩個結構中出現了vcall_addr, 它就是Microsoft 的Thunk技術核心所在。簡單的說,vcall_addr是一個指針,這個指針隱藏了它所指的函數是虛擬函數還是普通函數的區別。事實上,若它所指的成員函數是一個普通成員函數,那么這個地址也就是這個成員函數的函數地址。若是虛擬成員函數,那么這個指針指向一小段代碼,這段代碼會根據this指針和虛函數索引值尋找出真正的函數地址,然后跳轉(注意是跳轉jmp,而不是函數調用call)到真實的函數地址處執行。
看一個例子。
//源代碼 class C { public: int virtual virtual };
void foo(C *c) { int
pmf = &C::nv_fun1; (c->*pmf)(0x12345678);
pmf = &C::v_fun; (c->*pmf)(0x87654321);
pmf = &C::v_fun_2; (c->*pmf)(0x87654321); }
|
; foo的匯編代碼,release版本,部分地方進行了優化 :00401000 56 push esi :00401001 8B742408 mov esi, dword ptr [esp+08] ; pmf = &C::nv_fun1; ; (c->*pmf)(0x12345678); :00401005 6878563412 push 12345678 : : ; pmf = &C::v_fun; ; (c->*pmf)(0x87654321); :00401011 6821436587 push 87654321 :00401016 8BCE mov ecx, esi ;this :00401018 E803070000 call 00401720 ; pmf = &C::v_fun_2; ; (c->*pmf)(0x87654321); :0040101D 6821436587 push 87654321 :00401022 8BCE mov ecx, esi ;this :00401024 E807070000 call 00401730 :00401029 5E pop esi : |
:00401030 : |
:00401720 :00401722 FF20 jmp dword ptr [eax] |
:00401730 :00401732 FF6004 jmp [eax+04] |
從上面的匯編代碼可以看出vcall_addr的用法。00401030, 00401720, 00401730都是vcall_addr的值,其實也就是pmf的值。在調用的地方,我們不能分別出是不是虛函數,所看到的都是一個函數地址。但是在vcall_addr被當成函數地址調用后,進入vcall_addr,就有區別了。00401720,
00401730是兩個虛函數的vcall,他們都是先根據this指針,計算出函數地址,然后jmp到真正的函數地址。00401030是C::nv_fun1的真實地址。
Microsoft的這種實現需要對一個類的每個用到了的虛函數,都分別產生這樣的一段代碼。這就像一個template函數:
template <int
index>
void
vcall(void* this)
{
jmp this->vptr[index];
//pseudo asm code
}
每種不同的index都要產生一個實例。
Microsoft就是采用這樣的方式實現了虛成員函數指針的調用。
不過還有一個this調整的問題,我們還沒有解決。上面的例子為了簡化,我們故意避開了this指針調整。不過有了上面的基礎,我們再討論this指針調整就容易了。
首先我們需要構造一個需要進行this指針調整的情況。回憶這節開頭,我們討論了哪些情況下需要進行this指針調整。我們用一個單繼承的例子來進行說明。這次我們避開virtual/non-virtual function的問題暫不考慮。
class B { public: B():m_b(0x13572468){} int std::cout<<'B'<<std::endl; return } private: int };
class D : public B { public: D():m_d(0x24681357){} virtual std::cout<<'D'<<std::endl; return } private: int }; | // 注意這個例子中virtual的使用 |
void test_this_adjust(D *pd, int { (pd->*pmf)(0x12345678); }
| :00401000 mov eax, dword ptr [esp+04] ; this入參 :00401004 mov ecx, dword ptr [esp+ :00401008 push 12345678 ;參數入棧 : :00401013 ret |
void test_main(D *pd) { test_this_adjust(pd, test_this_adjust(pd, }
| ; test_this_adjust(pd, &D::foo); :00401020 xor ecx, ecx :00401022 push esi :00401023 mov esi, dword ptr [esp+08] ; pd, this指針 : :0040102E push esi :
; test_this_adjust(pd, :00401034 mov ecx, 00000004 ;和上面的調用不同了 :00401039 mov eax, 00401050 ; :0040103E push ecx ; push : :00401040 push esi ; push
:00401049 pop esi : |
注意這里和上面一個例子的區別:
在調用test_this_adjust(pd,
&D::foo)的時候,實際上傳入了3個參數,調用相當于
test_this_adjust(pd, vcall_address_of_foo, delta(=0));
調用test_this_adjust(pd,
&B::b_fun)的時候,也是3個參數
test_this_adjust(pd, vcall_address_of_b_fun, delta(=4));
兩個調用有個明顯的不同,就是delta的值。這個delta,為我們后來調整this指針提供了幫助。
再看看test_this_adjust函數的匯編代碼,和上一個例子的不同,也就是多了一句代碼:
:0040100D add ecx, eax ; this
= ecx= this+delta
這就是對this指針作必要的調整。
Microsoft根據情況選用下面的結構表示成員函數指針,使用Thunk技術(vcall_addr)實現虛擬函數/非虛擬函數的自適應,在必要的時候進行this指針調整(使用delta)。
struct pmf_type1{ void* };
|
struct pmf_type2{ void* int delta; }; |
GCC對于成員函數指針的實現和Microsoft的方式有很大的不同。
GCC對于成員函數指針統一使用類似下面的結構進行表示:
struct { void* __pfn; long __delta; // offset, 用來進行this指針調整 }; |
先來看看GCC是如何區分普通成員函數和虛擬成員函數的。
不管是普通成員函數,還是虛擬成員函數,信息都記錄在__pfn里面。這里有個小小的技巧。我們知道一般來說因為對齊的關系,函數地址都至少是4字節對齊的。這就意味這一個函數的地址,最低位兩個bit總是0。(就算沒有這個對齊限制,編譯器也可以這樣實現。) GCC充分利用了這兩個bit。如果是普通的函數,__pfn記錄該函數的真實地址,最低位兩個bit就是全0,如果是虛擬成員函數,最后兩個bit不是0,剩下的30bit就是虛擬成員函數在函數表中的索引值。
使用的時候,GCC先取出最低位兩個bit看看是不是0,若是0就拿這個地址直接進行函數調用。若不是0,就取出前面30位包含的虛擬函數索引,通過計算得到真正的函數地址,再進行函數調用。
GCC和Microsoft對這個問題最大的不同就是GCC總是動態計算出函數地址,而且每次調用都要判斷是否為虛擬函數,開銷自然要比Microsoft的實現要大一些。這也差不多可以算成一種時間換空間的做法。
在this指針調整方面,GCC和Mircrosoft的做法是一樣的。不過GCC在任何情況下都會帶上__delta這個變量,如果不需要調整,__delta=0。
這樣GCC的實現比起Microsoft來說要稍簡單一些。在所有場合其實現方式都是一樣的。而且這樣的實現也帶來多一些靈活性。這一點下面“陷阱”一節再進行說明。
GCC在不同的平臺其實現細節可能略有不同,我們來看一個基于Intel平臺的典型實現:
//source code int test_fun(Base *pb, int { return } //assembly 8048478: push %ebp 8048479: mov 804847b: sub 804847e: mov 8048481: mov 8048484: mov 8048487: mov 804848d: mov 8048490: and 8048493: test 8048495: je 80484b6
; virtual fun,是虛擬函數,計算函數地址 8048497: mov 804849d: add 80484ae: mov 80484b1: mov 80484b4: jmp 80484bc
; 80484b6: mov 80484b9: mov
; common invoking ; 80484bc: push 80484be: mov 80484cb: leave 80484cc: ret 80484cd: nop |
按照C++語言的規定,對于成員函數指針的使用,有如下限制:
不允許繼承類的成員函數指針賦值給基類成員函數指針。
如果我們一定要反其道而行,則存在this指針調整的陷阱,需要注意。這一節我們通過兩個例子,說明為什么這樣操作是危險的。
先看一個單繼承的例子。
class B { public: B():m_b(0x13572468){} /* virtual */ int b_fun(int) { //A std::cout<<'B'<<std::endl; return 0; } private: int m_b; }; class D : public B { public: D():m_d(0x24681357){} virtual int foo(int) { // B std::cout<<'D'<<std::endl; return 0; } private: int m_d; }; void test_consistent(B* pb, { (pb->*pmf)(0x12345678); } void test_main(D *pd) { typedef int (B::*B_PMF)(int); //test_consistent(pd, test_consistent(pd, // crash in MSVC }
int main() { D d; test_main(&d); return 0; } |
這句話在Microsoft Visual C++6.0下面一運行就crash。 表面上看我們傳的指針是D的指針,函數也是D的函數。但實際上不是那么簡單。函數調用的時候,pd賦值給pb,編譯器會進行this指針調整,pb指向pd內部B的子對象。這樣到了test_consistent函數內部的時候,就是用D::B對象調用D::foo函數,this指針不對,所以就crash了。
|
上面這個問題,GCC能正確的進行處理。其實錯誤的原因不在于pb=pd指針賦值的時候,編譯器將指針進行了調整,而在于在test_consistent內,成員函數指針被調用的時候,應該將this指針再調整回去!這個問題又是由static_cast的行為不適當引起的。
static_cast<B_PMF>(&D::foo)
這里的static_cast,
是將D的成員函數指針強制轉換為給B的成員函數指針。因為它是D的函數,雖然會經由B的指針或者對象調用,但是調用時this指針應該根據B的地址調整成D的首地址。所以經過static_cast之后,這個成員函數指針應該為{__pfn, __delta= -4 }。(B被包含在D內部,所以這里是-4!) GCC正確的執行了這個cast,并且每次使用成員函數指針調用時都進行this指針調整, 所以沒有問題。可是Microsoft的實現在這個地方卻無能為力,為什么呢?就算static_cast正確,在test_consistent里面根本就不會進行this指針調整! 因為它使用的其實是 struct{void *vcall_address;}這個結構,根本不知道要進行this指針調整。
Microsoft在這里要做的是將一個struct pmf_type2類型的對象,通過static_cast轉換成一個struct pmf_type1的對象。這種轉換根本不能成功,因為struct pmf_type1要少一個成員delta.這樣的轉換會丟失信息。
當然我們不能怪Microsoft,C++語言本來就規定了不能這樣用。不過Microsoft可以做得更好一點,至少可以不允許這樣的static_cast。(這樣的用法, VC2005能夠給出一個告警, 提示有可能產生不正確的代碼!)
我們可以很簡單的解決這個問題,在上面的代碼中A處,把注釋掉的virtual打開,也可以把B處的virtual注釋掉,使得所有地方都無需進行this調整,問題也就不再出現了。
這個例子可能有些牽強,我們把上面的代碼稍做修改,再舉一個涉及到多繼承的例子。
class B { public: B():m_b(0x13572468){} virtual int b_fun(int) { std::cout<<"B return 0; } private: int m_b; };
class B2 { public: B2():m_b2(0x24681357){} int b2_fun(int) { std::cout<<"B2 return 0; } private: int m_b2; };
class D :public B , public B2 { public: D():m_d(0x24681357){} int foo(int) { std::cout<<"D return 0; } private: int m_d; };
void test_consistent(B* pb, int (B::*pmf)(int)) { (pb->*pmf)(0x12345678); }
void test_main(D *pd) { typedef int //test_consistent(pd, &B2::b2_fun); //A //test_consistent(pd, static_cast<B_PMF>(&B2::b2_fun)); // B typedef int D_PMF pmf = test_consistent(pd, }
int main() { D d; test_main(&d); return 0; } |
|
|
先用Microsoft Visual C++進行測試。這段代碼執行結果是錯誤的。(沒有crash,比crash更糟)。先看注釋A處,語法錯誤,VC給出了正確的編譯錯誤。
B處,進行static_cast, VC也能給出正確的編譯錯誤,說int (B2::*)(int)類型不能轉換成int (B::*)(int)類型。這也很好。
這樣都不行,我們就繞一下,來個“智取”。先將int (B2::*)(int)轉換為int (D::*)(int)。這個轉換是C++標志規定必須實現的,屬于基類成員函數指針賦值給繼承類成員函數指針。然后再進一步使用static_cast轉換成int (B::*)(int)類型。編譯錯誤沒有了。可是執行結果不正確!原因和上一個例子一樣,this指針不能正確的進行調整。這里D類是需要進行this指針調整的,而B類,B2類都不需要調整,在test_consistent中調用函數指針的時候,不會進行this指針調整,所以出現了錯誤。
這個例子,GCC表現也相當好。這都歸根于GCC采用一致的成員函數指針的表示和實現!
在Microsoft新發布的Visual C++2005中, 上面的問題仍然存在。(再重復一下, 這不怪Microsoft, C++標準本來就不允許這樣用。)
GCC里面,不同類型的成員函數指針使用static_cast進行轉換,就是計算出合適的__delta值。
VC里面,使用static_cast進行轉換,做了什么?
C++規定編譯器必須提供一個從基類成員函數指針到繼承類成員函數指針的默認轉換。這個轉換,最關鍵的地方,其實也是this指針調整。
從上面的例子,我們得到如下教訓:
1)
static_cast不能隨便用。
2)
一般情況下不要將繼承類的成員函數指針賦值給基類成員函數指針。不同編譯器可能有不同的表現。這可能導致潛在的可移植性問題。
現在我們明白了將C++運行時多態特性和C++成員函數指針合起來使用的時候,可能有些不夠自然的地方,而且存在上面所描述的陷阱。這些陷阱都是因為this指針調整引起的。所以要避開這個陷阱,就要避開this指針調整,所以需要注意:
1)
不要使用static_cast將繼承類的成員函數指針賦值給基類成員函數指針,如果一定要使用,首先確定沒有問題。(這條可能會限制代碼的可擴展性。)
2)
如果一定要使用static_cast, 注意不要使用多繼承。
3)
如果一定要使用多繼承的話,不要把一個基類的成員函數指針賦值給另一個基類的函數指針。
4)
單繼承要么全部不使用虛函數,要么全部使用虛函數。不要使用非虛基類,卻讓子類包含虛函數。
最后,用Herb Sutter的話結個尾(如果我沒記錯的話):do what you know,and know what you do!
以上就是c++成員函數指針是什么,小編相信有部分知識點可能是我們日常工作會見到或用到的。希望你能通過這篇文章學到更多知識。更多詳情敬請關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。