c 多繼承debug經歷

NO IMAGE
  • 其實這次debug實質上與多繼承沒有什麼關係,只是在解決多繼承程式碼bug的經歷中瞭解到了VC 在編譯程式碼方式。

起因是我在一次專案的過程中,實現抽象工廠模式,把本應是純虛類的工廠父類寫成了實類,結果導致了一場血案,不過也從中學習到了不少知識。
起初,程式碼大概如下:

...
class A
{...
void dosome();(這裡應該是純虛擬函式,不然是編輯器回靜態聯編,你子類實現的方法父類指標沒法呼叫,這裡後續詳細講)
}

class B:publlic A
{...
void dosome();
}

class C:public B
{...
void dosome();
void say(char *string);
}

我首先說一點,這樣多重繼承要慎用,不知道哪天他會折麼的你生不如死,最好三思到底合適嗎?如果合適的話,那還是用吧。其實那天的程式碼差不多就是這個形式,只不過程式碼要多一點,一不注意,認為A類是純虛類,結果就引發了我接下來debug經歷。

在類建立完成以後我就在程序中建立物件,並呼叫其方法,就在這裡出現了問題

int  main()
{
...
A* pmain= new C();
pmain->dosome();
pmain->say("hello world!!!");  //這是我預想的操作
...
}

發現編譯器一直報錯,C類裡面沒有say()方法,這時我就覺得很奇怪,我建立C物件寫法沒有錯啊,檢查了幾遍發現應該不是語法問題,然後我覺得可能是建構函式出現了問題,於是我在C類的建構函式上打斷點,發現建立物件也完美,緊接著為在他父類和間接父類上都打上斷點,發現呼叫建構函式都很正常。這裡在思考了一會兒我想先確定我A類的指標指向的到底是什麼物件。
在相關類裡面加入了測試函式,就是一個空函式,建立的物件能不能呼叫,結果發現我建立的是A類的物件,這就突破我的認知了,明明是建立的C物件,結果是A物件,難道虛基類還能通過子類創造出自己的例項來?然後我又換了一種宣告方式:

{
...
B* pmain=new C();
pmain->say("hello world!!!");
...
}

結果也是不能呼叫,然後我用相同的方法確定了建立的物件是B類。我這就發現,什麼型別的指標,建立的就是什麼物件。我覺得這是我沒有瞭解到的C 的特性,便在網上查關於多重繼承和VC 編譯器在建立物件時的資料。

我先整理一下關於多重繼承和虛繼承(共享繼承)的資料:
淺語部落格
*假如我們有類A是父類,類B和類C繼承了類A,而類D既繼承類B又繼承類C(這種菱形繼承關係)。當我們例項化D的物件的時候,每個D的例項化物件中都有了兩份完全相同的A的資料。因為保留多份資料成員的拷貝,不僅佔用較多的儲存空間,還增加了訪問這些成員時的困難,容易出錯,而實際上,我們並不需要有多份拷貝。
針對這種情況,C 提供虛基類(virtual base class)的方法,使得在繼承間接共同基類時只保留一份成員。*

class A //宣告基類A
{
    A(int i); //申明一個帶有引數的建構函式
};
class B: virtual public A //A是B的虛基類
{
    B(int n):A(n){ }  //B類建構函式,在初始化列表中對虛基類A進行初始化
};
class C: virtual public A //A是C的虛基類
{
    C(int n):A(n){ }  //C類建構函式,在初始化列表中對虛基類A進行初始化
};
class D: public B, public C
{
    D(int n):A(n),B(n),C(n){ }  //D類建構函式,在初始化列表中對所有基類進行初始化
};

*【注意]:在定義類D的建構函式時,與以往使用的方法有所不同。以往,在派生類的建構函式中只需負責對其直接基類初始化,再由其直接基類負責對間接基類初始化。現在,由於虛基類在派生類中只有一份資料成員,所以這份資料成員的初始化必須由派生類直接給出。如果不由最後的派生類直接對虛基類初始化,而由虛基類的直接派生類(如類B和類C)對虛基類初始化,就有可能由於在類B和類C的建構函式中對虛基類給出不同的初始化引數而產生矛盾。所以規定:在最後的派生類中不僅要負責對其直接基類進行初始化,還要負責對虛基類初始化。
有的讀者會提出:類D的建構函式通過初始化表調了虛基類的建構函式A,而類B和類C的建構函式也通過初始化表呼叫了虛基類的建構函式A,這樣虛基類的建構函式豈非被呼叫了3次?大家不必過慮,C 編譯系統只執行最後的派生類對虛基類的建構函式的呼叫,而忽略虛基類的其他派生類(如類B和類C) 對虛基類的建構函式的呼叫,這就保證了虛基類的資料成員不會被多次初始化。*

我也查了許多相關資料發現我的問題並沒有解決,而且發現沒有相關的繼承語法解釋我的問題。於是我覺得並不是繼承產生的問題,應該是編譯器在構建子物件是有特殊的方法。於是我查資料,瞭解vc 在記憶體方面是如何構建一個物件的。

