文章目錄
- 1.什么是多態?
- 2.多態的語法實現
- 2.1 虛函數
- 2.2 多態的構成
- 2.3 虛函數的重寫
- 2.3.1 協變
- 2.3.2 析構函數的重寫
- 2.4 override 和 final
- 3.抽象類
- 4.多態原理
- 4.1 虛函數表
- 4.2 多態原理實現
- 4.3 動態綁定與靜態綁定
- 5.繼承和多態常見的面試問題
- 希望讀者們多多三連支持
- 小編會繼續更新
- 你們的鼓勵就是我前進的動力!
本篇將開啟 C++
三大特性中的多態篇章,多態允許你以統一的方式處理不同類型的對象,通過相同的接口來調用不同的實現方法。這意味著你可以編寫通用的代碼,而這些代碼可以在運行時根據對象的實際類型來執行特定的操作
1.什么是多態?
通俗來說,就是多種形態,具體點就是去完成某個行為,當不同的對象去完成時會產生出不同的狀態
??舉個例子:
比如買高鐵票的時候,我們都屬于 Person
類,買的時候會顯示為全價,那么我們又屬于 Student
類,繼承于 Person
類,這時買的時候又會顯示為半價,假設兩個類都有 BuyTicket
函數,那么相同的函數在繼承的基礎上,能夠實現不同的功能,這就是多態
2.多態的語法實現
2.1 虛函數
class Person
{
public:virtual void BuyTicket() { cout << "買票-全價" << endl;}
};
被 virtual
修飾的類成員函數稱為虛函數,注意這里和菱形虛擬繼承的 virtual
沒有關系,不過使用了同一個關鍵字而已
🔥值得注意的是:
- 內聯函數一般不能是虛函數。內聯函數是在編譯時將函數體插入到調用處,而虛函數是在運行時進行動態綁定的,兩者特性沖突
- 靜態成員不可以是虛函數,虛函數是通過對象的虛函數表指針來實現動態綁定的,也就是在運行時根據對象的實際類型來確定調用哪個虛函數。而靜態成員函數是屬于類的,不依賴于具體對象,沒有對象的概念,也沒有虛函數表指針,無法通過動態綁定來調用
- 構造函數不可以是虛函數,對象中的虛函數表指針是在構造函數初始化列表階段才初始化的
2.2 多態的構成
虛函數是實現多態的重要組成部分,將上面舉的例子以代碼形式實現如下:
class Person
{
public:virtual void BuyTicket(){cout << "買票-全價" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket() { cout << "買票-半價" << endl; }
};
多態是在不同繼承關系的類對象,去調用同一函數,產生了不同的行為,比如 Student
繼承了 Person
,Person
對象買票全價,Student
對象買票半價
那么在繼承中要構成多態還有兩個條件:
-
必須通過父類的指針或者引用調用虛函數
-
被調用的函數必須是虛函數,且派生類必須對基類的虛函數進行重寫
🔥值得注意的是: 多態構成條件缺一不可,如果多態產生問題,子類沒有對某個方法進行重寫,那么子類對象在調用該方法時,就會沿著繼承鏈向上查找,找到父類中對應的方法并調用
2.3 虛函數的重寫
class Person
{
public:virtual void BuyTicket() { cout << "買票全價" << endl; }
};class Student : public Person
{
public:virtual void BuyTicket() { cout << "買票半價" << endl; }
};void Func(Person& people)
{people.BuyTicket();
}int main()
{Person Mike;Func(Mike);Student Johnson;Func(Johnson);return 0;
}
Person
類的 BuyTicket
和 Student
類的 BuyTicket
構成重寫
虛函數的重寫: 又叫覆蓋,派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的返回值類型
、函數名字
、參數列表
完全相同),稱子類的虛函數重寫了基類的虛函數
🔥值得注意的是: 在重寫父類虛函數時,子類的虛函數在不加 virtual
關鍵字時,雖然也可以構成重寫(因為繼承后父類的虛函數被繼承下來了在子類依舊保持虛函數屬性),但是該種寫法不是很規范,不建議這樣使用
2.3.1 協變
class A {};
class B : public A {};class Person
{
public:virtual A* f() { return new A; }
};class Student : public Person
{
public:virtual B* f() { return new B; }
};
協變是重寫的一種特殊情況,簡單來說協變就是派生類重寫基類虛函數時,與基類虛函數返回值類型不同
,且要求父類虛函數類型和子類虛函數類型必須是父子關系的引用和指針
🔥值得注意的是: 必須都是引用或者都是指針,不能一個是引用一個是指針
2.3.2 析構函數的重寫
class Person
{
public:virtual ~Person() { cout << "~Person()" << endl; }
};class Student : public Person
{
public:virtual ~Student() { cout << "~Student()" << endl; delete[] ptr;}protected:int* ptr = new int[10];
};int main()
{Person* p = new Person;delete p;p = new Student;delete p;return 0;
}
這里單純講解很難理解,所以以一段代碼場景+一些提問
來解析:
🚩析構函數+virtual,是不是虛函數重寫?
是,雖然函數名不相同,看起來違背了重寫的規則,其實不然,這里可以理解為編譯器對析構函數的名稱做了特殊處理,編譯后析構函數的名稱統一處理成 destructor
🚩為什么要處理成統一名字?
因為要讓兩個析構函數構成重寫
🚩為什么要讓他們構成重寫?
假設我們上面的這個代碼沒有加 virtual
,運行代碼如下:
觀察可以發現子類 Student
部分沒有得到釋放,那么 ptr
指向的空間就會造成內存泄漏
根據 C++
內存管理學的知識可知
p
->destructor()
+operator delete
這里只能調用 p
這個類型的析構函數,但是我們為了實現能夠調用指向空間的析構函數,期望是個多態調用,而不是普通調用,所以必須讓這兩個析構函數構成重寫
🔥值得注意的是:
-
當使用父類指針指向子類對象,析構該指針時,
如果父類的析構函數不是虛函數
,那么將按指針本身的類型(即父類)來析構。這可能會導致子類部分的資源沒有被正確釋放,產生內存泄漏等問題 -
如果父類的析構函數是虛函數
,那么會按照指針實際指向的對象類型(即子類)來析構
2.4 override 和 final
🚩final:修飾虛函數,表示該虛函數不能再被重寫
class Car
{
public:virtual void Drive() final {}
};class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒適" << endl; }
};
🔥值得注意的是:
假設有個 A
類和 B
類,不想讓 B
類繼承 A
類,那么可以寫做:class A final
,避免 A
類被繼承,這是 C++11
才支持的,在這之前使用的是將 A
的構造函數私有化的方法
🚩override:檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫編譯報錯
class Car
{
public:virtual void Drive() {}
};class Benz :public Car
{
public:virtual void Drive() override { cout << "Benz-舒適" << endl; }
};
3.抽象類
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;}
};void Test()
{Car* pBenz = new Benz;pBenz->Drive();//訪問Benz的虛函數Car* pBMW = new BMW;pBMW->Drive();//訪問BMW的虛函數
}
在虛函數的后面寫上 = 0
,則這個函數為純虛函數
,包含純虛函數的類叫做抽象類
(也叫接口類
)
抽象類不能實例化出對象,即只要有純虛函數就不能實例化出對象,派生類繼承后也不能實例化出對象,只有重寫純虛函數,派生類才能實例化出對象。純虛函數規范了派生類必須重寫,另外純虛函數更體現出了接口繼承
🔥值得注意的是:
普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口。所以如果不實現多態,不要把函數定義成虛函數
4.多態原理
4.1 虛函數表
??以下我們通過多個例子進行詳細解析:
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};int main
{Base b;return 0;
}
sizeof(Base)是多少?
想必大部分人第一次做這道題都會覺得是
1
,但運行后發現答案是8
很奇怪,所以我們轉到調試查看
發現除了 _b
以外,還多一個 _vfptr
放在對象的前面(注意有些平臺可能會放到對象的最后面,這個跟平臺有關),對象中的這個指針我們叫做虛函數表指針( v
代表 virtual
,f
代表 function
)
通常虛函數都被放在代碼段
,_vfptr
就是虛函數的地址,被存放在虛函數表,虛函數表放在只讀數據段
,也就是常量區
,所以虛函數表本質上是個函數指針數組
,虛函數表是在編譯期間生成的
??那么多個虛函數是怎樣實現多態的,舉個例子:
class Base
{
public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;
};class Derive : public Base
{
public:virtual void Func1(){cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}
還是轉到監視窗口調試查看:
實際上虛函數表是按照一定規則實現的:
-
🚩復制基類虛表內容
子類在生成虛表時,首先會把父類虛表中的內容完整地復制一份。這意味著子類虛表初始狀態下包含了基類所有虛函數的地址,保證了子類對象可以調用父類的虛函數,這是因為子類繼承了基類的接口,在某些情況下可能會使用到基類定義的虛函數實現 -
🚩重寫虛函數的替換
如果子類對父類中的某個虛函數進行了重寫,那么在子類虛表中,對應父類虛函數的地址會被替換為子類自己重寫后的虛函數地址。當通過父類指針或引用調用該虛函數時,程序會根據對象的實際類型(即子類類型),從子類虛表中找到并重寫后的虛函數來執行,從而實現多態性 -
🚩新增虛函數的添加
對于子類自己新定義的虛函數,會按照它們在子類中聲明的先后順序依次添加到子類虛表的末尾。這些新增的虛函數是子類特有的,父類中并不存在。因此,它們會被單獨添加到虛表中,以確保子類對象能夠調用這些專屬的虛函數
🔥值得注意的是:
- 父類
b
對象和子類d
對象虛表是不一樣的,這里我們發現Func1
完成了重寫,所以d
的虛表中存的是重寫的Derive::Func1
,所以虛函數的重寫也叫作覆蓋,覆蓋就是指虛表中虛函數的覆蓋。重寫
是語法的叫法,覆蓋
是原理層的叫法 Func2
繼承下來后是虛函數,所以放進了虛表,Func3
也繼承下來了,但是不是虛函數,所以不會放進虛表- 虛函數表本質是一個存虛函數指針的指針數組,一般情況這個數組最后面放了一個
nullptr
- 一個類的不同對象共享同一個類的虛表
4.2 多態原理實現
那么回歸到多態的實現條件:
-
必須通過父類的指針或者引用調用虛函數
-
被調用的函數必須是虛函數,且子類必須對父類的虛函數進行重寫
我們可以提出兩個問題:
🚩為什么不是子類指針或者引用?
class Animal
{
public:virtual void speak() {cout << "Animal makes a sound" << endl;}
};class Dog : public Animal
{
public:void speak() override {cout << "Dog barks" << endl;}
};class Cat : public Animal
{
public:void speak() override {cout << "Cat meows" << endl;}
};int main() {Dog dog;Animal* animalPtr = &dog; // 父類指針指向子類對象animalPtr->speak(); // 運行時根據實際對象類型調用Dog的speak函數Cat cat;Animal& animalRef = cat; // 父類引用綁定到子類對象animalRef.speak(); // 運行時根據實際對象類型調用Cat的speak函數return 0;
}
這里的子類 Dog
和 Cat
都繼承于父類 Animal
,就是因為是父類的指針或引用才能想調用哪個子類都行
如果是子類的指針或引用,比如有個 Dog
類的指針 Dog* dogPtr
,它只能指向 Dog
類對象,沒辦法指向 Cat
類對象。如果想用它去調用 speak
函數,不管怎樣都是調用 Dog
類的 speak
函數,不能根據實際對象類型(Cat
或其他子類)來動態調用不同的 speak
函數,就實現不了多態了
🚩為什么不能是父類對象?
class Person
{
public:virtual void BuyTicket(){cout << "買票-全價" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket(){cout << "買票-半價" << endl;}
};int main()
{Person ps;Student st;ps = st;return 0;
}
如果是使用對象,而不是指針或引用,子類中特有的成員變量和函數將被截斷,丟失子類的特性
而使用父類指針或引用指向子類對象時,不會發生切片,能夠完整保留子類對象的所有信息,從而可以訪問子類重寫的虛函數以實現多態
🔥值得注意的是: 子類對象賦值給父類對象的時候,不會拷貝虛函數表過去,如果拷貝了,那么父類虛函數表中的虛函數就變成子類虛函數了,就失去多態的意義了
所以總結: 滿足多態以后的函數調用,不是在編譯時確定的,是運行起來以后到對象的中去找的。不滿足多態的函數調用時編譯時確認好的
4.3 動態綁定與靜態綁定
靜態綁定: 又稱為前期綁定(早綁定),在程序編譯期間確定了程序的行為,也稱為靜態多態,比如:函數重載
動態綁定: 又稱后期綁定(晚綁定),是在程序運行期間,根據具體拿到的類型確定程序的具體行為,調用具體的函數,也稱為動態多態
5.繼承和多態常見的面試問題
- 下面哪種面向對象的方法可以讓你變得富有( )
A
: 繼承
B
: 封裝
C
: 多態
D
: 抽象 - ( )是面向對象程序設計語言中的一種機制。這種機制實現了方法的定義與具體的對象無關,
而對方法的調用則可以關聯于具體的對象。
A
: 繼承
B
: 模板
C
: 對象的自身引用
D
: 動態綁定 - 面向對象設計中的繼承和組合,下面說法錯誤的是?()
A
:繼承允許我們覆蓋重寫父類的實現細節,父類的實現對于子類是可見的,是一種靜態復用,也稱為白盒復用
B
:組合的對象不需要關心各自的實現細節,之間的關系是在運行時候才確定的,是一種動態復用,也稱為黑盒復用
C
:優先使用繼承,而不是組合,是面向對象設計的第二原則
D
:繼承可以使子類能自動繼承父類的接口,但在設計模式中認為這是一種破壞了父類的封裝性的表現 - 以下關于純虛函數的說法,正確的是( )
A
:聲明純虛函數的類不能實例化對象
B
:聲明純虛函數的類是虛基類
C
:子類必須實現基類的純虛函數
D
:純虛函數必須是空函數 - 關于虛函數的描述正確的是( )
A
:派生類的虛函數與基類的虛函數具有不同的參數個數和類型
B
:內聯函數不能是虛函數
C
:派生類必須重新定義基類的虛函數
D
:虛函數可以是一個static型的函數 - 關于虛表說法正確的是( )
A
:一個類只能有一張虛表
B
:基類中有虛函數,如果子類中沒有重寫基類的虛函數,此時子類與基類共用同一張虛表
C
:虛表是在運行期間動態生成的
D
:一個類的不同對象共享該類的虛表 - 假設A類中有虛函數,B繼承自A,B重寫A中的虛函數,也沒有定義任何虛函數,則( )
A
:A類對象的前4個字節存儲虛表地址,B類對象前4個字節不是虛表地址
B
:A類對象和B類對象前4個字節存儲的都是虛基表的地址
C
:A類對象和B類對象前4個字節存儲的虛表地址相同
D
:A類和B類虛表中虛函數個數相同,但A類和B類使用的不是同一張虛表
參考答案:1.
A
2.D
3.C
4.A
5.B
6.D
7.D
- 下面程序輸出結果是什么? ()
#include<iostream>
using namespace std;class A
{
public:A(const char* s){ cout << s << endl; }~A() {}
};class B :virtual public A
{
public:B(const char* s1, const char* s2):A(s1) { cout << s2 << endl; }
};class C :virtual public A
{
public:C(const char* s1, const char* s2):A(s1) { cout << s2 << endl; }
};class D :public B, public C
{
public:D(const char* s1, const char* s2, const char* s3, const char* s4):B(s1, s2), C(s1, s3), A(s1){cout << s4 << endl;}
};int main()
{D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;
}
A
:class A class B class C class DB
:class D class B class C class A
C
:class D class C class B class AD
:class A class C class B class D
解析: 這是個菱形虛擬繼承,所以 A
只會被調用一次,D
類里的初始化列表是按聲明的順序來初始化的,所以按 ABCD
的順序,因此答案選 A
- 多繼承中指針偏移問題?下面說法正確的是( )
class Base1
{
public:int _b1;
};class Base2
{
public:int _b2;
};class Derive : public Base1, public Base2
{
public: int _d;
};int main()
{Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
A
:p1 == p2 == p3B
:p1 < p2 < p3C
:p1 == p3 != p2D
:p1 != p2 != p3
解析: 畫圖理解即可,選 C
- 以下程序輸出結果是什么()
class A
{
public:virtual void func(int val = 1) { cout << "A->" << val << endl; }virtual void test() { func(); }
};class B : public A
{
public:void func(int val = 0) { cout << "B->" << val << endl; }
};int main(int argc, char* argv[])
{B* p = new B;p->test();return 0;
}
A
: A->0B
: B->1C
: A->1D
: B->0E
: 編譯出錯F
: 以上都不正確
解析: 這題絕大多數人肯定會選到 D
,這題的知識點確實比較偏,首先我們要知道多態重寫的是實現,即只有 {}
內的內容是多態的,實際上子類的函數頭其實相當于是從父類拷貝過來的,因此函數頭的內容還是調用的父類的,所以答案選 B