(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));
      
    • 输出结果


    • 结论

子类的虚函数所站类型的大小,和数量,是否同名无关,始终处于最上方,且大小固定(为一个对齐系数的大小)

虚表问题

  • 之前的部分,基本把这个问题讲清楚了,但还留下了一个问题:为什么多个虚函数,也只用一个指针就够了???(由于此处重点非虚表,所以不做详细说明。)
    关于这个问题,我在这里简单解释一下,虚函数在类中,只需要保存一个指针即可,那么这个指针所指向的内容就很重要了,它会指向一个数组,在数组中保存着他所持有的虚函数即可,这样他只需要持有一个固定大小的指针就行了,而不需要考虑实际拥有几个虚函数的问题。但是,对于虚函数来说,还有一个更大的用途,那就是对于实现父类和子类中的虚函数的关系骑着非常重要的作用了。由之前的测试程序可以看出,对于各相同类型的不同对象,实际虚函数指针所指的区域是相同的。(比如Fruit21 f21和Fruit22 f22等),也就是虚表实际只和class相关,具体的对象只需指向这块内存空间即可。而编译器,实际在调用虚函数f21.xxVirtualFunction();时,实际编一起会将其转化为(* (f21 -> vptr)[n])(f21); 或 (*f21->vptr[n])(f21);来进行执行。可以看出实际是从数组中取出对应函数的指针,并将对象传入其中进行调用的过程。此时该数组中的元素指向,如果是存在子父类关系的同名虚函数(子类override父类虚函数的情况)的情况,虚表中的指针所指的虚函数的指针为同一个!这样的设计好处,由于对象是由参数传入的,所以能够轻松实现多态。

推荐阅读更多精彩内容