在看完《深度探索C++对象模型》的第三章后,发现原来各编译器对类内存模型的具体实现不是完全一致的,并且《深度探索C++对象模型》(后面直接以书中指代)这本书是问世已超过十年,各家编译器也经历了多次升级,对象模型的实现可能也发生了变化,所以本文主要是对编译器Visio stdio2019的内存模型的探索,由于本人接触c++时间很短并且语文水平十分有限,所有内容都只是个人看法无法保证正确性并且行文十分混乱,欢迎各位大佬指出其中的不足与错误。
目录
前提:
探索后面的内容之前,首先要有一个认识:类所占内存的大小与成员函数无关(无论是声明了static还是非static的),也与static成员变量无关(成员函数存放在进程的代码区,static成员变量存放在数据段,而非全局的类对象则存放在栈或堆中)
#include<iostream>
#include<cstdio>
using namespace::std;
class A
{
public:
A()
{
}
void show()
{
}
static int b;
private:
int a_;
};
int A::b = 0;
int main()
{
cout << sizeof(A) << endl;
system("pause");
return 0;
}
Visio stdio2019运行结果:
可见类对象的大小是与成员函数和static成员变量无关的
类对象的访问
在C++中实际上对于类内成员变量的访问是通过类对象地址加上类成员对象在类内的偏移地址进行访问的。
假设class A中有成员变量a,
class A
{
public:
int a;
}
A a0;
访问对象a0的成员对象a的形式为:
a0.a;
这种形式实际上是依靠类对象a0地址加上成员对象a的在类中的偏移地址来实际访问成员对象a0的,也就是说
a0.a 等价于伪代码 &a0 + &A::a;
其中&A::a是成员对象a在类内的偏移地址,也叫做指向Data Members的指针(Pointer to Data Members)
关于指向Data Members的指针,在书中提到为了区分一个“没有指向任何data member”的指针和一个指向“第一个data member”的指针,会为每一个真正的类内偏移地址加上1,但是在实际的测试中发现在Visio stdio2019中并不是如此
class A
{
public:
int a_;
char b_;
char c_;
};
int main()
{
A a;
printf("&A::a_ = %p \n", &A::a_);
printf("&A::b_ = %p \n", &A::b_);
printf("&A::c_ = %p \n", &A::c_);
system("pause");
return 0;
}
运行结果:
可见在编译器Visio stdio2019中指向Data Members的指针就是类内的偏移地址,并没有加1
类的内存字节对齐
非本文的重点内容,但是在过程中会用到这方面的知识,这里不再详细说明,可以查阅相关资料
非继承类对象模型的探索
空类型(类中不含有任何非static成员变量的类)
在C++中空类型(没有继承的情况下)占用内存大小是1个字节
具体原因是如果空类型占用内存大小是0的话就无法分配具体的内存地址,也就无法区分两个空类型的对象
class A
{
};
int main()
{
cout << sizeof(A) << endl;
system("pause");
return 0;
}
Visio stdio2019运行结果:
非空类型
按照书中所说,C++标准只指定了后声明的变量的地址一定要大于先声明变量的地址,并未要求变量之间必须连续存放(不同访问区段如public,private之间的变量存储也是如此)。接下来我们通过指向Data Members的指针来探究不同访问区段之间和同一访问区段之间的成员变量是如何存放的。
class A
{
public:
A(char a0 = '0', char a1 = '1', char a2 = '2', char a3 = '3', char a4 = '4', char a5 = '5')
:a0_(a0), a1_(a1), a2_(a2), a3_(a3), a4_(a4), a5_(a5)
{
}
char a0_;
char a1_;
protected:
char a2_;
char a3_;
private:
char a4_;
char a5_;
};
int main()
{
A a;
printf("&A::a_ = %p \n", &A::a0_);
printf("&A::b_ = %p \n", &A::a1_);
printf("&A::c_ = %p \n", &A::a2_);
printf("&A::c_ = %p \n", &A::a3_);
printf("&A::c_ = %p \n", &A::a4_);
printf("&A::c_ = %p \n", &A::a5_);
system("pause");
return 0;
}
按照书中的说法,这段代码应该是没有问题的,可能是在现代C++中提高了封装的安全性,禁止了这种访问形式,在Visio stdio2019中报错
为了继续进行探索,只能另辟蹊径,采用反证法,假设内存存放顺序是按照声明顺序的a0_,a1_,a2_..与访问段无关,只要我们在我们假设的地址取到的值与实际值一样,就能证明假设的内存存放顺序是正确的。
class A
{
public:
A(char a0 = '0', char a1 = '1', char a2 = '2', char a3 = '3', char a4 = '4', char a5 = '5')
:a0_(a0), a1_(a1), a2_(a2), a3_(a3), a4_(a4), a5_(a5)
{
}
char a0_;
char a1_;
protected:
char a2_;
char a3_;
private:
char a4_;
char a5_;
};
int main()
{
A a;
char* begin = (char*)&a;
//假设内存存放顺序是按照声明顺序的a0_,a1_,a2_..与访问段无关
//(为了避免字节对齐的影响,所有变量采用char的方式)
//根据假设,那么a0_的偏移地址为0,a1_的偏移地址为1,a2_的偏移地址为2以此类推
//只需验证&a加上偏移地址的值是否与实际变量值相等即可
cout << "偏移地址0的值为 " << *begin <<endl;
cout << "偏移地址1的值为 " << *(begin + 1) << endl;
cout << "偏移地址2的值为 " << *(begin + 2) << endl;
cout << "偏移地址3的值为 " << *(begin + 3) << endl;
cout << "偏移地址4的值为 " << *(begin + 4) << endl;
cout << "偏移地址5的值为 " << *(begin + 5) << endl;
system("pause");
return 0;
}
最后运行结果为:
可以证明假设是正确的,内存存放顺序是按照声明顺序的a0_,a1_,a2_..与访问段无关
小结
- 空类对象的大小为1
- 类内对象的内存存放顺序是按照声明顺序进行存放的与访问段无关
class A
{
public:
char a0_;
char a1_;
protected:
char a2_;
char a3_;
private:
char a4_;
char a5_;
};
Class A对象的内存模型为
非多态继承类(基类不含虚函数的继承)
基类为空的情况(基类不含有任何非static成员变量)
这里先说结论,当非虚基类为空时,在其派生类中,基类所继承的基类大小为0.
class A{};
class B
{
public:
int a;
};
class C :public A
{
public:
int a;
};
int main()
{
cout << "sizeof A = " << sizeof(A) << endl;
cout << "sizeof B = " << sizeof(B) << endl;
cout << "sizeof C = " << sizeof(C) << endl;
system("pause");
return 0;
}
最终结果为:
无论是否继承空基类A,类的占用大小都只等于唯一类内成员变量的大小4个字节,由此可以推断出空基类在派生类中占用内存大小为0
基类为非空类的情况(基类含有非static的成员变量)
这种情况下,我们从C++primer中可以得知派生类会得到基类对象的一个副本,那么这个副本会存在派生类的哪个位置呢?如果基类为了字节对齐而填充了空白位置,那么这个位置在派生类中是否会保留呢?
首先解决第一个问题,基类对象会在派生类的哪个位置呢?
一般而言可能出现的情况有三种:1、基类对象的副本在派生类内存的首部 2、在派生类成员变量之间插入了一个基类对象的副本 3、基类对象的副本在所有派生类成员对象之后
为了探究这个问题,需要用到指向Data Members的指针,只需要判断第一个成员对象的偏移地址,以及所有成员对象的是否连续存放就可以得出结论(为了不引发字节对齐,基类和派生类都只包含int类型的成员变量)
class A
{
public:
int a0;
int a1;
};
class B :public A
{
public:
int b0;
int b1;
int b2;
};
int main()
{
printf("&A::a0 = %p \n", &B::a0);
printf("&A::a1 = %p \n", &B::a1);
printf("&B::b0 = %p \n", &B::b0);
printf("&B::b1 = %p \n", &B::b1);
printf("&B::b2 = %p \n", &B::b2);
system("pause");
return 0;
}
程序运行结果为:
从结果可以看出,当基类对象的副本是存在与派生类对象的首部的
那么就到了第二个问题,当基类发生了字节对齐时,基类对齐的空白位置是否会保存到派生类中?
要探究这个问题只需在基类中添加一个char成员变量即可
class A
{
public:
int a0;
int a1;
char a2;
};
class B :public A
{
public:
int b0;
int b1;
int b2;
};
int main()
{
printf("&A::a0 = %p \n", &B::a0);
printf("&A::a1 = %p \n", &B::a1);
printf("&A::a2 = %p \n", &B::a2);
printf("&B::b0 = %p \n", &B::b0);
printf("&B::b1 = %p \n", &B::b1);
printf("&B::b2 = %p \n", &B::b2);
system("pause");
return 0;
}
运行结果为:
从程序运行结果可以看出变量b0的偏移地址为0000000C,也就是说基类对象字节对齐产生的三个空白位置被保留到了派生类中。
多继承
在确定了空类和非空类在单一继承的情况下在派生类对象中的内存模型后,就要考虑多继承情况下多个基类是怎么排列的
首先我们尝试使用指向Data Members的指针来判断位置
class A
{
public:
int a0;
int a1;
};
class B
{
public:
int b0;
int b1;
int b2;
};
class C :public A,public B
{
public:
int c0;
int c1;
int c2;
};
int main()
{
printf("&C::a0 = %p \n", &C::a0);
printf("&C::a1 = %p \n", &C::a1);
printf("&C::b0 = %p \n", &C::b0);
printf("&C::b1 = %p \n", &C::b1);
printf("&C::b2 = %p \n", &C::b2);
printf("&C::c0 = %p \n", &C::c0);
printf("&C::c1 = %p \n", &C::c1);
printf("&C::c2 = %p \n", &C::c2);
system("pause");
return 0;
}
程序运行结果:
可以看到偏移地址出现了重合,但是实际的内存肯定不是这样存放的,对这种情况我们可以和之前一样先假设基类的存放顺序再采用反证的方法来判断假设是否正确,也可以通过vs2019的内存查看工具进行之间查看,这里就通过工具来直接查看派生类对象的成员对象到底是如何存放的
程序:
class A
{
public:
A(int A0 = 0, int A1 = 2) : a0(A0), a1(A1)
{
}
int a0;
int a1;
};
class B
{
public:
B(int B0 = 4, int B1 = 8, int B2 = 16) : b0(B0), b1(B1), b2(B2)
{
}
int b0;
int b1;
int b2;
};
class C :public A,public B
{
public:
C(int C0 = 32, int C1 = 64, int C2 = 128) : c0(C0), c1(C1), c2(C2)
{
}
int c0;
int c1;
int c2;
};
int main()
{
C c;
printf("&c = %p \n", &c);
system("pause");
return 0;
}
运行结果:
根据c对象的地址查看内存可以看到
调换继承顺序后可以得到内存分布
通过内存的分布我们可以判断,多继承时多个基类在派生类中的排列顺序是和其在继承列表中的声明顺序是相同的
小结
1.基类为空时,在其派生类中基类占用内存为0
2.基类不为空的单继承中,基类对象在派生类内存的开头处,并且基类内存对齐时尝试的空白也会保留
3.多继承中多个基类在派生类对象的内存中的顺序与继承的声明顺序相同
多态非虚继承类
含有虚函数的基类
从书中我们可以得知,动态多态的基础是虚函数表指针和虚函数表。
虚函数表中存放着类对象中的虚函数,虚函数表指针指向了虚函数表。那么虚函数指针到底是放在类对象内存的哪个位置呢
判断虚函数表指针位于类对象内存的头部还是尾部,同样是通过Data Members的指针来判断虚函数表指针的位置。
class A
{
public:
virtual void show0()
{
std::cout << "virtual function 0" << std::endl;
}
virtual void show1()
{
std::cout << "virtual function 1" << std::endl;
}
int a0;
int a1;
};
int main()
{
printf("&A::a0 = %p \n", &A::a0);
printf("&A::a1 = %p \n", &A::a1);
system("pause");
return 0;
}
运行结果:
由于测试的环境选择的是32位的,指针的大小为4个字节,从结果看出a0对象的偏移地址为4,证明了地址0~3的三个字节为虚函数表指针,也就是说编译器Visio stdio2019中虚函数表指针位于类对象内存的开始处。
派生类派生自单个含有虚函数的基类
当基类含有虚函数时,派生类是否会有虚函数指针呢,如果有的话虚函数指针又是在哪里呢?
class A
{
public:
A(int A0 = 2, int A1 = 4) : a0(A0), a1(A1)
{
}
virtual void show0()
{
std::cout << "virtual function 0" << std::endl;
}
int a0;
int a1;
};
class B : public A
{
public:
B(int B0 = 8, int B1 = 16) : b0(B0), b1(B1)
{
}
int b0;
int b1;
};
int main()
{
B b;
printf("&b = %p \n", &b);
system("pause");
return 0;
}
运行结果:
通过对象b的地址可以看到内存结构为:
分析实际内存内容我们可以判断出虚函数指针是位于类对象内存的开头处,至于虚函数表的具体内容以后再分析。
派生类派生自多个含有虚函数的基类
通过前面的结果我们知道了单个含有虚函数基类时,派生类的在内存开头处继承了基类的结构,虚函数指针位于内存的开头处,那么当派生类派生自多个含有虚函数的基类时,内存模型是什么样的呢,会继承多个虚指针吗
class A
{
public:
A(int A0 = 2, int A1 = 4) : a0(A0), a1(A1)
{
}
virtual void show0()
{
std::cout << "virtual function 0" << std::endl;
}
int a0;
int a1;
};
class B
{
public:
B(int B0 = 8, int B1 = 16) : b0(B0), b1(B1)
{
}
virtual void show1()
{
std::cout << "virtual function 1" << std::endl;
}
int b0;
int b1;
};
class C :public A,public B
{
public:
C(int C0 = 32, int C1 = 64) : c0(C0), c1(C1)
{
}
virtual void show2()
{
std::cout << "virtual function 2" << std::endl;
}
int c0;
int c1;
};
int main()
{
C c;
printf("&c = %p \n", &c);
system("pause");
return 0;
}
运行结果:
通过根据对象c的地址查看内存
根据内存中存放的内容我们可以得出结论多继承时,派生类会继承所有基类的虚函数指针,继承的方式是存放在每个继承下来的基类的开头处,基类在内存中的排列顺序与继承时的声明顺序是相同的,并且派生类并虚函数指针的个数等于含有虚指针的基类的个数。
小结
1.派生类派生自单个含有虚函数的基类时,派生类也会继承基类的虚函数指针,如果派生类本身有新的虚函数也不会产生新的虚函数指针
2.派生类派生自多个含有虚函数的基类时,派生类会继承所有基类的虚函数指针,虚函数指针在各自基类的开头处,各个基类在内存中的排序与继承时声明顺序相同,如果派生类本身有新的虚函数也不会产生新的虚函数指针
多态纯虚继承
纯虚继承用于菱形继承时避免共同基类在派生类中被重复继承,那么纯虚继承的基类在派生类中的是以什么形式存在的呢?
无菱形继承
代码如下:
class A
{
public:
A(int A0 = 2,int A1 = 4) : a0(A0), a1(A1){}
virtual void test(){}
int a0;
int a1;
};
class B :public virtual A
{
public:
B(int B0 = 8, int B1 = 16) : b0(B0), b1(B1){}
virtual void test() override{}
int b0;
int b1;
};
int main()
{
B b;
B* temp = &b;
cout << sizeof(b) << endl;
printf("&b = %p \n", &b);
printf("&next = %p \n", temp + 1);
system("pause");
return 0;
}
运行结果为:
通过地址可以获得实际的内存布局为:
通过内存的布局我们可以发现虚继承时基类并不是放在派生类对象的头部而是放在尾部,并且在派生类的最后一个成员和基类对象副本之间填充了4个字节
发生菱形继承时
代码如下:
class A
{
public:
A(int A0 = 2,int A1 = 4) : a0(A0), a1(A1){}
virtual void test(){}
int a0;
int a1;
};
class B :public virtual A
{
public:
B(int B0 = 8, int B1 = 16) : b0(B0), b1(B1){}
virtual void test() override{}
int b0;
int b1;
};
class C :public virtual A
{
public:
C(int C0 = 32, int C1 = 64) : c0(C0), c1(C1){}
virtual void test() override{}
int c0;
int c1;
};
class D :public B, public C
{
public:
D(int D0 = 128) : d(D0){}
void test() override{}
int d;
};
int main()
{
D d;
D* temp = &d;
cout << sizeof(d) << endl;
printf("&d = %p \n", &d);
printf("&next = %p \n", temp + 1);
system("pause");
return 0;
}
运行结果:
根据地址可以查看具体的内存布局
观察内存的布局我们可以发现虚继承而来的公共基类被放在了类D对象的内存的最后部分,而其他基类则是按声明顺序依次存放在类D对象的内存的开头部分