目錄
1. 菱形虛擬繼承原理剖析
1.1.虛基表
2. 單繼承和多繼承的虛函數表深入探索
2.1 單繼承虛函數表深入探索
2.2 多繼承虛函數表深入探索
?編輯
2.3 菱形繼承、菱形虛擬繼承
3. 繼承和多態考察的一些常見問題
1. 菱形虛擬繼承原理剖析
繼承的文章中我們講到C++的多繼承就會引發一些場景出現菱形繼承,有了菱形繼承,就會出現數據冗余和二義性的問題,C++又引入了虛繼承來解決數據冗余和二義性。
class Person
{
public:string _name; // 姓名
};
// class Student : public Person
class Student : virtual public Person
{
protected:int _num; // 學號
};
// class Teacher : public Person
class Teacher : virtual public Person
{
protected:int _id; // 職工編號
};
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修課程
};
void Test()
{// 這樣會有二義性無法明確知道訪問的是哪一個Assistant a;a._name = "peter";// 需要顯示指定訪問哪個父類的成員可以解決二義性問題,但是數據冗余問題無法解決a.Student::_name = "xxx";a.Teacher::_name = "yyy";
}
? 為了研究虛擬繼承原理,我們給出了一個簡化的菱形繼承繼承體系,再借助內存窗口觀察對象成員的模型。要注意的是這里必須借助內存窗口才能看到真實的底層對象內存模型,vs編譯器的監視窗口是經過特殊處理的,以它的角度給出了一個方便看的樣子,但并不是本來的樣子。但是有時想看清真實的內存模型,往往需要借助內存窗口。
1.1.虛基表
在前面繼承的文章中,我們了解到為了避免菱形繼承所導致的數據冗余,子類會將重復繼承的部分合并為一份,放在類的最上或者最下面。但是這里引出一個問題是當我們通過父類指針訪問子類對象,這是對于合并的部分,要如何確定位置呢?大家可能覺得合并的部分不是已經放在最后或者最上面了嗎?但是這里如果我們使用不同的父類指針,偏移多少才能到底呢?因此需要虛基表記錄父類對應的偏移量。
虛基表是編譯器為了解決多重繼承場景下的菱形繼承問題所設計的,虛基表(vbtable)通過記錄虛基類實例的偏移量來指示派生類如何訪問唯一的虛基類實例。當子類通過多繼承方式繼承多個具有共同基類的父類時,如果不使用虛繼承,子類會包含多分共同基類的數據,這會導致數據冗余。而是要虛繼承,子類中只會包含一份共同基類的數據。
? 通過下面的簡化菱形虛擬繼承模型,我們可以看到,D對象中的B和C部分中分別包含一個指向虛基表的指針,B指向的虛基表中存儲了B對象部分距離公共的A的相對偏移量距離,C指向的虛基表中存儲了C對象部分距離公共的A的相對偏移量距離。這樣公共的虛基類A部分在D對象中就只有一份了,這樣就解決了數據冗余和二義性的問題。
? 通過B的對象模型,我們發現菱形虛擬繼承中B和C的對象模型跟D保持的一致的方式去存儲管理A,這樣當B的這指針訪問A時,無論B指針切片指向D對象,還是B指針直接指向B對象,訪問A成員都是通過虛基表指針的方式查找到A成員再訪問。
class A
{
public:int _a;
};
// class B : public A
class B : virtual public A
{
public:int _b;
};
// class C : public A
class C : virtual public A
{
public:int _c;
};
class D : public B, public C
{
public:int _d;
};
int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._a = 3;d._b = 4;d._c = 5;d._d = 6;return 0;
}
int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._a = 3;d._b = 4;d._c = 5;d._d = 6;B b;b._a = 7;b._b = 8;// B的指針指向B對象B *p2 = &b;// B的指針指向D對象切片B *p1 = &d;// p1和p2分別對指向的_a成員訪問修改// 分析內存模型,我們發現B對象也使用了虛基表指向A成員的模型// 所以打開匯編我們看到下面的訪問_a的方式是一樣的p1->_a++;p2->_a++;return 0;
}
2. 單繼承和多繼承的虛函數表深入探索
2.1 單繼承虛函數表深入探索
? vs編譯器的監視窗口是經過特殊處理的,以它的角度給出了一個方便看的樣子,但并不是本來的樣子。多態部分我們講了,虛函數指針都要放進虛函數表,這里我們通過監視窗口觀察Derive對象,看不到func3和func4在虛表中,借助內存窗口可以看到一個地址,但是并不確認是不是func3和func4的地址。所以下面我們寫了一份特殊代碼,通過指針的方式,強制訪問了虛函數表,調用了虛函數,確認繼承中虛函數表中的真實內容。
class Base
{
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }private:int a;
};
class Derive : public Base
{
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; }void func5() { cout << "Derive::func5" << endl; }private:int b;
};
typedef void (*VFPTR)();
void PrintVTable(VFPTR vTable[])
{// 依次取虛表中的虛函數指針打印并調用。調用就可以看出存的是哪個函數cout << " 虛表地址>" << vTable << endl;// 注意如果是在g++下面,這里就不能用nullptr去判斷訪問虛表結束了for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d個虛函數地址 :0X%p,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}
int main()
{Base b;Derive d;// 32位程序的訪問思路如下:// 需要注意的是如果是在64位下,指針是8byte,對應程序位置就需要進行更改// 思路:取出b、d對象的頭4bytes,就是虛表的指針,前面我們說了虛函數表本質是一個存虛函數指針的指針數組,vs下這個數組最后面放了一個nullptr,g++ 下面最后沒有nullptr// 1.先取b的地址,強轉成一個int*的指針// 2.再解引用取值,就取到了b對象頭4bytes的值,這個值就是指向虛表的指針// 3.再強轉成VFPTR*,因為虛表就是一個存VFPTR類型(虛函數指針類型)的數組。// 4.虛表指針傳遞給PrintVTable進行打印虛表// 5.需要說明的是這個打印虛表的代碼經常會崩潰,因為編譯器有時對虛表的處理不干凈,虛表最后面沒有放nullptr,導致越界,這是編譯器的問題。我們只需要點目錄欄的 -生成 - 清理解決方 案,再編譯就好了。 VFPTR *vTable1 = (VFPTR *)(*(int *)&b);PrintVTable(vTable1);VFPTR *vTable2 = (VFPTR *)(*(int *)&d);PrintVTable(vTable2);return 0;
}
2.2 多繼承虛函數表深入探索
class Base1
{
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }private:int b1;
};
class Base2
{
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }private:int b2;
};
class Derive : public Base1, public Base2
{
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }private:int d1;
};
typedef void (*VFPTR)();
void PrintVTable(VFPTR vTable[])
{cout << " 虛表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d個虛函數地址 :0X%p,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}
int main()
{Derive d;VFPTR *vTableb1 = (VFPTR *)(*(int *)&d);PrintVTable(vTableb1);VFPTR *vTableb2 = (VFPTR *)(*(int *)((char *)&d + sizeof(Base1)));PrintVTable(vTableb2);Base1 *p1 = &d;p1->func1();Base2 *p2 = &d;p2->func1();d.func1();return 0;
}
? 跟前面單繼承類似,多繼承時Derive對象的虛表在監視窗口也觀察不到部分虛函數的指針。所以我們一樣可以借助上面的思路強制打印虛函數表。
? 需要注意的是多繼承時,Derive中同時繼承了Base1和Base2,內存中先繼承的對象在前面,并且Derive中包含的Base1和Base2各有一張虛函數表,通過觀察我們發現Derive沒有重寫的虛函數func3,選擇放在先繼承的Base1的虛函數表中。
? 另外需要注意的是,有些細心的讀者發現Derive對象中重寫的Base1虛表的func1地址和重寫Base2虛表的func1地址不一樣,這是為什么呢?這個問題還比較復雜。需要我們分別對這兩個函數進行多態調用,并翻閱對應的匯編代碼進行分析,才能捋清楚問題所在。這里簡單說一個結論就是本質Base2虛表中func1的地址并不是真實的func1的地址,而是封裝過的func1地址,因為Base2指針p2指向Derive時,Base2部分在中間位置,切片時,指針會發生偏移,那么多態調用p2->func1()時,p2傳遞給this前需要把p2給修正回去指向Derive對象,因為func1是Derive重寫的,里面this應該是指向Derive對象的。
2.3 菱形繼承、菱形虛擬繼承
實際中我們不建議設計出菱形繼承及菱形虛擬繼承,一方面太復雜容易出問題,另一方面這樣的模
型,訪問基類成員有一定得性能損耗。所以菱形繼承、菱形虛擬繼承我們的虛表本文就不看了,一般我們也不需要研究清楚,因為實際中很少用。好奇心強的讀者,可以去看下面的兩篇鏈接文章。
1. C++ 虛函數表解析
2. C++ 對象的內存布局
class A
{
public:virtual void func1() {}public:int _a;
};
class B : virtual public A
{
public:virtual void func1() {}virtual void func2() {}public:int _b;
};
class C : virtual public A
{
public:virtual void func1() {}virtual void func3() {}public:int _c;
};
class D : public B, public C
{
public:D(): _d(1){}inline virtual void func1() {}virtual void func4() {}public:int _d;
};
int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._a = 3;d._b = 4;d._c = 5;d._d = 6;return 0;
}
3. 繼承和多態考察的一些常見問題
1. 什么是多態?答:參考前面多態文章
2. 什么是重載、重寫(覆蓋)、重定義(隱藏)?答:參考前面多態文章
3. 多態的實現原理?答:參考前面多態文章
4. inline函數可以是虛函數嗎?答:可以,不過編譯器就忽略inline屬性,這個函數就不再是inline屬性,因為虛函數要放到虛表中去,也就是說inline屬性和虛函數屬性是不同同時存在的。
5. 靜態成員可以是虛函數嗎?答:不能,因為靜態成員函數沒有this指針,使用類型::成員函數的調用方式無法訪問虛函數表,所以靜態成員函數無法放進虛函數表。
6. 構造函數可以是虛函數嗎?答:不能,因為對象中的虛函數表指針是在構造函數初始化列表階段才初始化的。
7. 析構函數可以是虛函數嗎?什么場景下析構函數是虛函數?答:可以,并且最好把基類的析構函數定義成虛函數。參考本文內容
8. 對象訪問普通函數快還是虛函數更快?答:首先如果是普通對象調用,是一樣快的。如果是指針或者是引用對去調用,則調用的普通函數快,因為構成多態,運行時調用虛函數需要到虛函數表中去查找。
9. 虛函數表是在什么階段生成的,存在哪的?答:虛函數表是在編譯階段就生成的,一般情況下存在代碼段(常量區)的。
10. C++菱形繼承的問題?虛繼承的原理?答:參考前面繼承文章。注意這里不要把虛函數表和虛基表搞混了。
11. 什么是抽象類?抽象類的作用?答:參考前面繼承文章;抽象類強制重寫了虛函數,另外抽象類體現出了接口繼承關系。