? ? ? ? ?
づ?ど
?🎉?歡迎點贊支持🎉
個人主頁:勵志不掉頭發的內向程序員;
專欄主頁:C++語言;
文章目錄
前言
一、多態的概念
二、多態的定義及實現
2.1、多態的構成條件
(1)虛函數
(2)虛函數的重寫/覆蓋
?(3)實現多態的兩個必須重要條件
(4)虛函數重寫的一些其他問題
(5)override 和 final 關鍵字
(6)重載/重寫/隱藏的對比
三、純虛函數和抽象類
四、多態的原理
4.1、虛函數表指針
4.2、多態的原理
(1)多態是如何實現的
(2)動態綁定與靜態綁定
(3)虛函數表
總結
前言
學習了繼承以后,我們接著來看看我們繼承的延伸—多態,多態的內容也是非常的多,我們大家得仔細學習,我們想要成為?C++ 大佬是無論如何都繞不開我們繼承和多態的。
一、多態的概念
多態通俗來說就是多種形態。多態分為編譯時多態(靜態多態)和運行時多態(動態多態),這里我們重點講運行時多態。
編譯時多態主要就是我們前面講的函數重載和函數模板,他們通過傳不同類型的參數就可以調用不同的函數,通過參數的不同達到多種形態,之所以叫編譯時多態,是因為他們實參傳給形參的參數匹配是在編譯時完成的,我們把編譯時一般歸為靜態,運行時歸為動態。
運行時多態,具體點就是去完成某個行為(函數),可以傳不同的對象就會完成不同的行為,就達到多種形態。比如吃飯這個行為,當中國人吃飯時,拿的是筷子調羹;美國人吃飯時,拿的是刀和叉子;印度人吃飯時直接用手等。再比如同樣是動物叫的一個行為(函數),傳貓對象過去就是 "喵喵";狗對象過去就是 "汪汪"。
二、多態的定義及實現
2.1、多態的構成條件
(1)虛函數
類成員函數前面加 virtual 修飾,那么這個成員函數就被稱為虛函數。注意非成員函數不能加 virtual 修飾。
class Person
{
public:// 虛函數virtual void EatFood(){cout << "用餐具吃飯" << endl;}
};
(2)虛函數的重寫/覆蓋
虛函數的重寫/覆蓋:派生類中有一個跟基類完全相同的虛函數,即派生類虛函數與基類虛函數的返回值、函數名字、參數列表完全相同(簡稱?"三同"),稱派生類的虛函數重寫了基類的虛函數。
class Person
{
public:virtual void EatFood(){cout << "用餐具吃飯" << endl;}
};class Chinese : public Person
{
public:// 此處構成虛函數的覆蓋/重寫virtual void EatFood(){cout << "用筷子吃飯" << endl;}
};
我們發現上面的代碼在重寫我們的虛函數時,派生類的虛函數前也加了 virtual 關鍵字。但其實派生類的虛函數在不加 virtual 關鍵字時,也可以構成重寫(因為繼承后基類的虛函數被繼承下來了在派生類依舊保持虛函數屬性),但是這樣寫不是非常規范,不建議這樣使用。我們要明白虛函數的具體寫法,而不是看前面有沒有 virtual。
class Person
{
public:virtual void EatFood(){cout << "用餐具吃飯" << endl;}
};class Chinese : public Person
{
public:// 此處依然構成虛函數的覆蓋/重寫void EatFood(){cout << "用筷子吃飯" << endl;}
};
這樣寫也可以,但是強烈不建議。
?(3)實現多態的兩個必須重要條件
我們在學習完我們上面的內容,我們結合上面內容來看看實現多態的兩個重要條件。
- 必須是基類的指針或者引用調用虛函數。
- 被調用的函數必須是虛函數,并且完成了虛函數重寫/覆蓋。
我們在繼承那一章節學習了切片,子類和子類的指針引用可以賦值給我們父類以達到切片的效果,但是反過來就不太行,所以這里的函數就得調用基類的指針或者引用,這樣我們不管是傳父類還是子類(子類會被父類切片)都可以傳參。
同時子類必須對父類的虛函數進行重寫/覆蓋,不然我們不管調用父類的函數還是子類的函數都是一樣的效果,也沒辦法實現多態的不同形態的效果。
class Person
{
public:// 虛函數virtual void EatFood(){cout << "用餐具吃飯" << endl;}
};class Chinese : public Person
{
public:// 基類虛函數的重寫/覆蓋virtual void EatFood(){cout << "用筷子吃飯" << endl;}
};void Func(Person* ptr)
{// 這?可以看到雖然都是Person指針Ptr在調?EatFood// 但是跟ptr沒關系,?是由ptr指向的對象決定的。ptr->EatFood();
}int main()
{Person ps;Chinese ce;Func(&ps);Func(&ce);return 0;
}
看上去我們都是調用的同一個函數,但其實這個函數的輸出是和我們傳的 ptr 的類型是有關聯的,指向父類調用父類,指向子類調用子類。
我們傳給函數 Person 的 引用也是可以的。
void Func(Person& ptr)
{ptr.EatFood();
}
但是如果我們破環掉這兩個必須的重要條件,我們的程序就無法實現多態的效果了。
破壞第一條,我們傳值給 ptr。
void Func(Person ptr)
{ptr.EatFood();
}
或者破壞第二條,我們不去給我們的從父類繼承下來的虛函數進行重寫。
class Chinese : public Person
{
public:
};void Func(Person& ptr)
{ptr.EatFood();
}
我們的多態都會失效。
當然,我們多個子類之間也可以實現多態的條件。
class Animal
{
public:virtual void talk() const{}
};class Dog : public Animal
{
public:virtual void talk() const{cout << "汪汪" << endl;}
};class Cat : public Animal
{
public:virtual void talk() const{cout << "(>^ω^<)喵" << endl;}
};class Mouse : public Animal
{
public:virtual void talk() const{cout << "吱吱" << endl;}
};void letsHear(const Animal& animal)
{animal.talk();
}int main()
{Cat cat;Dog dog;Mouse mouse;letsHear(cat);letsHear(dog);letsHear(mouse);return 0;
}
(4)虛函數重寫的一些其他問題
協變(了解)
派生類重寫基類虛函數時,與基類函數返回值類型不同。即基類虛函數返回基類對象的指針或者引用,派生類虛函數返回派生類對象的指針或者引用,稱為協變。協變的實際意義并不大,所以我們了解一下即可。
class A{};class B : public A {};class Person
{
public:virtual A* EatFood(){cout << "用餐具吃飯" << endl;return nullptr;}
};class Chinese : public Person
{
public:virtual B* EatFood(){cout << "用筷子吃飯" << endl;return nullptr;}
};void Func(Person& ptr)
{// 這?可以看到雖然都是Person指針Ptr在調?EatFood// 但是跟ptr沒關系,?是由ptr指向的對象決定的。ptr.EatFood();
}int main()
{Person ps;Chinese ce;Func(ps);Func(ce);return 0;
}
我們的虛函數的返回值不相同,父類對象返回一個父類指針或引用,子類對象返回一個子類指針或引用。同樣可以實現多態的效果,我們就稱為協變。
析構函數的重寫
基類的析構函數為虛函數,此時派生類析構函數只要定義,無論是否加 virtual 關鍵字,都與基類的析構函數構成重寫。雖然基類與派生類析構函數名字不同看起來不符合重寫規則,實際上編譯器對析構函數的名稱做了特殊處理,編譯后析構函數的名稱統一處理成 destructor,所以基類的析構函數加了 virtual 修飾,派生類的析構函數就構成重寫了。
class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};class B : public A
{
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10];
};
我們這里可以看到,我們 B 的析構名字和 A 的不一樣,按道理來說是不會構成重寫的,但實際上它們是構成的,因為我們編譯器會把析構的名字統一處理成 destructor,所以此時它們名字相同了就構成了重寫了。
我們要讓析構函數變成虛函數的原因是因為當我們創建了一個父類的指針,有可能指向父類對象,也有可能指向子類對象。如果指向子類對象,此時我們調用父類的 delete 就會無法析構子類的那一部分。
int main()
{// 沒問題A* p1 = new A;delete p1;// 有問題A* p2 = new B;delete p2;return 0;
}
我們 delete p2 時我們其實是期望它調用子類的析構函數的,不然就會出現問題。所以我們就得實現虛函數的重寫去讓我們指向父類調用父類,指向子類調用子類。
(5)override 和 final 關鍵字
從上面可以看出,C++ 對函數重寫的要求比較嚴格,但是有些情況下由于疏忽,比如函數名寫錯參數寫錯等導致無法構成重寫,而這種錯誤在編譯期間是不會報出的,而在程序運行時沒有得到預期結果去 debug 就會得不償失,因此 C++11 提供了 override,可以幫助用戶檢測是否重寫。如果我們不想讓派生類重寫這個虛函數,那么可以用 final 去修飾。
class Car
{
public:virtual void Dirve(){}
};class Benz :public Car {
public:virtual void Drive() override { cout << "Benz-舒適" << endl;}
};
此時我們的 Dirve 由于寫錯了而導致無法構成重寫,此時我們在后面加入的 override 就起作用了。
當然,如果有基類的成員函數不想被重寫,加入 final 就不能被繼承了。
class Car
{
public:virtual void Drive() final {}
};class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒適" << endl; }
};
(6)重載/重寫/隱藏的對比
它們都有函數之間的關系,相似性極高。
三、純虛函數和抽象類
在虛函數的后面寫上 =0,則這個函數為純虛函數,純虛函數不需要定義實現(實現沒啥意義,因為要被派生類重寫,但是語法上可以實現),只要聲明即可。包含純虛函數的類叫做抽象類,抽象類不能實例化出對象,如果派生類繼承后不重寫純虛函數,那么派生類也是抽象類。純虛函數某種程度上強制了派生類重寫虛函數,因為不重寫實例化不出對象。
class Car
{
public:virtual void Drive() = 0;
};class Benz :public Car
{
public:virtual void Drive(){cout << "Benz-舒適" << endl;}
};class BMW :public Car
{
public:virtual void Drive(){cout << "BMW-操控" << endl;}
};
此時我們 Car 中的 Drive 就是我們的純虛函數,Car 就是我們的抽象類。我們的抽象類無法實例出對象。
int main()
{// 編譯報錯?法實例化抽象類Car car;return 0;
}
正是因為無法實例化出對象,所以我們沒有必要去實現我們的純虛函數,因為無法調用。
如果我們的子類不去實現我們的純虛函數,我們的類依舊是抽象類,因為依舊會繼承我們的父類的純虛函數。但是我們的子類也無法實例化出對象了。
class Benz :public Car
{
public:
};int main()
{Car* pBenz = new Benz;pBenz->Drive();return 0;
}
所以說我們的純虛函數的作用就是強制我們的派生類去實現這個純虛函數,不然就無法調用。當我們實現了,就能夠成功調用了。
int main()
{Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;
}
四、多態的原理
4.1、虛函數表指針
我們來算一下下面代碼的運行結果。
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
protected:int _b = 1;char _ch = 'x';
};int main()
{Base b;cout << sizeof(b) << endl;return 0;
}
我們大家一看,這不是很簡單嗎,Base 類中有一個 int 整型,char 整型,所以就是 8 字節秒了。實則不然。
我們的答案是 12 字節,我們通過調試發現,除了 _b 和 _c 成員,還多一個 _vfptr 放在對象的前面(有的平臺可能放對象后面,和平臺有關)。
對象中的這個指針我們叫做虛函數表指針(v 表示 virtual,f 表示 function)。一個含有虛函數的類中都至少有一個虛函數表指針,因為一個類所有的虛函數的地址要被放到這個類對象的虛函數中,虛函數表也稱為虛表。嚴格來說,它是一個函數指針數組。
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}virtual void Func2(){cout << "Func2()" << endl;}virtual void Func3(){cout << "Func3()" << endl;}protected:int _b = 1;char _ch = 'x';
};
4.2、多態的原理
(1)多態是如何實現的
從底層的角度 Func 函數中的 ptr->BuyTicket(),是如何作為 ptr 指向 Person 對象調用 Person::BuyTicket,ptr 指向 Student 對象調用 Student::BuyTicket 的呢?
class Person
{
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
private:string _name;
};class Student : public Person
{
public:virtual void BuyTicket() { cout << "買票-打折" << endl; }
private:string _id;
};class Soldier : public Person
{
public:virtual void BuyTicket() { cout << "買票-優先" << endl; }
private:string _codename;
};void Func(Person* ptr)
{// 這?可以看到雖然都是Person指針Ptr在調?BuyTicket// 但是跟ptr沒關系,?是由ptr指向的對象決定的。ptr->BuyTicket();
}int main()
{// 其次多態不僅僅發?在派?類對象之間,多個派?類繼承基類,重寫虛函數后// 多態也會發?在多個派?類之間。Person ps;Student st;Soldier sr;Func(&ps);Func(&st);Func(&sr);return 0;
}
通過下圖我們可以看到,滿足多態條件后,底層不再是編譯時通過調用對象確定函數的地址,而是運行時到指向的對象的虛表中確定對應的虛函數的地址,這樣就實現了指針或引用指向基類就調用基類,指向派生類就調用派生類對應的虛函數。
由于切片,導致我們傳遞給 Func 函數的每一個類都只有 Person 的那一部分,此時我們編譯器就是更具我們的虛函數表指針中的地址找到我們不同的函數位置,從而形成多態行為的。
這張圖就是我們 Person 類傳遞給 ptr,ptr 指向 Person,此時我們就會調用 Person 的 _vfptr 去找尋關于 BuyTicket 函數的指針。
(2)動態綁定與靜態綁定
- 對不滿足多態條件(指針或者引用 + 調用虛函數)的函數調用是在編譯時綁定的,也就是編譯時確定調用函數的地址,叫做靜態綁定
- 滿足多態條件的函數調用是在運行時綁定,也就是在運行時指向對象的虛函數表中找到調用函數的地址,也叫做動態綁定。
我們可以從匯編來看看。
// ptr是指針+BuyTicket是虛函數滿?多態條件。
// 這?就是動態綁定,編譯在運?時到ptr指向對象的虛函數表中確定調?函數地址
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax
// BuyTicket不是虛函數,不滿?多態條件。
// 這?就是靜態綁定,編譯器直接確定調?函數地址
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch)
(3)虛函數表
- 基類對象的虛函數表中存放基類所有的虛函數的地址。同類型的對象共用一張虛表,不同類型的對象各自有獨立的虛表,所以基類和派生類有各自獨立的虛表。
- 派生類由兩部分構成,繼承下來的基類和自己的成員,一般情況下,繼承下來的基類中有虛函數表指針,自己就不會再生成虛函數表指針。但是要注意這里繼承下來的基類部分虛函數表指針和基類對象的虛函數表指針不是同一個,就像基類對象的成員和派生類對象中的基類對象成員也是相互獨立的。
- 派生類中重寫基類的虛函數,派生類的虛函數表中對應的虛函數就會被覆蓋成派生類重寫的虛函數地址。
- 派生類的虛函數表中包含:(1)基類的虛函數地址;(2)派生類重寫的虛函數地址完成覆蓋;(3)派生類自己的虛函數地址三個部分。
- 虛函數表本質是一個存虛函數指針的指針數組,一般情況這個數組最后面放了一個0x00000000 標記(這個 C++并沒有進行規定,各個編譯器自行定義的,vs 系列編譯器會在后面放,g++系列編譯不會放)
- 虛函數和普通函數一樣,編譯好后是一段指令,都是存在代碼段的,至少虛函數的地址又存在虛表中。
- 虛函數表存放的位置嚴格來說并沒有標準答案 C++ 標準并沒有規定,在 vs 中存在代碼段(常量區)
我們每個基類和派生類如果有虛函數都有一張虛表,但是如果它們類型相同,那它們使用的虛表也是相同的。我們子類繼承的父類的虛函數表,但是它們不是同一個虛函數,而是獨立的。
如果我們的子類沒有重寫我們的父類,那我們子類的虛函數表就會在自己的虛函數表中從父類的虛函數表拷貝一份放到子類中屬于父類的那一部分虛函數表中。
如果我們有重寫,那我們重寫的內容就會把基類對應位置進行覆蓋,然后寫上自己的函數內容。
總結
以上便是我們多態的全部內容,繼承和多態的產生,使我們 C++ 的代碼的層次更加的多樣,在我們以后的大型項目中會經常遇見,我們這里一定得多多學習了解。
🎇堅持到這里已經很厲害啦,辛苦啦🎇
? ? ? ? ?
づ?ど