深入剖析C++多態的實現與原理-詳解

来源:https://www.cnblogs.com/DSCL-ing/p/18240152
-Advertisement-
Play Games

目錄多態基礎虛函數虛函數的繼承虛類/虛基類重寫/覆蓋條件:概念:多態的條件其他的多態行為多態中子類可以不寫virtual協變代碼舉例繼承遺留問題解決析構函數具體解決方式:題目1答案:解析:題目2答案:C++11 override和finalfinal功能1:禁用繼承使用場景:功能2:禁用重寫使用場景 ...


目錄

多態基礎

虛函數

在函數前加上virtual就是虛函數

class A{
public:
	virtual void func(){}; //這是一個虛函數
};

虛函數的繼承

虛函數的繼承體現了介面繼承

繼承了介面等於繼承了函數的殼,這個殼有返回值類型,函數名,參數列表,還包括了預設參數

只需要重寫/覆蓋介面的實現(函數體)

虛類/虛基類

含有虛函數的類是虛類.

是虛類,且是基類,則是虛基類

重寫/覆蓋

條件:

三同:函數名,參數(平常說的參數都是說參數的類型,與預設參數無關),返回值都要相同

概念:

重寫/覆蓋是指該函數是虛函數且函數的名字、類型、返回值完全一樣的情況下,子類的函數體會替換掉繼承下來的父類虛函數的函數體

  • 體現介面繼承

  • 重寫/覆蓋只有虛函數才有,非虛函數的是隱藏/重定義.註意區別

  • 重寫/覆蓋只對函數體有效,返回值類型,函數名,參數列表,和預設參數都不能修改

  • 只要子類寫上滿足三同的虛函數都會觸發重寫.無論是否修改函數體

多態的條件

多態有兩個條件,任何一個不滿足都不能執行多態 ,分別是

  1. 虛函數的重寫

多態的基礎

   class Person {
   public:
       virtual void BuyTicket() {            //是虛函數
           std::cout<<"全票"<<std::endl;
       }
   };
   
   class Student :public Person {
   public:
       virtual void BuyTicket() {           //虛函數的重寫
           std::cout<<"半票"<<std::endl;
       }
   };
  1. 父類類型的指針或引用(接收父類對象或子類對象)的對象去調用虛函數
    void func(Person& p){                 //父類的指針或引用去調用
       p.BuyTicket();
    }
    
    int main(){
        Person p;
        Student s;
        func(p);
        func(s);
        return 0;
    }
    
    image-20240602202113081

其他的多態行為

多態中子類可以不寫virtual

多態中子類可以不寫virtual,而且只要父類是虛函數,之後繼承的子孫類都是虛函數(待驗證,是否位於虛表)

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

class Student :public Person {
public:
    void BuyTicket() {
        std::cout << "半票" << std::endl;
    }
};

class Children : public Person {
public:
    void BuyTicket(){
        std::cout << "三折票" << std::endl;
    }
};

void func(Person& p){
    p.BuyTicket();
}

int main()
{
    Person p;    
    Student s;    
    Children c;
    func(p);     
    func(s);      
    func(c);
    return 0;
}

image-20240602205417847

  • 說法1:體現介面繼承:繼承了介面==繼承了函數的殼,只需要重寫介面的實現(函數體),這樣就是體現了介面繼承

  • 說法2: 可能存在父類子類不是同一個人實現的情況.

    ​ 假設子類必須是虛函數才能實現多態,如果父類是虛函數,而另外一個人寫子類時忘記加上virtual,這是就有可能發生記憶體泄露問題,如切片後再析構的情況(只析構父類,不析構子類).

    ​ 因此,父類是虛函數的情況下,子類不強制需要virtual才能發生多態這種行為,能有一定的安全作用.

缺點:沒有統一規範. 最好還是全都加上virtual

協變

概念引入:協變與逆變

協變與逆變規定了編程語言中的類型父子關係的方向

引入這個概念是為了類型安全


​ 協變(父←子)

動物 - 哺乳類 - 熊科 - 黑熊

​ 逆變(子→父)


協變場景下三同中返回值可以不同,且返回值必須是父類或派生類關係的指針或引用

其他方面讀者可以閱讀更具體的資料

C++協變(covariant)-CSDN博客

代碼舉例
  1. 舉例1:父類返回類型為父類,子類返回類型為子類

    class Person {
    public:
        virtual Person& BuyTicket() {
            std::cout << "全票" << std::endl;
            Person p;
            return p;
        }
    };
    
    class Student :public Person {
    public:
        Student& BuyTicket() {
            std::cout << "半票" << std::endl;
            Student s;
            return s;
    
        }
    };
    

    image-20240603085723451

  2. 舉例2:父類子類返回類型全部是父類

    class Person {
    public:
        virtual Person& BuyTicket() {
            std::cout << "全票" << std::endl;
            Person p;
            return p;
        }
    };
    
    class Student :public Person {
    public:
        Person& BuyTicket() {
            std::cout << "半票" << std::endl;
            Person s;
            return s;
    
        }
    };
    

    image-20240603090057277

  3. 舉例3:返回值類型為非所在類類型

    class A{};
    class B : public A{};
    
    class Person {
    public:
        virtual A* BuyTicket() {
            std::cout << "全票" << std::endl;
            return nullptr;
        }
    };
    
    class Student :public Person {
    public:
        B* BuyTicket() {
            std::cout << "半票" << std::endl;
            return nullptr;
        }
    };
    

    image-20240603090926512

  4. 返回值為非虛函數所在類類型,且都是返回父類

    image-20240603091109300

