C++繼承詳解三 ----菱形繼承、虛繼承
今天呢,我們來講講菱形繼承與虛繼承。這兩者的講解是分不開的,要想深入瞭解菱形繼承,你是繞不開虛繼承這一點的。它倆有著什麼關係呢?值得我們來剖析。
菱形繼承也叫鑽石繼承,它是多繼承的一種特殊例項吧,它的基本架構如下圖:
在我們的設想中,D所對應的物件模型應該如下圖所示:
下面我們來用一段程式碼驗證一下:
class A
{
public:
A()
{
cout << "A()" << endl;
}
~A()
{
cout << "~A()" << endl;
}
char a;
};
class B :public A
{
public:
B()
{
cout << "B()" << endl;
}
~B()
{
cout << "~B()" << endl;
}
char b;
};
class C :public A
{
public:
C()
{
cout << "C()" << endl;
}
~C()
{
cout << "~C()" << endl;
}
int c;
};
class D :public B, public C
{
public:
D()
{
cout << "D()" << endl;
}
~D()
{
cout << "~D()" << endl;
}
int d;
};
int main()
{
cout << sizeof(A)<< endl; //1
cout << sizeof (B)<< endl; //2
cout << sizeof(C)<< endl; //8
cout << sizeof(D)<< endl; //16
system("pause");
return 0;
}
上面顯示的大小似乎證實了我們的猜想,但實際上物件模型不是這樣的,如下圖所示
但是你會發現,這裡面存在一個問題,物件D中有兩個‘a’,存在資料冗餘的問題,如果物件B,C中有兩個同名的函式或同名成員變數(本例中的變數‘a’),那麼物件D在呼叫該函式或該成員變數時,該選擇呼叫哪個呢?這也就可以看出還存有二義性問題。那麼該如何處理呢?
解決二義性問題很簡單,你在呼叫函式時加上作用域運算子(::),但是資料冗餘問題還是沒有解決。那麼編譯器是如何處理這兩個問題的呢?
為了解決二義性問題和資料冗餘問題,C++引入了虛繼承這一概念。下面重點來看虛繼承。
虛繼承
虛繼承又稱共享繼承,是面向物件程式設計的一種技術,是指一個指定的基類,在繼承體系結構中,將其成員資料例項共享給也從這個基類直接或間接派生的其他類。虛擬繼承是多重繼承中特有的概念,虛擬繼承就是為了解決多重繼承而出現的。
這裡我想引入《C++ Primer》這本書中對虛繼承的有關描述。
在C++語言中我們通過虛繼承的機制來解決共享問題。虛繼承的目的是令某個類作出宣告,承諾共享它的基類。其中,共享的基類子物件稱其為虛基類。在這種機制下,不論虛基類在繼承體系中出現了多少次,在派生類中都只含有唯一一個共享的虛基類子物件。
這裡還有一個概念,虛基類。虛基類是通過virtual繼承而來的派生類的基類。例如:B虛繼承了A,所以A是B的虛基類,而不是說A是虛基類。
看下圖瞭解普通基類與虛基類的區別:
按照上面的說法,在物件D中應該只含有一個共享的虛基類子物件,也就是例子中的_a。確實,這樣就解決了資料冗餘與二義性問題。我們來驗證上面的的說法。(為了計算簡單,我將上例中每個類成員變數變為整形int)
下面我們來看一段程式碼:
class A
{
public:
A()
{
cout << "A()" << endl;
}
~A()
{
cout << "~A()" << endl;
}
void print()
{
printf("A");
}
int _a;
};
class B :virtual public A //B虛繼承A
{
public:
B()
{
cout << "B()" << endl;
}
~B()
{
cout << "~B()" << endl;
}
int _b;
};
class C :virtual public A //C虛繼承A
{
public:
C()
{
cout << "C()" << endl;
}
~C()
{
cout << "~C()" << endl;
}
int _c;
};
class D :public B, public C
{
public:
D()
{
cout << "D()" << endl;
}
~D()
{
cout << "~D()" << endl;
}
int _d;
};
int main()
{
cout << sizeof(A)<< endl;
cout << sizeof(B)<< endl;
cout << sizeof(C)<< endl;
cout << sizeof(D)<< endl;
B bb;
C cc;
D dd;
dd.B::_a = 1;
dd.C::_a = 2;
dd._b = 3;
dd._c = 4;
dd._d = 5;
system("pause");
return 0;
}
B和C都是虛擬繼承,
按照我們之前的推理,物件D的結構應該如圖所示:
我們來通過vs2013除錯中的記憶體視窗來驗證一下:
看到這個結果是不是嚇壞寶寶了?和我們預測的完全不一樣,物件A和B中的_a跑到了最底部,這種結構明顯沒有了資料冗餘和二義性問題了,這是怎麼實現的呢?這就要引入一新的概念——虛基類表。
虛基類表:又稱虛基表,編譯器會給虛繼承而來的派生類生成一個指標vbptr指向一個虛基表,而虛基表中存放的是偏移量。
我們來看物件D中的物件B,它的第一部分(第一行)就是虛基類表指標vbptr,它存的是虛基表的地址,虛基表中存的是共享基類成員變數_a的相對此位置的偏移量,我們來看看,“01259b60”是個地址,利用記憶體視窗我們可以發現裡面存著兩部分第一行“00 00 00 00”和第二行“00 00 00 14”,虛基表中分兩部分:第一部分儲存的是物件相對於存放vptr指標的偏移量(在這就是“00 00 00 00”,偏移量為0),第二部分儲存的是物件中基類物件部分相對於存放vbptr指標的地址的偏移量(在這就是“00 00 00 14”),
即20(十六進位制下14就是十進位制的20),也就是說偏移量是20個位元組,你可以用他們的地址相減驗證一番。你可以看圖數一下,而物件D中的C的第一部分也是一樣,是個虛基表,存的一樣也是偏移量,它存的地址“00369b68”,裡面是“00 00 00 0c”即十進位制的12,即偏移量為12位元組。可以看下圖:
下面我再講一個概念——虛擬函式,這會在下篇文章多型中重點講解,但是這裡有必要了解一下。
虛擬函式——類的成員函式前面加上virtual關鍵字,則這個函式被稱為虛擬函式。
虛擬函式:用於定義型別特定行為的成員函式。通過引用和指標對虛擬函式的呼叫直到執行時才被解析,依據是引用或指標所繫結物件的型別。(《C++ Primer》中定義)
虛擬函式重寫(覆蓋):當在子類定義了一個和父類完全相同的虛擬函式時,則稱這個這個子類的函式重寫了(覆蓋了)父類的虛擬函式。
既然說到這,就有必要區分一下幾個概念:
過載:在同一作用域內,函式名相同,引數不同,返回值可不同的一對函式被稱為過載。
隱藏(重定義):在不同作用域(一般指基類和派生類),函式名相同,引數列表也相同,但不需要virtual關鍵字的一組函式稱為隱藏。
覆蓋:不在同一作用域(一般指派生類和基類),完全相同(協變除外)基類中函式必須有virtual關鍵字的一對函式被稱為重定義。
注:
1,基類中定義了虛擬函式,在派生類中該函式始終保持虛擬函式的特性。
2,只有類的成員函式才能定義為虛擬函式。
3,靜態成員函式不能定義為虛擬函式。
4,如果在類外定義虛擬函式,只能在宣告處加virtual關鍵字,類外定義函式時不能加virtual關鍵字。
5,建構函式不能為虛擬函式。
6,最好不要將賦值運算子過載定義為虛擬函式,因為使用容易混淆。
7,不要在建構函式和解構函式呼叫虛擬函式,在建構函式和解構函式中物件是不完整的,可能會發生未定義的行為。
8,最好將基類的解構函式定義為虛擬函式。(注:雖然基類的解構函式和派生類的解構函式名稱不一樣,但構成覆蓋,因為編譯器做了特殊處理)
9,虛繼承只對虛繼承子類後面派生出的子類有影響,對虛繼承自雷本身沒有影響。
純虛擬函式
純虛擬函式——在成員函式的後面加上=0,則成員函式為純虛擬函式。一個純虛擬函式無需定義,但也可以定義,但是必須在類外,也就是說我們不能在類內部為一個帶有=0的函式提供函式體。包含純虛擬函式的類被稱為抽象類,也叫介面類。抽象類不能例項化出物件。他只是作為基類服務於派生類,如果派生類不對基類的虛擬函式進行覆蓋,那他仍將是抽象基類。
class Father //抽象類(介面類)
{
public:
virtual void fun() = 0; //定義純虛擬函式
protected:
int _a;
};
class Child
{
public:
virtual void fun() = 0; //覆蓋,否則Child也是抽象類(介面類)
};
繼承和友元
友元關係不能繼承,也就是說基類友元不能訪問子類私有和保護成員。
繼承和靜態成員
基類中定義了靜態成員,則整個繼承體系中只有一個這樣的成員。無論派生出多少的子類,都只有一個靜態成員例項。