(Boolan) C++ 類型大小和內存分布(虛函數指針、虛表、內存對齊問題)

題目要求

回答:

(以下大部分都是基于x64編譯器下的windows平臺的gcc version 5.3.0 (GCC)編譯器的測試結果,不能其他平臺也能得出完全一致的結論,如果在x32下編譯結果會指出)
由于class相較于struct,默認的成員就是private,代碼中沒有特地強調private

  • Fruit的類型大小為32,Apple的類型大小為40。

    • 完整測試用代碼:
      ***http://rextester.com/AUJV82101 ***
      • 點擊上方連接可以進入全套代碼,點擊左下角的“run”按鈕可以查看運行后的結果。
      • 說明:
        • 程序所有的對象均創建在棧中,由系統自動管理,無需手動釋放內存
  • 圖示:

Fruit類型的大小所占的內存(x64編譯器下的結構) 4 * 8 = 32 Byte

注:虛函數指針因為是一個指針,其大小應該為4個字節,但在此我想說,如果使用x64編譯器生成的64位程序的指針大小為8個字節。(一個只含有虛函數的struct,x64編譯旗下,虛函數指針為8字節;x86編譯器上虛函數指針和普通指針沒啥區別,都是4個字節)。
在后續我有詳細的測試論證過程。

Fruit類型的大小所占的內存(x86編譯器下的結構) 4 * 8 = 32 Byte
Apple類型的大小所占的內存(x64編譯器下的結構) 5 * 8 = 40 Byte
Apple類型的大小所占的內存(x86編譯器下的結構) 5 * 8 = 40 Byte

關于答案以下是非常詳細的測試和推理,篇幅較長,感謝您閱讀,希望您多多指正。

答案分析:

  • 完整測試用代碼:
    ***http://rextester.com/AUJV82101 ***
    • 點擊上方連接可以進入全套代碼,點擊左下角的“run”按鈕可以查看運行后的結果。
    • 代碼運行的初級結論


      代碼初級結論(x64編譯器的結果)
- Fruit類和Apple類的相關定義

        class Fruit {
            int no;
            double weight;
            char key;
        public:
            void print() {   }
            virtual void process() {   }
        };

        class Apple : public Fruit {
            int size;
            char type;
        public:
            void save() {   }
            virtual void process() {   }
        };
  • 提出疑問
    1 對于Fruit類來說,成員由int、double和char組成,其中,不難由程序員算結果可知sizeof(int) = 4、sizeof(double) = 8、sizeof(char) = 1,那么1+4+8=13,為何sizeof(Fruit)的結果為32?
    2 對于Apple來說,成員有int、char組成,其中,不難由程序得知sizeof(int) = 4、sizeof(char) = 1,那么1 + 4 = 5,為何sizeof(Apple)的結果為40呢?
    3 這樣定義是否合理,是否存在著內存的浪費?
    4 內存中的額外空間用做了什么?這些空間是否有規律可循?他們是什么?都占多大的內存空間?
    ......

  • 分析:

    為了弄清楚這些疑問,需要準備一系列的代碼來做實驗。

    • 首先我們先來驗證最基礎的一個特點就是內存對齊的問題。
    • 什么是內存對齊。內存對齊是** 編譯器 **層面管理的問題,是編譯器管理數據位置的一種組織方式。
    • 對齊系數。其實可以把他理解為編譯器的來存放內存時,劃分內存空間的一把“尺子”。通過這個尺子來量出該怎么劃分內存空間。也可以把它理解為切內存——這塊蛋糕,所用的最小單位。如果被選中了相應的對其系數,那么,也就決定了存放數據的內存單元的每一行有多寬,所以得出來的內存空間大小,一定是對其系數的倍數!
      • 那么如何來得到對齊系數呢?

      方式一:
      程序員可以通過預編譯命令#pragma pack(n),n=1,2,4,8,16來改變這一系數,其中的n就是你要指定的“對齊系數”。

      方式二:
      由編譯器自信決定。對于我這次測試的平臺來說,這個編譯器的規則為,采用成員中最長的變量的長度作為對其系數
      - 既然知道了對齊系數,那么是否可以幫助解釋之前提出的疑問呢?!
      答案是,可以解釋部分內容,想要全部弄明白還得等等,我們先來看看這塊能解釋多少吧。
      如果這時候那Fruit為例來看,它其中的成員有int,double和char所組成,這三個變量中,最長的應該是double了。所以Fruit的大小一定是sizeof(double)的倍數,也就是8的倍數。目前看,Fruit的大小為32,是符合這個觀點的。那么這三個成員是如何排列呢?
      其實他們的安排順序還是狠簡單粗暴的,就是定義變量的順序來組織他們在內存中的位置。
      比如,Fruit的成員定義順序是int,double,char,則編譯器會先將int按照,對齊系數放入內存中,再看后面的變量,如果,兩者相加小于對齊系數,則放在同一行,如果大于,就單獨再開一行。那么,Fruit的對齊系數為double的8,sizeof(int)+sizeof(double) > 8,那么double會單獨開一行,放進去。這時候,Fruit的內存已經為8*2=16了。接下來再看char,由于double單獨為一行,那么char會單獨開一行,所以此時的內存為8 * 3 = 24。具體的圖形如下圖:

      只考慮成員變量的內存圖

      關于這幅圖,int占用了4個內存,double占用了8個內存單元,char占用了1個內存單元。其中紅色的部分為浪費的內存空間。
      那么說了這么多,到底如何呢,我們接下來用代碼看看。