註意:

  • 反過來,父類中返回值是子類,子類中返回值是父類是不支持的.

image-20240603085402169

  • 全部返回子類也不支持

    image-20240603085641002

  • 返回值類型為非所在類類型的情況也是如此

總之,當虛函數返回值為基類類型的指針或引用時,編譯器才會檢查是否是協變類型.此時如果派生類虛函數返回值是基類或派生類的指針或引用,則判定為協變;否則不是協變

繼承遺留問題解決

析構函數

先看繼承關係中直接實例對象的代碼

class Person {
public:
    ~Person() { std::cout << "~Person()" << "\n"; }
};

class Student :public Person {
public:
    ~Student() { std::cout << "~Student()" << "\n"; }
};

int main(){
    Person per; 
    Student stu;
    return 0;
}

結果沒有問題,析構執行是正確的

image-20240530215817962

再看指針切片樣例

int main(){
    Person* ptr1 = new Person; 
    Person* ptr2 = new Student;

    delete ptr1;
    delete ptr2;
    return 0;
}

結果:

image-20240601223719246

顯然,沒有正確的析構.

  • 結果說明對切片後的對象進行析構時,只會執行對應切片類型的析構函數.

在繼承篇有提起過的繼承體系中析構函數會被重命名成Destructor.

本意:根據指針(引用)指向的對象類型來選擇對應的析構函數
結果:根據指針(引用)的類型的來選擇對應的析構函數

雖然結果符合正常語法,但是我們在這種情況下並不希望是這樣,我們希望它是根據指針(引用)指向的對象類型來選擇對應的函數執行.

而根據指針(引用)指向的對象類型來選擇對應的函數,這正好就是多態的理念.

因此,為瞭解決切片中這樣的析構函數問題,我們選擇將其轉化成多態來解決.

此時我們已經滿足多態構造的2個條件的其中之一:基類的指針或引用, 剩下的我們需要滿足派生類的析構函數構成對基類析構函數的重寫。而重寫的條件是:返回值類型,函數名,參數列表都相同。對於析構函數,目前還缺的就是函數名相同,因此,析構函數的名稱統一處理為destructor.

具體解決方式:

析構函數都成為虛函數

class Person {
public:
     virtual ~Person() { std::cout << "~Person()" << "\n"; }
};

class Student :public Person {
public:
     virtual ~Student() { std::cout << "~Student()" << "\n"; }
};

int main(){
    Person* ptr1 = new Person; 
    Person* ptr2 = new Student;

    delete ptr1;
    delete ptr2;

    return 0;
}

image-20240601223536006

至此,徹底解決繼承體系中析構函數問題.

題目1

1.以下程式輸出結果是什麼()

   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: 以上都不正確

答案:

image-20240603123956521

解析:

B*p = new B;這裡p是普通的指針,不滿足多態.

p->test();這裡調用了繼承下來的test();

test()的實際原型是test(A*this),因此函數體內即為(A*)->func();

因為test()在B中,B會將自己的this傳參給test(),即父類類型指針接收子類類型指針.同時func也是虛函數.

因此滿足多態,即test()中調用的是子類的func().

又因為虛函數的繼承是介面繼承,只有函數體是子類的,其他都是父類的,預設參數也是父類的,因此答案是B->1

題目2

以下程式輸出結果是什麼()

class A
{
public:
    virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
};

class B : public A
{
public:
    void func(int val = 0) { std::cout << "B->" << val << std::endl; }
    virtual void test() { func(); }
};

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: 以上都不正確

答案:

D: B->0

image-20240603171613568




C++11 override和final

final

功能1:禁用繼承

C++11中允許將類標記為final,繼承該類會導致編譯錯誤.

用法:直接在類名後面使用關鍵字final

class A final
{};

class B : public A //編譯錯誤
{};
使用場景:

明確該類未來不會被繼承時,可以使用final明確告知.

功能2:禁用重寫

C++中還允許將函數標記為final,禁用子類中重寫該方法

用法:在函數體前使用關鍵字final

class A {
public:
     virtual void func() final {}
};

class B : public A {
public:
    void func() {}    //編譯錯誤
};

image-20240603200701142

使用場景

一般情況下,只有最終實現的情況下會使用final: 當你在一個派生類中實現了某個虛函數,並且認為這是該函數的“最終”或“最完善”的實現,不希望後續的派生類再次改變其行為。使用final關鍵字可以確保這一點,防止函數被進一步重寫。

