【C++語言】多態

一、多態的概念

? ? ? ?多態的概念:通俗來說,就是多種形態,具體點就是去完成某種行為,當不同的對象去完成時會產生出不同的狀態。

我們可以舉一個例子:

???????比如買票這種行為,當普通人買票時,是全價買票;學生買票時,是半價買票;軍人買票時是優先買票。

再舉一個例子:

??????? 會最近為了爭奪在線支付市場,支付寶年底經常做誘人的掃紅包-支付-給獎勵金的活動。那么大家想想為什么有人掃的紅包又大又新鮮8塊、10塊...,而有人掃的紅包都是1毛,5毛....。其實這背后也是一個多態行為。支付寶首先會分析你的賬戶數據,比如你是新用戶、比如你沒有經常支付寶支付等等,那么你需要被鼓勵使用支付寶,那么就你掃碼金額 = random()%99;比如你經常使用支付寶支付或者支付寶賬戶中常年沒錢,那么就不需要太鼓勵你去使用支付寶,那么就你掃碼金額 = random()%1;總結一下:同樣是掃碼動作,不同的用戶掃得到的不一樣的紅包,這也是一種多態行為。ps:支付寶紅包問題純屬瞎編,大家僅供娛樂。

二、多態的定義與實現

2.1?虛函數

虛函數:即被 virtual 修飾的類成員函數稱為虛函數。

class Person
{
public:virtual void BuyTicket(){std::cout << "買票-全價" << std::endl;}
}

???????虛函數(virtual function)是面向對象編程(OOP)中的一個重要概念,特別是在C++等支持多態的語言中。虛函數允許在子類中重寫(覆蓋)父類中的函數,達到動態綁定或運行時多態的效果。下面是虛函數的一些關鍵要點:

2.1.1 虛函數的基本定義

???????虛函數是在基類中聲明的函數,其聲明前使用關鍵字 virtual。虛函數的作用是允許在派生類中重新定義該函數,并且在運行時,程序會根據對象的實際類型來決定調用哪一個版本的函數,而不是根據指針或者引用的類型來決定

2.1.2 虛函數的語法

在基類中聲明一個虛函數的基本語法如下:

class Base {
public:virtual void show() {std::cout << "Base class show()" << std::endl;}
};

2.1.3 虛函數的作用

???????虛函數的主要作用是實現多態性,即允許通過基類指針或引用來調用派生類的重寫函數。

class Derived : public Base {
public:void show() override {std::cout << "Derived class show()" << std::endl;}
};

???????如果你使用基類指針或引用指向派生類對象,調用虛函數時,實際執行的是派生類中的版本,而不是基類中的版本:

Base* basePtr;
Derived derivedObj;basePtr = &derivedObj;
basePtr->show();  // 輸出:Derived class show()

???????在這個例子中,show()函數的調用會根據basePtr指向的實際對象(derivedObj)來決定使用派生類中的show()函數,而不是基類中的show()

2.1.4 虛函數與靜態綁定/動態綁定

  • 靜態綁定:即編譯時決定調用哪個函數,通常發生在沒有使用虛函數的情況下。
  • 動態綁定:即運行時決定調用哪個函數,這就是虛函數的作用所在。

???????在虛函數的情況下,程序在運行時通過動態綁定來決定實際調用的是基類的還是派生類的函數。

2.1.5 虛函數的析構函數

???????通常,基類的析構函數也應該聲明為虛函數。這是因為,如果通過基類指針刪除派生類對象,虛析構函數可以確保派生類的析構函數被調用,從而避免資源泄漏

class Base {
public:virtual ~Base() {std::cout << "Base class destructor" << std::endl;}
};class Derived : public Base {
public:~Derived() override {std::cout << "Derived class destructor" << std::endl;}
};

???????如果沒有虛析構函數,刪除派生類對象時可能只會調用基類的析構函數,導致派生類部分沒有正確釋放,造成資源泄漏。

2.1.6 純虛函數

???????當一個類中的虛函數沒有具體的實現時,稱其為純虛函數。純虛函數在基類中只聲明,并且在函數聲明的末尾加上= 0。包含純虛函數的類叫做抽象類,抽象類不能直接實例化。

class Base {
public:virtual void show() = 0;  // 純虛函數
};

? ?Base類是一個抽象類,不能直接實例化。必須通過繼承并提供show()函數的實現才能創建派生類對象。

2.1.7 虛函數的性能開銷

???????使用虛函數會有一定的性能開銷,因為系統需要在運行時查找函數的實際實現。這通常通過虛函數表(VTable)來實現。每個類中包含一個虛函數表,表中存儲了指向虛函數實現的指針。調用虛函數時,程序會查找虛函數表并調用相應的函數。因此,虛函數調用比普通函數調用稍慢,但這種開銷通常可以忽略不計,除非在非常性能敏感的代碼中。

2.1.8 總結

???????虛函數是面向對象編程中實現運行時多態性的重要工具。它使得通過基類指針或引用調用派生類重寫的方法成為可能,從而提高了程序的靈活性和擴展性。在實際應用中,虛函數常用于需要多態行為的場景,比如圖形庫中的形狀對象(如CircleRectangle等)都可以通過基類指針調用draw()函數,而不需要關心具體是哪一種形狀。

2.2?多態的構成條件

???????多態是在不同繼承關系的類對象,去調用同一個函數,產生了不同的行為。比如 Student 繼承了 Person,Person 對象買票全價,Student 對象買票半價。

那么在繼承中要構成多態還有兩個條件:

  1. 必須通過基類的指針或者引用調用虛函數
  2. 被調用的函數必須是虛函數,且派生類必須對基類的虛函數進行重寫

