C++ 虛函數相關,從頭至尾捋一遍

衆所周知,C++虛函數是一大難點,也是面試過程當中必考部分。這次,從虛函數的相關概念、虛函數表、純虛函數、再到虛繼承等等跟虛函數相關部分,作一個比較細緻的整理和複習。面試

  • 虛函數
    • OOP的核心思想是多態性(polymorphism)。把具備繼承關係的多個類型稱爲多態類型。引用或指針的靜態類型與動態類型不一樣這一事實正是C++實現多態性的根本。
    • C++ 的多態實現便是經過虛函數。在C++中,基類將類型相關的函數與派生類不作改變直接繼承的函數區別對待。對於某些函數,基類但願它的派生類各自定義適合自身的版本,此時基類就將這些函數聲明爲虛函數(virtual function)。
    • C++在使用基類的引用或指針調用一個虛函數成員函數時會執行動態綁定。由於只有直到運行時才能知道調用了那個版本的虛函數,因此全部的虛函數必須有定義。
    • 動態綁定只有當經過指針或引用調用虛函數時纔會發生。
    • 一旦某個函數被聲明爲虛函數,則在全部派生類中它都是虛函數。因此在派生類中能夠再一次使用virtual指出,也能夠不用。
    • 若是某次函數調用使用了默認實參,則該實參值由本次調用的靜態類型決定。換句話說,若是咱們經過基類的引用或指針調用函數,則使用基類中定義的默認實參,即便實際運行的是派生類的函數版本也是如此。此時,傳入派生類函數的將是基類函數定義的默認實參。
    • 在某些狀況下,咱們但願對虛函數的調用不進行動態綁定,而是強迫其執行虛函數的某個特定版本。
      cpp //強行調用基類中定義的函數版本而無論baseP的動態類型究竟是什麼 double price = basePtr->Base::net_price();
      一般狀況下,只有成員函數(或友元)中的代碼才須要使用做用域運算符來回避虛函數的機制。
  • 抽象基類
    • 純虛函數:一個純虛函數無須定義。經過在函數體的位置(即在聲明語句的分號以前)書寫 =0 將一個虛函數說明爲純虛函數。其中 =0 只能出如今類內部的虛函數聲明語句處。
    • 值得注意的是,咱們也能夠爲純虛函數提供定義,不過函數體必須定義在類的外部,不能在類的內部爲一個 =0 的函數提供函數體。
    • 含有純虛函數的類是抽象基類。
      • 含有(或者未經覆蓋直接繼承)純虛函數的類是抽象基類。抽象基類負責定義接口,然後續的類能夠覆蓋接口。咱們不能(直接)建立一個抽象基類的對象。
        cpp //Base 聲明瞭純虛函數,而 Derive將覆蓋該函數 Base b; //錯誤,不能定義Base的對象 Derive d; //正確,Derive中沒有純虛函數
  • 虛函數表指針和虛函數表
    • 對於每個定義了虛函數的類,編譯器會爲其建立一個虛函數表,該虛函數表被全部的類對象所共享,即它不是跟着對象走的,而是至關於靜態成員變量,是跟着類走的。
    • 虛函數表指針vptr,每個類的對象都有一個虛函數表指針,該指針指向類的虛函數表的位置。爲了實現多態,當一個對象調用某個虛函數時,其實是根據該虛函數指針vptr所指向的虛函數表vtable裏找到相應的函數指針並調用之。
    • 關於vptr在對象內存佈局中的存放位置,通常都是放在內存佈局的最前面,固然,也可能有其餘實現方式。
    • 基類定義以下所示:
      ```cpp
      class Base{
      public:
      Base()
      :a(0), b(0), c('\0'){}函數

      virtual void fun1(){
           cout << "Base::fun1()" << endl;
       }
      
       virtual void fun2(){
           cout << "Base::fun2()" << endl;
       }

      private:
      int a;
      double b;
      char c;
      };
      ```
      類Base對象其內存佈局方式爲:
      佈局

    • 考慮繼承的狀況,以下所示
      ```cpp
      class Derive : public Base{
      public:
      Derive()
      :Base(),d(0), f(0){}學習

      virtual void fun1(){
            cout << "Derive::fun1()" << endl;
        }
      
        virtual void fun3(){
            cout << "Derive::fun3()" << endl;
        }

      private:
      int d;
      float f;
      };
      ```
      類Derive對象其內存佈局以下所示:
      .net

      • 其實Derive對象的內存佈局是能夠這樣理解,可是也不是很準確。
        如上所示,在Derive的定義中,我從新實現了Base的fun1(),直接繼承了Base::fun2(),再新定義了 Derive::fun3()
        經過調試,即上面的右圖發現,在Derive的對象中,可以看到的虛函數表是從Base繼承而來的,其中裏面覆寫fun1(),繼承了fun2(),可是並無fun3()的函數指針。因此按照上邊的左圖,給出內存佈局的話,可能會有一些誤導。指針

      • 當派生類繼承基類時,若是覆寫了基類中的虛函數,在基類的虛函數表中,會使用覆寫的函數覆蓋基類對應的虛函數,若是沒有覆寫,則直接繼承基類的虛函數。如上圖所示的fun1 和 fun2 則是這種狀況。
      • 當派生類再定義新的虛函數時,此時在基類的虛函數表中是沒法體現出來的。因此,此時編譯器會爲派生類維護不止一個屬於派生類的虛函數表,其中的有從基類繼承而來的虛函數表,可是跟基類的不一樣,由於其中可能有函數覆寫。另外則有一個用來記錄當前派生類新定義的虛函數,函數 fun3即屬於這種狀況。固然,新維護的虛函數表的位置由編譯器決定,也能夠直接接到繼承而來的虛函數表的後面,即也就只有一個表,可是這跟編譯器的具體實現有關。因此,有那個意思就好了,不用太過深究具體實現細節。通常狀況下,按照上面左圖形式理解便可。
      • 由上可知,派生類若是沒有定義新的虛函數,則直接繼承虛類的虛函數表,並在其中作相應修改。若是定義了新的虛函數,不止要繼承虛類的,還要維護本身的。
        因此上面的Derive的內存佈局的另外一種狀況多是:
        調試

    • 下面給出一個多重繼承的討論狀況:
      ```cpp
      class Base1{
      public:
      Base1()
      {}code

      virtual void fun1(){
            cout << "Base1::fun1()" << endl;
        }
      
        virtual void fun2(){
            cout << "Base1::fun2()" << endl;
        }

      };對象

      class Base2{
      public:
      Base2(){}blog

      virtual void fun3(){
            cout << "Base2::fun3()" << endl;
        }
      
        virtual void fun4(){
            cout << "Base2::fun4()" << endl;
        }

      };

      class Derive : public Base1, public Base2(){
      public:
      Derive()
      :Base1(), Base2() {}

      virtual void fun2(){
            cout << "Derive::fun2()" << endl;
        }
      
        virtual void fun3(){
            cout << "Derive::fun3()" << endl;
        }
      
        virtual void fun5(){
            cout << "Derive::fun5()" << endl;
        }

      }
      ```
      Derive的對象內存佈局以下:

      注意:
      • 注意派生類和基類的覆蓋關係和繼承關係
      • 關於字節對齊問題,虛函數表指針,做爲隱藏成員加入到類對象中,而隱藏成員的加入不能影響其後成員的字節對齊,因此,虛函數表指針老是佔有最大字節對齊數的內存。
  • 虛繼承
    • 這是篇好文章C++ 多繼承和虛繼承的內存佈局,雖然不是很懂,可是確實有幫助。下面在給出一些相關概念。

    • 概念:爲了解決從不一樣途徑繼承來的同名的數據成員在內存中有不一樣的拷貝形成數據不一致的問題,將共同基類設置爲虛基類。此時,從不一樣途徑繼承過來的同名數據成員在內存中只有一個拷貝,同一個函數名也只有一個映射。解決了二義性問題,同時,也節省了內存,避免了數據不一致的問題。
    • C++ 對象的內存佈局(下)關於虛擬繼承的例子部從這篇文章學習,推薦。

    • 總結以下:
      • 不管是GCC仍是VC++,除了一些細節上的不一樣,其大致上的對象佈局是同樣的。都是從Base1, 到Base2, 再到 Derive, 最後是虛基類 Base。
      • 關於虛函數表,尤爲是第一個,GCC和VC++有很大的不同。
  • 討論
    • 帶有虛函數的類的sizeof問題
      ```cpp
      1. class Base{
        public:
        virtual void fun(){}
        private:
        int a;
        };

      很明顯: sizeof(Base) = 8
      緣由:帶有虛函數的類具備虛函數指針,而後再加上int

      1. class Base{
        public:
        virtual void fun(){}
        private:
        int a;
        double b;
        };

      乍一看 sizeof(Base) = 16, 其實應該是 sizeof(Base) = 24
      爲何呢, 由於前面關於字節對齊中,提到過 類的隱藏對象不能影響其後的數據成員的對齊,因此通常隱藏對象都是最大對齊字節的整數倍。此時 最大對齊爲8,因此 虛函數表指針佔4個字節,但須要填充4個。而後 int 佔 4 個,再填充 4 個,最後double佔8個。一共24個。

      1. class A {
        int a;
        virtual ~A(){}
        };

        class B:virtual public A{
        virtual void funB(){}
        };

        class C:virtual public A{
        virtual void funC(){}
        };

        class D:public B,public C{
        virtual void funD(){}
        };

        sizeof(A) = 8
        sizeof(B) = 12
        sizeof(C) = 12
        sizeof(D) = 16

        A 中是虛函數指針 + int
        B、C 虛繼承A,大小爲 A + 指向虛基類的指針,B、C雖然新定義了虛函數,可是共享A中的虛函數指針。
        D 因爲是普通繼承 B、C,可是因爲 B 、C是虛繼承,因此D中保留A的一個副本。因此大小爲 A + B指向虛基類的指針 + C指向虛基類的指針
        ```

    • 最後給出一個上面討論 2 的具體實例。在VS2013下查看內存佈局以下:


      上圖中沒有搞懂的部分,應該是隨機數,系統隨機的。不用管。

相關文章
相關標籤/搜索