對虛函數使用final後,編譯器可以做出一些優化,比如內聯調用,因為它知道不會有其他版本的函數存在。

override

場景:

C++對函數重寫的要求是比較嚴格的.如果某些情況因為疏忽而導致函數沒有進行重寫,這種情況在編譯期間是不會報錯的,只有程式運行時沒有得到預期結果才可能意識到出現了問題,等到這時再debug已經得不償失了.

因此,C++11提供了override關鍵字,可以幫助用戶檢測是否完成重寫

描述:

override(覆蓋)關鍵字用於檢查派生類虛函數是否重寫了基類的某個虛函數,如果沒有則無法通過編譯。

用法:

在需要進行重寫的虛函數的函數體前或參數列表花括弧後加上override

class A {
public:
     virtual void func()  {}
};

class B : public A {
public:
    void func(int i) override{ }
};

image-20240603214317898

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

image-20240603203200542

純虛函數

概念:

在虛函數後面寫上=0,這個函數就為純虛函數.

virtual void fun() = 0;

純虛函數只能寫聲明,不能寫函數體.

抽象類/純虛類

概念

含有純虛函數的類是純虛類,更多的是叫抽象類(也叫做介面類)

class A{
	virtual void func() = 0;
};

特點

  • 抽象類不能實例化對象

  • 抽象類的派生類如果不重寫純虛函數,則還是抽象類

  • 純虛函數規範了派生類必須重寫,更體現介面繼承

  • 純虛類可以有成員變數

介面繼承和實現繼承

從類中繼承的函數包含兩部分:一是"介面"(interface),二是 "實現" (implementation).

  • 介面就是函數的"殼",是函數除了函數體外的所有組成.

  • 實現就是函數的函數體.


純虛函數 => 繼承的是:介面 (interface)

普通虛函數 => 繼承的是:介面 + 預設實現 (default implementation)

非虛成員函數 => 繼承的是:介面 + 強制實現 (mandatory implementation) 

  • 普通函數的繼承是一種實現繼承,派生類繼承了基類函數,繼承的是函數的實現,目的是為了復用函數實現.

  • 普通虛函數的繼承是一種介面繼承,派生類繼承的是基類虛函數的介面+預設實現,目的是為了重寫,達成多態.

  • 純虛函數只繼承了介面,要求用戶必須要重寫函數的實現.

如果不實現多態,不要把函數定義成虛函數。






多態原理

引入(多態的原理)

計算下麵虛類的大小

class Base{
public:
    virtual void func() {}
private:
    int _a;
    char _b;
};

int main(int argc, char* argv[])
{
    std::cout<<sizeof(Base)<<"\n";
    return 0;
}

結果:

image-20240604121929826

如果是一般的類,那我們會認為是計算結構體對齊之後的大小,結果應當是8.

但計算結果發現,虛類的結果是12,說明虛類比普通類多了一些東西.

實例化對象Base b;查看監視視窗

image-20240604170652509

可以發現對象的頭部多了一個指針_vfptr;這個指針叫做虛函數表指針,它指向了虛函數表

虛函數表指針

指向虛表的指針,叫虛函數表指針,位於對象的頭部.

定義:

​ 如果在類中定義了虛函數,則對象中會增加一個隱藏的指針,叫虛函數表指針__vfptr,虛函數表指針在成員的前面,直接占了4/8位元組.

虛函數表/虛表

描述:

虛函數表指針所指向的表,叫做虛函數表(virtual function table),也叫做虛表

虛函數表本質是一個虛函數指針數組.元素順序取決於虛函數的聲明順序.大小由虛函數的數量決定.

虛表的特性(單繼承)

  • 虛表在編譯期間生成.

    虛表是由虛函數的地址組成,而編譯期間虛函數的地址已經存在,因此能夠在編譯期間完成.

  • 虛函數繼承體系中,虛基類先生成一份虛表,之後派生類自己的虛表都是基於從父類繼承下來的虛表.

  • 特例,為了方便使用,VS在虛表數組最後面放了一個nullptr.(其他編譯器不一定有)

  • 子類會繼承父類的虛函數表(開闢一個新的數組,淺拷貝)
  • 如果派生類重寫了基類中某個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數,如果子類沒有重寫,則虛函數表和父類的虛函數表的元素完全一樣
  • 派生類自己新增加的虛函數,從繼承的虛表的最後一個元素開始,按其在派生類中的聲明次序增加到派生類虛表的最後。
  • 派生類自己新增的虛函數放在繼承的虛表的後面,如果是基類則是按順序從頭開始放,總而言之,自己新增的虛函數位置一定比繼承的虛函數位置後
  • 虛函數和普通函數一樣的,都是存在代碼段的,只是他的指針又存到了虛表中.另外對象中存的不是虛表,存的是虛表指針
  • 虛表是在編譯階段就完成了,在初始化列表完成的是虛表指針的初始化
  • 同一類型直接定義的對象共用同一個虛表
  • 子類對象直接賦值給父類對象後就變成了父類對象,只拷貝成員,不拷貝虛表,虛表還是父類的

