C++ 类的内存分布学习笔记(含多态原理分析)

前几天bigo面试,面试官写了个有纯虚函数的类,问了我sizeof是多少……
吃了没用过的亏,特开起了linux虚拟机耍起了sizeof
以下总结

前置知识 基本数据、指针类型大小(单位字节)

char 1
short 2
int 4
float 4
double 8
long long 8
long 等于机器位数(32位机器为4,64位机器为8)
指针 等于机器位数
引用 等于所引用数据类型

类的大小基本测试

测试全部在Ubuntu 16.04 64位系统进行

先只来一波数据类型

class C{
        int a;
        short b;
        char c;
        double d;
};

按上面的计算,结果应该是4+2+1+8=15。测试结果是16
这里会有内存对齐的情况
我的理解就是,内存按每8个字节分一块(64位机。32位机以4字节分一块),定义在前面的变量会先占用前8字节的内存,a,b,c占了其中4,2,1字节,到d的时候,虽然还剩1字节空间 ,但加上d变量后会超过8字节(来到15字节),因此d不会挤在那个8字节空间里,而会占据下一个8字节,原来多出来的1字节空间是空闲的,但是记在sizeof里(由于class的内存空间从a开始的地址到d结束在内存里地址差是16)。
这个class C在内存里的布局就是这样子

后来一想,真的是因为这个d的存在使得要多占8字节才会sizeof=16吗,于是把double d去掉,只剩前3个变量,再取sizeof

class C{
	int a;
	short b;
	char c;
};

结果是8。(不是7)
那么这个sizeof本身并不是计算最后一个变量的末端地址与第一个变量的首地址之差,因为这个1字节的空闲块也算进去了

那么问题又来了,类内只要有一个变量定义了,sizeof就一定是8字节吗
测试

class C{
	int a;
}

答案是4。
不是8诶,后来对所有变量都只单独定义一个,取sizeof都会输出这个变量类型的大小。单一变量不会发生内存对齐

class C{
	short a;
	char b;
};

输出4。这边char也被对齐了,占了2个空间。基本可以猜想,内存会对齐到类内定义的最大的那个数据类型的size上,类内所有的变量根据定义顺序占的内存都会按照那个size对齐(而不一定是8。有定义了8字节的变量或者定义指针时才会向8对齐)

class C{
	int a,b;
	short c;
	char d;
};

输出12,印证了我的猜想。

class C{
	double a;
	char c[9];	
};////sizeof? 17? 18? 24?

类里定义静态变量占不占内存空间?

const变量是占内存空间的。但static呢?这东西的生存期是整个程序运行时,然后变量是全类共用(甚至可以在不定义任何对象时调用这个变量),理论上是不在类里占空间的?
类里面定义一个static变量然后sizeof这个类,如下

class C{
	static int a;
};

执行sizeof,答案是1。
呃为啥是1,下面讲空类提一下,但可以肯定的是,static成员变量是不在类里占内存的。(计算sizeof时,忽略static变量)

空类

class C{};

空类的sizeof是1。根据所查阅的资料,空类在内存中会有1字节的占位符。空类在生成对象的时候也必须要在内存中分配空间,否则无法使用这个对象。

如果往空类里面加函数(普通函数,构造函数,析构函数,static,const等)

class C{
public:
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
        void print(){cout<<"print C"<<endl;}
        static void s_print(){cout<<"print static C"<<endl;}
        void c_print() const{cout<<"print const C"<<endl;}
};

上面那堆花里胡哨的函数加进去,sizeof还是1。这些函数只与类有关,与实例无关。(部分资料说根据编译器不同,可能空类的size是一个大于1的数、我目前使用g++做得实验,结果是1,什么编译器的空类会大于1请大佬指点)
但是,如果不是空类,那这1个占位符会失效,内存空间直接就是这些占内存的东西的总和(加上内存对齐的结果)。

虚函数、纯虚函数

如果往类里面加虚函数,结果会有点不一样。

class C{
public:
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
        virtual void print(){cout<<"print C"<<endl;}
};

答案是8。虚函数会有一个虚表指针(布局在类的开头),这个虚表指针就占一个指针的空间
但如果有两个虚函数呢?比如说

class C{
public:
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
        virtual void print(){cout<<"print C"<<endl;}
        virtual void print2(){cout<<"print C"<<endl;}
};

sizeof之后还是8。说明不是每个虚函数都占8字节空间,类定义时只是在类的开头加了一个指向虚函数表的指针,而不是在类里加了个虚函数数组。虚函数表也不是定义在类里面的,这个类产生多个对象时,会共用同样的虚函数表,这和static变量有点类似。详情参考这篇文章
ps:析构函数可以设为虚函数,结果还是8。关于析构函数必须设为虚函数的场景,可以参考这里
那么纯虚函数的sizeof呢?抽象类不能生成对象,按理说不能sizeof。