C 物件繼承記憶體分佈struct Employee { … };
*struct Manager : Employee { … };
struct Worker : Employee { … };
struct MiddleManager : Manager, Worker { … };
如果經理類和工人類都繼承自僱員類,很自然地,它們每個類都會從僱員類獲得一份資料拷貝。如果不作特殊處理,一線經理類的例項將含有兩個僱員類例項,它們分別來自兩個僱員基類。如果僱員類成員變數不多,問題不嚴重;如果成員變數眾多,則那份多餘的拷貝將造成例項生成時的嚴重開銷。更糟的是,這兩份不同的僱員例項可能分別被修改,造成資料的不一致。因此,我們需要讓經理類和工人類進行特殊的宣告,說明它們願意共享一份僱員基類例項資料。
很不幸,在C 中,這種“共享繼承”被稱為“虛繼承”,把問題搞得似乎很抽象。虛繼承的語法很簡單,在指定基類時加上virtual關鍵字即可。
struct Employee { … };
struct Manager : virtual Employee { … };
struct Worker : virtual Employee { … };
struct MiddleManager : Manager, Worker { … };
使用虛繼承,比起單繼承和多重繼承有更大的實現開銷、呼叫開銷。回憶一下,在單繼承和多重繼承的情況下,內嵌的基類例項地址比起派生類例項地址來,要麼地址相同(單繼承,以及多重繼承的最靠左基類),要麼地址相差一個固定偏移量(多重繼承的非最靠左基類)。然而,當虛繼承時,一般說來,派生類地址和其虛基類地址之間的偏移量是不固定的,因為如果這個派生類又被進一步繼承的話,最終派生類會把共享的虛基類例項資料放到一個與上一層派生類不同的偏移量處。請看下例:*
這裡寫圖片描述
*struct G : virtual C {
int g1;
void gf();
};
譯者注:GdGvbptrG(In G, the displacement of G’s virtual base pointer to G)意思是:在G中,G物件的指標與G的虛基類表指標之間的偏移量,在此可見為0,因為G物件記憶體佈局第一項就是虛基類表指標; GdGvbptrC(In G, the displacement of G’s virtual base pointer to C)意思是:在G中,C物件的指標與G的虛基類表指標之間的偏移量,在此可見為8。*
這裡寫圖片描述
struct H : virtual C {
int h1;
void hf();
};
這裡寫圖片描述
*struct I : G, H {
int i1;
void _if();
};
暫時不追究vbptr成員變數從何而來。從上面這些圖可以直觀地看到,在G物件中,內嵌的C基類物件的資料緊跟在G的資料之後,在H物件中,內嵌的C基類物件的資料也緊跟在H的資料之後。但是,在I物件中,記憶體佈局就並非如此了。VC 實現的記憶體佈局中,G物件例項中G物件和C物件之間的偏移,不同於I物件例項中G物件和C物件之間的偏移。當使用指標訪問虛基類成員變數時,由於指標可以是指向派生類例項的基類指標,所以,編譯器不能根據宣告的指標型別計算偏移,而必須找到另一種間接的方法,從派生類指標計算虛基類的位置。
在VC 中,對每個繼承自虛基類的類例項,將增加一個隱藏的“虛基類表指標”(vbptr)成員變數,從而達到間接計算虛基類位置的目的。該變數指向一個全類共享的偏移量表,表中專案記錄了對於該類而言,“虛基類表指標”與虛基類之間的偏移量。
其它的實現方式中,有一種是在派生類中使用指標成員變數。這些指標成員變數指向派生類的虛基類,每個虛基類一個指標。這種方式的優點是:獲取虛基類地址時,所用程式碼比較少。然而,編譯器優化程式碼時通常都可以採取措施避免重複計算虛基類地址。況且,這種實現方式還有一個大弊端:從多個虛基類派生時,類例項將佔用更多的記憶體空間;獲取虛基類的虛基類的地址時,需要多次使用指標,從而效率較低等等。
在VC 中,G擁有一個隱藏的“虛基類表指標”成員,指向一個虛基類表,該表的第二項是GdGvbptrC。(在G中,虛基類物件C的地址與G的“虛基類表指標”之間的偏移量(當對於所有的派生類來說偏移量不變時,省略“d”前的字首))。比如,在32位平臺上,GdGvptrC是8個位元組。同樣,在I例項中的G物件例項也有“虛基類表指標”,不過該指標指向一個適用於“G處於I之中”的虛基類表,表中一項為IdGvbptrC,值為20。
觀察前面的G、H和I,我們可以得到如下關於VC 虛繼承下記憶體佈局的結論:
 首先排列非虛繼承的基類例項;
 有虛基類時,為每個基類增加一個隱藏的vbptr,除非已經從非虛繼承的類那裡繼承了一個vbptr;
 排列派生類的新資料成員;
 在例項最後,排列每個虛基類的一個例項。*

學習了這麼多,快要寫一個C 編譯器了(開玩笑),但是最後發現只是沒寫純虛類,導致編譯器靜態聯編函式,用什麼指標在編譯階段編譯器就已經確定了建立物件,當然不會有子類的方法,雖然沒浪費時間,但是也想把自己打一頓,太蠢了。讓我明白看來殺不死你的bug才能讓你更強大啊。