虛表的一般示例:

class Person {
public:
    virtual void BuyTicket(int val = 1) {
        std::cout << "全票" << ":" << val << "\n";
    }
    virtual void func(int val = 1) {
        std::cout << "全票" << ":" << val << "\n";
    }
};

class Student :public Person {
public:
    void BuyTicket(int val = 0) {                 //覆蓋
        std::cout << "半票" << "=" << val << "\n";
    }
};

int main() {
    Person p;
    Student s;
    return 0;
}

image-20240604210835377

對象中的虛表指針在構造函數中初始化

image-20240608144225054

註:虛表指針和成員誰先初始化由編譯器決定

虛表的位置

虛表沒有明確說必須在哪裡,不過我們可以嘗試對比各個區的地址,看虛表的大致位置

class Base{
public:
    virtual void func(){
    }
private:
    int _a;
};

class Derive :public Base {
};

int main()
{
    Base b;
    Derive d;
    int x = 0;
    int *y = new int;
    static int z = 1;
    const char * str = "hello world";

    printf("棧對象地址:        %p\n",&x);
    printf("堆對象地址:        %p\n",y);
    printf("靜態區對象地址:    %p\n",&z);
    printf("常量區對象地址:    %p\n",str);
    printf("Base對象虛表指針:  %p\n",*(int**)(&b)); //32位環境
    printf("Derive對象虛表指針:%p\n",*(int**)(&d)); 
    
    return 0;
}

image-20240608153139376

根據地址分析,虛表指針與常量區對象地址距離最近,因此可以推測虛表位於常量區.

另外,在監視視窗中觀察虛表指針與虛函數地址也可以發現,虛表指針與虛函數地址也是比較接近,也可以大致推測在代碼段中.(代碼段常量區很貼近,比較ambiguous,模棱兩可的)

從應用角度來說,虛表也應當位於常量區中,因為虛表在編譯期間確定好後,不會再發生改變,在常量區也是比較合適的.

談談對象切片

我們可以使用子類對象給父類類型賦值,但要註意C++中不支持通過對象切片實現多態.

首先賦值過程會涉及大量拷貝.成本開銷比較大.

其次,拷貝只拷貝成員,不會拷貝虛表.

因為子類中繼承的自父類的虛表可能被子類覆蓋過,如果切片給父類對象,那麼父類對象的虛表中就會有子類重寫的虛函數,顯然不合理.

談談多態的原理

多態是怎麼實現的,其實程式也不知道自己調用的是子類還是父類的,在它眼裡都是一樣的父類指針或引用.

如果是虛函數,則在調用時,會進入到"父類"中去,找到虛函數表中的函數去調用,是父類的就調用父類的,是子類就調用子類的.如果不是虛函數,則直接調用.

多態的實際原理也是傳什麼調什麼,編譯期間虛函數表已經確定好了

再看多態的兩個條件

  • 為什麼需要虛函數重寫,虛表中存的就是子類的虛函數,重寫後就和父類不同了,也就能實現多態的效果.

  • 為什麼需要父類的指針或引用,就是因為指針或引用既能指向父類也能指向子類,能夠實現切片,區分父類和子類

虛函數覆蓋這個詞的由來就是,子類重寫的虛函數會覆蓋父類的.

覆蓋是原理層的叫法.重寫是語法的叫法

虛表列印

常式1.VS查看虛表

class Person {
public:
    virtual void BuyTicket(int val = 1) {
        std::cout << "全票" << ":" << val << "\n";
    }
    virtual void func(int val = 1) {
        std::cout << "全票" << ":" << val << "\n";
    }
};

class Student :public Person {
public:
    void BuyTicket(int val = 0) {
        std::cout << "半票" << "=" << val << "\n";
    }
    virtual void Add()
    {
        std::cout<<"Studetn"<<"\n";
    }
};

class C : public Student {
public:
    virtual void Add()
    {
        std::cout<<"C"<<"\n";
    }
    int _c = 3;
};

void fun(Student &s){
    s.Add();
}

int main() {
    Person p;
    Student s;
    C c;
    fun(c);
    return 0;
}

對上例函數查看VS監視時,發現虛表不顯示完全

image-20240604224428751

需要在監視視窗中手動輸入(void**)0x虛函數表指針,10,表示以(void*)[10]方式展開

image-20240604224622771

此後就能全部顯示虛表了

常式2.程式列印虛表

源碼:

(僅適用VS,因為VS會將虛表末尾置空,如果是g++,則需要明確虛表有幾個虛函數)

class A {
public:
    virtual void fun1(){
        std::cout<<"func1()"<<"\n";
    }
    virtual void fun2(){
        std::cout<<"func2()"<<"\n";
    }
};

