虛方法的呼叫是怎麼實現的(單繼承VS多繼承)
我們知道通過一個指向之類的父類指標可以呼叫子類的虛方法,因為子類的方法會覆蓋父類同樣的方法,通過這個指標可以找到物件例項的地址,通過例項的地址可以找到指向對應方法表的指標,而通過這個方法的名字就可以確定這個方法在方法表中的位置,直接呼叫就行,在多繼承的時候,一個類可能有多個方法表,也就有多個指向這些方法表的指標,一個類有多個父類,怎麼通過其中一個父類的指標呼叫之類的虛方法?
其實前面幾句話並沒有真正說清楚,在單繼承中,父類是怎麼呼叫子類的虛方法的,還有多繼承又是怎麼實現這點的,想知道這些,請認真往下看。
我們先看單繼承是怎麼實現的。先上兩個簡單的類:
#include <iostream> usingnamespace std; class A { public: A():a(0){} virtual ~A(){} virtual void GetA() { cout<<"A::GetA"<<endl; } void SetA(int _a) { a=_a; } int a; }; class B:public A { public: B():A(),b(0){} virtual~B(){} virtual void GetA() { cout<<"B::GetA"<<endl; } virtual void GetB() { cout<<"B::GetB"<<endl; } private: int b; }; typedef int (*Fun)(void); void TestA() { Fun pFun; A a; cout<<"類A的虛方法(第0個是A的解構函式):"<<endl; int** pVtab0 = (int**)&a; for (int i=1; (Fun)pVtab0[0][i]!=NULL; i++){ pFun = (Fun)pVtab0[0][i]; cout << " ["<<i<<"] "; pFun(); } cout<<endl; B b ; A* b1=&b; cout<<"類B的虛方法(第0個是B的解構函式)通過類B的例項:"<<endl; int** pVtab1 = (int**)&b; for (int i=1; (Fun)pVtab1[0][i]!=NULL; i++){ pFun = (Fun)pVtab1[0][i]; cout << " ["<<i<<"] "; pFun(); } cout<<endl; cout<<"類B的虛方法(第0個是B的解構函式)通過類A的指標:"<<endl; int** pVtab2 = (int**)&*b1; for (int i=1; (Fun)pVtab2[0][i]!=NULL; i++){ pFun = (Fun)pVtab2[0][i]; cout << " ["<<i<<"] "; pFun(); } cout<<endl; cout<<" b的地址:"<<&b<<endl; cout<<"b1指向的地址:"<<b1<<endl<<endl; }
執行結果如下:
通過執行結果我們知道:通過父類指向子類的指標呼叫的是子類的虛方法。在單一繼承中,雖然父類有父類的虛方法表,子類有子類的虛方法表,但是子類並沒有指向父類虛方法的指標,在子類的例項中,子類和父類是公用一個虛方法表,當然只有一個指向方法表的指標,為什麼可以公用一個虛方法表呢,虛方法表的第一個方法是解構函式,子類的方法會覆蓋父類的同樣的方法,子類新增的虛方法放在虛方法表的後面,也就是說子類的虛方法表完全覆蓋父類的虛方法表,即子類的每個虛方法與父類對應的虛方法,在各種的方法表中的索引是一樣的。
但是在多繼承中就不是這樣了,第一個被繼承的類使用起來跟單繼承是完全一樣的,但是後面被繼承的類就不是這樣了,且仔細往下看。
還是先上3個簡單的類
#include <iostream> using namespace std; class A { public: A():a(0){} virtual ~A(){} virtual void GetA() { cout<<"A::GetA"<<endl; } int a; }; class B { public: B():b(0){} virtual ~B(){} virtual void SB() { cout<<"B::SB"<<endl; } virtual void GetB() { cout<<"B::GetB"<<endl; } private: int b; }; class C:public A,public B { public: C():c(0){} virtual ~C(){} virtual void GetB()//覆蓋類B的同名方法 { cout<<"C::GetB"<<endl; } virtual void GetC() { cout<<"C::GetC"<<endl; } virtual void JustC() { cout<<"C::JustC"<<endl; } private: int c; }; typedef int (*Fun)(void); void testC() { C* c=new C(); A* a=c; B* b=c; Fun pFun; cout<<"sizeof(C)="<<sizeof(C)<<endl<<endl; cout<<"c的地址:"<<c<<endl; cout<<"a的地址:"<<a<<endl; cout<<"b的地址:"<<b<<endl<<endl<<endl; cout<<"類C的虛方法(第0個是C的解構函式)(通過C型別的指標):"<<endl; int** pVtab1 = (int**)&*c; for (int i=1; (Fun)pVtab1[0][i]!=NULL; i++){ pFun = (Fun)pVtab1[0][i]; cout << " ["<<i<<"] "<<&*pFun<<" "; pFun(); } cout<<endl<<endl; cout<<"類C的虛方法(第0個是C的解構函式)(通過B型別的指標):"<<endl; pVtab1 = (int**)&*b; for (int i=1; (Fun)pVtab1[0][i]!=NULL; i++){ pFun = (Fun)pVtab1[0][i]; cout << " ["<<i<<"] "<<&*pFun<<" "; pFun(); } }
執行結果如下:
從結果說話:
Sizeof(C)=20,我們並不意外,在單繼承的時候,父類和子類是公用一個指向虛方法表的指標,在多繼承中,同樣第一個父類和子類公用這個指標,而從第二個父類開始就有自己單獨的指標,其實就是父類的例項在子類的記憶體中保持完整的結構,也就是說在多重繼承中,之類的例項就是每一個父類的例項拼接而成的,當然可能因為繼承的複雜性,會加一些輔助的指標。
指標a與指標c指向同一個地址,即c的首地址,而b所指的地址與a所指的地址相差8位元組剛好就是類A例項的大小,也就是說在C的記憶體佈局中,先存放了A的例項,在存放B的例項,sizeof(B)=8(欄位int b和指向B虛方法表的指標),在家上C自己的欄位int c剛好是20位元組。
讓我有點意外的是:方法B::SB,C::GetB並沒有出現在類C的方法表中,而且C::GetB是C覆寫B中的GetB方法,怎麼沒有出現在C的方法表中呢?在《深入探索C++物件模型》一書中講到,這兩個方法同時應該出現在C的方法表中,同樣也會覆蓋B的虛方法表。可能是不通的編譯器有不同的實現,我用的是VS2010,那本書上講的是編譯器cfront
OK,我們不用管不同的編譯器實現上的區別,這點小區別無傷大雅,虛方法的呼叫機制還是一樣的。
先來分析幾個小例子,看看虛方法的實現機制。
C* c=new C();
A* a=c;
a->GetA();
c->GetA();
c->GetC();
上面已經說了,a與c指向的是同一個地址,且公用同一個虛方法表,而方法GetA,GetC的地址就在這個方法表中,那麼呼叫起來就簡單多了,大致就是下面這個樣子:
a->GetA() -> (a->vptr1[1])(a); // GetA在方法表中的索引是1
c->GetA() -> (c->vptr1[1])(c); // GetA在方法表中的索引是1
c->GetC() -> (a->vptr1[2])(c); // GetC在方法表中的索引是2
vptr1表示指向類C第一個方法表的指標,這個指標實際的名字會複雜一些,暫且將指向類C的第一個方法表的指標命名為vptr2,下面會用到這個指標。
再來分析幾行程式碼:
B* b=c;
c->GetB();
b->GetB();
指標b和指標c指向的不是同一個地址,那麼B* b=c;到底是做了啥呢?大致是會轉換成下面這個樣子:
B* b=c+sizeof(A);
c所指的地址加上A的大小,剛好是b所指的地址。
c->GetB();同樣需要轉換,因為方法GetB根本不在c所指的那個方法表中,可能轉換成這個樣子(實際轉換成啥樣子我真不知道):
this=c+sizeof(A);
(this->vptr2[2])(c);
如果像編譯器cfront所說的那樣,方法GetB在vptr1所指的方法表中,那麼就不用產生調整this指標了,如果在vptr1所指的方法表中,就讓方法表變大了,且跟別的方法表是重複的。
b->GetB();就不需要做過多的轉換了,因為b正好指向vptr2,可能轉換成下面這個樣子:
b->GetB() -> (b->vptr2[2])(b); // GetB在方法表中的索引是2
總之指標所指的方法表如果沒有要呼叫的方法,就要做調整,虛方法需要通過方法表呼叫,相對於非虛方法,效能就慢那麼一點點,這也是別人常說的C++效能不如C的其中一點。
虛多繼承就更麻煩了,不熟悉可能就會被坑。《深入探索C++物件模型》這本書是這樣建議的:不要在一個virtual base class中宣告nonstatic data members,如果這樣做,你會距複雜的深淵越來越近,終不可拔。
virtual base class還是當做介面來用吧。