Tizeng's blog Ordinary Gamer

《深度探索C++对象模型》读书笔记

2026-01-20
Tizeng

这本书毕业时就有耳闻,买来之后发现以当时的水平根本看不下去,现在陆续把基本c++有关的书都读了之后,是时候补一下这本书了。 购于2019.2.28

与C的关系

第一章提到c++为了与c兼容费了不少劲,如class和struct,二者在底层被视为同类,除了访问级别的区别,更多的是我们想要一个什么样的数据结构。

C中一些对struct的操作到了c++可能会失效。如在struct的末尾声明一个单元素的数组,在分配内存的时候可以按需多分配任意那个数组类型大小的内存,达到“柔性数组”的目的。 其原理是利用了c允许访问超出数组大小的元素,而在struct末尾多分配的内存保证是连续的,因此我们可以按自己的需要去分配和访问。 但到了c++情况就不同了,因为class内存的布局和public、protect、private的位置有关,只有在同一个acess section内的成员,才保证其在内存中按顺序分布,不同的section中的成员是不确定的。

默认构造

没有任何参数,或所有参数都有默认值的构造称为默认构造。 第二章提到编译器只在自己需要的时候为class生成默认构造,但并不会帮用户初始化成员变量,除非是class类型且也存在默认构造的成员。 一共有4中情况编译器会生成,具体的就不列出了,重点是是否为编译器所需,比如自身或基类包含虚函数的类,需要一个vptr来指向vtbl,那么这个vptr的初始化就需要有地方去完成(在所有的构造函数中)。 如果用户未定义任何构造函数,编译器就会生成一个默认构造来做这些事。 除此之外编译器并不会为我们合成,因为并不需要。

拷贝构造

2.2一直在讨论编译器什么时候不会执行bitwise copy,换句话说只要条件允许,编译器便会去执行类似memcpy的操作,因为这样效率最高。 与默认构造类似,拷贝构造会在编译器需要的时候生成,如需在子类对象赋值给基类对象时,需要保证基类的vptr仍然指向基类的vtbl而不是子类的。 类似这样的情况便不能进行bitwise copy。 需要注意的是,要进行bitwise copy,条件是拷贝构造必须为trivial,也就是说必须由编译器判断当前没有额外的事情需要处理,且用户也没有定义拷贝构造时。 只要用户定义了拷贝构造,就会被视为nontrivial,除非让它 = default

返回值优化

NRV(named return value)优化,指的是函数内部返回一个有名字的局部变量所作的优化。 原因是局部变量要经过构造、拷贝、销毁三个过程,而如果可以直接在需要的地方构造返回结果,便可以省掉额外的拷贝和销毁操作。 经查早期的编译器以用户是否定义了拷贝构造为依据,判断是否需要使用NRV优化,如果是,则为函数生成一个隐式的返回值类型参数,直接进行构造和操作。

这可能引起另一个问题,用户希望在拷贝构造中做一些事情,以相应的方式写代码,而编译器为了优化将这个过程跳过了。

RVO(return value optimization)

新c++标准下的返回值优化,在这本书写的时候还没有这个说法,通常只会使用NRV优化。

Data

一个什么都没有的空类,随其sizeof的大小是多少?好像之前有个面试问到了这个问题,当时我以为是0,其实不然。 答案是1,因为地址是以字节为单位,为了区分每个对象的地址,至少要有1个字节,对空的类,编译器会在其中加入一个char成员占用1字节。 通常来说继承一个空类的子类也会继承到这1个字节,然而有一种情况编译器不会为子类增加这1字节,称为empty virtual base class处理, 即一个类作为基类被virtual继承时,子类中会存在一个指针指向基类的subobject或一个表,占用4个字节(据操作系统),此时并不需要额外再增加一个字节来占位,如果没有这个处理,4+1再加上对齐就需要占用8字节。

继承下的内存布局

基类和子类的成员在类中会分开进行内存对齐,即子类的成员并不会去填补基类中因为对齐而多占用的内存空间。 例如基类有一个char,子类有另一个char,那么子类将占用8字节,因为分别发生了对齐。

这样做的原因是在发生拷贝的时候,由于基类指针可以指向任意子类对象,拷贝的时候要保证基类的数据被正确覆盖。 如果子类的成员占用了基类的内存,那么在一个基类对象向子类对象拷贝时,其子类的成员数据会被未定义的内存覆盖(参阅书中第88页3.4节)。

多继承

若一个类继承两个不相干的基类,那么这两个基类的成员会按先后顺序在这个子类的内存中存储。 当把子类对象的地址赋值给后面那个基类的指针时,需要进行偏移才能得到正确的地址,因为地址的前端已经被另一个基类占据。 谁能想到还要处理这种事,只能说编译器承担了太多。

而当指针赋值时情况又不一样,因为指针存在为空的情况,如果为空则不需要进行偏移直接设为0就行,否则会从0进行另一个基类大小的偏移得到未知的地址。

虚继承

虚继承是为了解决在多继承时多个基类间有一样基类的情况,即菱形继承。 与使用虚函数表类似,发生虚继承使用一个指针指向基类的地址,这样在多继承时,如果存在相同类型的基类,它们的指针就会指向同一块内存。 为了优化访问的效率,可以像虚函数表那样建立一个virtual base class表,再进一步,可以直接使用虚函数表的指针,只需在反方向存储基类地址的offset即可,然后在需要获取的时候使用负索引。 offset是相对于当前类型的地址偏移,基类和子类占用的内存是连续分布的,因此可以直接把指针移过去。

指向成员变量的指针

这东西我之前都不知道,声明一个指针指向一个类的成员变量(int ClassName::*mptr),它本质上是这个成员在类中的偏移量,因此使用的时候必须带上对象,否则只靠偏移量无法获取数据(obj.*mptr)。

为了区分不指向任何成员的指针和指向第一个成员的指针(都是0),成员的偏移都会加上1(现代编译器似乎会像我们隐藏这个处理,不需要额外关心这个1了。

多继承发生时(真是麻烦的特性),多个基类中的数据会影响成员在内存中的偏移,因此编译器需要对每个占用了前面内存位置的基类做一个额外偏移,才能得到最终那个成员最终的正确偏移,同时还要防范空指针。

函数


Comments

Content