class B :public A {
public:
    virtual void fun3(){
        std::cout<<"func3()"<<"\n";
    }
};

using VFPTR = void(*)(void);
void PrintVFTable(VFPTR table[])
{
    for (int i = 0; table[i]; i++)
    {
        //1.列印虛類對象的虛表
        printf("%p",table[i]);
        //2.指針不夠直觀的情況下.可以執行函數指針得到更具象的結果
        VFPTR f = table[i];
        f(); 
        //小細節:f()能夠正常執行,說明這樣的調用方式能夠自動將虛表所在對象的this傳參到虛函數中.
        
    }
}

int main()
{
    A a;
    B b;
    PrintVFTable((VFPTR*)(*((VFPTR*)&a))); //方式1 (修改,VFPTR*比int*更通用)
    puts("");
    PrintVFTable(*(VFPTR**)&b); //方式2 ,在明確指向邏輯的情況下,二級指針更為簡潔
    
    /* 代碼理解:
    1.typedef和using語法層面功能都是將類型重命名,這個重命名會被認定成一個新類型,需要時再進行解釋.
    2.int*在32位和64位下解引用都是4位元組.而指針大小在32位下是4位元組,64位下是8位元組.在64位機器下使用int*解引用的話,就會得到錯誤的結果.因此int*不夠普遍.
    3.VFPTR被當作一個新類型來看待.直接使用VFPTR時,編譯器認為是非指針變數;使用VFPTR*時,編譯器認為是一級指針變數.(VFPTR*)&a即為將a的地址轉成類型為VFPTR的一級指針.之後,解引用則以VFPTR的大小為步長,取出相應的數據(虛表指針,也是虛表首地址).VFPTR實際類型為函數指針,32位下為4位元組,64位下為8位元組,因此解引用後能夠取得正確的結果. 
    */
    
    return 0;
}

image-20240605141210126

模型圖

image-20240609181110964

多繼承虛表

先看虛函數多繼承體系下記憶體佈局

class Base1 {
public:
    virtual void func1() { std::cout << "Base1::func1" <<std::endl; }
    virtual void func2() { std::cout << "Base1::func2" <<  std::endl; }
private:
    int b1 = 1;
};

class Base2 {
public:
    virtual void func1() { std::cout << "Base2::func1" << std::endl; }
    virtual void func2() { std::cout << "Base2::func2" << std::endl; }
private:
    int b2 = 1;
};

class Derive : public Base1, public Base2 {
public:
//子類重寫func1
    virtual void func1() { std::cout << "Derive::func1" << std::endl; }
//子類新增func3
    virtual void func3() { std::cout << "Derive::func3" << std::endl; }
private:
    int d1 =2;
};

int main()
{
    Derive d;
    return 0;
}

image-20240608170245199

簡單分析可知,虛函數多繼承體系下派生類會根據聲明順序依次繼承父類.繼承方式類似於虛繼承.

多繼承下子類自己新增的虛函數在哪?

我們知道,單繼承中,子類自己新增的虛函數會尾插到虛表的末尾.

那麼多繼承呢?是每個父類都添加?還是只添加到其中一個?添加到一個的話添加到哪裡?

要知道結果,必須要看一眼虛表的真實情況.因此我們列印所有虛表看看情況.

多繼承虛表列印代碼

int main()
{
    Derive d;
    /*列印d中Base1的虛表*/
    std::cout<<"Base1的虛表"<<"\n";
    PrintVFTable(*(VFPTR**)(&d));
   
    puts("");
    /*列印d中Base2的虛表*/
    std::cout<<"Base2的虛表"<<"\n";
     //方法1,手動計算指針偏移
    //PrintVFTable((VFPTR*)*(VFPTR*)((char*)&d+sizeof(Base1)));
    //PrintVFTable(*(VFPTR**)((char*)&d+sizeof(Base1)));
    
    //方法2,切片,自動計算指針偏移 -- 推薦,不容易出錯
    Base2 *b2 = &d;
    PrintVFTable(*(VFPTR**)b2);
    return 0;
}

image-20240608185435572

結論與發現:
  • 通過結果能證明,子類自己新增的虛函數只會添加進第一個繼承的父類的虛表中,也就是尾插.
  • 子類會繼承所有父類的虛表,有多少個父類就有多少個虛表

image-20240609180923732

  • 結果也證明,子類重寫會對所有父類的同名函數進行覆蓋

  • 觀察結果還發現,兩個func1的地址居然不一樣.這其實涉及到C++this指針的原理問題->this指針修正.

要搞明白是什麼情況,我們需要觀察彙編代碼,去看更深層次的邏輯.

this指針修正分析

常式代碼

int main(){
    Derive d;
    Base1 *ptr1 = &d;
    Base2 *ptr2 = &d;
    ptr1->func1();
    ptr2->func1();
    return 0;
}

先觀察ptr1

image-20240608214928031

再看ptr2

image-20240608222602445

對比可以發現,ptr2要比ptr1走多了好幾步才能正確調用fun1.