class C{
public:
	virtual void fun()=0;
}

int main(){
	cout<<sizeof(C)<<endl;
	return 0;
}

这个代码居然可以编译通过,而且答案也是8。写2个纯虚函数的sizeof也是8,看上去和虚函数是一个效果(至少同时定义了虚函数和纯虚函数的类的sizeof就是8,应该两种函数共享虚表指针)。
原理暂时不明,待求证

继承类的sizeof

代码:

class A{
private:
        int a,b,c;
public:
        A(){};
        int geta(){return a;}
        void write(){cout<<"A::a="<<a<<endl;}
};

class C:public A{
public:
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
        virtual void print(){}
};

上面对C求sizeof的答案是24。首先很容易得出A占据12字节,C在继承了A之后仅仅是一个虚表指针占了8字节。答案是24
可以看出继承了A之后,那些A的元素也需要内存对齐,对齐到虚表指针的8字节上。
那么问题来了,在C的内存结构里,虚指针在C对象地址开头还是A的变量在开头?
继续实验

//这里把上面的代码抄下来;ps:由于不能直接读取变量a,需要改变量权限为public

int main(){
	C c;
	cout<<&c<<endl;
	cout<<&c.a<<endl;
	return 0;
}

结果如下:

yuna@ubuntu:~$ ./auto.sh
hello C
0x7ffee9944b10
0x7ffee9944b18
goodbye C

c的首地址和里面a变量的地址是不一样的,这说明是虚指针排在了开头。

接下来,如果在派生类里也定义一个4字节变量呢?

class A{
private:
        int a,b,c;
public:
        A(){};
        int geta(){return a;}
        void write(){cout<<"A::a="<<a<<endl;}
};

class C:public A{
        float d;
public:
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
};

这里sizeof( C )=16,说明d变量和A那边继承的abc挤到了一块。但是d在前面还是abc在前面?为了验证,这里依然把四个变量的权限改为public(private无法直接取对象地址)

class A{
public:
        int a,b,c;
        A(){};
        int geta(){return a;}
        void write(){cout<<"A::a="<<a<<endl;}
};

class C:public A{
public:
        float d;
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
};

int main(){
        A a;
        cout<<sizeof(C)<<endl;
        C c;
        cout<<&c<<" "<<&(c.a)<<" "<<&(c.b)<<" "<<&(c.c)<<" "<<&(c.d)<<endl;
        return 0;
}

输出是这样的

16
hello C
0x7fff6d0b5770 0x7fff6d0b5770 0x7fff6d0b5774 0x7fff6d0b5778 0x7fff6d0b577c
goodbye C

可以看到a地址和对象的首地址是一致的,并且后面的变量都往后推了4个字节。d变量比c变量多了4。这说明派生类的内存布局,就是基类定义的在前,派生类新定义的在后。
如果派生类里定义了一个和基类某变量一样名字的变量呢?我把上面代码的派生类里的d改成a,并且main函数的输出里去掉了c.d。
输出如下:

16
hello C
0x7fff45cce700 0x7fff45cce70c 0x7fff45cce704 0x7fff45cce708
goodbye C

还是16字节,说明派生类新定义的变量叫a还是叫d不影响内存size,派生类定义的同名变量不会覆盖基类的变量。
然后对地址比较一番,发现c.a的地址在c.c靠后4字节。说明c.a这里调用的是派生类的a。而三个变量的地址都不等于类的起始地址。说明基类的a仍然存在,还是占据着首地址(需要调用c.A::a才能看到)
我们把基类的print函数改为虚函数。再输出一次:

24
hello C
0x7fff318484a0 0x7fff318484a8 0x7fff318484ac 0x7fff318484b0
goodbye C

A::a的地址已经比首地址大8,头顶多了个虚表指针,并且这个虚表指针一并继承过来了。

多态来了!我们往派生类重写基类的虚函数(此时代码变成了这样)

class A{
public:
        int a,b,c;
        A(){};
        int geta(){return a;}
        virtual void write(){cout<<"A::a="<<a<<endl;}
};
class C:public A{
public:
        float a;
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
        virtual void write(){cout<<"C::a="<<a<<endl;}
};

int main(){
        A a;
        cout<<sizeof(C)<<endl;
        C c;
        cout<<&c<<" "<<&(c.A::a)<<" "<<&(c.b)<<" "<<&(c.c)<<endl;
        return 0;
}

再执行以下:

24
hello C
0x7ffcdd3b5f40 0x7ffcdd3b5f48 0x7ffcdd3b5f4c 0x7ffcdd3b5f50
goodbye C

