目錄多態基礎虛函數虛函數的繼承虛類/虛基類重寫/覆蓋條件:概念:多態的條件其他的多態行為多態中子類可以不寫virtual協變代碼舉例繼承遺留問題解決析構函數具體解決方式:題目1答案:解析:題目2答案:C++11 override和finalfinal功能1:禁用繼承使用場景:功能2:禁用重寫使用場景 ...
目錄
多態基礎
虛函數
在函數前加上virtual就是虛函數
class A{
public:
virtual void func(){}; //這是一個虛函數
};
虛函數的繼承
虛函數的繼承體現了介面繼承
繼承了介面等於繼承了函數的殼,這個殼有返回值類型,函數名,參數列表,還包括了預設參數
只需要重寫/覆蓋介面的實現(函數體)
虛類/虛基類
含有虛函數的類是虛類.
是虛類,且是基類,則是虛基類
重寫/覆蓋
條件:
三同:函數名,參數(平常說的參數都是說參數的類型,與預設參數無關),返回值都要相同
概念:
重寫/覆蓋是指該函數是虛函數且函數的名字、類型、返回值完全一樣的情況下,子類的函數體會替換掉繼承下來的父類虛函數的函數體
-
體現介面繼承
-
重寫/覆蓋只有虛函數才有,非虛函數的是隱藏/重定義.註意區別
-
重寫/覆蓋只對函數體有效,返回值類型,函數名,參數列表,和預設參數都不能修改
-
只要子類寫上滿足三同的虛函數都會觸發重寫.無論是否修改函數體
多態的條件
多態有兩個條件,任何一個不滿足都不能執行多態 ,分別是
- 虛函數的重寫
多態的基礎
class Person {
public:
virtual void BuyTicket() { //是虛函數
std::cout<<"全票"<<std::endl;
}
};
class Student :public Person {
public:
virtual void BuyTicket() { //虛函數的重寫
std::cout<<"半票"<<std::endl;
}
};
- 父類類型的指針或引用(接收父類對象或子類對象)的對象去調用虛函數
void func(Person& p){ //父類的指針或引用去調用 p.BuyTicket(); } int main(){ Person p; Student s; func(p); func(s); return 0; }
其他的多態行為
多態中子類可以不寫virtual
多態中子類可以不寫virtual,而且只要父類是虛函數,之後繼承的子孫類都是虛函數(待驗證,是否位於虛表)
class Person {
public:
virtual void BuyTicket() {
std::cout << "全票" << std::endl;
}
};
class Student :public Person {
public:
void BuyTicket() {
std::cout << "半票" << std::endl;
}
};
class Children : public Person {
public:
void BuyTicket(){
std::cout << "三折票" << std::endl;
}
};
void func(Person& p){
p.BuyTicket();
}
int main()
{
Person p;
Student s;
Children c;
func(p);
func(s);
func(c);
return 0;
}
-
說法1:體現介面繼承:繼承了介面==繼承了函數的殼,只需要重寫介面的實現(函數體),這樣就是體現了介面繼承
-
說法2: 可能存在父類子類不是同一個人實現的情況.
假設子類必須是虛函數才能實現多態,如果父類是虛函數,而另外一個人寫子類時忘記加上virtual,這是就有可能發生記憶體泄露問題,如切片後再析構的情況(只析構父類,不析構子類).
因此,父類是虛函數的情況下,子類不強制需要virtual才能發生多態這種行為,能有一定的安全作用.
缺點:沒有統一規範. 最好還是全都加上virtual
協變
概念引入:協變與逆變
協變與逆變規定了編程語言中的類型父子關係的方向
引入這個概念是為了類型安全
協變(父←子)
動物 - 哺乳類 - 熊科 - 黑熊
逆變(子→父)
協變場景下三同中返回值可以不同,且返回值必須是父類或派生類關係的指針或引用
其他方面讀者可以閱讀更具體的資料
代碼舉例
-
舉例1:父類返回類型為父類,子類返回類型為子類
class Person { public: virtual Person& BuyTicket() { std::cout << "全票" << std::endl; Person p; return p; } }; class Student :public Person { public: Student& BuyTicket() { std::cout << "半票" << std::endl; Student s; return s; } };
-
舉例2:父類子類返回類型全部是父類
class Person { public: virtual Person& BuyTicket() { std::cout << "全票" << std::endl; Person p; return p; } }; class Student :public Person { public: Person& BuyTicket() { std::cout << "半票" << std::endl; Person s; return s; } };
-
舉例3:返回值類型為非所在類類型
class A{}; class B : public A{}; class Person { public: virtual A* BuyTicket() { std::cout << "全票" << std::endl; return nullptr; } }; class Student :public Person { public: B* BuyTicket() { std::cout << "半票" << std::endl; return nullptr; } };
-
返回值為非虛函數所在類類型,且都是返回父類
註意:
- 反過來,父類中返回值是子類,子類中返回值是父類是不支持的.
-
全部返回子類也不支持
-
返回值類型為非所在類類型的情況也是如此
總之,當虛函數返回值為基類類型的指針或引用時,編譯器才會檢查是否是協變類型.此時如果派生類虛函數返回值是基類或派生類的指針或引用,則判定為協變;否則不是協變
繼承遺留問題解決
析構函數
先看繼承關係中直接實例對象的代碼
class Person {
public:
~Person() { std::cout << "~Person()" << "\n"; }
};
class Student :public Person {
public:
~Student() { std::cout << "~Student()" << "\n"; }
};
int main(){
Person per;
Student stu;
return 0;
}
結果沒有問題,析構執行是正確的
再看指針切片樣例
int main(){
Person* ptr1 = new Person;
Person* ptr2 = new Student;
delete ptr1;
delete ptr2;
return 0;
}
結果:
顯然,沒有正確的析構.
- 結果說明對切片後的對象進行析構時,只會執行對應切片類型的析構函數.
在繼承篇有提起過的繼承體系中析構函數會被重命名成Destructor.
本意:根據指針(引用)指向的對象類型來選擇對應的析構函數
結果:根據指針(引用)的類型的來選擇對應的析構函數
雖然結果符合正常語法,但是我們在這種情況下並不希望是這樣,我們希望它是根據指針(引用)指向的對象類型來選擇對應的函數執行.
而根據指針(引用)指向的對象類型來選擇對應的函數,這正好就是多態的理念.
因此,為瞭解決切片中這樣的析構函數問題,我們選擇將其轉化成多態來解決.
此時我們已經滿足多態構造的2個條件的其中之一:基類的指針或引用, 剩下的我們需要滿足派生類的析構函數構成對基類析構函數的重寫。而重寫的條件是:返回值類型,函數名,參數列表都相同。對於析構函數,目前還缺的就是函數名相同,因此,析構函數的名稱統一處理為destructor.
具體解決方式:
析構函數都成為虛函數
class Person {
public:
virtual ~Person() { std::cout << "~Person()" << "\n"; }
};
class Student :public Person {
public:
virtual ~Student() { std::cout << "~Student()" << "\n"; }
};
int main(){
Person* ptr1 = new Person;
Person* ptr2 = new Student;
delete ptr1;
delete ptr2;
return 0;
}
至此,徹底解決繼承體系中析構函數問題.
題目1
1.以下程式輸出結果是什麼()
class A
{
public:
virtual void func(int val = 1){ std::cout<<"A->"<< val <<std::endl;}
virtual void test(){ func();}
};
class B : public A
{
public:
void func(int val=0){ std::cout<<"B->"<< val <<std::endl; }
};
int main(int argc ,char* argv[])
{
B*p = new B;
p->test();
return 0;
}
A: A->0 B: B->1 C: A->1 D: B->0 E: 編譯出錯 F: 以上都不正確
答案:
解析:
B*p = new B;
這裡p是普通的指針,不滿足多態.
p->test();
這裡調用了繼承下來的test();
test()
的實際原型是test(A*this)
,因此函數體內即為(A*)->func();
因為test()
在B中,B會將自己的this傳參給test(),即父類類型指針接收子類類型指針.同時func也是虛函數.
因此滿足多態,即test()中調用的是子類的func().
又因為虛函數的繼承是介面繼承,只有函數體是子類的,其他都是父類的,預設參數也是父類的,因此答案是B->1
題目2
以下程式輸出結果是什麼()
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
virtual void test() { func(); }
};
int main(int argc ,char* argv[])
{
B*p = new B;
p->test();
return 0;
}
A: A->0 B: B->1 C: A->1 D: B->0 E: 編譯出錯 F: 以上都不正確
答案:
D: B->0
C++11 override和final
final
功能1:禁用繼承
C++11中允許將類標記為final,繼承該類會導致編譯錯誤.
用法:直接在類名後面使用關鍵字final
class A final
{};
class B : public A //編譯錯誤
{};
使用場景:
明確該類未來不會被繼承時,可以使用final明確告知.
功能2:禁用重寫
C++中還允許將函數標記為final,禁用子類中重寫該方法
用法:在函數體前使用關鍵字final
class A {
public:
virtual void func() final {}
};
class B : public A {
public:
void func() {} //編譯錯誤
};
使用場景
一般情況下,只有最終實現的情況下會使用final: 當你在一個派生類中實現了某個虛函數,並且認為這是該函數的“最終”或“最完善”的實現,不希望後續的派生類再次改變其行為。使用final
關鍵字可以確保這一點,防止函數被進一步重寫。
對虛函數使用final後,編譯器可以做出一些優化,比如內聯調用,因為它知道不會有其他版本的函數存在。
override
場景:
C++對函數重寫的要求是比較嚴格的.如果某些情況因為疏忽而導致函數沒有進行重寫,這種情況在編譯期間是不會報錯的,只有程式運行時沒有得到預期結果才可能意識到出現了問題,等到這時再debug已經得不償失了.
因此,C++11提供了override關鍵字,可以幫助用戶檢測是否完成重寫
描述:
override(覆蓋)關鍵字用於檢查派生類虛函數是否重寫了基類的某個虛函數,如果沒有則無法通過編譯。
用法:
在需要進行重寫的虛函數的函數體前或參數列表花括弧後加上override
class A {
public:
virtual void func() {}
};
class B : public A {
public:
void func(int i) override{ }
};
重載、覆蓋(重寫)、隱藏(重定義)的對比
純虛函數
概念:
在虛函數後面寫上=0,這個函數就為純虛函數.
virtual void fun() = 0;
純虛函數只能寫聲明,不能寫函數體.
抽象類/純虛類
概念
含有純虛函數的類是純虛類,更多的是叫抽象類(也叫做介面類)
class A{
virtual void func() = 0;
};
特點
-
抽象類不能實例化對象
-
抽象類的派生類如果不重寫純虛函數,則還是抽象類
-
純虛函數規範了派生類必須重寫,更體現介面繼承
-
純虛類可以有成員變數
介面繼承和實現繼承
從類中繼承的函數包含兩部分:一是"介面"(interface),二是 "實現" (implementation).
-
介面就是函數的"殼",是函數除了函數體外的所有組成.
-
實現就是函數的函數體.
純虛函數 => 繼承的是:介面 (interface)
普通虛函數 => 繼承的是:介面 + 預設實現 (default implementation)
非虛成員函數 => 繼承的是:介面 + 強制實現 (mandatory implementation)
-
普通函數的繼承是一種實現繼承,派生類繼承了基類函數,繼承的是函數的實現,目的是為了復用函數實現.
-
普通虛函數的繼承是一種介面繼承,派生類繼承的是基類虛函數的介面+預設實現,目的是為了重寫,達成多態.
-
純虛函數只繼承了介面,要求用戶必須要重寫函數的實現.
如果不實現多態,不要把函數定義成虛函數。
多態原理
引入(多態的原理)
計算下麵虛類的大小
class Base{
public:
virtual void func() {}
private:
int _a;
char _b;
};
int main(int argc, char* argv[])
{
std::cout<<sizeof(Base)<<"\n";
return 0;
}
結果:
如果是一般的類,那我們會認為是計算結構體對齊之後的大小,結果應當是8.
但計算結果發現,虛類的結果是12,說明虛類比普通類多了一些東西.
實例化對象Base b;
查看監視視窗
可以發現對象的頭部多了一個指針_vfptr
;這個指針叫做虛函數表指針,它指向了虛函數表
虛函數表指針
指向虛表的指針,叫虛函數表指針,位於對象的頭部.
定義:
如果在類中定義了虛函數,則對象中會增加一個隱藏的指針,叫虛函數表指針__vfptr,虛函數表指針在成員的前面,直接占了4/8位元組.
虛函數表/虛表
描述:
虛函數表指針所指向的表,叫做虛函數表(virtual function table),也叫做虛表
虛函數表本質是一個虛函數指針數組.元素順序取決於虛函數的聲明順序.大小由虛函數的數量決定.
虛表的特性(單繼承)
-
虛表在編譯期間生成.
虛表是由虛函數的地址組成,而編譯期間虛函數的地址已經存在,因此能夠在編譯期間完成.
-
虛函數繼承體系中,虛基類先生成一份虛表,之後派生類自己的虛表都是基於從父類繼承下來的虛表.
-
特例,為了方便使用,VS在虛表數組最後面放了一個nullptr.(其他編譯器不一定有)
- 子類會繼承父類的虛函數表(開闢一個新的數組,淺拷貝)
- 如果派生類重寫了基類中某個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數,如果子類沒有重寫,則虛函數表和父類的虛函數表的元素完全一樣
- 派生類自己新增加的虛函數,從繼承的虛表的最後一個元素開始,按其在派生類中的聲明次序增加到派生類虛表的最後。
- 派生類自己新增的虛函數放在繼承的虛表的後面,如果是基類則是按順序從頭開始放,總而言之,自己新增的虛函數位置一定比繼承的虛函數位置後
- 虛函數和普通函數一樣的,都是存在代碼段的,只是他的指針又存到了虛表中.另外對象中存的不是虛表,存的是虛表指針
- 虛表是在編譯階段就完成了,在初始化列表完成的是虛表指針的初始化
- 同一類型直接定義的對象共用同一個虛表
- 子類對象直接賦值給父類對象後就變成了父類對象,只拷貝成員,不拷貝虛表,虛表還是父類的
虛表的一般示例:
class Person {
public:
virtual void BuyTicket(int val = 1) {
std::cout << "全票" << ":" << val << "\n";
}
virtual void func(int val = 1) {
std::cout << "全票" << ":" << val << "\n";
}
};
class Student :public Person {
public:
void BuyTicket(int val = 0) { //覆蓋
std::cout << "半票" << "=" << val << "\n";
}
};
int main() {
Person p;
Student s;
return 0;
}
對象中的虛表指針在構造函數中初始化
註:虛表指針和成員誰先初始化由編譯器決定
虛表的位置
虛表沒有明確說必須在哪裡,不過我們可以嘗試對比各個區的地址,看虛表的大致位置
class Base{
public:
virtual void func(){
}
private:
int _a;
};
class Derive :public Base {
};
int main()
{
Base b;
Derive d;
int x = 0;
int *y = new int;
static int z = 1;
const char * str = "hello world";
printf("棧對象地址: %p\n",&x);
printf("堆對象地址: %p\n",y);
printf("靜態區對象地址: %p\n",&z);
printf("常量區對象地址: %p\n",str);
printf("Base對象虛表指針: %p\n",*(int**)(&b)); //32位環境
printf("Derive對象虛表指針:%p\n",*(int**)(&d));
return 0;
}
根據地址分析,虛表指針與常量區對象地址距離最近,因此可以推測虛表位於常量區.
另外,在監視視窗中觀察虛表指針與虛函數地址也可以發現,虛表指針與虛函數地址也是比較接近,也可以大致推測在代碼段中.(代碼段常量區很貼近,比較ambiguous,模棱兩可的)
從應用角度來說,虛表也應當位於常量區中,因為虛表在編譯期間確定好後,不會再發生改變,在常量區也是比較合適的.
談談對象切片
我們可以使用子類對象給父類類型賦值,但要註意C++中不支持通過對象切片實現多態.
首先賦值過程會涉及大量拷貝.成本開銷比較大.
其次,拷貝只拷貝成員,不會拷貝虛表.
因為子類中繼承的自父類的虛表可能被子類覆蓋過,如果切片給父類對象,那麼父類對象的虛表中就會有子類重寫的虛函數,顯然不合理.
談談多態的原理
多態是怎麼實現的,其實程式也不知道自己調用的是子類還是父類的,在它眼裡都是一樣的父類指針或引用.
如果是虛函數,則在調用時,會進入到"父類"中去,找到虛函數表中的函數去調用,是父類的就調用父類的,是子類就調用子類的.如果不是虛函數,則直接調用.
多態的實際原理也是傳什麼調什麼,編譯期間虛函數表已經確定好了
再看多態的兩個條件
為什麼需要虛函數重寫,虛表中存的就是子類的虛函數,重寫後就和父類不同了,也就能實現多態的效果.
為什麼需要父類的指針或引用,就是因為指針或引用既能指向父類也能指向子類,能夠實現切片,區分父類和子類
虛函數覆蓋這個詞的由來就是,子類重寫的虛函數會覆蓋父類的.
覆蓋是原理層的叫法.重寫是語法的叫法
虛表列印
常式1.VS查看虛表
class Person {
public:
virtual void BuyTicket(int val = 1) {
std::cout << "全票" << ":" << val << "\n";
}
virtual void func(int val = 1) {
std::cout << "全票" << ":" << val << "\n";
}
};
class Student :public Person {
public:
void BuyTicket(int val = 0) {
std::cout << "半票" << "=" << val << "\n";
}
virtual void Add()
{
std::cout<<"Studetn"<<"\n";
}
};
class C : public Student {
public:
virtual void Add()
{
std::cout<<"C"<<"\n";
}
int _c = 3;
};
void fun(Student &s){
s.Add();
}
int main() {
Person p;
Student s;
C c;
fun(c);
return 0;
}
對上例函數查看VS監視時,發現虛表不顯示完全
需要在監視視窗中手動輸入(void**)0x虛函數表指針,10
,表示以(void*)[10]
方式展開
此後就能全部顯示虛表了
常式2.程式列印虛表
源碼:
(僅適用VS,因為VS會將虛表末尾置空,如果是g++,則需要明確虛表有幾個虛函數)
class A {
public:
virtual void fun1(){
std::cout<<"func1()"<<"\n";
}
virtual void fun2(){
std::cout<<"func2()"<<"\n";
}
};
class B :public A {
public:
virtual void fun3(){
std::cout<<"func3()"<<"\n";
}
};
using VFPTR = void(*)(void);
void PrintVFTable(VFPTR table[])
{
for (int i = 0; table[i]; i++)
{
//1.列印虛類對象的虛表
printf("%p",table[i]);
//2.指針不夠直觀的情況下.可以執行函數指針得到更具象的結果
VFPTR f = table[i];
f();
//小細節:f()能夠正常執行,說明這樣的調用方式能夠自動將虛表所在對象的this傳參到虛函數中.
}
}
int main()
{
A a;
B b;
PrintVFTable((VFPTR*)(*((VFPTR*)&a))); //方式1 (修改,VFPTR*比int*更通用)
puts("");
PrintVFTable(*(VFPTR**)&b); //方式2 ,在明確指向邏輯的情況下,二級指針更為簡潔
/* 代碼理解:
1.typedef和using語法層面功能都是將類型重命名,這個重命名會被認定成一個新類型,需要時再進行解釋.
2.int*在32位和64位下解引用都是4位元組.而指針大小在32位下是4位元組,64位下是8位元組.在64位機器下使用int*解引用的話,就會得到錯誤的結果.因此int*不夠普遍.
3.VFPTR被當作一個新類型來看待.直接使用VFPTR時,編譯器認為是非指針變數;使用VFPTR*時,編譯器認為是一級指針變數.(VFPTR*)&a即為將a的地址轉成類型為VFPTR的一級指針.之後,解引用則以VFPTR的大小為步長,取出相應的數據(虛表指針,也是虛表首地址).VFPTR實際類型為函數指針,32位下為4位元組,64位下為8位元組,因此解引用後能夠取得正確的結果.
*/
return 0;
}
模型圖
多繼承虛表
先看虛函數多繼承體系下記憶體佈局
class Base1 {
public:
virtual void func1() { std::cout << "Base1::func1" <<std::endl; }
virtual void func2() { std::cout << "Base1::func2" << std::endl; }
private:
int b1 = 1;
};
class Base2 {
public:
virtual void func1() { std::cout << "Base2::func1" << std::endl; }
virtual void func2() { std::cout << "Base2::func2" << std::endl; }
private:
int b2 = 1;
};
class Derive : public Base1, public Base2 {
public:
//子類重寫func1
virtual void func1() { std::cout << "Derive::func1" << std::endl; }
//子類新增func3
virtual void func3() { std::cout << "Derive::func3" << std::endl; }
private:
int d1 =2;
};
int main()
{
Derive d;
return 0;
}
簡單分析可知,虛函數多繼承體系下派生類會根據聲明順序依次繼承父類.繼承方式類似於虛繼承.
多繼承下子類自己新增的虛函數在哪?
我們知道,單繼承中,子類自己新增的虛函數會尾插到虛表的末尾.
那麼多繼承呢?是每個父類都添加?還是只添加到其中一個?添加到一個的話添加到哪裡?
要知道結果,必須要看一眼虛表的真實情況.因此我們列印所有虛表看看情況.
多繼承虛表列印代碼
int main()
{
Derive d;
/*列印d中Base1的虛表*/
std::cout<<"Base1的虛表"<<"\n";
PrintVFTable(*(VFPTR**)(&d));
puts("");
/*列印d中Base2的虛表*/
std::cout<<"Base2的虛表"<<"\n";
//方法1,手動計算指針偏移
//PrintVFTable((VFPTR*)*(VFPTR*)((char*)&d+sizeof(Base1)));
//PrintVFTable(*(VFPTR**)((char*)&d+sizeof(Base1)));
//方法2,切片,自動計算指針偏移 -- 推薦,不容易出錯
Base2 *b2 = &d;
PrintVFTable(*(VFPTR**)b2);
return 0;
}
結論與發現:
- 通過結果能證明,子類自己新增的虛函數只會添加進第一個繼承的父類的虛表中,也就是尾插.
- 子類會繼承所有父類的虛表,有多少個父類就有多少個虛表
-
結果也證明,子類重寫會對所有父類的同名函數進行覆蓋
-
觀察結果還發現,兩個func1的地址居然不一樣.這其實涉及到C++this指針的原理問題->this指針修正.
要搞明白是什麼情況,我們需要觀察彙編代碼,去看更深層次的邏輯.
this指針修正分析
常式代碼
int main(){
Derive d;
Base1 *ptr1 = &d;
Base2 *ptr2 = &d;
ptr1->func1();
ptr2->func1();
return 0;
}
先觀察ptr1
再看ptr2
對比可以發現,ptr2要比ptr1走多了好幾步才能正確調用fun1.
解釋:
看ptr2的的中間過程有句彙編sub ecx,8
,功能是ecx-8再放到ecx中.而ecx在類中通常表示類的this指針,即sub ecx,8
得功能是將this指針-8,這裡8剛好是sizeof(Base1)的值,因此sub ecx,8
就可以解釋成this向下偏移8個位元組,因為對象的this指針位於低位元組,這就同時剛好滿足了this指向Base2.再結合問題場景,就可以同步證明ptr2多走的這幾步目的就是為了讓指針正確偏移回對象d的this.
再結合切片原理,切片後會自動計算將ptr2指向了d中Base2的首地址,可以推測切片後ecx也指向了Base2的首地址.為了能夠發生多態,需要將ecx重新偏移至正確位置.
這就是多繼承下多態的原理
虛表中地址(概念修正)
- 虛函數地址:這是虛函數在程式記憶體中的實際地址,即函數體開始的位置。
- 虛表中的地址:虛表中存儲的地址通常直接指向虛函數的實際地址。然而,在某些情況下,如為了實現一些優化,編譯器可能不會直接在虛表中存儲虛函數的地址,而是存儲一個“跳躍”函數的地址,這個跳躍函數再跳轉到虛函數的真實地址。這種跳躍函數可以用來做額外的檢查或者優化,例如性能計數、調試信息插入等。
所以,虛表中存放的地址大多數情況下就是虛函數的真實地址,但在某些特定的優化場景下,它可能指向一個中間函數,這個中間函數再負責跳轉到真實的函數地址。這種間接調用的機制有時被稱為“thunk”,它允許編譯器在運行時進行更複雜的控制流分析和優化。
對於現代的C++編譯器,如GCC或Clang,它們預設的行為是在虛表中直接存儲虛函數的真實地址,除非有特殊的優化需求。如果想瞭解具體的實現,可以通過反彙編工具(如objdump, IDA Pro等)查看編譯後的二進位文件,檢查虛表的結構和內容。
菱形繼承+多態 與 菱形虛擬繼承+多態
菱形繼承本來就是很複雜的東西,再加上多態,更加複雜,實際工作中也很少會使用菱形繼承多態.
簡單演示一下,有興趣的讀者可以自行擴展研究.
菱形繼承+多態
class A {
public:
virtual void func1() {}
public:
int _a;
};
class B : public A {
public:
virtual void func1() {}
public:
int _b;
};
class C : public A {
public:
virtual void func1() {}
public:
int _c;
};
class D : public B, public C {
public:
virtual void func1() {}
public:
int _d;
};
int main() {
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
記憶體佈局:
菱形虛擬繼承+多態(子類沒有新增虛函數)
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:
virtual void func1() {}
public:
int _d;
};
(其中要求最遠類必須重寫虛基類A的虛函數,因為要消除二義性,是B是C都不好,最好是D.)
記憶體佈局:
和非多態菱形虛擬繼承很類似.只重寫虛基類虛函數時,只有虛基類有虛表
菱形虛擬繼承+多態(子類自己新增了虛函數)
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:
virtual void func1() {}
public:
int _d;
};
記憶體佈局:
64位環境下記憶體佈局
推測虛基表中低四位元組是存放虛表偏移量,也可能是到B或C類型首部的偏移量.
一些概念
動態綁定和靜態綁定
-
靜態綁定又稱為前期綁定(早綁定),在程式編譯期間就確定了程式的行為,即編譯時,也稱為靜態多態.
靜態多態例子:函數重載,如
std::cout<<
的類型自動識別,原理就是函數名修飾規則將operator<<(不同的參數)在編譯時生成多份(都是生成多份,C語言需要程式員手動,C++由編譯器自動生成),使傳的參數不同時能夠對外表現出不同的行為.這種技術給開發者和用戶都帶來了使用上的便利. -
動態綁定也稱為後期綁定(晚綁定),是在程式運行期間,即運行時,根據具體拿到的類型確定程式的具體行為,調用具體的函數,也稱為動態多態.虛函數多態就是動態多態.
內聯函數inline 和 虛函數virtual
inline如果被編譯器識別成內聯函數,則該函數是沒有地址的. 與虛表中存放虛函數的地址有衝突.
但事實上,inline 和 virtual 可以一起使用 :
-
這取決於使用該函數的場景:內聯是一個建議性關鍵字,如果發生多態,則編譯器會忽略內聯.如果沒有發生多態,才有可能成為內聯函數
-
即:多態和內聯可以一起使用,但同時只能有一個發生
靜態函數static 與 虛函數
靜態成員函數不能是虛函數,因為靜態成員函數沒有this指針,與多態發生條件矛盾
-
父類引用/指針去調用
-
static函數沒有隱藏this參數.不滿足虛函數重寫條件"三同"
-
靜態成員函數目的是給所有對象共用,不是為了實現多態
構造函數、拷貝構造函數、賦值運算符重載 與 虛函數
-
構造,拷貝構造不能是虛函數
- 構造函數需要幫助父類完成初始化,必須一起完成,不能像多態那樣非父即子(父對象調父的,子對象調子的);
- 虛表指針初始化是在構造函數的初始化列表中完成的,要先執行完構造函數,才能有虛函數
- 構造函數多態沒有意義
-
賦值運算符重載也和拷貝構造一樣,不建議寫成虛函數,雖然編譯器不報錯.
虛函數和普通函數誰快?
一般來說,普通函數會比構成多態調用的虛函數快.但要註意,是虛函數在構成多態調用的情況下.
看例子1:
class AA {
public:
virtual void func1() {}
};
class BB : public AA {
void func2(){};
};
int main() {
AA a;
BB b;
a.func1();
b.func1();
return 0;
}
在VS2019-32位環境下,兩種函數在對象的調用下彙編代碼是一樣的.因此這種情況下它們一樣快.
看例子2:
成員函數為非虛函數時,指針調用是普通調用
class AA {
public:
void func1() {}
};
class BB : public AA {
};
int main()
{
BB b;
BB*ba = &b;
pb->func1();
return 0;
}
看例子3:
class AA {
public:
virtual void func1() {}
};
class BB : public AA {
};
int main()
{
AA*pa = &a;
pa->func1();
BB*pb = &b;
pa->func1();
return 0;
}
在虛函數情況下(包括繼承和非繼承),使用指針調用都會觸發多態的調用方式,顯然這時調用虛函數效率會比普通函數慢.
小結:
通過上面幾個例子分析,發現有虛函數,且是指針的情況下,無論有沒有發生多態,調用方式都會發生改變.上面舉的名詞"多態的調用方式"是為了描述這種調用方式.
這種調用方式簡化了編譯器的調用邏輯:只要是虛函數,且是指針/引用,都會去虛表中找.如果滿足多態的條件就能發生多態的現象,否則就是正常調用.
因此,需要註意區分多態的調用方式與多態的現象.常說的多態的兩個條件是指滿足這兩個條件才能觸發多態的現象.與是否是多態的調用方式無關.