解釋:

看ptr2的的中間過程有句彙編sub ecx,8,功能是ecx-8再放到ecx中.而ecx在類中通常表示類的this指針,即sub ecx,8得功能是將this指針-8,這裡8剛好是sizeof(Base1)的值,因此sub ecx,8就可以解釋成this向下偏移8個位元組,因為對象的this指針位於低位元組,這就同時剛好滿足了this指向Base2.再結合問題場景,就可以同步證明ptr2多走的這幾步目的就是為了讓指針正確偏移回對象d的this.

再結合切片原理,切片後會自動計算將ptr2指向了d中Base2的首地址,可以推測切片後ecx也指向了Base2的首地址.為了能夠發生多態,需要將ecx重新偏移至正確位置.

這就是多繼承下多態的原理

虛表中地址(概念修正)
  1. 虛函數地址:這是虛函數在程式記憶體中的實際地址,即函數體開始的位置。
  2. 虛表中的地址:虛表中存儲的地址通常直接指向虛函數的實際地址。然而,在某些情況下,如為了實現一些優化,編譯器可能不會直接在虛表中存儲虛函數的地址,而是存儲一個“跳躍”函數的地址,這個跳躍函數再跳轉到虛函數的真實地址。這種跳躍函數可以用來做額外的檢查或者優化,例如性能計數、調試信息插入等。

所以,虛表中存放的地址大多數情況下就是虛函數的真實地址,但在某些特定的優化場景下,它可能指向一個中間函數,這個中間函數再負責跳轉到真實的函數地址。這種間接調用的機制有時被稱為“thunk”,它允許編譯器在運行時進行更複雜的控制流分析和優化。

對於現代的C++編譯器,如GCC或Clang,它們預設的行為是在虛表中直接存儲虛函數的真實地址,除非有特殊的優化需求。如果想瞭解具體的實現,可以通過反彙編工具(如objdump, IDA Pro等)查看編譯後的二進位文件,檢查虛表的結構和內容。

菱形繼承+多態 與 菱形虛擬繼承+多態

菱形繼承本來就是很複雜的東西,再加上多態,更加複雜,實際工作中也很少會使用菱形繼承多態.
簡單演示一下,有興趣的讀者可以自行擴展研究.

菱形繼承+多態

class A {
public:
	virtual void func1() {}
public:
	int _a;
};

class B : public A {
public:
	virtual void func1() {}
public:
	int _b;
};

class C : public A {
public:
	virtual void func1() {}
public:
	int _c;
};

class D : public B, public C {
public:
	virtual void func1() {}
public:
	int _d;
};

int main() {
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	return 0;
}

記憶體佈局:

image-20240609191001622

菱形虛擬繼承+多態(子類沒有新增虛函數)

class A {
public:
	virtual void func1() {}
public:
	int _a;
};

class B : virtual public A {
public:
	virtual void func1() {}
	//virtual void func2() {}
public:
	int _b;
};

class C : virtual public A {
public:
	virtual void func1() {}
	//virtual void func3() {}
public:
	int _c;
};

class D : public B, public C {
public:
	virtual void func1() {}
public:
	int _d;
};

(其中要求最遠類必須重寫虛基類A的虛函數,因為要消除二義性,是B是C都不好,最好是D.)

記憶體佈局:

image-20240609192312109

和非多態菱形虛擬繼承很類似.只重寫虛基類虛函數時,只有虛基類有虛表

菱形虛擬繼承+多態(子類自己新增了虛函數)

class A {
public:
	virtual void func1() {}
public:
	int _a;
};

class B : virtual public A {
public:
	virtual void func1() {}
	virtual void func2() {}
public:
	int _b;
};

class C : virtual public A {
public:
	virtual void func1() {}
	virtual void func3() {}
public:
	int _c;
};

class D : public B, public C {
public:
	virtual void func1() {}
public:
	int _d;
};

記憶體佈局:

image-20240609193315514

64位環境下記憶體佈局

image-20240609194613065

推測虛基表中低四位元組是存放虛表偏移量,也可能是到B或C類型首部的偏移量.

一些概念

動態綁定和靜態綁定

  • 靜態綁定又稱為前期綁定(早綁定),在程式編譯期間就確定了程式的行為,即編譯時,也稱為靜態多態.

    靜態多態例子:函數重載,如std::cout<<的類型自動識別,原理就是函數名修飾規則將operator<<(不同的參數)在編譯時生成多份(都是生成多份,C語言需要程式員手動,C++由編譯器自動生成),使傳的參數不同時能夠對外表現出不同的行為.這種技術給開發者和用戶都帶來了使用上的便利.

  • 動態綁定也稱為後期綁定(晚綁定),是在程式運行期間,即運行時,根據具體拿到的類型確定程式的具體行為,調用具體的函數,也稱為動態多態.虛函數多態就是動態多態.

內聯函數inline 和 虛函數virtual