啊咧,还是24,表明虚表指针还是只有一个,不会说两个类都写了虚函数就会有两个虚表指针。(实际上,派生类的同名同参函数是否带virtual关键字,它都是一个虚函数,基类指针指向派生类对象都会调用派生类的同名同参函数(如果存在))
往派生类里面再加一个不是基类定义过的虚函数:

class A{
public:
        int a,b,c;
        A(){};
        int geta(){return a;}
        virtual void write(){cout<<"A::a="<<a<<endl;}
};

class C:public A{
public:
        float a;
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
        virtual void write(){cout<<"C::a="<<a<<endl;}
        virtual void fun(){};
};

int main(){
        C c;
        cout<<sizeof(c)<<endl;;
        return 0;
}

输出

hello C
24
goodbye C

还是24,可以证明C类里面是只有一个虚表指针的,而这个指针指向谁就是动态绑定。
(无论A类还是C类指针指向C类对象时,调用write()都是指向C的write())

虚继承的sizeof

最后是一个比较恶心的问题:虚继承的sizeof是多少
虚继承这个东西是用来解决多重继承的问题的,可以保证多重继承时终极派生类只能有虚基类的一个副本。
继承关系大概长这样

		A
	 /	 	\
	 B		C
	 \		/
	 	D

先来看代码:

class A{
public:
        int a,b,c;
        A(){};
        int geta(){return a;}
        virtual void write(){cout<<"A::a="<<a<<endl;}
};

class C:virtual public A{
public:
        float a;
        C(){cout<<"hello C"<<endl;}
        ~C(){cout<<"goodbye C"<<endl;}
        virtual void write(){cout<<"C::a="<<a<<endl;}
};

A是虚基类,如果没有那个virtual,答案是24。但如果有的话就不是24了(正确答案是40)。
根据虚继承的概念,类比虚函数可以得知,C类要有一个指针指向类A,但这个类A是不是类C的一部分呢?
假设有以下主函数:

int main(){
        C c;
        cout<<"c="<<&c<<endl;
        cout<<"c.A::a"<<&(c.A::a)<<endl;
        cout<<"c.c"<<&(c.c)<<endl;
        cout<<"c.a"<<&(c.a)<<endl;
        return 0;
}

分别输出对象c的地址,A基类的a的地址,c(基类A定义)的地址,c自己的a的地址。
结果如下:

hello C
c=0x7ffd2f61d6c0
c.A::a0x7ffd2f61d6d8
c.c0x7ffd2f61d6e0
c.a0x7ffd2f61d6c8
goodbye C

这几个的大小顺序是c<c.a<c.A::a<c.c,其中c.a的地址比c高8,那么c的前8位是C的虚表指针。c.A::a的地址比c.a高16,由于c.a为4字节,此外C类里不再定义有其他元素,因此可以猜想:内存里首先是C的虚表指针,然后是C的成员变量,内存对齐。接下来是虚继承的A类的虚表指针,然后是A类的变量,内存对齐。但无法证明A类的这些变量是否c对象的一部分。为了证明,写一个完整的多重继承

class A{
public:
        int a,b,c;
        A(){};
        int geta(){return a;}
        virtual void write(){cout<<"A::a="<<a<<endl;}
};

class B:virtual public A{
public:
        int x;
};

class C:virtual public A{
public:
        float y;
        virtual void write(){cout<<"C::a="<<a<<endl;}
};

class D:public B, public C{
public:
        int z;
};

int main(){
        B b;
        C c;
        D d;
        cout<<"sizeof(D)="<<sizeof(D)<<endl;
        cout<<"b="<<&b<<endl;
        cout<<"c="<<&c<<endl;
        cout<<"d="<<&d<<endl;
        cout<<"ba="<<&(b.a)<<endl;
        cout<<"ca="<<&(c.a)<<endl;
        cout<<"da="<<&(d.a)<<endl;
        cout<<"dba="<<&(d.B::a)<<endl;
        cout<<"dca="<<&(d.C::a)<<endl;
        cout<<"dx="<<&(d.x)<<endl;
        cout<<"dy="<<&(d.y)<<endl;
        cout<<"dz="<<&(d.z)<<endl;

        return 0;
}

输出结果如下

sizeof(D)=56
b=0x7ffca66c2e70
c=0x7ffca66c2ea0
d=0x7ffca66c2ed0
ba=0x7ffca66c2e88
ca=0x7ffca66c2eb8
da=0x7ffca66c2ef8
dba=0x7ffca66c2ef8
dca=0x7ffca66c2ef8
dx=0x7ffca66c2ed8
dy=0x7ffca66c2ee8
dz=0x7ffca66c2eec