再看代碼之前,先簡單說明一下代碼的功能

測試定義順序對內存的影響
the memory of Fruit8---------------------
Address of Fruit8: 0x 0x7ffcdb6a4110 | Size = 24
88  28  40  00  00  00  00  00  
00  00  00  00  00  00  00  00  
01  00  00  00  00  00  00  00  
-------------------------------

1 結果輸出測試類型的名稱
2 結果輸出該類型的對象的地址和該類型的大小
3 結果輸出對應地址下的內容(按字節,以十六進制的方式輸出)

  • 類的定義

    // Fruit類和Fruit4類之間的區別主要是定義成員變量的順序
    
      //原始定義
      class Fruit {
          int no;
          double weight;
          char key;
      public:
          void print() {   }
          virtual void process() {   }
      };
    
      //定義順序調整(虛函數同名)(優化后)
      //Fruit的定義成員函數的順序為從小到大。
      class Fruit8 {
          char key;
          int no;
          double weight;
      public:
          void print() {   }
          virtual void process() {   }
      };            
    
    //定義順序調整(虛函數同名)(優化后)
    class Fruit4 {
      char key;
      int no;
      double weight;
    public:
      Fruit4(int n, double w, char k) :no(n), weight(w), key(k) {}
        void print() {   }
      virtual void process() {   }
    };
    
    // 定義了char、char、int、double
    class Fruit9 {
      char key;
      char x;
      int no;
      double weight;
    public:
      void print() {   }
        virtual void process() {   }
        Fruit9(char a, char b, int n, double w) :key(a), x(b), no(n), weight(w){}
    };
    
  • 測試代碼
    #include <iostream>
    #include <string>
    #include <iomanip>

      using namespace std;
              
              //為了方便閱讀,這個函數再次給出,但之后不在贅述
      string operator*(string z, int n) {
          string temp = z;
          for (int i = 0; i < n; i++) {
              z += temp;
          }
          return z;
      }
    
              //為了方便閱讀,這個函數再次給出,但之后不在贅述
      void printMemo(char* name, void* f, int size) {
          string s = "-";
          cout << "the memory of " << name << s*20 <<"\n";
          cout << "Address of "<<name << ": 0x " << hex << f <<  " | Size = " << dec <<size <<endl;
          unsigned char* x = (unsigned char*)f;
          for (int i = 0; i < size; i++) {
              cout << setfill('0') << setw(2) << hex << (unsigned int)*x << "  ";
              if (!((i + 1) % (size>8? 8: 4))) {
                  cout << "\n";
              }
              x++;
          }
          cout << s*30 <<"\n\n";
      }
      
      int main()
      {   
          cout << "測試輸出最原始結構" << endl;
    
          Fruit f;
          Fruit* ft = &f;
          printMemo("Fruit", ft, sizeof(Fruit));
    
          Fruit8 f8;
          Fruit8* ft8 = &f8;
          printMemo("Fruit8", ft8, sizeof(Fruit8));
          
          cout << "定義順序調整(虛函數同名)(優化后)" << endl;
          Fruit4 f4(1, 4.456, 'c');
          Fruit4* ft4 = &f4;
          printMemo("Fruit4", ft4, sizeof(Fruit4));
    
          Fruit9 f9('a', 'b', 77777777, 1.234);
          Fruit9* ft9 = &f9;
          printMemo("Fruit9", ft9, sizeof(Fruit9));
          return 0;
      }
    
  • 運行結果


    Fruit的測試結果

    Fruit8的測試結果

    Fruit4的測試結果

    Fruit9的測試結果
  • 結果分析

    • 僅調整成員定義的順序,Fruit8的大小為24字節,而Fruit的字節為32字節。

      按照之前的分析,只考慮成員的定義順序,會得到一下的內存
      修改后的(Fruit4 )內存圖
  • 內存輸出的結果


    Fruit4 內存輸出結果
    • 以上說明了內存分布和抽象畫成的一致,但是,觀察可以發現,內存空間并不連續,char和int之間并不連續。因為int如果與char連續的話,int的內存起止的位置都會為奇數,則此時,編譯器會跳過一部分內存。為了驗證內存跳過的情況,可以比較Fruit4 和Fruit9對比可以看出其內存圖分配,就可以看出int內存的跳過的情況。其中77777777的十六進制數為:0x 04 A2 CB 71,a和b的ASCII碼的十六進制數分別為61和62,因此內存情況,可以得到具體內存圖。
