一、多態的概念
多態的概念:通俗來說,就是多種形態, 具體點就是去完成某個行為,當不同的對象去完成時會 產生出不同的狀態。舉個栗子:比如買票這個行為,當普通人買票時,是全價買票;學生買票時,是半價買票;軍人 買票時是優先買票。
?二、多態的定義及實現
2.1 多態的構成條件
多態是在不同繼承關系的類對象,去調用同一函數,產生了不同的行為。比如Student繼承了 Person。Person對象買票全價,Student對象買票半價。
那么在繼承中要 構成多態還有兩個條件:
1. 必須通過基類的指針或者引用調用虛函數
2. 被調用的函數必須是虛函數,且派生類必須對基類的虛函數進行重寫![]()
?2.2 虛函數
虛函數:即被virtual修飾的類成員函數稱為虛函數。
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl;}
};
?2.3 虛函數的重寫
虛函數的重寫(覆蓋):派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的 返回值類型、函數名字、參數列表完全相同),稱子類的虛函數重寫了基類的虛函數。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;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);/*在 Func 函數中,參數是一個 Person 類型的引用,但傳入的是一個 Student 對象。由于 Student 是 Person 的派生類,因此可以將 Student 對象隱式地轉換為 Person 類型的引用。因為 BuyTicket 函數在 Person 和 Student 類中都被聲明為虛函數,并且 Student 類重寫了基類的虛函數,所以在運行時會根據對象的實際類型來確定調用哪個版本的函數。*/return 0;
}
?虛函數重寫的兩個例外:
- 協變(基類與派生類虛函數返回值類型不同)
派生類重寫基類虛函數時,與基類虛函數返回值類型不同。即基類虛函數返回基類對象的指針或者引用,派生類虛函數返回派生類對象的指針或者引用時,稱為協變。(了解)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。#define _CRT_SECURE_NO_WARNINGS #include <iostream> using namespace std;class Person { public:virtual ~Person() { cout << "~Person()" << endl; } };class Student : public Person { public:virtual ~Student() { cout << "~Student()" << endl; } };/*只有派生類Student的析構函數重寫了Person的析構函數, 下面的delete對象調用析構函數,才能構成多態, 才能保證p1和p2指向的對象正確的調用析構函數。*/ int main() {Person* p1 = new Person;Person* p2 = new Student;/*通過調用 delete 刪除這些對象時,由于基類的析構函數是虛函數,因此會根據對象的實際類型來調用相應的析構函數,實現多態行為。*/delete p1; // 輸出:~Person()delete p2; // 輸出:~Student() ~Person(),確保正確調用派生類的析構函數/*在多態的情況下,刪除指向派生類對象的基類指針時,會先調用派生類的析構函數,再調用基類的析構函數。因此,先調用 ~Student() 再調用 ~Person()*/return 0; }
在C++中,基類的析構函數如果被聲明為虛函數,那么當通過基類指針刪除派生類對象時,會按照派生類的實際類型調用析構函數的機制就是多態性。這種行為被稱為動態綁定或運行時多態。
當基類的析構函數是虛函數時,編譯器會在運行時根據對象的實際類型來調用相應的析構函數。這種行為保證了在繼承關系中正確地析構對象,防止內存泄漏和對象資源未被正確釋放。
具體來說,當刪除一個指向派生類對象的基類指針時,首先調用派生類的析構函數,然后再調用基類的析構函數。這是因為派生類對象中可能包含基類對象的部分,所以需要先執行派生類的析構函數來清理派生類特有的資源,然后再調用基類的析構函數來清理基類部分的資源。
這一規則確保了對象的析構順序與構造順序相反,從派生類到基類,保證了每個類的資源能夠得到正確釋放,避免了潛在的內存泄漏問題。
2.4 C++11 override 和 final
從上面可以看出,C++對函數重寫的要求比較嚴格,但是有些情況下由于疏忽,可能會導致函數 名字母次序寫反而無法構成重載,而這種錯誤在編譯期間是不會報出的,只有在程序運行時沒有 得到預期結果才來debug會得不償失,因此:C++11提供了override和final兩個關鍵字,可以幫助用戶檢測是否重寫。
- final:修飾虛函數,表示該虛函數不能再被重寫
#include <iostream>class Car { public:virtual void Drive() final {} };class Benz : public Car { public:void Drive() { std::cout << "Benz-舒適" << std::endl; } };int main() {Car* car = new Benz();car->Drive(); // 輸出 "Benz-舒適"delete car;return 0; }
- ?override: 檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫編譯報錯。
#define _CRT_SECURE_NO_WARNINGS #include <iostream>class Car { public:virtual void Drive() {} };class Benz : public Car { public:void Drive(int speed) override { std::cout << "Drive at " << speed << "km/h" << std::endl; } };int main() {Car* car = new Benz();car->Drive(); // 編譯錯誤delete car;return 0; }
#define _CRT_SECURE_NO_WARNINGS #include <iostream>class Car { public:virtual void Drive() {} };class Benz : public Car { public:void Drive() override { std::cout << "Benz-舒適" << std::endl; } };int main() {Car* car = new Benz();car->Drive(); // 輸出 "Benz-舒適"delete car;return 0; }
2.5 重載、覆蓋(重寫)、隱藏(重定義)的對比
三 、抽象類
3.1 概念
在虛函數的后面寫上 =0 ,則這個函數為純虛函數。包含純虛函數的類叫做抽象類(也叫接口 類),抽象類不能實例化出對象。派生類繼承后也不能實例化出對象,只有重寫純虛函數,派生 類才能實例化出對象。純虛函數規范了派生類必須重寫,另外純虛函數更體現出了接口繼承。
#include <iostream>
using namespace std;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();Car* pBMW = new BMW;pBMW->Drive();
}int main() {Test();return 0;
}
?3.2 接口繼承和實現繼承
普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實 現。虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口。所以如果不實現多態,不要把函數定義成虛函數。
?四、多態的原理
4.1 虛函數表
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;// 這里常考一道筆試題:sizeof(Base)是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};int main()
{ Base b;return 0;
}
通過觀察測試可以發現b對象是8bytes,除了_b成員,還多一個__vfptr放在對象的前面(注意有些 平臺可能會放到對象的最后面,這個跟平臺有關),對象中的這個指針我們叫做虛函數表指針(v代 表virtual,f代表function)。一個含有虛函數的類中都至少都有一個虛函數表指針,因為虛函數 的地址要被放到虛函數表中,虛函數表也簡稱虛表,。那么派生類中這個表放了些什么呢?我們接著往下分析
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
// 針對上面的代碼我們做出以下改造
// 1.我們增加一個派生類Derive去繼承Base
// 2.Derive中重寫Func1
// 3.Base再增加一個虛函數Func2和一個普通函數Func3
// 定義基類Base
class Base
{
public:// 基類中的虛函數Func1virtual void Func1(){cout << "Base::Func1()" << endl;}// 基類中的虛函數Func2virtual void Func2(){cout << "Base::Func2()" << endl;}// 基類中的普通函數Func3void Func3(){cout << "Base::Func3()" << endl;}private:int _b = 1;
};// 派生類Derive繼承自Base
class Derive : public Base
{
public:// 派生類中重寫基類的虛函數Func1virtual void Func1(){cout << "Derive::Func1()" << endl;}private:int _d = 2;
};int main()
{// 創建基類對象bBase b;// 創建派生類對象dDerive d;return 0;
}
通過觀察和測試,可以發現了以下幾點問題:
- ?派生類對象d中也有一個虛表指針,d對象由兩部分構成,一部分是父類繼承下來的成員,虛表指針也就是存在部分的另一部分是自己的成員。
- ?基類b對象和派生類d對象虛表是不一樣的,這里我們發現Func1完成了重寫,所以d的虛表 中存的是重寫的Derive::Func1,所以虛函數的重寫也叫作覆蓋,覆蓋就是指虛表中虛函數的覆蓋。重寫是語法的叫法,覆蓋是原理層的叫法。
- 另外Func2繼承下來后是虛函數,所以虛函數的指針放進了虛表,Func3也繼承下來了,但是不是虛函數,所以虛函數的指針不會放進虛表。
- 虛函數表本質是一個存虛函數指針的指針數組,一般情況這個數組最后面放了一個nullptr。
- 總結一下派生類的虛表生成:
a.先將基類中的虛表內容拷貝一份到派生類虛表中
b.如果派生類重寫了基類中某個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數
c.派生類自己 新增加的虛函數按其在派生類中的聲明次序增加到派生類虛表的最后。- 這里還有一個很容易混淆的問題:虛函數存在哪的?虛表存在哪的? 答:虛函數存在虛表,虛表存在對象中。注意上面的回答的錯的。注意: 虛表存的是虛函數指針,不是虛函數,虛函數和普通函數一樣的,都是存在代碼段的,只是 他的指針又存到了虛表中。另外對象中存的不是虛表,存的是虛表指針。那么虛表存在哪的呢?實際我們去驗證一下會發現vs下是存在代碼段的。
代碼段:
存放代碼、不允許被修改,也就是我們所寫的函數,是存放在這里的
代碼區中的東西是隨整個程序一起的,啟動時 生、結束時 亡
有時候放在代碼段的不只是代碼,還有const類型的常量,還有字符串常量。(const類型的常量、字符串常量有時候放在常量區有時候放在代碼段,取決于平臺)int main() {Base b; // 創建基類對象bDerive d; // 創建派生類對象dint i = 0; // 定義一個整型變量i,存放在棧上static int j = 1; // 定義一個靜態整型變量j,存放在靜態存儲區int* p1 = new int; // 動態分配一個整型變量,存放在堆上const char* p2 = "xxxxxxxx"; // 定義一個指向常量字符數組的指針,指向代碼段/常量區/*代碼段*/printf("棧:%p\n", &i); printf("堆:%p\n", p1);printf("靜態區:%p\n", &j); printf("代碼段(常量區):%p\n", p2); Base* p3 = &b; // 基類指針指向基類對象Derive* p4 = &d; // 派生類指針指向派生類對象/*為什么需要對指針進行強制類型轉換呢?這是因為指針p3和p4實際上是指向基類和派生類對象的指針,而我們想要訪問的是這兩個對象的虛表地址。在C++中,虛表地址通常存儲在指向對象的第一個位置,而虛表本身是一個指針數組。*/printf("Base虛表地址:%p\n", *(int*)p3); // 輸出基類對象的虛表地址printf("Base虛表地址:%p\n", *(int*)p4); // 輸出派生類對象的虛表地址// printf("代碼段地址: %p\n", (void*)main);return 0; }
C/C++內存分區
可以發現虛表地址離代碼段的地址近,由此我們可以得出在vs中虛表實際上是存在代碼段的。虛函數表(vtable)的地址在代碼段(或稱為text段)中的存儲并不是由它的物理位置決定的,而是取決于編譯器的設計。在C++中,虛函數表是存儲虛函數地址的指針數組,這個指針數組在編譯階段就已經確定,并且在運行時不會改變,虛函數表的存在是為了實現多態。當我們通過基類指針調用虛函數時,實際執行的是哪個函數(基類的還是派生類的)取決于虛函數表中的函數地址。這個地址在編譯階段就被確定并且在運行時不可改變,因此存放在只讀的代碼段。
注意:
虛函數表/虛表是存儲類中虛函數地址的表格,用于實現動態多態性。每個包含虛函數的類都有一個對應的虛函數表,其中存儲了該類所有虛函數的地址。當使用基類指針或引用調用虛函數時,程序會根據對象的實際類型找到對應的虛表,然后調用正確的虛函數。
虛基表是用于解決虛基類在多重繼承中的問題。當一個類同時繼承自多個含有共同虛基類的類時,為了避免虛基類的重復存儲,編譯器會在派生類中插入一個虛基表指針,指向虛基表。虛基表中存儲了虛基類子對象在派生類對象中的偏移量,確保對虛基類子對象的訪問是正確的。?
?4.2多態的原理
上面分析了這個半天了那么多態的原理到底是什么?還記得這里Func函數傳Person調用的 Person::BuyTicket,傳Student調用的是Student::BuyTicket
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;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 Mike;Func(&Mike);Student Johnson;Func(&Johnson);return 0;
}//void Func(Person& p) {
// p.BuyTicket();
//}
//
//
//int main() {
// Person Mike;
// Func(Mike);
//
// Student Johnson;
// Func(Johnson);
//
// return 0;
//
//}
/*p.BuyTicket() 和 p->BuyTicket() 是兩種不同的方式來調用對象的成員函數。p.BuyTicket(): 這種方式適用于對象實例,其中 p 是一個對象實例,使用.操作符可以直接調用對象的成員函數。這種方式適用于對象而不是指針或引用。p->BuyTicket(): 這種方式適用于指向對象的指針或引用,其中 p 是指向對象的指針或引用。-> 操作符用于通過指針或引用訪問對象的成員函數或成員變量。
*/
- 觀察下圖的紅色箭頭我們看到,p是指向mike對象時,p->BuyTicket在mike的虛表中找到虛 函數是Person::BuyTicket。
- ?觀察下圖的藍色箭頭我們看到,p是指向johnson對象時,p->BuyTicket在johson的虛表中 找到虛函數是Student::BuyTicket。
- 這樣就實現出了不同對象去完成同一行為時,展現出不同的形態。
- ?反過來思考我們要達到多態,有兩個條件,一個是虛函數覆蓋,一個是對象的指針或引用調 用虛函數。反思一下為什么?
為什么必須構成虛函數重寫
在C++中,實現多態性的關鍵是通過虛函數和動態綁定來實現的。為了實現多態性,必須滿足以下兩個條件:
- ??? 基類中定義虛函數:在基類中通過 virtual 關鍵字聲明一個成員函數為虛函數。這樣在派生類中可以對這個虛函數進行重寫。
- ??? 派生類中重寫虛函數:在派生類中重新定義基類中聲明的虛函數,從而覆蓋基類中的虛函數。這樣在運行時,通過基類指針或引用調用這個虛函數時,會根據指針或引用所指向的對象的實際類型來確定調用哪個版本的虛函數。
如果沒有在派生類中對基類中的虛函數進行重寫,即使使用基類指針或引用調用這個虛函數,也只會調用基類中的版本,而不會根據對象的實際類型來確定調用哪個版本的虛函數,無法實現多態性。
為什么一定要用基類的指針或者引用去調用呢?
這是因為在編譯時,編譯器只知道指針或引用的靜態類型(即指針或引用聲明的類型),而不知道它們指向或引用的對象的實際類型。如果直接通過對象調用虛函數,編譯器只會根據對象的靜態類型來確定調用哪個版本的函數,而不會考慮對象的實際類型,這樣就無法實現多態。
通過基類的指針或引用調用虛函數,可以在運行時根據對象的實際類型來確定調用哪個版本的虛函數,從而實現多態性。
?4.3 動態綁定與靜態綁定
通過調試反匯編,看出滿足多態以后的函數調用,不是在編譯時確定的,是運行起來以后到對象的中取找的。不滿足多態的函數調用時編譯時確認好的。
int main() {Person mike;Func(&mike); //通過基類指針 Person* p 調用虛函數 BuyTicket(),動態綁定mike.BuyTicket(); //接通過對象調用了虛函數 BuyTicket(),不構成多態,靜態綁定return 0;
}
?構成多態,匯編指令變多了,原因是在運行時去通過找虛表找到對應的虛函數調用,是動態綁定。
?
?不構成多態調用,直接就是call函數地址是在編譯期間完成的,是靜態綁定。
// 以下匯編代碼解析
void Func(Person* p)
{
...p->BuyTicket();
// p中存的是mike對象的指針,將p移動到eax中
//00B62471 mov eax,dword ptr [p]
// [eax]就是取eax值指向的內容,這里相當于把mike對象頭4個字節(虛表指針)移動到了edx
//00B62474 mov edx,dword ptr [eax]
// [edx]就是取edx值指向的內容,這里相當于把虛表中的頭4字節存的虛函數指針移動到了eax
//00B62476 mov esi,esp 將當前的棧頂地址保存到esi中
//00B62478 mov ecx,dword ptr [p] 將指針p所指向的對象的地址加載到寄存器ecx中
//00B6247B mov eax,dword ptr [edx]
//將虛函數表的第一個函數(即BuyTicket())的地址加載到寄存器eax中// call eax中存虛函數的指針。這里可以看出滿足多態的調用,不是在編譯時確定的,是運行起來
以后到對象的中取找的。
00B6247D call eax
00B6247F cmp esi,esp
}
int main()
{
...
// 首先BuyTicket雖然是虛函數,但是mike是對象,不滿足多態的條件,所以這里是普通函數的調
用轉換成地址時,是在編譯時已經從符號表確認了函數的地址,直接call 地址mike.BuyTicket();
00B62083 lea ecx,[mike]
00B62086 call Student::Student (0B611C2h) ...
}
- 1. 靜態綁定又稱為前期綁定(早綁定),在程序編譯期間確定了程序的行為,也稱為靜態多態, 比如:函數重載
- 2. 動態綁定又稱后期綁定(晚綁定),是在程序運行期間,根據具體拿到的類型確定程序的具體 行為,調用具體的函數,也稱為動態多態。
五、單繼承和多繼承關系中的虛函數表
5.1 單繼承中的虛函數表
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;// 基類 Base
class Base {
public:// 虛函數 func1virtual void func1() { cout << "Base::func1" << endl; }// 虛函數 func2virtual void func2() { cout << "Base::func2" << endl; }
private:int a;
};// 派生類 Derive,繼承自 Base
class Derive : public Base {
public:// 重寫虛函數 func1virtual void func1() { cout << "Derive::func1" << endl; }// 新增虛函數 func3virtual void func3() { cout << "Derive::func3" << endl; }// 新增虛函數 func4virtual void func4() { cout << "Derive::func4" << endl; }
private:int b;
};int main() {Base b;Derive d;return 0;
}
?觀察下圖中的監視窗口中我們發現看不見func3和func4。這里是編譯器的監視窗口故意隱藏了這 兩個函數,也可以認為是他的一個小bug。
那么我們如何查看d的虛表呢?下面我們使用代碼打印出虛表中的函數。?
typedef void(*VFPTR) ();
/*typedef 是一個關鍵字,它用于給某個數據類型起一個別名。
在這個語句中,我們使用 typedef 給一個函數指針類型起了一個別名,這個別名是 VFPTR。
括號中的內容:*VFPTR。這表示 VFPTR 是一個指針類型,指向一個函數。
但是,在 C++ 中,函數指針是非常復雜的類型。因為函數可以有不同的參數、返回值和異常規格,
所以函數指針的類型必須準確地匹配函數的簽名。
這就導致了一個問題:如何聲明一個通用的函數指針類型,使其可以指向任何類型的函數?
答案是使用一個空參數列表作為函數指針類型的聲明。例如,void(*)() 表示一個沒有參數和返回值的函數類型。這個函數類型的指針類型就是 void(*)()。
因此,我們現在知道了 VFPTR 是函數指針類型的別名,這個函數沒有參數和返回值。
()這表示這個函數沒有參數。
所以,typedef void(*VFPTR) (); 的含義是:定義一個函數指針類型 VFPTR,
它可以指向沒有參數和返回值的函數。*///虛函數表本質是一個存虛函數指針的指針數組
void PrintVTable(VFPTR vTable[])
{// 依次取虛表中的虛函數指針打印并調用。調用就可以看出存的是哪個函數cout << " 虛表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){
/*"%x" 表示以十六進制的形式輸出,"0"表示不足位數時用0填充,"X"表示字母大寫。
因此,"0X%x" 的作用是以十六進制的形式輸出一個無符號整數,并補齊到8位。*/printf(" 第%d個虛函數地址 :0X%x,->", i, vTable[i]);VFPTR f = vTable[i]; //將當前循環中獲取的函數指針賦值給變量 ff(); //用了函數指針 f 所指向的函數}cout << endl;
}int main() {Base b;Derive d;/* 思路:取出b、d對象的頭4bytes,就是虛表的指針,
前面說了虛函數表本質是一個存虛函數指針的指針數組,這個數組最后面放了一個nullptr1.先取b的地址,強轉成一個int*的指針2.再解引用取值,就取到了b對象頭4bytes的值,這個值就是指向虛表的指針3.再強轉成VFPTR*,因為虛表就是一個存VFPTR類型(虛函數指針類型)的數組。4.虛表指針傳遞給PrintVTable進行打印虛表5.需要說明的是這個打印虛表的代碼經常會崩潰,因為編譯器有時對虛表的處理不干凈,虛表最后面沒有放nullptr,導致越界,這是編譯器的問題。我們只需要點目錄欄的 - 生成 - 清理解決方案,再編譯就好了。*///獲取基類對象 b 的虛函數表指針VFPTR * vTableb = (VFPTR*)(*(int*)&b);PrintVTable(vTableb);//獲取派生類對象 d 的虛函數表指針VFPTR* vTabled = (VFPTR*)(*(int*)&d);PrintVTable(vTabled);return 0;
}
?5.2 多繼承中的虛函數表
#define _CRT_SECURE_NO_WARNINGS #include <iostream>
using namespace std;// 基類 Base1
class Base1 {
public:// 基類 Base1 的虛函數 func1virtual void func1() { cout << "Base1::func1" << endl; }// 基類 Base1 的虛函數 func2virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};// 基類 Base2
class Base2 {
public:// 基類 Base2 的虛函數 func1virtual void func1() { cout << "Base2::func1" << endl; }// 基類 Base2 的虛函數 func2virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2;
};// 派生類 Derive
class Derive : public Base1, public Base2 {
public:// 重寫虛函數 func1virtual void func1() { cout << "Derive::func1" << endl; }// 新增虛函數 func3virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1;
};// 定義函數指針類型 VFPTR
typedef void(*VFPTR) ();// 打印并調用虛函數表中的虛函數
void PrintVTable(VFPTR vTable[]) {cout << " 虛表地址>" << vTable << endl; // 打印虛表地址for (int i = 0; vTable[i] != nullptr; ++i) {printf(" 第%d個虛函數地址 :0X%x,->", i, vTable[i]); // 打印第 i 個虛函數的地址VFPTR f = vTable[i]; // 獲取當前虛函數指針f(); // 調用虛函數}cout << endl;
}int main() {Derive d;// 獲取派生類對象 d 中基類 Base1 的虛函數表VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);// 打印并調用基類 Base1 的虛函數表中的虛函數PrintVTable(vTableb1);/*在 C++ 中,指針的加法操作會根據指針類型的大小進行偏移,因此將指針轉換為 char* 類型可以讓我們以字節為單位進行偏移操作。sizeof(Base1) 表示基類 Base1 的大小,即在派生類對象中所占的字節數。(char*)&d + sizeof(Base1) 的含義是將派生類對象 d 的地址加上基類 Base1 的大小,
得到基類 Base2 在派生類對象中的起始地址。*/// 獲取派生類對象 d 中基類 Base2 的虛函數表VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));// 打印并調用基類 Base2 的虛函數表中的虛函數PrintVTable(vTableb2);return 0;
}
?觀察下圖可以看出:多繼承派生類的未重寫的虛函數放在第一個繼承基類部分的虛函數表中
?5.3. 菱形繼承、菱形虛擬繼承
實際中我們不建議設計出菱形繼承及菱形虛擬繼承,一方面太復雜容易出問題,另一方面這樣的 模型,訪問基類成員有一定得性能損耗。所以菱形繼承、菱形虛擬繼承的虛表我們就不看 了,一般我們也不需要研究清楚,因為實際中很少用。
六、繼承和多態常見的面試問題
- 什么是多態?
答:多態指的是同一個方法調用可以根據對象的不同類型而具有不同的行為。- 什么是重載、重寫(覆蓋)、重定義(隱藏)?
答:重載是指在同一個作用域內,可以定義多個同名函數,它們具有不同的參數列表。在調用時根據傳入的參數類型和數量來決定具體調用哪一個函數。重寫指的是子類重新定義(覆蓋)了父類中的某個方法,子類中的方法名稱、參數列表和返回值必須與父類中的方法相同。通過重寫,子類可以提供自己的實現邏輯,從而修改或擴展父類的行為。重定義是指在派生類中定義了一個與基類中的同名函數,但是參數列表不同的函數。這樣在派生類中,基類中的同名函數會被隱藏起來,在使用派生類對象調用該函數時,實際上調用的是派生類中的函數而不是基類中的函數。- ?多態的實現原理?
答:多態的實現原理主要依賴于兩個關鍵的概念:動態綁定和虛函數。1. 動態綁定:在運行時確定對象的實際類型,以決定調用哪個方法。通過動態綁定,可以將父類的引用或指針指向子類的對象,并在調用方法時根據對象的實際類型來確定調用哪個子類的方法。2. 虛函數:使用虛函數可以在基類中聲明一個方法為虛函數,在派生類中重寫該虛函數。虛函數通過在運行時動態綁定來實現多態。當通過基類的指針或引用調用虛函數時,會根據對象的實際類型來調用相應的派生類方法,而不是只調用基類方法。具體實現多態的步驟如下:1. 在基類中聲明一個或多個虛函數。2. 在派生類中重寫(覆蓋)基類的虛函數。3. 創建基類的指針或引用,并將其指向派生類的對象。4. 通過基類的指針或引用調用虛函數。5. 根據對象的實際類型,動態綁定會選擇調用相應的派生類方法。- inline函數可以是虛函數嗎?
答:可以,不過編譯器就忽略inline屬性,這個函數就不再是 inline,因為虛函數要放到虛表中去。- ?靜態成員可以是虛函數嗎?
答:不能,因為靜態成員函數沒有this指針,使用類型::成員函數 的調用方式無法訪問虛函數表,所以靜態成員函數無法放進虛函數表。- ? 構造函數可以是虛函數嗎?
答:不能,因為對象中的虛函數表指針是在構造函數初始化列表 階段才初始化的。- 析構函數可以是虛函數嗎?什么場景下析構函數是虛函數?
答:可以,并且最好把基類的析 構函數定義成虛函數。在繼承關系中,當基類指針或引用指向派生類對象,并且通過基類指針或引用調用析構函數時,如果析構函數不被聲明為虛函數,那么只會調用基類的析構函數而不會調用派生類的析構函數,導致派生類的資源無法得到正確的釋放。因此,當存在繼承關系且基類指針或引用可能指向派生類對象時,需要將析構函數聲明為虛函數,以確保在通過基類指針或引用調用析構函數時,能夠正確調用派生類的析構函數,從而實現多態的析構。- 對象訪問普通函數快還是虛函數更快?
答:首先如果是普通對象,是一樣快的。如果是指針對象或者是引用對象,則調用的普通函數快,因為構成多態,運行時調用虛函數需要到虛函 數表中去查找。- 虛函數表是在什么階段生成的,存在哪的?
答:虛函數表是在編譯階段就生成的,一般情況 下存在代碼段(常量區)的。- ?C++菱形繼承的問題?虛繼承的原理?
答:菱形繼承是指一個類同時繼承自兩個間接基類,而這兩個基類又繼承自同一個共同的基類,形成了菱形的繼承結構。這種繼承結構可能會導致一些問題,主要是由于多條路徑繼承同一份基類而引起的二義性。虛繼承可以解決菱形繼承中的二義性問題,其原理如下:1. 虛基類:在菱形繼承結構中,位于頂部的共同基類被聲明為虛基類。通過在派生類對共同基類的繼承前加上關鍵字 "virtual",來聲明虛基類。2. 虛基類子對象的唯一性:使用虛繼承后,虛基類在派生類中只會有一份實例,而不會重復出現。這樣可以避免菱形繼承中出現多份共同基類子對象而導致的二義性問題。3. 構造函數和析構函數調用:在派生類的構造函數中,對虛基類的構造函數會由最底層的派生類負責調用,而不是每一級派生類都調用。在析構函數中,同樣只會由最底層的派生類負責調用虛基類的析構函數。通過虛繼承,可以解決菱形繼承可能帶來的二義性問題,確保派生類對共同基類的訪問和使用是正確的。- 什么是抽象類?抽象類的作用?
答:抽象類是一種不能被實例化的類,其目的是為了提供一個接口或者基類,定義了一些方法的簽名但沒有具體實現。抽象類用于表示一個概念上的類,其中包含了一些通用的方法或屬性,但具體的實現留給其派生類來完成。在 C++ 中,通過在類中聲明純虛函數,可以將該類定義為抽象類。純虛函數是在基類中聲明但沒有具體實現的虛函數,派生類必須實現這些純虛函數才能被實例化。如果一個類中有至少一個純虛函數,那么這個類就是抽象類,不能被實例化。抽象類的特點包括:1. 無法被實例化:抽象類不能創建對象,只能被用作基類。2. 包含純虛函數:抽象類中至少包含一個純虛函數,這些函數只有方法簽名而沒有具體實現。3. 提供接口定義:抽象類定義了一組接口或者方法,規定了派生類需要實現的方法。抽象類常用于定義一些通用的方法和屬性,并要求其派生類提供具體的實現。通過繼承抽象類并實現其中的純虛函數,可以使代碼更加模塊化和可擴展。