2.3 虛函數的重寫

???????在面向對象編程中,虛函數重寫是指在派生類中重新定義和實現基類中的虛函數。通過重寫,派生類可以改變或者擴展基類提供的功能。虛函數重寫是實現運行時多態的核心機制。

2.3.1?虛函數重寫的條件

???????虛函數的重寫(覆蓋):派生類中有一個跟基類完全相同的虛函數(即派生類和基類虛函數的返回值類型、函數名字、參數列表完全相同),稱子類的虛函數重寫了基類的虛函數。

class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};class Student : public Person {
public:virtual void BuyTicket() { cout << "買票-半價" << endl; }/*注意:在重寫基類虛函數時,派生類的虛函數在不加virtual關鍵字時,雖然也可以構成重寫(因
為繼承后基類的虛函數被繼承下來了在派生類依舊保持虛函數屬性),但是該種寫法不是很規范,不建議
這樣使用*//*void BuyTicket() { cout << "買票-半價" << endl; }*/
};void Func(Person& p)
{ p.BuyTicket(); 
}int main()
{Person ps;Student st;Func(ps);Func(st);return 0;
}

注意:?在重寫基類虛函數時,派生類的虛函數在不加 virtual 關鍵字時,雖然也可以構成重寫(因為繼承后基類的虛函數被繼承下來了在派生類依舊保持虛函數屬性),但是該種寫法不是很規范,不建議這樣使用),但是如果基類虛函數沒有寫 virtual,則不會構成多態。?

建議:兩個虛函數都加上 virtual???

2.3.2 虛函數重寫的兩個例外

2.3.2.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.2 析構函數的重寫(基類與派生類析構函數的名字不同)

???????如果基類的析構函數為虛函數,此時派生類的析構函數只要定義,無論是否加上 virtual 關鍵字,都與基類的析構函數構成重寫,雖然基類與派生類函數的名字不同。雖然函數名不相同,看起來違背了重寫的規則,其實不然,這里可以理解為編譯器對析構函數的名稱做了特殊處理,編譯后析構函數的名稱統一處理成 destructor。

???????下面,這一份代碼就是另外一種情況,基類的析構函數沒有進行加上 virtual,這就沒有構成多態,當去析構子類的時候,只會調用父類的析構函數,造成內存泄露。

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 p1;delete p2;return 0;
}

2.3.3 普通調用和多態調用

???????普通調用(靜態綁定調用)和多態調用(動態綁定調用)是 C++ 中函數調用的兩種基本方式,他們有著本質的區別,尤其體現在運行時函數選擇的方式上。下面,我們來詳細解釋他們的概念和區別:

2.3.3.1 普通調用(靜態綁定調用)

???????普通調用是指在編譯時就確定了調用的函數,編譯器通過函數的名字、參數作用域來確定該調用。普通調用發生在編譯時,也稱為靜態綁定早綁定

特點:
  • 編譯時確定調用函數
  • 適用于非虛函數。
  • 函數調用和具體的函數實現是靜態綁定的,編譯器可以直接決定調用哪個函數。