Fruit9的內存
Fruit9的內存圖

函數問題

  • 關于成員屬性在內存中是如何分布的基本說明白了,但是,目前還沒有討論完全,因為,Fruit的實際大小為32,我們通過以上理論,解釋了內存為24的空間還有8字節的空間去了哪呢?那么會不會是由于成員函數而影響的呢

那么我們先來驗證一下,函數到底會不會影響類型的大小呢?
二話不說,先上代碼~~~~~

  • 先來看看構造函數

    //原始定義
    class Fruit {
        int no;
        double weight;
        char key;
    public:
        void print() {   }
        virtual void process() {   }
    };
    //添加構造函數后的定義
    class Fruit2 {
        int no;
        double weight;
        char key;
    public:
        Fruit2(int n, double w, char k) :no(n), weight(w), key(k) {}
        void print() {   }
        virtual void process() {   }
    };
    
  • 測試代碼(不含預先定義的部分,需要請查看上方)

    Fruit f;
    Fruit* ft = &f;
    printMemo("Fruit", ft, sizeof(Fruit));
    
    cout << "添加構造函數后的定義" << endl;
    Fruit2 f2(1, 2.345, 'c');
    Fruit2* ft2 = &f2;
    printMemo("Fruit2", ft2, sizeof(Fruit2));
    
  • 運行結果


  • 結論分析

