文章目錄
- 前言
- 多態的概念
- 多態的定義和實現
- 虛函數
- 虛函數的重寫(覆蓋)
- 多態的構成條件
- override 和 final(C++11提出)
- final
- override
- 重載、覆蓋(重寫)、隱藏(重定義)的對比
- 抽象類
- 接口繼承和實現繼承
- 多態的原理
- 虛函數表(也叫做虛表)
- 引申:虛表的打印
- 多態的原理
- 靜態多態和動態多態
- 多繼承中的虛函數表
- 作業部分
前言
多態是面向對象編程的三大核心特性(封裝、繼承、多態)之一,它使得同一接口可以呈現出不同的行為,極大地提升了代碼的靈活性和可擴展性。在 C++ 中,多態的實現與虛函數、虛表等機制緊密相關,其底層邏輯涉及編譯期與運行期的不同處理方式。
本文將系統梳理 C++ 多態的概念、實現條件、核心機制(虛函數與虛表),并深入解析多態在繼承場景下的表現,同時結合典型問題與示例代碼,幫助讀者全面理解多態的本質與應用。無論是基礎的虛函數重寫,還是復雜的多繼承虛表結構,本文都將逐一剖析,為開發者在實際編程中合理運用多態提供清晰指引。
多態的概念
通俗來說,就是多種形態,具體點就是去完成某個行為,當不同的對象去完成時會產生出不同的狀態。
為了更方便和靈活的實現多種形態的調用
多態的定義和實現
虛函數
概念:被virtual修飾的類成員函數稱為虛函數(和前面的虛繼承區分)
eg:class Person { public:virtual void text() {};
虛函數的重寫(覆蓋)
概念:派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的返回值類型、函數名字、參數列表完全相同),稱子類的虛函數重寫或者覆蓋了基類的虛函數。
省流:虛函數+三同
虛構函數重寫的兩個例外情況:
1.協變:
此時基類與派生類虛函數返回值類型可以不同,但是返回值必須是父子關系的指針和引用
一個虛函數返回值是指針,一個是引用這樣也不行 但是一個返回的是父的,一個返回的是子的沒關系
2.派生類重寫虛函數可以不加
virtual
(但是建議加上)
總問題: 析構函數可以是虛函數嗎?為什么需要是虛函數?
析構函數加virtual,是不是虛函數重寫?
是,因為類析構函數都被處理成destructor這個統一的名字為什么要這么處理呢?
因為要讓他們構成重寫
那為什么要讓他們構成重寫呢?
因為下面的場景
(Person是基類,Student是派生類) Person* p = new Person;p->text();delete p;p = new Student;//注意:這里的p還是Person類的p->text();delete p; // p->destructor() + operator delete(p)// 這里我們期望p->destructor()是一個多態調用,而不是普通調用
多態的構成條件
1.必須通過基類的指針或者引用調用虛函數(注意是基類!!!)
2.被調用的函數必須是虛函數,且派生類必須對基類的虛函數進行重寫
注意:多態調用看的是指向的對象,普通的調用看的是當前的類型
eg: class Person{
public:
virtual void text(){}
};class Student : public Person{
public:virtual void text(){}//--a
};void func(const Person& p)
{
p.text();
}main函數里面func(Student());是調用的a的
問題:
1.為什么必須要是父類的指針或引用,而不是父類對象或者子類的指針或引用
(編譯器把這幾種行為
ban
了的原因)原因:
1.不能是父類對象的原因:
不會拷貝子類的虛表和其他特有的,所以這個父類對象根本不知道子類的存在(指針和引用就可以避開這一點)
編譯器選擇不拷貝子類的虛表指針的原因:
害怕別人不知道父類對象虛表中是父類的還是子類的
2.不能是子類指針或引用:
怕去訪問到父類中沒有的成員
引申:
1.子類虛表的構建:
子類繼承父類時,會先復制一份父類的虛表。如果子類沒有重寫父類的虛函數,那么虛表中對應函數指針就指向父類虛函數實現;若子類重寫了某個虛函數,就會用子類自己的虛函數地址覆蓋虛表中從父類繼承來的對應函數指針。
2.子類賦值給父類對象切片,不會拷貝虛表,父類還是會要自己的虛表
override 和 final(C++11提出)
final
作用:1.修飾虛函數,表示該虛函數不能再被重寫
2.使用
final
關鍵字修飾類,直接禁止任何類繼承它eg: class Person final{};
用法:eg:virtual void text() final {}(前有無virtual不重要哈)
引申:一個有final一個無final也能構成重載和隱藏
override
作用:檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫編譯報錯
class Person{
public:virtual void text(){}
};class Student :public Person {
public:virtual void text() override {}
};
重載、覆蓋(重寫)、隱藏(重定義)的對比
抽象類
概念:
在虛函數的后面寫上 =0 ,則這個函數為純虛函數。包含純虛函數的類叫做抽象類(也叫接口類),抽象類不能實例化出對象。派生類繼承后也不能實例化出對象,只有重寫純虛函數,派生類才能實例化出對象。
作用:強制要求派生類重寫虛函數,另外抽象類體現出了接口繼承的關系
比如:class Car { public: virtual void Drive() = 0; };
接口繼承和實現繼承
普通函數的繼承是一種實現繼承,繼承的是函數的實現。
虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口。
多態的原理
虛函數表(也叫做虛表)
包含虛函數的類會有虛函數表指針,虛函數表指針指向的是虛函數表的地址
虛函數表里面存了虛函數的指針
引申:函數不符合多態,編譯時就確定地址了 符合多態,運行時到指向對象的虛函數表中找調用函數的地址
注意:同一個類的所有實例對象共享同一個虛函數表
比較特殊的是:VS編譯器的虛表指向的地址后面會有0作為結束(可以用內存窗口看)
比如:
但是在進行增量編譯之后,可能這個0就沒了,這時候需要清理一下解決方案或者重新生成解決方案才行
引申:虛表的打印
虛表本質上是函數指針數組
typedef void(*FUNC_PTR) ();
//這里就是將 一個void(*)()的函數指針類型取別名為FUNC_PTR// 打印函數指針數組的方法
void PrintVFT(FUNC_PTR* table)
{for (size_t i = 0; table[i] != nullptr; i++){printf("[%d]:%p->", i, table[i]);FUNC_PTR f = table[i];f();}printf("\n");
}int main()
{Student st;int vft2 = *((int*)&st);
//這個強轉之后就一次++指++(int)個字節的東西了;而且這個32位上正好一個指針4個字節,正好讀完
//注意:Linux是64位的!!!PrintVFT((FUNC_PTR*)vft2);
//發現隱式類型轉換會報錯,就改成強轉了return 0;
}
注意:成員變量的變化會導致虛表的打印出錯–因為可能會影響到內存布局
虛表和虛基表都是在編譯階段生成的
對象實例化之后,才會與虛表有聯系(通過虛表指針)
多態的原理
核心的實現機制就是虛函數表和虛指針
滿足多態的話,子類的虛指針指向的虛表中的虛函數就會覆蓋父類的虛函數的地址,然后調用的就是子類的虛函數了
靜態多態和動態多態
靜態多態,又叫靜態綁定,前期綁定(早綁定),在程序編譯期間就確定了程序的行為
比如:函數重載
動態多態又稱為動態綁定,后期綁定(晚綁定),是在程序運行期間才確定調用什么函數的
也就是繼承+虛函數重寫實現的多態
在默認情況下,多態一般指的是動態多態
多繼承中的虛函數表
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;
};int main()
{Derive d;cout << sizeof(d) << endl;
//X86環境下,這個占20個字節,組成:兩個基類(都是一個虛表指針加一個成員變量)加一個成員變量Base1* ptr1 = &d;ptr1->func1();Base2* ptr2 = &d;ptr2->func1();//通過修正this指針,來讓this指針指向派生類的頭return 0;
}
問題:為什么重寫func1,Base1和Base2的虛表中func1的地址不一樣?
Base2中func1的地址不一樣是為了jmp去修正this指針的位置
注意:
1.多繼承派生類的未重寫的虛函數放在第一個繼承基類部分的虛函數表中(其實是末尾)
作業部分
設計不想被繼承類,如何設計?方法1:基類構造函數私有 (C++98)方法2:基類加一個final (C++11)方法1:eg:
class A
{
public:static A CreateObj()//這個static不能去掉,不然就不能通過域名去調用了{return A();}
private:A(){}
};//當然,用析構函數這么搞也行哈
int main()
{A::CreateObj();return 0;
}方法2:
class A final
{}
這里常考一道筆試題:sizeof(Base)是多少?(X86環境下的話)
答案:8個字節//不是一個字節,也不是四個字節
要注意的是:類里面還有一個虛函數表指針(_vfptr)
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:char _b = 1;
};int main()
{cout << sizeof(Base) << endl;return 0;
}
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[])//相當于int main()
{B* p = new B;p->test();return 0;
}
結果:輸出B->1引申:如果把test()放在了B里面的話,就應該輸出B->0了
因為此時this->func()的this不是父類指針,不構成多態
派生類那里不用加virtual的原因:
本質上只重寫了實現
面試常考題:
1.什么是多態?–靜態多態和動態多態都要答
2.inline函數可以是虛函數嗎?答:可以,不過編譯器就忽略inline屬性,這個函數就不再是
inline,因為虛函數要放到虛表中去。
3.靜態成員可以是虛函數嗎?答:不能,因為靜態成員函數沒有this指針,使用類型::成員函數
的調用方式無法訪問虛函數表,所以靜態成員函數無法放進虛函數表。(語法也會強制檢查這個,會報錯)
4.構造函數可以是虛函數嗎?答:不能,因為對象中的虛函數表指針是在構造函數初始化列表
階段才初始化的。
5.對象訪問普通函數快還是虛函數更快?答:首先如果是普通對象,是一樣快的。如果是指針
對象或者是引用對象,則調用的普通函數快,因為構成多態,運行時調用虛函數需要到虛函
數表中去查找。
6.虛函數表是在什么階段生成的,存在哪的?答:虛函數表是在編譯階段就生成的,一般情況
下存在代碼段(常量區)的。