🔥個人主頁:Quitecoder
🔥專欄:c++筆記倉
朋友們大家好,通過本篇文章,來詳細理解多態的內容
目錄
- `1.多態的定義及實現`
- `1.1多態的構成條件`
- `1.2虛函數的重寫`
- `1.3 C++11 override 和 final`
- `1.4重載、覆蓋(重寫)、隱藏(重定義)的對比`
- `2.多態的原理`
- `2.1虛函數表`
- `2.2多態的原理`
- `2.3單繼承的虛函數表`
- `3.抽象類`
- `3.1接口繼承與實現繼承`
- `3.2靜態多態與動態多態`
- `3.3例題`
- `4.多繼承中的虛函數表`
- `4.1菱形繼承和菱形虛擬繼承`
- `4.2菱形虛擬繼承:`
- `5.虛表的存儲位置`
1.多態的定義及實現
多態的基本概念:多態指的是對象可以通過指向它們的基類的引用或指針被操縱,同時還能保持其派生類部分的特性。將派生類對象當作基類對象來對待,這允許不同類的對象響應相同的消息以不同的方式,換句話說,同一個接口,使用不同的實例而執行不同操作
比如買票,普通人買票時,是全價買票;學生買票時,是半價買票
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "買票-半價" << endl; }
};
void Func(Person& p)
{p.BuyTicket();
}
int main()
{Person ps;Student st;Func(ps);Func(st);return 0;
}
普通人全價,學生半價
1.1多態的構成條件
多態是在不同繼承關系的類對象,去調用同一函數,產生了不同的行為。比如Student繼承了Person。Person對象買票全價,Student對象買票半價
那么在繼承中要構成多態還有兩個條件:
- 必須通過基類的指針或者引用調用虛函數
- 被調用的函數必須是虛函數,且派生類必須對基類的虛函數進行重寫
指向誰調用誰
void Func(Person p)
{p.BuyTicket();
}
如果這樣調用,就不是指針或引用了,現在就不是多態:
1.2虛函數的重寫
虛函數:即被virtual修飾的類成員函數稱為虛函數
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
虛函數的重寫(覆蓋):派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的返回值類型、函數名字、參數列表完全相同),稱子類的虛函數重寫了基類的虛函數
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "買票-半價" << endl; }
};
虛函數重寫的三個例外:
- 協變(基類與派生類虛函數返回值類型不同):
派生類重寫基類虛函數時,與基類虛函數返回值類型不同。即基類虛函數返回基類對象的指針或者引用,派生類虛函數返回派生類對象的指針或者引用時,稱為協變。
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; }
};
- 析構函數的重寫(基類與派生類析構函數的名字不同)
如果基類的析構函數為虛函數,此時派生類析構函數只要定義,無論是否加virtual關鍵字,都與基類的析構函數構成重寫,雖然基類與派生類析構函數名字不同。雖然函數名不相同,看起來違背了重寫的規則,其實不然,這里可以理解為編譯器對析構函數的名稱做了特殊處理,編譯后析構函數的名稱統一處理成destructor
只有派生類Student的析構函數重寫了Person的析構函數,下面的delete對象調用析構函數,才能構成多態,才能保證p1和p2指向的對象正確的調用析構函數
class Person {
public:virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:virtual ~Student() { cout << "~Student()" << endl; }
};int main()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;
}
當我們通過基類的指針來刪除一個派生類的對象時,如果基類的析構函數沒有被聲明為虛擬的(virtual
),將會發生對象的不完全析構。這意味著只有基類的析構代碼會被執行,而派生類的析構邏輯不會調用,可能導致資源泄露或其他問題。
在給定的代碼中,Person
類的析構函數被聲明為虛擬的:
virtual ~Person() { cout << "~Person()" << endl; }
這意味著任何從 Person
派生的類,像 Student
,都應該提供析構函數的一個覆蓋版本:
virtual ~Student() { cout << "~Student()" << endl; }
當 delete p2;
被執行的時候(其中 p2
是一個基類 Person
類型的指針,指向一個 Student
對象),Student
的析構函數首先會被調用(子類),然后是 Person
的析構函數(基類)
因此,重寫基類的虛擬析構函數確保了當通過基類指向派生類對象的指針進行 delete
操作時,能夠按照正確的順序調用派生類和基類的析構函數
- 派生類可以不寫virtual
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
class Student : public Person {
public:void BuyTicket() { cout << "買票-半價" << endl; }
};
在重寫基類虛函數時,派生類的虛函數在不加virtual關鍵字時,雖然也可以構成重寫(因為繼承后基類的虛函數被繼承下來了在派生類依舊保持虛函數屬性),但是該種寫法不是很規范,不建議這樣使用
1.3 C++11 override 和 final
C++對函數重寫的要求比較嚴格,但是有些情況下由于疏忽,可能會導致函數名字母次序寫反而無法構成重載,而這種錯誤在編譯期間是不會報出的,只有在程序運行時沒有得到預期結果才來debug會得不償失,因此:C++11提供了override和final兩個關鍵字,可以幫助用戶檢測是否重寫
- final:修飾虛函數,表示該虛函數不能再被重寫
class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒適" << endl; }
};
用final修飾的類叫做最終類,不能被繼承
class Car final{
public:virtual void Drive() {}
};
class Benz :public Car {
public:virtual void Drive() { cout << "Benz-舒適" << endl; }
};
- override: 檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫編譯報錯
class Car {
public:virtual void Drive() {}
};
class Benz :public Car {
public:virtual void Drive() override { cout << "Benz-舒適" << endl; }
};
1.4重載、覆蓋(重寫)、隱藏(重定義)的對比
重載發生在同一作用域內。當兩個或者更多的函數擁有相同的名字,但是**參數列表不同(參數類型、參數個數或者參數順序不同)**時,這些函數被稱為重載函數。
class MyClass {
public:void func() void func(int i)void func(double d)
};
重寫僅在基類和派生類之間發生,且只針對虛函數。當派生類定義一個與基類中虛函數簽名完全相同的函數時(即函數名、參數列表和返回類型相同),派生類函數會覆蓋(重寫)基類中對應的虛函數。這是多態的基礎,使得在運行時可以通過基類的指針或引用調用派生類的函數實現
示例:
class Base {
public:virtual void func() { /* ... */ }
};class Derived : public Base {
public:void func() override { /* ... */ } // 覆蓋(重寫)基類中的func
};
隱藏也是在類的繼承關系中發生,但它和是否為虛函數無關。在派生類中定義了一個新的函數,如果這個函數的名字與基類中的某個函數的名字相同,但是參數列表不同,那么它會隱藏(也稱為重定義)所有與它同名的基類函數,不論基類中同名函數參數列表如何
示例:
class Base {
public:void func() { /* ... */ }void func(int i) { /* ... */ }
};class Derived : public Base {
public:void func(double d) { /* ... */ } // 隱藏了基類的func()// 注意:現在Base的func()和func(int)都被隱藏,只能通過Derived的對象訪問新的func(double)
};
在繼承的類中隱藏了基類中的同名函數(不論是重載還是同簽名的函數),如果想要調用被隱藏的函數,需要顯式地指明作用域:
Derived obj;
obj.Base::func(); // 顯式調用Base類中被隱藏的func()
obj.Base::func(42); // 顯式調用Base類中被隱藏的func(int)
obj.func(3.14); // 調用Derived類中的func(double)
兩個基類和派生類的同名函數,不構成重寫就是隱藏
2.多態的原理
2.1虛函數表
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};
sizeof(Base)
是多少?
答案是8,我們進行測試觀察:
除了_b成員,還多一個__vfptr放在對象的前面,對象中的這個指針我們叫做虛函數表指針(v代表virtual,f代表function)。一個含有虛函數的類中都至少都有一個虛函數表指針,因為虛函數的地址要被放到虛函數表中,虛函數表也簡稱虛表
用內存窗口觀察:
它是占八個字節的
2.2多態的原理
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }private:int _i = 1;
};class Student : public Person {
public:virtual void BuyTicket() { cout << "買票-半價" << endl; }int _j = 2;
};void Func(Person* p)
{p->BuyTicket();
}int main()
{Person Mike;Func(&Mike);Student Johnson;Func(&Johnson);return 0;
}
這里的指向父類調父類,指向子類調子類是怎么實現的呢? 我們進行調試
Johnson首先繼承了父類的部分,有虛表和虛表指針,這兩個虛表指針不一樣,他們指向內容不一樣,一個指向父類的Buyticket,另一個指向子類的
p是指向mike對象時,p->BuyTicket在mike的虛表中找到虛函數Person::BuyTicket
p是指向johnson對象時,p->BuyTicket在johson的虛表中找到虛函數是Student::BuyTicket
這樣就實現出了不同對象去完成同一行為時,展現出不同的形態
反過來思考我們要達到多態,有兩個條件,一個是虛函數覆蓋,一個是對象的指針或引用調用虛函數。反思一下為什么
滿足多態條件,這里的調用生成的指令就會指向對象的虛表中找對應的虛函數調用
滿足多態以后的函數調用,不是在編譯時確定的,是運行起來以后到對象的中取找的。不滿足多態的函數調用時編譯時確認好的
p->BuyTicket();
009924E1 mov eax,dword ptr [p]
009924E4 mov edx,dword ptr [eax]
009924E6 mov esi,esp
009924E8 mov ecx,dword ptr [p]
009924EB mov eax,dword ptr [edx]
009924ED call eax
009924EF cmp esi,esp
009924F1 call __RTC_CheckEsp (09912B2h)
滿足多態的情況下
-
p中存的是mike對象的指針,將p移動到eax中
-
[eax]就是取eax值指向的內容,這里相當于把mike對象頭4個字節(虛表指針)移動到了edx
-
[edx]就是取edx值指向的內容,這里相當于把虛表中的頭4字節存的虛函數指針移動到了eax
-
call eax中存虛函數的指針。這里可以看出滿足多態的調用,不是在編譯時確定的,是運行起來以后到對象的中取找的
同類型共用一個虛表:
Person Mike;
Func(&Mike);Person p1;
Func(&p1);
現在如果不滿足多態呢?
我將父類進行修改
class Person {
public:void BuyTicket() { cout << "買票-全價" << endl; }
private:int _i = 1;
};
p->BuyTicket();
005B24E1 mov ecx,dword ptr [p]
005B24E4 call Person::BuyTicket (05B149Ch)
它在編譯鏈接時就確定了
2.3單繼承的虛函數表
來看下面的類:
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a = 1;
};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; }
private:int b = 2;
};
int main()
{Base b;Derive d;return 0;
}
我們發現Derive少了兩個虛表指針,它只有重寫的func1和繼承的func2,沒有func3,func4,這里是監視窗口的問題
在 Derive
類的虛表中,會有以下指向虛函數的指針:
- 指向
Derive::func1
的指針 (重寫了Base::func1
) - 指向
Base::func2
的指針 (繼承自Base
,Derive
沒有重寫) - 指向
Derive::func3
的指針 (Derive 新增的虛函數) - 指向
Derive::func4
的指針 (Derive 新增的虛函數)
我們通過內存來確認:
我們不是很確認后面兩個地址就是func3和func4的地址
那么我們如何查看d的虛表呢?下面我們使用代碼打印出虛表中的函數
這里我們用到函數指針數組來實現:
虛函數表的本質就是函數指針數組
void(*p[10])();
這個就定義了一個函數指針數組,我們用typedef來進行優化一下:
typedef void(*VFPTR)();
VFPTR p2[10];
我們定義一個打印虛表的函數
void PrintVFT(VFPTR* vft)
{for (size_t i = 0; i < 4; i++){printf("%p->", vft[i]);VFPTR pf = vft[i];(*pf)();//pf();}
}
依次取虛表中的虛函數指針打印并調用。調用就可以看出存的是哪個函數
函數寫好后,關鍵是我如何取到它的地址?
Derive d;
int ptr = (int)d;
上面是不支持轉換的,只有有關聯的類型才能互相轉換
但是,指針可以隨意轉換
VFPTR* ptr = (VFPTR*)(*((int*)&d));
&d
取得d
對象的地址。(int*)&d
將d
對象的地址轉換為int*
類型的指針。這里假定int
大小足夠存儲指針*((int*)&d)
對轉換后的指針進行解引用,得到的是d
對象內存起始處的值。由于在C++中,一個包含虛函數的對象在內存起始地址處通常存儲著指向虛表的指針,因此這步操作實際上獲取的是指向Derive
虛表的指針(VFPTR*)
將int
類型的值強制轉換為VFPTR*
類型,也就是指向函數指針的指針。- 最終,
ptr
就是指向Derive
類的虛表的指針。
因此,VFPTR* ptr
就是指向目標對象 d
的虛表的指針。之后調用 PrintVFT(ptr);
就可以遍歷虛表中的每個條目并調用對應的函數(這里的函數都是通過函數指針 VFPTR
調用的)
3.抽象類
在虛函數的后面寫上 =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;}
};
3.1接口繼承與實現繼承
普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口。所以如果不實現多態,不要把函數定義成虛函數
3.2靜態多態與動態多態
- 靜態綁定又稱為前期綁定(早綁定),在程序編譯期間確定了程序的行為,也稱為靜態多態,比如:函數重載
- 動態綁定又稱后期綁定(晚綁定),是在程序運行期間,根據具體拿到的類型確定程序的具體行為,調用具體的函數,也稱為動態多態
3.3例題
下面函數輸出結果是什么?
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
類 B
繼承自類 A
并且 復寫了 A
中的虛函數 func
。
首先,復寫(覆蓋)的本質是派生類提供基類虛函數的一個新的實現。基類中的虛函數定義了一個接口,而派生類通過覆蓋這個虛函數,提供了這個接口的特定實現
當創建了派生類 B
的實例,并通過它調用 test()
時,過程如下:
test()
是在基類A
中定義的,因此它會調用func
時使用A
中定義的默認參數,即1
。- 由于
func
是虛函數,并且我們實際上是在操作B
類的對象,因此調用的是B
類中覆蓋的func
版本。 - 被調用的
B
類的func
輸出 “B->”,然后使用傳遞給它的參數值,此時是基類的默認參數值1
。
綜上所述,輸出是 B->1
。
要明白一個重要的細節:虛函數的默認參數是靜態綁定的,而非動態綁定。也就是說,虛函數的默認參數會在編譯時根據函數的靜態類型決定,而函數的動態類型會決定在運行時實際調用哪個版本的覆蓋函數。這意味著即使 B::func
定義了一個默認值 0
,在 A::test
中調用 func()
時,由于它在編譯時是視為 A
類型的函數調用,所以使用的是 A::func
定義的默認參數 1
。這就是為什么是 B->1
而不是 B->0
4.多繼承中的虛函數表
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;
};
這里有兩個虛表指針,繼承了兩個父類,兩個父類的虛表不能合在一起,這里對兩張虛表都進行了重寫,那么這里func3
放在哪個虛表中了呢,是都放呢還是只放一個呢?
我們可以用上面的打印虛表的函數進行打印
void PrintVTable(VFPTR vTable[])
{cout << " 虛表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d個虛函數地址 :0X%x,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}
void test()
{Derive d;VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);PrintVTable(vTableb1);VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));PrintVTable(vTableb2);
}
這里第一個虛表已經講過,找第二個虛表先強轉為char,再進行字節相加*
func3放入第一個虛表中
4.1菱形繼承和菱形虛擬繼承
class A
{
public:virtual void func1() { cout << "A::func1" << endl; }int _a;
};
class B : public A
//class B : virtual public A
{
public:virtual void func2() { cout << "B::func2" << endl; }int _b;
};class C : public A
//class C : virtual public A
{
public:virtual void func3() { cout << "C::func3" << endl; }int _c;
};class D : public B, public C
{
public:virtual void func4() { cout << "D::func4" << endl; }int _d;
};int main()
{D d;cout << sizeof(d) << endl; return 0;
}
菱形繼承與多繼承相似,d里面的虛函數放在B的虛表中
4.2菱形虛擬繼承:
class B : virtual public A
class C : virtual public A
這里除了虛表指針,還有上篇文章講解的存儲偏移量的虛基表指針
int main()
{D d;cout << sizeof(d) << endl;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;return 0;
}
菱形虛擬繼承,每個類都有一個虛函數,這里ABC都有自己的虛表,但是BC的虛函數不能放在A的虛表中,因為這里虛基類A是共享的
子類有虛函數,繼承的父類有虛函數就有虛表,子類對象中就不需要單獨建立虛表
但是菱形虛擬繼承就需要自己建立虛表,不能往父類中放
再看下面的代碼:
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) //A B, C(s1, s3) //A C, A(s1) //A{// Dcout << s4 << endl;}
};
int main() {D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;
}
當創建一個派生類的對象時,構造函數會按照特定的順序執行,確保所有的基類和成員變量都被正確初始化。在多繼承和虛繼承的情況下,這個順序變得更加復雜。上面代碼涉及到虛繼承,這意味著基類 A
只會有一個實例,即使它被多次包含在派生類層次結構中,在 B
和 C
中
D(const char* s1, const char* s2, const char* s3, const char* s4):B(s1, s2) //A B, C(s1, s3) //A C, A(s1) //A{
D
的構造函數,我們發現它首先調用 B
的構造函數,然后是 C
的構造函數,最后調用 A
的構造函數。然而,在虛繼承的情況下,共享的基類(在該例子中是 A
)只會被初始化一次,而且是由最底層的派生類(D
)來初始化。無論 B
和 C
在其構造函數中怎么嘗試初始化 A
,它們的嘗試都會被忽略
根據上述規則,執行 new D("class A", "class B", "class C", "class D");
的過程如下:
- 首先,最底層的派生類
D
的構造器被調用。 - 因為
A
是通過虛繼承被B
和C
繼承的,所以D
的構造器負責初始化A
。這里將輸出 “class A” - 接下來,
D
的構造器調用B
的構造函數。雖然B
試圖先調用A
的構造函數,但這個調用會被忽略,因為A
已經被初始化了。然后,B
的構造器繼續執行并輸出 “class B” C
的構造函數也會被調用,但同樣,其對A
構造函數的調用被忽略,并且C
的構造器繼續執行,輸出 “class C”- 最后,在
D
的構造函數中的代碼執行之前,所有基類都已經初始化完成。最后輸出 “class D”。
class A
class B
class C
class D
所以,盡量不要寫菱形虛擬繼承,坑點十分多
5.虛表的存儲位置
我們可以通過下面的代碼來推斷虛表在哪存儲的:
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};class Student : public Person {
public:virtual void BuyTicket() { cout << "買票-半價" << endl; }
};
void tese()
{int i = 0;static int j = 1;int* p1 = new int;const char* p2 = "xxxxxxxx";printf("棧:%p\n", &i);printf("靜態區:%p\n", &j);printf("堆:%p\n", p1);printf("常量區:%p\n", p2);Person p;Student s;Person* p3 = &p;Student* p4 = &s;printf("Person虛表地址:%p\n", *(int*)p3);printf("Student虛表地址:%p\n", *(int*)p4);
}
可以推斷出存儲位置在常量區
本節內容到此結束!!感謝大家閱讀!!