呃,基本上可以说破案了
(1)bcd三个对象虽然虚继承了A,但内存没有重叠部分,不会共用一个虚基类A,也就是说,A是对象的一部分。
(2)对象d在直接调用基类A的变量a使用了3种方法,调用的是同一个内存地址的a。a的确在内存中只有一份拷贝。
(3)查看d各变量的地址,大小关系为d<d.x<d.y<d.z<d.a。其中
d.x-d=0x8。这8个字节有点迷(?,问题一),稍后分析
d.y-d.x=0x10。从x到y用了16个字节,可以看到C里是定义了一个虚函数的,因此会有一个虚表指针占据8字节,如果这个说法说得通,那么从0x7……e0到e7这段内存就是C的虚表指针(?,问题二),待会再做个实验分析。
d.z-d.y=0x4。4个字节,可以确定就是d.y占的地盘。
d.a-d.z=0xc。12个字节,因为A里面也有虚函数,因此可能也携带了虚表指针,这个虚表指针和C的虚表指针可能是相互独立的(?,问题三),接下来会进一步分析。至少8字节对齐的话,A的其实地址可能是0x7……f0,然后前8字节是虚表指针,是可以说得通的。
那么d的内存分布基本上可以确定为:先是B、后C,再到D、最后是A。A是D的一部分。
为了解决上面3个问题,画个图表示一下d已有变量的内存分布,以8字节为一行(简单起见,前面什么7f……2e都去掉了,因为都一样,保留后两位)

首先解决问题二,0x7……e0到e7(上图第3行空白)是虚表指针吗?我们把C里定义的虚函数去掉。再输出一遍,结果是:

sizeof(D)=56
b=0x7ffccdd626c0
c=0x7ffccdd626f0
d=0x7ffccdd62720
ba=0x7ffccdd626d8
ca=0x7ffccdd62708
da=0x7ffccdd62748
dba=0x7ffccdd62748
dca=0x7ffccdd62748
dx=0x7ffccdd62728
dy=0x7ffccdd62738
dz=0x7ffccdd6273c

结果d.y-d.x=0x10,答案为否,而且sizeof(D)也没有变化。那到底是为什么呢?
从图上的分析,由于存在内存对齐,那么e0到e7那里是一个完整的8字节的东西,但我们从头到尾在任何类里都没有定义8字节变量,那么这个8字节的东西很可能是一个指针,包括第一行d0-d7也是一个指针,由于B、C、D都不再定义有虚函数,因此这个指针很可能是B、C类各自的一个指向虚基类A的虚指针(相互独立)。为了验证这个猜想,加入一个类使D实现三重继承(即类E虚继承A,D继承E,空类,代码略)
输出结果:

sizeof(D)=72
b=0x7ffc4e37f8c0
c=0x7ffc4e37f8f0
d=0x7ffc4e37f920
ba=0x7ffc4e37f8d8
ca=0x7ffc4e37f908
da=0x7ffc4e37f958
dba=0x7ffc4e37f958
dca=0x7ffc4e37f958
dx=0x7ffc4e37f928
dy=0x7ffc4e37f938
dz=0x7ffc4e37f948

可以看到size+16。重新摆一下各个变量的位置。和上图相比,y和z变量直接多出了一个空行。
那么可以说明这个新增的虚继承的E类是会影响其派生类D的内存结构,并且多添加的也是一个8字节的指针。可以证明,虚继承(virtual public A)会对每一个派生类都添加一个虚指针,指向虚基类,然后再添加接下来自身定义的非静态成员变量。
内存布局顺序,为对象所属类(D)中,按继承类列表的顺序,首先添加这个类的虚指针(类内后续再定义虚函数与否不影响虚指针数量),然后按其成员变量定义的顺序定义各个变量,内存对齐到8字节。然后是类D自己的变量(D定义虚函数与否不影响size,读者可实验一下)。最后是虚继承的类(A)的整体布局(如果A存在虚函数,开头同样要一个虚指针)。

以上就是对C++类与继承类、多重继承类的内存分析,如有错误还请大佬指正。

全部评论
经验证,你的后面有很多结论有问题啊,比如“d的内存分布基本上可以确定为:先是B、后C,再到D、最后是A。A是D的一部分。”,我得到的结果是由D到C到B,再到A,内存依次从低地址到高地址分配
点赞 回复 分享
发布于 2020-09-29 11:43

相关推荐

10-17 12:16
同济大学 Java
7182oat:快快放弃了然后发给我,然后让我也泡他七天最后再拒掉,狠狠羞辱他一把😋
点赞 评论 收藏
分享
评论
1
5
分享
牛客网
牛客企业服务