C++对象模型总结

C++对象模型总结

单一继承(含虚函数)

这种情况下的虚表结构很简单,在子类中保存一张虚表,虚表里有着父类的RTTI信息,并且排列着自己独有的虚函数地址以及被override的虚函数地址。

值得注意的是,子类生成的这张表与父类的表有着某种程度上的对应。比如说在父类里的某个虚函数,在它表里对应的slot为1,那么在子类里,即使覆盖了这个虚函数,对应的slot同样得是1。因为在编译期不能决议的仅仅是指针(或引用)具体指向的内容,但对于调用的虚函数在表里的偏移,这在编译期是已知的。(总不能连这都不知道,等到运行时去遍历虚表吧)

多重继承

多重继承相比于单一继承来说会复杂一点,复杂的点主要在于指针的调整上。我们可以想象,在单一继承里,指向父类的指针与指向子类的指针是具有相同值的;但在多重继承里,这种性质不再这么自然。

我们先来看看多重继承下的对象内存布局。这种情况下的内存布局不算复杂,按照父类的声明顺序,将父类成员堆叠起来,除此之外,为每个父类的区域,插入一个对应的虚表指针。至于为什么要为每个父类准备一个虚表指针,我们可以想象不这样做,让它们共享一个表,我们在前面提到过,在编译期是已知调用函数的slot号的,现在继承于多个父类,在共享一张表的情况下,肯定会有两个父类的不同函数对应相同slot,这就给我们造成了麻烦。

说完了对象的内存布局,我们再来看看虚函数表。可以知道的是,每个父类区域的虚表,一定是通过这个父类指针可以访问的虚函数。除此之外,对于位于最左的父类的虚表做了补充,补充的内容是最左父类访问不到,但是子类可以访问的虚函数。

聊完了虚函数表的内容,我们再来思考其他的问题。假设A,B是C的父类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class A{
public:
virtual void f(){}
};
class B{
public:
virtual void g(){}
};
class C:public A,public B{
public:
void f() override{}
void g() override{}
};

int main(){
C* c = new C;
B* b = c;
}

在我们的主函数的语句里,我们可以想象,C中的B部分的起始地址与C的起始地址是不同的。实际上,在编译这条语句时,编译器是做了处理的。

1
B* b = c + sizeof(A);

当我们实际输出b和c的地址时就能看到这种区别(用==判断不行,编译器做了处理)

当然,这是在编译期做的处理,也很好理解。让我们来看看其他几种类似的但是不能在编译期解决的问题。我们叫做调整this指针的负担。

  1. 基类指针调用子类函数

当第二个或后继的基类调用子类的虚函数时,需要对this指针进行调整。这应该很好理解,假设不进行调整,在该函数里对子类成员的任意访问都会出现问题。但是这种调整在编译期无法做到(在编译期对你会调用哪个函数都不确定,又谈什么调用前调整指针呢),因此我们需要一种执行期调整的技术。在我了解的范围里,有两种技术。

  • 扩展虚函数表。为每一个虚函数表的表项增加调整的偏移量内容。也就是说,现在每一项都包含一个函数指针和一个偏移量。在调用时便进行调整。这种办法虽然简洁,但是并不是所有的函数都需要调整呀。这样显式的为项都增加内容显得有点浪费。
  • thunk技术。对于thunk我并不是太了解,但并不影响我们思考这么模型。我将thunk简单的理解成一个扩充了调整this指针的函数。只不过这个函数是一段assembly代码。现在调用时需要调整this指针的虚函数指针现在指向的便是thunk
  1. 子类指针调用第二个或后继父类函数

这个与第一种情况类似。

  1. 语言扩充的性质。

允许一个virtual function的返回值类型有所变化,可能是base type,也可能是publicly derived type。

1
2
Base2 *pb1 = new Derived;
Base2 *pb2 = pb1->clone();

这有点类似于我们先前提到的子类指针赋值给父类指针。不同的是,之前可以直接在编译器加以修改,而这里因为pb1调用的函数是运行期决定的,返回值类型也是运行期决定的,所以需要进行执行期的修改。具体技术我们这里不进行讨论。

虚拟继承

就虚拟继承而言,它的内存布局是很复杂的。复杂的点在于,父类不再是子类连续内存的一部分,而是为多个子类所共享的。也就是说,对于虚基类的子类而言,虚基类的内存相对于自己的偏移是不固定的。可能现在你离我还特别近,可能在继承层次更深一点后,你离我又更远了。之前我有一个疑问就是,既然一个有虚基类的子类的布局在编译期是已知的,那直接在编译期为所有对虚基类成员的访问加上偏移不就行了吗?问题的点在于对虚基类成员的访问的访问者是谁在编译期无法确认。比方说在虚函数里访问虚基类成员,虚函数的决议本身就是运行期,况且知道是哪个虚函数也无法确定当前的偏移,所以想在编译期将一切确定下来几乎是不可能的。

这个时候我们考虑到为虚拟继承加点动态的信息。比如说B和C虚拟继承自A,D多重继承自B和C。我们可以在D的B区域加上个指针,用以指明虚基类相对于B的偏移。

这种做法固然能解决问题,但这极大增加了对象的存储负担。除此之外,我们可以直接将信息放在虚函数表里,用以指明虚基类的地址。这样就可以在运行期指明虚基类地址,并且用少量的时间换了大量的空间。

关于虚拟继承的虚表,主要是对于this的调整,关于这点建议阅读 ABI。以及下面的博客。

https://zhuanlan.zhihu.com/p/41309205