1 首先可以看出,添加了構造函數,并沒有影響類型的內存大小,都還是32字節,說明** 構造函數,并不影響類型的大小**
2 其次,觀察內存空間不難發現,圖中畫雙框的部分的內存很相似,而且大小也正是八個字節的大小,只要研究清楚這個是什么,也就明白了類型的大小到底是怎么一回事。

  • 考察虛函數
    二話不說,刷代碼
    • 代碼

       //原始定義
      class Fruit {
          int no;
          double weight;
          char key;
      public:
          void print() {   }
          virtual void process() {   }
      };
      
        //去掉虛函數后的定義
      class Fruit1 {
          int no;
          double weight;
          char key;
      public:
          void print() {   }
          //virtual void process() {   }
      };
      
    • 測試代碼(不含預先定義的部分,需要請查看上方)

      Fruit f;
      Fruit* ft = &f;
      printMemo("Fruit", ft, sizeof(Fruit));
      
      cout << "去掉虛函數后的定義" << endl;
      Fruit1 f1;
      Fruit1* ft1 = &f1;
      printMemo("Fruit1", ft1, sizeof(Fruit1));
      
    • 運行結果


      原始

      去掉虛函數
    • 結果分析

    1 總算發現了這八個字節的根本來源——** 虛 函 數 !?。?*

現在知道了一直困擾我們的八個字節是來自與虛函數的定義,那么,問題接著就有來了,虛函數的所占內存的大小是多少? 是否遵循對其的原則呢?二話不說,趕快上代碼測試??!

虛函數的內存問題

  • 代碼

     //原始定義
    class Fruit {
        int no;
        double weight;
        char key;
    public:
        void print() {   }
        virtual void process() {   }
    };
    //純虛函數是否影響
    class Fruit5 {
        int no;
        double weight;
        char key;
    public:
        Fruit5(int n, double w, char k) :no(n), weight(w), key(k) {}
        void print() {   }
        virtual void process() = 0;
    };
    
    class Fruit6 {
    public:
        Fruit6()  {}
        void print() {   }
        virtual void process() {};
    };
    
    //驗證虛函數和對齊
    class Fruit7 {
        char n;
    public:
        Fruit7(char a):n(a) {}
        void print() {   }
        virtual void process() {};
    };
    
      //多個虛函數
    class Fruit10 {
        int no;
        double weight;
        char key;
    public:
        void print() {   }
        Fruit10(char a, int n, double w) :key(a), no(n), weight(w) {}
        virtual void process1() {   }
        virtual void process2() {   }
        virtual void process3() {   }
    };
    
  • 測試代碼

    Fruit f;
    Fruit* ft = &f;
    printMemo("Fruit", ft, sizeof(Fruit));
    Apple a;
    Apple* at = &a;
    printMemo("Apple", at, sizeof(Apple));
    
    cout << "純虛函數是否影響" << endl;
    //抽象類不能創建對象
    printMemo("Fruit5", NULL, sizeof(Fruit5));
    
    cout << "測試虛函數的大小" << endl;
    Fruit6 f6;
    Fruit6* ft6 = &f6;
    printMemo("Fruit6", ft6, sizeof(Fruit6));
    
    cout << "驗證虛函數和對齊" << endl;
    Fruit7 f7('a');
    Fruit7* ft7 = &f7;
    printMemo("Fruit7", ft7, sizeof(Fruit7));
    
    cout << "多個虛函數測試,對齊情況" << endl;
    Fruit10 f10(1, 10.1056, 'c');
    Fruit10* ft10 = &f10;
    printMemo("Fruit10", ft10, sizeof(Fruit10));
    
  • 運行結果

原始
Fruit5(抽象類)
Fruit6(x64環境下結果)
Fruit6(x86環境下結果)
Fruit7(x64環境下結果)
Fruit7(x86環境下結果)
多個虛函數
  • 結論

1 由原始數據和Fruit5(純虛函數的抽象類)的輸出的結果可以看到,雖然Fruit5不能創建對象,但是不難看出兩者的大小是相同的,所以虛函數和純虛函數占用的空間相同
2 由Fruit6的輸出的結果可以得出,在x86和x64平臺的結果不相同,*** 在32位平臺的虛函大小為4字節,在64位平臺下的虛函數的大小為8字節***
3 由Fruit7可以看出,虛函數所占內存大小的分配規則,符合對齊的規則,x64平臺下,虛函數加char,會浪費7個字節的空間,x86平臺下會浪費3個字節。
4 由Fruit10可以看出,多個虛函數的情況,實際占用與一個虛函數的情況相同。