inline如果被編譯器識別成內聯函數,則該函數是沒有地址的. 與虛表中存放虛函數的地址有衝突.

但事實上,inline 和 virtual 可以一起使用 :

  • 這取決於使用該函數的場景:內聯是一個建議性關鍵字,如果發生多態,則編譯器會忽略內聯.如果沒有發生多態,才有可能成為內聯函數

  • 即:多態和內聯可以一起使用,但同時只能有一個發生

靜態函數static 與 虛函數

靜態成員函數不能是虛函數,因為靜態成員函數沒有this指針,與多態發生條件矛盾

  1. 父類引用/指針去調用

  2. static函數沒有隱藏this參數.不滿足虛函數重寫條件"三同"

  3. 靜態成員函數目的是給所有對象共用,不是為了實現多態

構造函數、拷貝構造函數、賦值運算符重載 與 虛函數

  • 構造,拷貝構造不能是虛函數

    1. 構造函數需要幫助父類完成初始化,必須一起完成,不能像多態那樣非父即子(父對象調父的,子對象調子的);
    2. 虛表指針初始化是在構造函數的初始化列表中完成的,要先執行完構造函數,才能有虛函數
    3. 構造函數多態沒有意義
  • 賦值運算符重載也和拷貝構造一樣,不建議寫成虛函數,雖然編譯器不報錯.

虛函數和普通函數誰快?

一般來說,普通函數會比構成多態調用的虛函數快.但要註意,是虛函數在構成多態調用的情況下.

看例子1:

class AA {
public:
	virtual void func1() {}
};

class BB : public AA {
	void func2(){};
};

int main() {
	AA a;
	BB b;
	a.func1();
	b.func1();
	return 0;
}

image-20240609213911409

在VS2019-32位環境下,兩種函數在對象的調用下彙編代碼是一樣的.因此這種情況下它們一樣快.

看例子2:

成員函數為非虛函數時,指針調用是普通調用

class AA {
public:
    void func1() {}
};

class BB : public AA {
};

int main()
{
	BB b;
	BB*ba = &b;
	pb->func1();
	return 0;
}

image-20240609214741399

看例子3:

class AA {
public:
    virtual void func1() {}
};

class BB : public AA {
};

int main()
{
	AA*pa = &a;
	pa->func1();
	BB*pb = &b;
	pa->func1();

	return 0;
}

image-20240609214244189

在虛函數情況下(包括繼承和非繼承),使用指針調用都會觸發多態的調用方式,顯然這時調用虛函數效率會比普通函數慢.

小結:

通過上面幾個例子分析,發現有虛函數,且是指針的情況下,無論有沒有發生多態,調用方式都會發生改變.上面舉的名詞"多態的調用方式"是為了描述這種調用方式.

這種調用方式簡化了編譯器的調用邏輯:只要是虛函數,且是指針/引用,都會去虛表中找.如果滿足多態的條件就能發生多態的現象,否則就是正常調用.

