发表于

C++对象在运行时内存中分布

«Inside C++ Object Model»是一本抽象层次很低的书,其实就是在讲写的代码变成二进制,在运行期间的内存分布情况。我感觉真正需要掌握的知识点也不多,而且翻译的总觉很别扭。

  • 编译器在对象构造和析构中的行为
  • 合成构造和合成拷贝构造
  • 继承对对象内存分布影响(vtptr)
  • 虚继承对对象分布的影响(vbcptr)
  • 计算偏移量
  • 计算成员地址

当然里面还有非常多零星的知识,比如三种成员函数的调用过程等。最核心的问题大概就是上面这些吧。这些知识和ELF文件格式对照着一起消化,应该有不错的效果。

对象

对象模型

内存中的布局是数据成员,vptr,其中vptr指向vtbl,而vtbl的第一个槽存储的是RTTI的type_info,后续是各种虚函数。非虚函数是存储再对象之外的,且第一个参数必须是对象的引用。

内存分布

  • 非静态成员大小
  • 内存对齐,为了总线效率;填充字节也会继承到子类对象中
  • 虚表指针vptr及vtbl对应成本

对象的差异

指针和引用类型是动态类型,除了内建类型外。函数参数为指针的情况下,实参传递引用也会造成多态。

基于对象和面向对象是两个不同概念!面向对象一定要充分利用指针和引用类型实现动态类型!因为动态类型是在继承体系的基础上来实现的,充分利用了继承体系。

资源不仅仅是内存,还可能是文件句柄,锁,线程句柄等,都需要释放。所以RAII不仅仅是针对内存来设计的。

构造函数

如果一个类的数据成员对应类型有自定义构造函数,那么该类就是non-trivial。

四种情况下会构造出implicit non-trivial default constructor,且该构造函数只会满足编译器的需求,一些其他的非静态成员对象是程序员的职责去初始化:

拷贝构造函数也有类似的情况,当类是non-trivial的时候才会默认构造,因为不能执行bitwise-copy。

当子类或者成员对象拥有virtual属性时,如果子类和父类对象之间拷贝,因为vtbl对于每个对象来说肯定不同的,所以肯定不能拷贝vptr,所以不是bitwise-copy的。

注意两点:

  • 构造函数和拷贝构造函数,对于non-trivial类型都是依次调用自己成员对象的构造函数和拷贝构造函数的;
  • 编译器隐式构造函数不会初始化trivial的成员对象;

对象成员

按照声明顺序地址增加排列对象,但是对象之间可能有因为内存对齐导致的填充字节。 vptr到底是放在所有成员对象之前还是之后,这是和编译器有关的。

成员函数的调用,第一个参数默认是this指针,函数内的成员都是通过this+offset来进行间接寻址。如果有vptr则是通过this+vptr+offset来间接寻址。

基类如果有很多内存对齐的填充字节,那么子类中也会有该填充字节,导致子类空间浪费。这是为了保证bitwise-copy而允许了子类中的基类对象空间分布不被编译器破坏,所以不调整填充字节。

函数成员

vptr加上vtbl中的RTTI来实现的多态!因为基类指针指向派生类对象,这个派生类对象的RTTI信息就用来实现多态!

在msvc中派生类重载函数的操作符&是非法操作,但是在gcc中操作符&是可以获得偏移量的。

构造、析构、拷贝、移动

这本书是1996年的,肯定是没有讲到移动的。

抽象类是存在纯虚函数成员的类,是不能够产生对象的。当抽象类内有数据成员时,该数据成员的初始化就是非常头疼的事情。另外还需要定义纯虚析构函数,因为每个在该抽象类上派生的子类,一定是要定义自己的析构函数的。

FAQ

vptr到底放在哪里?如何获得? msvc应该是放在了头部

vtbl如何获得?结构如何? 第一个位置存放RTTI信息;后续存放每个虚函数地址,是否包含基类的虚函数呢?

vptr对于每个对象只有一个吗? 对于每个子对象有零个或者1一个vptr,但是通过继承之后的子类的虚函数会增加到每个子对象的vtbl中去。图4.2很明显的显示了这个布局。或者说整个继承体系构成了一个继承树,有多少个叶子结点就有多少个vptr。每个vtbl要把到根结点中每个类的虚函数加入进来。

虚函数,虚继承,多重继承会导致子类对象的内存分布发生什么样的变化? msvc中虚基类的地址,是在vtbl中存储了一个虚基类的偏移量来实现的;虚基类在每次派生过程中都有可能移动位置,所以需要在vtbl中记录这种变动。vptr索引为正是函数,索引为负是虚基类地址;这是SUN编译器实现方法。msvc并不像SUN编译器,还是有vbcptr的。

如何计算不同类型对象地址之间的差值? 需要将地址转换成字节指针,比如(char*)&object,然后在进行相减。

如何理解关键字virtual 当用于虚拟继承时,说明虚基类要直接作为子对象放入对象中且只有一份,但是位置是不确定的;所以需要增加vbcptr。 当用于修饰函数时,说明该对象需要增加vptr对象指向一个vtbl。如果是具体继承,则子对象和对象共用同一个vptr。

书中的vbcptr是在vtbl中的,这和msvc的实现不同。

class A{vitual void funA();};
class B:public A{virtual void funB()};

这里A和B的大小都是一个字长,因为可以公用vptr。

class A{vitual void funA();};
class B:public virtual A{virtual void funB()};

这里A的大小是字长,B的大小是三个字长;因为B的对象中要有一份A的子对象,除此之外还要自己的vptr因为不能公用,和指向子对象的vbcptr。也就是两个vptr加上一个vbcptr,所以总共是三个字长。

打印地址和打印偏移量 用取地址符&可以打印地址,打印偏移量要用printf比如

printf("&Point3d::x = %p\n",&Point3d::x)

注意!这里偏移量只是相对于子对象来说!继承类的偏移量还是0,这是书中没有讲清楚的地方!如下

class Vertex:public Point3d{...};
printf("&Point3d::x = %p\n",&Point3d::x)
printf("&Vertex::x = %p\n",&Vertex::x)

所以有指向成员的指针,比如float Point3d::* p=&Point3d::x,注意如果有虚基类,该指针应该是会变化的。

Point3d p;
printf("&p.x = %p\n",&p.x);

对于绑定的对象来说,&p.x得到的是真实的内存地址。

多态中的构造和析构会调整指针 基类指针指向派生类,生成的指针并不是指向了派生类的初始位置!析构的时候也不是从指针指向位置开始析构的!

base* ptr=new Derived;
delete ptr;