現在已經完成了類型大小的整理,但是還差一件事,就是父類和子類的關系。

父類和子類

  • 基本版
    二話不說上代碼
    • 代碼

      //原始定義
      class Fruit {
          int no;
          double weight;
          char key;
      public:
          void print() {   }
          virtual void process() {   }
      };
      
      class Apple : public Fruit {
          int size;
          char type;
      public:
          void save() {   }
          virtual void process() {   }
      };
      
      //去掉虛函數后的定義
      class Fruit1 {
          int no;
          double weight;
          char key;
      public:
          void print() {   }
          //virtual void process() {   }
      };
      
      class Apple1 : public Fruit1 {
          int size;
          char type;
      public:
          void save() {   }
          //virtual void process() {   }
      };
      
    • 測試代碼

      cout << "測試輸出最原始結構" << endl;
      
      Fruit f;
      Fruit* ft = &f;
      printMemo("Fruit", ft, sizeof(Fruit));
      Apple a;
      Apple* at = &a;
      printMemo("Apple", at, sizeof(Apple));
      
    • 輸出結果


    • 分析
      1 Apple為Fruit的子類,并且Fruit具有三個可能影響類型大小的成員,分別為int、char、虛函數。其中int為4字節,char為1字節,對齊系數為4,那么成員屬性的大小為8字節。Fruit的大小為32字節,Apple的大小為40字節。
      2 對于去除了虛函數的情況,包含虛函數的父類和子類大小分別為32、40,去除掉后,大小分別為24, 32。相當于每個類減少了8個字節(父類的對齊系數)

    • 結論

  1. 子類會繼承父類的對齊系數,子類的成員是依據父類的對齊系數來計算的
  2. 子類的虛函數,不對大小產生影響
  • 父類在子類中的位置
    • 代碼
    class Fruit2 {
        int no;
        double weight;
        char key;
    public:
        Fruit2(int n, double w, char k) :no(n), weight(w), key(k) {}
        void print() {   }
        virtual void process() {   }
    };

    class Apple2 : public Fruit2 {
        int size;
        char type;
    public:
        Apple2(int s, char t, int n, double w, char k) :size(s), type(t), Fruit2(n, w, k) {}
        void save() {   }
        virtual void process() {   }
    };

    //定義順序調整(虛函數同名)(優化后)
    class Fruit4 {
        char key;
        int no;
        double weight;
    public:
        Fruit4(int n, double w, char k) :no(n), weight(w), key(k) {}
        void print() {   }
        virtual void process() {   }
    };

    class Apple4 : public Fruit4 {
        char type;
        int size;
    public:
        Apple4(int s, char t, int n, double w, char k) :Fruit4(n, w, k),  size(s), type(t){}
        void save() {   }
        virtual void process() {   }
    };
  • 測試代碼
    cout << "測試元素分布" << endl;
    //驗證位置(未優化1)
    Fruit2 f21(1, 2.345, 'c');
    Fruit2* ft21 = &f21;
    printMemo("Fruit2", ft21, sizeof(Fruit2));

        //驗證位置(未優化2)
        Fruit2 f22(3, 2.345, 'b');
        Fruit2* ft22 = &f22;
        printMemo("Fruit2", ft22, sizeof(Fruit2));
        
        //驗證位置(未優化1)
        Apple2 a21(9, 'd', 2, 6.789, 'e');
        Apple2* at21 = &a21;
        printMemo("Apple2", at21, sizeof(Apple2));
        
        //驗證位置(未優化2)
        Apple2 a22(8, 'a', 3, 6.789, 'f');
        Apple2* at22 = &a22;
        printMemo("Apple2", at22, sizeof(Apple2));
    
        //驗證位置(優化后1)
        Fruit4 f41(1, 41.4156, 'c');
        Fruit4* ft41 = &f41;
        printMemo("Fruit41", ft41, sizeof(Fruit4));
    
        //驗證位置(優化后2)
        Fruit4 f42(2, 41.4156, 'b');
        Fruit4* ft42 = &f42;
        printMemo("Fruit42", ft42, sizeof(Fruit4));
    
        Apple4 a41(9, 'd', 1, 41.4156, 'c');
        Apple4* at41 = &a41;
        printMemo("Apple41", at41, sizeof(Apple4));
    
        Apple4 a42(8, 'e', 1, 41.4156, 'g');
        Apple4* at42 = &a42;
        printMemo("Apple42", at42, sizeof(Apple4));
    
  • 運行結果