因此,需要註意區分多態的調用方式多態的現象.常說的多態的兩個條件是指滿足這兩個條件才能觸發多態的現象.與是否是多態的調用方式無關.


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 前言 眾所周知,vue3的template中使用ref變數無需使用.value。還可以在事件處理器中進行賦值操作時,無需使用.value就可以直接修改ref變數的值,比如:<button @click="msg = 'Hello Vue3'">change msg</button>。你猜vue是在編 ...
  • 政務雲參考技術架構行業優勢總體架構 政務雲平臺技術框架圖,由機房環境、基礎設施層、支撐軟體層及業務應用層組成,在運維、安全和運營體系的保障下,為政務雲使用單位提供統一服務支撐。功能架構標準雙區隔離 參照國家電子政務規範,打造符合標準的雙區隔離的政務雲平臺,互聯網區承載對公服務業務,政務外網區承載各單 ...
  • 介紹: 在當今數字化時代,網路上的信息量龐大,如何使自己的網站在搜索引擎中脫穎而出成為了每個網站管理員都面臨的挑戰。網頁的原創度不僅能提升用戶體驗,還有利於搜索引擎排名。本文將介紹如何利用Coz API來重寫課件PPT網的網頁標題和正文內容,以增加網頁的原創度。 Coz API簡介: Coz是位元組出 ...
  • 前言 大家好,我是老馬。很高興遇到你。 我們為 java 開發者實現了 java 版本的 nginx https://github.com/houbb/nginx4j 如果你想知道 servlet 如何處理的,可以參考我的另一個項目: 手寫從零實現簡易版 tomcat minicat 手寫 ngin ...
  • Trick: \(x\) 與各位數之和模 \(9\) 同餘(CF10D) st表 和 線段樹 可以存 gcd(CF10D) 註意函數增減性(CF1632D) dp 時若下標太大,可以調換下標和存儲的數值(CF1974E) 貪心不成立時,可以用反悔貪心(CF1974G) 乘法一般比加法更優(CF187 ...
  • 1.概述 1.1 介紹 Qt:它是一套基於C++的跨平臺開發框架,包括GUI、字元串、多線程處理、文件IO、網路IO、3D渲染等 時間:它誕生於1991年,由Haavard Nord和Eirik Chambe-Eng共同締造 發展:歷經Qt Company、Nokia、Digia多個公司開發迭代 版 ...
  • 寫在前面 這是PB案例學習筆記系列文章的第4篇,該系列文章適合具有一定PB基礎的讀者。 通過一個個由淺入深的編程實戰案例學習,提高編程技巧,以保證小伙伴們能應付公司的各種開發需求。 文章中設計到的源碼,小凡都上傳到了gitee代碼倉庫https://gitee.com/xiezhr/pb-proje ...
  • C-12.資料庫其他調優策略 1.資料庫調優的措施 1.1 調優的目標 儘可能節省系統資源,以便系統可以提供更大負荷的服務。(吞吐量更大) 合理的結構設計和參數調整,以提高用戶操作響應的速度。(響應速度更快) 減少系統的瓶頸,提高MySQL資料庫整體的性能。 1.2 如何定位調優問題 不過隨著用戶量 ...
一周排行
    -Advertisement-
    Play Games
  • PasteSpider是什麼? 一款使用.net編寫的開源的Linux容器部署助手,支持一鍵發佈,平滑升級,自動伸縮, Key-Value配置,項目網關,環境隔離,運行報表,差量升級,私有倉庫,集群部署,版本管理等! 30分鐘上手,讓開發也可以很容易的學會在linux上部署你得項目! [從需求角度介 ...
  • SQLSugar是什麼 **1. 輕量級ORM框架,專為.NET CORE開發人員設計,它提供了簡單、高效的方式來處理資料庫操作,使開發人員能夠更輕鬆地與資料庫進行交互 2. 簡化資料庫操作和數據訪問,允許開發人員在C#代碼中直接操作資料庫,而不需要編寫複雜的SQL語句 3. 支持多種資料庫,包括但 ...
  • 在C#中,經常會有一些耗時較長的CPU密集型運算,因為如果直接在UI線程執行這樣的運算就會出現UI不響應的問題。解決這類問題的主要途徑是使用多線程,啟動一個後臺線程,把運算操作放在這個後臺線程中完成。但是原生介面的線程操作有一些難度,如果要更進一步的去完成線程間的通訊就會難上加難。 因此,.NET類 ...
  • 一:背景 1. 講故事 前些天有位朋友在微信上丟了一個崩潰的dump給我,讓我幫忙看下為什麼出現了崩潰,在 Windows 的事件查看器上顯示的是經典的 訪問違例 ,即 c0000005 錯誤碼,不管怎麼說有dump就可以上windbg開幹了。 二:WinDbg 分析 1. 程式為誰崩潰了 在 Wi ...
  • CSharpe中的IO+NPOI+序列化 文件文件夾操作 學習一下常見的文件、文件夾的操作。 什麼是IO流? I:就是input O:就是output,故稱:輸入輸出流 將數據讀入記憶體或者記憶體輸出的過程。 常見的IO流操作,一般說的是[記憶體]與[磁碟]之間的輸入輸出。 作用 持久化數據,保證數據不再 ...
  • C#.NET與JAVA互通之MD5哈希V2024 配套視頻: 要點: 1.計算MD5時,SDK自帶的計算哈希(ComputeHash)方法,輸入輸出參數都是byte數組。就涉及到字元串轉byte數組轉換時,編碼選擇的問題。 2.輸入參數,字元串轉byte數組時,編碼雙方要統一,一般為:UTF-8。 ...
  • CodeWF.EventBus,一款靈活的事件匯流排庫,實現模塊間解耦通信。支持多種.NET項目類型,如WPF、WinForms、ASP.NET Core等。採用簡潔設計,輕鬆實現事件的發佈與訂閱。通過有序的消息處理,確保事件得到妥善處理。簡化您的代碼,提升系統可維護性。 ...
  • 一、基本的.NET框架概念 .NET框架是一個由微軟開發的軟體開發平臺,它提供了一個運行時環境(CLR - Common Language Runtime)和一套豐富的類庫(FCL - Framework Class Library)。CLR負責管理代碼的執行,而FCL則提供了大量預先編寫好的代碼, ...
  • 本章將和大家分享在ASP.NET Core中如何使用高級客戶端NEST來操作我們的Elasticsearch。 NEST是一個高級別的Elasticsearch .NET客戶端,它仍然非常接近原始Elasticsearch API的映射。所有的請求和響應都是通過類型來暴露的,這使得它非常適合快速上手 ...
  • 參考delphi的代碼更改為C# Delphi 檢測密碼強度 規則(仿 google) 仿 google 評分規則 一、密碼長度: 5 分: 小於等於 4 個字元 10 分: 5 到 7 字元 25 分: 大於等於 8 個字元 二、字母: 0 分: 沒有字母 10 分: 全都是小(大)寫字母 20 ...