class Base {
public:void display() {   // 非虛函數std::cout << "Base display" << std::endl;}
};class Derived : public Base {
public:void display() {   // 非虛函數std::cout << "Derived display" << std::endl;}
};int main() {Base b;Derived d;b.display();  // 普通調用,調用的是 Base 的 displayd.display();  // 普通調用,調用的是 Derived 的 displayreturn 0;
}

???????在上述代碼中,display()是普通的非虛函數調用。當b.display()d.display()被調用時,編譯器在編譯時根據對象的類型(BaseDerived)確定調用哪個函數,這種調用方式稱為靜態綁定

2.3.3.2 多態調用(動態綁定調用)

???????多態調用是指在程序運行時,基于實際對象的類型來確定調用哪個函數,而不是編譯時就決定。這種機制依賴于虛函數和繼承關系,通常會使用虛函數來實現,稱為動態綁定晚綁定

特點:
  • 運行時決定調用的函數
  • 適用于虛函數(需要在基類中聲明為virtual)。
  • 通過對象的實際類型來決定調用哪個函數,而不是通過聲明時的類型來決定。
class Base {
public:virtual void display() {  // 虛函數std::cout << "Base display" << std::endl;}
};class Derived : public Base {
public:void display() override {  // 重寫基類虛函數std::cout << "Derived display" << std::endl;}
};int main() {Base* b = new Derived();  // 基類指針指向派生類對象b->display();  // 多態調用,調用的是 Derived 的 displaydelete b;return 0;
}

???????在這個例子中,Base類中的display()函數是虛函數。在main函數中,基類指針b指向了Derived類的對象。調用b->display()時,由于函數是虛函數,編譯器在運行時會根據對象的實際類型(Derived)來決定調用Derived類中的display()函數,而不是基類Base中的display()函數。這種調用方式就是多態調用,它依賴于運行時的動態綁定。?

2.3.3.3 區別

2.3.3.4 怎么判斷是普通調用還是多態調用

???????判斷是普通調用還是多態調用的關鍵在于是否涉及到繼承方法重寫(或者接口實現)。具體來說,可以通過以下幾個方面來判斷:

類的繼承結構

  • 普通調用:通常是在同一個類中直接調用方法,沒有涉及繼承關系。
  • 多態調用:通常會涉及到父類和子類的繼承關系,在父類中定義了方法,在子類中重寫(Override)了該方法。

方法重寫

  • 普通調用:在調用方法時,如果沒有涉及子類重寫父類的方法,調用的就是父類定義的普通方法。
  • 多態調用:如果存在方法重寫,且在父類引用指向子類對象時,實際調用的是子類重寫的方法,而不是父類的版本。

引用類型

  • 普通調用:調用的方法是根據引用類型(例如A類的引用)來確定的。
  • 多態調用:調用的方法是根據實際對象的類型(即運行時的類型)來決定的,而不是引用類型。例如,當使用父類引用指向子類對象時,運行時會調用子類中的方法。

是否使用 virtual 和 override

  • 普通調用:沒有使用?virtual(在父類方法上),或沒有使用?override(在子類中)。
  • 多態調用:在父類方法上使用了?virtual,并在子類中使用了?override

方法調用時的實際對象模型

  • 普通調用:通常在調用方法時,方法調用根據編譯時確定的引用類型來決定。
  • 多態調用:即使引用類型是父類類型,實際調用的也是子類的重寫方法,這是基于對象的實際類型(運行時類型)來決定的。

???????當子類調用子類的虛函數時,這依然屬于普通調用,因為這種情況是在同一個類中進行的,且沒有涉及到父類和子類之間的多態特性。

2.4 C++11 的 override 和 final

實現一個類,這個類不能被繼承。

  • 方法一:父類構造函數私有化,派送類實例不出來對象。
  • 方法二:C++11 中的 final 修飾的類為最終類,不能被繼承。
class A
{
private:A() {}
}// 方法二
class A final
{}

2.4.1 final

final 修飾虛函數,表示該旭函數不能再被重寫

class Car
{
public:virtual void Drive() final {}
};class Benz :public Car
{
public:virtual void Drive() {cout << "Benz-舒適" << endl;}
};

2.4.2 override

override 檢查派生類虛函數是否重寫了基類某一個虛函數,如果美歐重寫編譯報錯

class Car{
public:virtual void Drive(){}
};class Benz :public Car {
public:virtual void Drive() override {cout << "Benz-舒適" << endl;}
};

2.5 重載、覆蓋(重寫)、隱藏(重定義)的對比

2.5.1 重載

???????重載發生在同一個類中,當一個類中存在多個方法名稱相同但參數列表不同的方法時。重載是基于方法簽名(方法名稱、參數類型、參數個數等)來區分的。

特點:
  • 發生在同一個類中
  • 方法名相同,但參數不同(參數個數或類型不同),返回類型可以相同也可以不同(但通常只看參數區分)。
  • 編譯時決定調用哪個方法。
  • 重載方法并不涉及繼承和多態。
class MyClass {void print(int a) {System.out.println("Printing integer: " + a);}void print(double a) {System.out.println("Printing double: " + a);}void print(int a, double b) {System.out.println("Printing int and double: " + a + " and " + b);}
}

2.5.2 覆蓋(重寫)

???????覆蓋發生在子類中,當子類繼承父類的方法并重新定義該方法時。重寫是基于方法簽名相同來實現的。

特點:
  • 發生在繼承關系中,即父類和子類之間。
  • 方法名、返回類型、參數列表相同,僅僅是子類重新定義了父類方法的實現。
  • 動態綁定,方法的調用是在運行時決定的,依賴于對象的實際類型。
  • 在子類中使用?@Override?注解標記(Java 中),可以避免方法簽名不匹配時出現錯誤。
#include <iostream>
using namespace std;class Animal {
public:virtual void sound() { // 虛函數,允許子類重寫cout << "Animal makes a sound" << endl;}
};class Dog : public Animal {
public:void sound() override { // 重寫父類的 sound 方法cout << "Dog barks" << endl;}
};int main() {Animal* animal = new Dog();animal->sound();  // 調用的是 Dog 類中的 sound() 方法delete animal;return 0;
}

2.5.3 隱藏(重定義)

???????隱藏是指子類重新定義了父類中已經存在的方法、字段或屬性,但這種重新定義并不屬于覆蓋(重寫)的范疇。對于字段來說,隱藏是通過子類聲明一個同名的字段來實現的;對于方法來說,它指的是子類聲明一個與父類方法名稱相同但簽名不同的方法。

特點:
  • 字段隱藏:子類中聲明一個與父類字段名稱相同的字段,這樣父類的字段在子類中就被隱藏了。
  • 方法隱藏:子類中聲明一個與父類方法名稱相同的方法,且方法簽名不同,實際上并沒有實現覆蓋(重寫)。這種情況下,父類的方法不會被動態調用,而是根據引用的類型決定使用哪個方法。
  • 編譯時決定:方法和字段的隱藏會根據引用的類型(而非實際對象類型)來決定使用哪個成員。
2.5.3.1 字段隱藏
#include <iostream>
using namespace std;class Animal {
public:int numLegs = 4;  // 父類字段
};class Dog : public Animal {
public:int numLegs = 3;  // 子類字段,隱藏父類字段
};int main() {Dog d;cout << "Dog has " << d.numLegs << " legs." << endl;  // 輸出 3Animal a;cout << "Animal has " << a.numLegs << " legs." << endl;  // 輸出 4return 0;
}
2.5.3.2 方法隱藏
#include <iostream>
using namespace std;class Animal {
public:void sound() {cout << "Animal makes a sound" << endl;}
};class Dog : public Animal {
public:void sound(int times) {  // 方法隱藏,參數不同for (int i = 0; i < times; i++) {cout << "Dog barks" << endl;}}
};int main() {Dog d;d.sound(3);  // 調用 Dog 類的 sound(int) 方法Animal a;a.sound();   // 調用 Animal 類的 sound() 方法return 0;
}

總結:

  • 重載:同一類中多個方法名相同但參數不同,編譯時決定。
  • 覆蓋(重寫):子類重新定義父類的方法,方法簽名相同,運行時動態綁定,支持多態。
  • 隱藏(重定義):子類重新定義了父類的方法或字段,方法簽名不同或字段名相同,編譯時決定。

三、抽象類

3.1 概念

???????在虛函數的后面寫上 = 0, 則這個函數稱為純虛函數。包含純虛函數的類叫做抽象類(也叫做接口類),抽象類不能實例化出對象。派生類繼承后也不能實例化出對象,只有重寫純虛函數,派生類才能實例化出對象。純虛函數規范了派生類必須重寫,另外純虛函數更體現出接口繼承。

3.2 接口繼承和實現繼承

???????普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口,所以如果不實現多態,不要把函數定義為虛函數。

四、多態的原理

4.1 虛函數表

我們可以通過一道題目來引出虛函數表:

答案為:8

// 這里常考一道筆試題:sizeof(Base)是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};

???????通過觀察測試我們發現 b 對象是 8 bytes,除了 _b 成員,還多了一個 _vfptr 放在對象的前面(注意有些平臺可能會放到對象的最后面,這個跟平臺有關),對象中的這個指針我們叫做虛函數表指針(v 代表 virtual,f 代表 function)。一個含有虛函數的類中都至少有一個虛函數表指針,因為虛函數的地址要被放到虛函數表中,虛函數表也簡稱為虛表。那么派生類中這個表中放了些什么,我們來繼續分析:

// 針對上面的代碼我們做出以下改造
// 1.我們增加一個派生類Derive去繼承Base
// 2.Derive中重寫Func1
// 3.Base再增加一個虛函數Func2和一個普通函數Func3
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;
}

通過觀察和測試,我們發現了以下幾點問題:

  1. 派生類對象 d 中也有一個虛表指針,d 對象由兩個部分構成,一部分是父類繼承下來的成員,虛表指針也就是存在父類的和自己重寫覆蓋的,另一部分是自己的成員。
  2. 基類 b 對象和派生類 d 對象虛表是不一樣的,這里我們發現 Func1 完成了重寫,所以 d 的虛表中存的是重寫的 Derive::Func1,所以虛函數的重寫叫做覆蓋,覆蓋就是指虛表中虛函數的覆蓋。重寫是語法的叫法,覆蓋是原理層的叫法。
  3. 另外 Func2 繼承下來后是虛函數,所以放進了虛表,Func3 也繼承下來了,但是不是虛函數,所以不會放進虛表中。
  4. 虛函數表本質是一個存虛函數指針的指針數組,一般情況下這個數據最后放了一個 nullptr。
  5. 總結一下派生類的虛表生成:
    • 先將基類中的虛表內容拷貝一份到派生類虛表中;
    • 如果派生類重寫了基類中的某一個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數;
    • 派生類自己新增加的虛函數按其在派生類中的聲明義序增加到派生類虛表的最后
  6. 這里還有一個容易混淆的問題:虛函數存在哪里?虛表存在哪里?注意虛表存的是虛函數指針,不是虛函數,虛函數和普通函數一樣的,都是存在代碼段中,只是他的指針又存到了虛表中。另外對象中存的不是虛表,存的是虛表指針。那么虛表存在哪的呢?實際我們去驗證一下會發現 vs 下是存在代碼段的,Linux中是什么呢??

檢驗虛函數表在 vs 中的位置:

class Base
{
public:Base():_b(2){//cout << "Base()" << endl;}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;}virtual void Func3(){cout << "Derive::Func3()" << endl;}
private:int _d = 2;
};int main()
{Base b;Base b1;Base b2;Derive d;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);Base* p3 = &b;Derive* p4 = &d;printf("Base虛表地址:%p\n", *(int*)p3);printf("Base虛表地址:%p\n", *(int*)p4);return 0;
}

?檢驗虛函數表在 Linux?中的位置:

#include <iostream>
using namespace std;class Base
{
public:Base(): _b(2){// cout << "Base()" << endl;}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;}virtual void Func3(){cout << "Derive::Func3()" << endl;}private:int _d = 2;
};int main()
{Base b;Base b1;Base b2;Derive d;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);Base *p3 = &b;Derive *p4 = &d;printf("Base虛表地址:%p\n", *(int *)p3);printf("Base虛表地址:%p\n", *(int *)p4);return 0;
}

4.2 多態的原理

???????上面分析了這個半天了那么多態的原理到底是什么?還記得這里Func 函數傳 Person 調用的
Person::BuyTicket ,傳 Student 調用的是 Student::BuyTicket

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;
}
  1. 觀察下圖的紅色箭頭我們看到,p 是指向 mike 對象時,p->BuyTicket 在 mike 的虛表中找到虛函數是 Person::BuyTicket
  2. 觀察下圖的藍色箭頭我們看到,p 是指向 johnson 對象時,p->BuyTicket 在 jonhson 的虛表中找到虛函數是 Student::BuyTicket
  3. 這樣就實現了不同對象去完成同一行為時,展示出不同的形態
  4. 反過來思考我們要達多態,有兩個條件:一個是虛函數覆蓋,一個是對象的指針或者引用調用虛函數?

4.3 動態綁定與靜態綁定

  • 靜態綁定又稱為前期綁定(早綁定),在程序編譯期間確定了程序的行為,也稱為靜態多態,比如:函數重載
  • 動態綁定又稱后期綁定(晚綁定),是在程序運行期間,根據具體拿到的類型確定程序的具體行為,調用具體的函數,也稱為動態多態

4.4 虛函數表和虛表指針是什么時候初始化的

???????虛函數表(VTable)和虛函數表指針(VPtr)的初始化通常與對象的構造過程相關,尤其是在對象的類型包含虛函數時。理解這兩個概念對于深入理解 C++ 的面向對象機制以及多態的實現方式是非常重要的。以下是關于它們初始化時機的詳細說明。

4.4.1 虛函數表

???????虛函數表是一個內部的數據結構,用于存儲類的虛函數地址。每個含有虛函數的類(即包含至少一個虛函數的類)都有一個與之關聯的虛函數表。

  • 在類加載時(編譯時):虛函數表本身是由編譯器在程序加載時生成的,并且是與類本身關聯的。虛函數表本質上是一個數組,其中的每個元素是指向虛函數的指針。每個類有且僅有一個虛函數表,它存儲了該類的虛函數的地址。

  • 在程序啟動時初始化:虛函數表通常在程序的啟動階段被加載到內存中(例如,由操作系統的加載器或運行時環境)。每個類的虛函數表在程序啟動時就已經初始化,實際上是在靜態存儲區域中維護的,并且對于所有的對象實例都是共享的。

4.4.2 虛函數表指針

???????虛函數表指針(VPtr)是每個對象實例中隱式存在的指針,指向該對象所屬類的虛函數表。它的初始化時機緊密地與對象的構造過程有關。

  • 在對象構造時初始化:虛函數表指針是由編譯器自動生成的,并且會在對象的構造函數中被初始化。每當一個對象實例被創建時,虛函數表指針會被初始化為指向該對象所屬類的虛函數表。

    • 在構造函數中初始化:虛函數表指針的初始化通常發生在對象構造過程中。在 C++ 中,構造函數的執行順序是:首先執行基類的構造函數,然后執行派生類的構造函數(如果有)。在基類的構造函數執行時,虛函數表指針已經指向基類的虛函數表(即使在派生類的構造函數執行之前,虛函數表指針已經是有效的)。如果對象是派生類的實例,虛函數表指針會在構造過程中被更新為指向派生類的虛函數表(具體取決于當前構造函數執行到的部分)。

    • 如果是動態分配的對象(通過 new 創建),虛函數表指針會在分配內存后、構造函數執行之前初始化,確保對象的虛函數表指針正確地指向相應的虛函數表。

4.4.3 總結初始化時機

  • 虛函數表(VTable):在程序加載時初始化,由編譯器生成。它是與類相關的靜態數據結構。
  • 虛函數表指針(VPtr):在對象的構造函數中初始化。對于每個對象實例,虛函數表指針會在對象構造期間被設置為指向相應類的虛函數表。對于基類的構造函數,虛函數表指針指向基類的虛函數表;對于派生類的構造函數,虛函數表指針會在構造過程中更新為指向派生類的虛函數表。
#include <iostream>class Base {
public:Base() {std::cout << "Base constructor\n";// 虛函數表指針已經指向 Base 的虛函數表}virtual void foo() {std::cout << "Base foo\n";}
};class Derived : public Base {
public:Derived() {std::cout << "Derived constructor\n";// 虛函數表指針會在此時指向 Derived 的虛函數表}void foo() override {std::cout << "Derived foo\n";}
};int main() {Base* b = new Derived();b->foo();  // 會調用 Derived::foo,展示了虛函數的多態性delete b;return 0;
}

執行流程

  1. 程序啟動時,虛函數表會被生成并加載到內存中。Base?類和?Derived?類分別擁有自己的虛函數表。
  2. 當?Derived?對象被創建時Base?類的構造函數會首先執行。在此時,虛函數表指針(vptr)會被設置為指向?Base?類的虛函數表。
  3. 當?Derived?類的構造函數執行時,虛函數表指針會被更新為指向?Derived?類的虛函數表。
  4. 調用?foo()?時,因為?vptr?已經指向?Derived?類的虛函數表,最終調用的是?Derived?類的?foo()?函數。

???????因此,虛函數表和虛函數表指針的初始化是與對象的構造過程密切相關的,虛函數表是靜態的,早期就初始化;虛函數表指針是動態的,隨著對象的構造過程逐步設置。?

五、單繼承和多繼承關系的虛函數表

???????需要注意的是在單繼承和多繼承關系中,下面我們去關注的是派生類對象的虛表模型,因為基類的虛表模型沒有什么特別研究的了。

5.1 單繼承中的虛函數表

???????在單繼承中,一個派生類從一個基類繼承并可能重寫一些虛函數。虛函數表的結構比較簡單:每個類有一個虛函數表(VTable),該表包含指向該類的虛函數實現的指針。每個對象實例有一個虛函數表指針(VPtr),指向當前對象所屬類的虛函數表。

#include <iostream>
using namespace std;class Base {
public:virtual void foo() {cout << "Base foo\n";}
};class Derived : public Base {
public:void foo() override {cout << "Derived foo\n";}
};int main() {Base* b = new Derived();b->foo();  // 輸出 "Derived foo"delete b;return 0;
}
虛函數表結構:
  • Base?類有一個虛函數表(VTable),它存儲指向?Base::foo?的指針。
  • Derived?類有一個虛函數表(VTable),它存儲指向?Derived::foo?的指針。
  • 對于?Derived?對象,虛函數表指針(VPtr)指向?Derived?的虛函數表,調用?foo()?時將調用?Derived::foo
初始化過程:
  • 在?Base?類的構造函數中,虛函數表指針指向?Base?類的虛函數表。
  • 在?Derived?類的構造函數中,虛函數表指針會被更新為指向?Derived?類的虛函數表。

class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a;
};
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;
};int main()
{Base b;Derive d;return 0;
}

???????通過觀察下圖的監視窗口中我們發現看不見 func3 和 func4。這里是編譯器的監視窗口故意隱藏了這兩個函數,也可以認為是他的一個小bug,那么我們應該如何查看d的虛表呢??下面我們使用代碼打印出虛表中的函數。

代碼打印虛表

typedef void(*VF_PTR)();// 打印虛表,本質打印指針(虛函數指針)數組
//void PrintVFT(VF_PTR vft[], int n)
//void PrintVFT(VF_PTR vft[])
void PrintVFT(VF_PTR* vft)
{for (size_t i = 0; vft[i] != nullptr; i++){printf("[%d]:%p->", i, vft[i]);VF_PTR f = vft[i];f();//(*f)();}cout << endl << endl;
}

5.2 多繼承中的虛函數表

5.2.1 多繼承中的虛函數表介紹

???????多繼承涉及一個類從多個基類繼承。在這種情況下,每個基類都有一個自己的虛函數表,因此在對象中需要存儲多個虛函數表指針(VPtr)。每個基類的虛函數表指針將指向對應基類的虛函數表,派生類的虛函數表指針指向派生類的虛函數表。

#include <iostream>
using namespace std;class Base1 {
public:virtual void foo() {cout << "Base1 foo\n";}
};class Base2 {
public:virtual void bar() {cout << "Base2 bar\n";}
};class Derived : public Base1, public Base2 {
public:void foo() override {cout << "Derived foo\n";}void bar() override {cout << "Derived bar\n";}
};int main() {Derived d;d.foo();  // 輸出 "Derived foo"d.bar();  // 輸出 "Derived bar"return 0;
}
虛函數表結構:
  • Base1?類有一個虛函數表(VTable1),存儲指向?Base1::foo?的指針。
  • Base2?類有一個虛函數表(VTable2),存儲指向?Base2::bar?的指針。
  • Derived?類有一個虛函數表(VTable3),它存儲指向?Derived::foo?和?Derived::bar?的指針。
  • Derived?對象中會有三個虛函數表指針(VPtrs),分別指向:
    1. Base1?的虛函數表(VTable1)
    2. Base2?的虛函數表(VTable2)
    3. Derived?的虛函數表(VTable3)
初始化過程:
  • 當?Derived?類對象被創建時,虛函數表指針將指向?Base1Base2?和?Derived?類的虛函數表。基類的構造函數(Base1?和?Base2)會負責初始化這些虛函數表指針。
  • 在構造過程中,Derived?的虛函數表指針會被更新為指向?Derived?類的虛函數表。
多繼承的虛函數表布局:
  1. Base1 類:有一個虛函數表指針,指向?Base1?的虛函數表。
  2. Base2 類:有一個虛函數表指針,指向?Base2?的虛函數表。
  3. Derived 類:有一個虛函數表指針,指向?Derived?的虛函數表。

5.2.2?多繼承中虛函數表的具體細節

???????多繼承時,由于每個基類都有自己的虛函數表,編譯器需要在對象中為每個基類維護獨立的虛函數表指針。如果派生類重寫了某個基類的虛函數,編譯器會更新相應的虛函數表指針,確保調用的是正確的虛函數。

  • 當?Derived?類對象創建時:
    • Base1 的虛函數表指針指向?Base1?的虛函數表。
    • Base2 的虛函數表指針指向?Base2?的虛函數表。
    • Derived 的虛函數表指針指向?Derived?的虛函數表,包含?Derived?類重寫的虛函數。

5.2.3?虛函數表指針的布局

???????對于多繼承來說,虛函數表指針的存儲順序通常是由繼承的順序決定的。例如,如果一個類 D 繼承了 B1B2,那么 D 的對象通常會首先存儲一個指向 B1 的虛函數表指針,然后存儲指向 B2 的虛函數表指針,最后是指向 D 自身虛函數表的指針。

???????在內存布局上,一個對象的虛函數表指針數組的順序與繼承的順序有關。例如,Derived 對象的內存布局可能如下所示:

+------------------+      +-------------------+      +-------------------+
| VPtr to Base1    | ---> | VPtr to Base2     | ---> | VPtr to Derived   |
+------------------+      +-------------------+      +-------------------+

5.2.4?總結

  • 單繼承:每個類有一個虛函數表,虛函數表指針在對象構造過程中初始化并指向當前類的虛函數表。
  • 多繼承:每個基類有獨立的虛函數表,每個基類有一個虛函數表指針,派生類的虛函數表指針會指向每個基類的虛函數表,同時也會指向自己的虛函數表。
  • 內存布局:多繼承會在對象中存儲多個虛函數表指針,按繼承順序布局。

???????虛函數表機制的設計使得 C++ 在處理繼承和多態時能夠有效地實現動態綁定,盡管多繼承的情況下需要處理多個虛函數表指針的復雜情況。

為什么在多繼承中,子類有自己的虛函數指針,不是使用父類的虛函數指針??

???????在C++的多繼承中,子類會有自己的虛函數指針,而不是直接使用父類的虛函數指針,這是由于以下幾個關鍵原因:

  • 多繼承中的虛函數指針獨立性

???????在多繼承的情況下,子類不僅繼承了多個父類的屬性和方法,還可能重寫父類的虛函數。為了能夠正確地支持動態多態和虛函數的調用,編譯器必須為每個基類維護獨立的虛函數表指針(VPtr)。這意味著每個父類的虛函數表(VTable)仍然存在,并且在派生類中每個父類都有一個指向其對應虛函數表的指針。這樣,每個父類的虛函數都可以獨立地被調用,而不會與其他父類的虛函數表發生沖突。

  • 子類可能重寫父類的虛函數

???????在多繼承中,子類可能會重寫某些基類的虛函數。如果子類直接使用父類的虛函數指針,那么在運行時就無法正確地調用到子類重寫后的虛函數。

???????舉個例子,假設有兩個基類 Base1Base2,并且它們各自有一個虛函數 foo(),同時子類 Derived 重寫了這兩個虛函數。如果子類的虛函數表指針只是簡單地繼承父類的指針,那么當通過父類指針調用 foo() 時,可能會調用到父類的版本,而不是子類的版本。為了確保動態多態正確性,編譯器需要給每個父類維護獨立的虛函數表指針。

  • 虛函數表指針的布局

???????在多繼承中,編譯器會為每個基類創建獨立的虛函數表,并為每個類對象創建多個虛函數表指針。在派生類對象中,每個基類的虛函數表指針指向對應基類的虛函數表,派生類的虛函數表指針指向派生類自己的虛函數表。

???????這種做法確保了即使在多繼承的情況下,每個基類的虛函數能夠正確地進行調用和重寫。具體來說,派生類的虛函數表會包含指向它自己的虛函數實現的指針,而每個基類的虛函數表會包含指向基類的虛函數實現的指針。如果基類中的虛函數被子類重寫了,那么虛函數表中的指針會指向子類的重寫版本。

  • 子類和父類的虛函數表的不同

???????即使父類和子類都定義了虛函數,子類的虛函數表通常會與父類的虛函數表不同。子類可能會在虛函數表中替換父類的虛函數指針,指向子類自己的實現。

  • 避免虛函數調用沖突

???????如果沒有子類自己的虛函數表指針,而是直接使用父類的虛函數指針,那么會有潛在的沖突。例如,如果父類有相同名稱但不同實現的虛函數,或者在派生類中有重寫的虛函數,直接使用父類的虛函數表指針就無法確保正確的動態綁定。每個類自己的虛函數表指針確保了正確的多態性,避免了父類指針或虛函數表指針間的沖突。

???????在多繼承中,子類有自己的虛函數指針,而不是簡單地使用父類的虛函數指針,主要是為了確保:

  1. 動態多態能夠正確地調用到子類重寫的虛函數。
  2. 每個基類的虛函數表指針可以獨立維護,避免了不同基類虛函數的沖突。
  3. 確保每個基類在多繼承中的虛函數都能夠正確地調用,不受到其他基類的影響。

多繼承派生類的未重寫的虛函數放在第一個繼承基類部分的虛函數表中。

5.3 菱形繼承、菱形虛擬繼承(了解)

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/web/63843.shtml
繁體地址,請注明出處:http://hk.pswp.cn/web/63843.shtml
英文地址,請注明出處:http://en.pswp.cn/web/63843.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

Centos7配置webrtc-streamer環境

Centos7配置webrtc-streamer環境 安裝webrtc-streamer0.7版本 升級gdb 1、yum安裝2、查看gdb版本3.下載待升級的gdb版本4.QA 1、預編譯的時候報錯no acceptable C compiler found in $PATH2、make的時候報錯[all-bfd] Error3、make的時候報錯 升級GCC 1.源碼編譯升級gcc9.3.0…

Vue.js 響應接口

Vue.js 響應接口 引言 Vue.js,作為當前前端開發領域中的佼佼者,以其簡潔、高效和靈活的特點,贏得了廣大開發者的喜愛。其核心功能之一便是響應式系統,它使得數據與視圖之間的同步變得異常簡單。本文將深入探討Vue.js的響應接口,解析其工作原理,并展示如何在實際項目中有…

深入了解藍牙Profile類型與設備的對應關系

在現代技術中,藍牙作為一種無線通信技術,廣泛應用于各種設備之間的短距離通信。不同的設備在連接時使用不同的藍牙Profile(配置文件),每種Profile都為特定的設備功能提供支持,例如音頻流傳輸、語音通話、文件傳輸等。在本文中,我們將詳細介紹藍牙Profile的常見類型及其對…

LLMs之PDF:MinerU(將PDF文件轉換成Markdown和JSON格式)的簡介、安裝和使用方法、案例應用之詳細攻略

LLMs之PDF&#xff1a;MinerU(將PDF文件轉換成Markdown和JSON格式)的簡介、安裝和使用方法、案例應用之詳細攻略 目錄 MinerU的簡介 0、日志 1、MinerU 的主要特點 2、已知問題 MinerU 安裝和使用方法 1、MinerU的三種體驗方式 T1、在線演示 T2、快速CPU演示 T3、GPU …

【AIGC】ChatGPT 結構化 Prompt 的高級應用

博客主頁&#xff1a; [小????????] 本文專欄: AIGC | ChatGPT 文章目錄 &#x1f4af;前言&#x1f4af;標識符的使用&#xff08;Use of Identifiers&#xff09;1. #2. <>3. - 或 4. [] &#x1f4af;屬性詞的重要性和應用應用場景 &#x1f4af;具體模塊…

Python繪制圖表

Python提供了多種可視化庫&#xff0c;常用的有matplotlib、seaborn和plotly等。這些庫可以用于繪制各種類型的圖表&#xff0c;如折線圖、散點圖、柱狀圖、餅圖等。 下面是一個使用matplotlib繪制折線圖的示例&#xff1a; python import matplotlib.pyplot as plt # 準備數…

Python 練習

一、列表練習 1、求偶數元素的和[1,2,1,2,3,3,6,5,8] 1 2 3 4 5 6 list01 [1, 2, 1, 2, 3, 3, 6, 5, 8] sum 0 for i in list01: if int(i) % 2 0: sum sum i print(f"列表中所有偶數和是: {sum}") 2、計算 1 - 2 3 - 4 ... 99 中除88以外…

OpenEuler 22.03 安裝 flink-1.17.2 集群

零&#xff1a;規劃 本次計劃安裝三臺OpenEuler 22.03 版本操作系統的服務器&#xff0c;用于搭建 flink 集群。這里使用flink1.17.2 的原因&#xff0c;是便于后續與springboot的整合 服務器名IP地址作用其他應用flink01192.168.159.133主jdk11、flink-1.17.2flink02192.168.…

Docker 安裝 禪道-21.2版本-外部數據庫模式

Docker 安裝系列 1、拉取最新版本&#xff08;zentao 21.2&#xff09; [rootTseng ~]# docker pull hub.zentao.net/app/zentao Using default tag: latest latest: Pulling from app/zentao 55ab1b300d4b: Pull complete 6b5749e5ef1d: Pull complete bdccb03403c1: Pul…

寬帶ANC、窄帶ANC、正弦噪聲抑制組成混合噪聲控制系統結構

混合控制結構由寬帶ANC子系統&#xff08;BANC&#xff09;、窄帶ANC子系統&#xff08;NANC&#xff09;和正弦噪聲抑制子系統&#xff08;SNC&#xff09;三部分組成。這種混合系統的設計目標是有效地控制同時包含寬帶噪聲和窄帶噪聲&#xff08;例如周期性的正弦噪聲&#x…

車載網關性能 --- GW ECU報文(message)處理機制的技術解析

我是穿拖鞋的漢子,魔都中堅持長期主義的汽車電子工程師。 老規矩,分享一段喜歡的文字,避免自己成為高知識低文化的工程師: 所謂雞湯,要么蠱惑你認命,要么慫恿你拼命,但都是回避問題的根源,以現象替代邏輯,以情緒代替思考,把消極接受現實的懦弱,偽裝成樂觀面對不幸的…

【潛意識Java】深度解析黑馬項目《蒼穹外賣》與藍橋杯算法的結合問題

目錄 為什么要結合項目與算法&#xff1f; 1. 藍橋杯與《蒼穹外賣》項目的結合 實例&#xff1a;基于藍橋杯算法思想的訂單配送路徑規劃 問題描述&#xff1a; 代碼實現&#xff1a;使用動態規劃解決旅行商問題 代碼解析&#xff1a; 為什么這個題目與藍橋杯相關&#x…

自己搭建專屬AI:Llama大模型私有化部署

前言 AI新時代&#xff0c;提高了生產力且能幫助用戶快速解答問題&#xff0c;現在用的比較多的是Openai、Claude&#xff0c;為了保證個人隱私數據&#xff0c;所以嘗試本地&#xff08;Mac M3&#xff09;搭建Llama模型進行溝通。 Gpt4all 安裝比較簡單&#xff0c;根據 G…

大語言模型中的Agent優勢及相關技術;Agent和RAG區別

大語言模型中的Agent優勢及相關技術: 強大的任務規劃與執行能力 技術:通過將復雜任務拆解為多個子任務,并依據任務間的邏輯關系和優先級進行規劃,確定執行順序,調用相應工具或模型來完成各子任務,最終實現復雜任務的整體解決。如微軟的Jarvis,可利用LLM的推理規劃能力拆…

深入理解構造函數:C++ 編程中的基石

一、概念 構造函數(Constructor) 是一種特殊的成員函數&#xff0c;用于在創建對象時初始化對象的狀態&#xff08;即成員變量&#xff09;。它的主要作用是保證對象在創建時具有有效的初始值。 二、特點 與類同名&#xff1a; 構造函數的名稱與類名相同&#xff0c;沒有返回…

GIS數據處理/程序/指導,街景百度熱力圖POI路網建筑物AOI等

簡介其他數據處理/程序/指導&#xff01;&#xff01;&#xff01;&#xff08;1&#xff09;街景數據獲取&#xff08;2&#xff09;街景語義分割后像素提取&#xff0c;指標計算代碼&#xff08;綠視率&#xff0c;天空開闊度、視覺熵/景觀多樣性等&#xff09;&#xff08;3…

微前端qiankun的使用——實踐

qiankun 創建主應用項目——vue2 main.js注冊子應用 $ yarn add qiankun # 或者 npm i qiankun -Simport { registerMicroApps, start } from qiankun; import Vue from "vue"; import App from "./App.vue"; import router from "./router"; …

后端項目java中字符串、集合、日期時間常用方法

我這里只介紹了項目中最常用的哈,比如像集合有很多,但我們最常用的就是ArrayList。 然后我這里會以javascript中的字符串、數組的方法為基準來實現,有些方法js和java會有些區別也會介紹 字符串 每次修改 String 對象都會創建一個新的對象,而 StringBuffer 可以在同一個對象…

Ubuntu 22.04永久保存路由

在 Ubuntu 22.04 上&#xff0c;可以按照以下方式配置讓流量訪問 172.19.201.207 走指定的路由。 1. 臨時添加路由 臨時路由規則只在當前系統會話中有效&#xff0c;重啟后會丟失。 添加路由規則 運行以下命令&#xff1a; sudo ip route add 172.19.201.207 via 192.168.2…

實用 Linux 之命令(Practical Linux Commands)

實用 Linux之 命令&#xff0c;可以解決日常99%的問題~ 1、基本命令 uname -m 顯示機器的處理器架構uname -r 顯示正在使用的內核版本dmidecode -q 顯示硬件系統部件(SMBIOS / DMI) hdparm -i /dev/hda 羅列一個磁盤的架構特性hdparm -tT /dev/sda 在磁盤上執行測試性讀取操作…