Fruit2的系列
Fruit4的系列
  • 分析
Fruit2系列的分析
整理后的Fruit4的系列的分析
  • 結論

由之前的分析可以看出來,對于子類來說,虛函數指針是相同的位置,子類成員所占的內從空間始終在父類之后,父類空間后面所剩下的位置,可以與子類共用

  • 子類與虛函數(多個,同名(override)與不同名的虛函數的關系)
    • 代碼

        //多個虛函數
      class Fruit10 {
          int no;
          double weight;
          char key;
      public:
          void print() {   }
          Fruit10(char a, int n, double w) :key(a), no(n), weight(w) {}
          virtual void process1() {   }
          virtual void process2() {   }
          virtual void process3() {   }
      };
      
      class Apple10 : public Fruit10 {
          int size;
          char type;
      public:
          Apple10(char t, int s, char a, int n, double w) :Fruit10(a, n, w), type(t), size(s) {}
          void save() {   }
          virtual void process1() {   }
          virtual void process2() {   }
          virtual void process4() {   }
      };
      
    • 測試代碼

      cout << "多個虛函數測試,對齊情況" << endl;
      Fruit10 f10(1, 10.1056, 'c');
      Fruit10* ft10 = &f10;
      printMemo("Fruit10", ft10, sizeof(Fruit10));
      
      Apple10 a10(9, 'd', 1, 10.1056, 'c');
      Apple10* at10 = &a10;
      printMemo("Apple10", at10, sizeof(Apple10));
      
    • 輸出結果


    • 結論

子類的虛函數所站類型的大小,和數量,是否同名無關,始終處于最上方,且大小固定(為一個對齊系數的大?。?/strong>

虛表問題

  • 之前的部分,基本把這個問題講清楚了,但還留下了一個問題:為什么多個虛函數,也只用一個指針就夠了???(由于此處重點非虛表,所以不做詳細說明。)
    關于這個問題,我在這里簡單解釋一下,虛函數在類中,只需要保存一個指針即可,那么這個指針所指向的內容就很重要了,它會指向一個數組,在數組中保存著他所持有的虛函數即可,這樣他只需要持有一個固定大小的指針就行了,而不需要考慮實際擁有幾個虛函數的問題。但是,對于虛函數來說,還有一個更大的用途,那就是對于實現父類和子類中的虛函數的關系騎著非常重要的作用了。由之前的測試程序可以看出,對于各相同類型的不同對象,實際虛函數指針所指的區域是相同的。(比如Fruit21 f21和Fruit22 f22等),也就是虛表實際只和class相關,具體的對象只需指向這塊內存空間即可。而編譯器,實際在調用虛函數f21.xxVirtualFunction();時,實際編一起會將其轉化為(* (f21 -> vptr)[n])(f21); 或 (*f21->vptr[n])(f21);來進行執行。可以看出實際是從數組中取出對應函數的指針,并將對象傳入其中進行調用的過程。此時該數組中的元素指向,如果是存在子父類關系的同名虛函數(子類override父類虛函數的情況)的情況,虛表中的指針所指的虛函數的指針為同一個!這樣的設計好處,由于對象是由參數傳入的,所以能夠輕松實現多態。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容