C++ 对象内存布局
多态
众所周知,C++为了实现多态(运行期),引进了虚函数(语言标准支持的,其它实现方式不在本文讨论范围内),而虚函数的实现机制则是通过虚函数表。这块的知识点不算多,却非常重要,因此往往是面试必问之一,当然,对于我也不例外。作为候选人,如果没有把运行期多态的实现机制讲清楚,那么此次面试基本凉凉~~
仍然以上一篇文章的代码为例,代码如下:
class Base1 {
public:virtual void fun() {}virtual void f1() {}int a;
};class Derived : public Base {
public:void fun() {} // override Base::fun()int b;
};void call(Base *b) {b->fun();
}
在上述示例call()函数中,当b指向Base对象时候,call()函数实际调用的是Base::fun();当b指向Derived对象时候,call()函数实际调用的是Derived::fun()。之所以可以这么实现,是因为虚函数后面的实现机制–虚函数表(后面称为Vtable)
:
对于每个类(存在虚函数,后面文中不再赘述),存在一个表,表的内容包含虚函数等(不仅仅是虚函数,在后面会有细讲),类似于如下这种:
vtable_Base = {&Base::func, ...}
vtable_Derived = {&Derived::func, ...}
在创建类对象时候,对象最前部会有一个指针(称之为vptr),指向给类虚函数表的对应位置。PS:(需要注意的是并不是指向Vtable的头,这块一定要注意
)
那么,call()函数在运行的时候,因为不知道其参数b所指向具体类型是什么,所以只能通过其它方式进行调用。在前面的内容中,有提到过每个对象会有一个指针指向其类的虚函数表,那么就可以通过该虚函数表进行相应的调用。因此,call()函数中的b->fun()就类似于如下:
((Vtable*)b)[0]()
多重继承
class Base1 {
public:void f0() {}virtual void f1() {}int a;
};class Base2 {
public:virtual void f2() {}int b;
};class Derived : public Base1, public Base2 {
public:void d() {}void f2() {} // override Base2::f1()int c;
};int main() {Base2 *b2 = new Base2;Derived *d = new Derived;
}
class Derived对象有两个vptr,那么有没有可能将这俩vptr合并成一个呢?
答案是不行。这是因为与单继承不同,在多继承中,class Base1和class Base2相互独立,它们的虚函数没有顺序关系,即f1和f2有着相同对虚表起始位置的偏移量,所以不可以按照偏移量的顺序排布;并且class Base1和class Base2中的成员变量也是无关的,因此基类间也不具有包含关系;这使得class Base1和class Base2在class Derived中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数表索引。
偏移(offset)
在前面的内容中,我们多次提到了top offset,在上节Derived的虚函数表中,有两个top offset,其值分别为0和-16,那么这个offset起什么作用呢?
在此,先给出结论:将对象从当前这个类型转换为该对象的实际类型的地址偏移量。
仍然以前面的class Derived为例,其虚函数表布局如下:
Vtable for Derived
Derived::_ZTV7Derived: 7u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Derived)
16 (int (*)(...))Base1::f1
24 (int (*)(...))Derived::f2
32 (int (*)(...))-16
40 (int (*)(...))(& _ZTI7Derived)
48 (int (*)(...))Derived::_ZThn16_N7Derived2f2Ev
为了能方便理解本节内容,我们不妨将Derived虚函数表认为是 class Base1和class Base2两个类的虚函数表拼接而成 。因为是多重继承,所以编译器将先继承的那个认为是 主基类(primary base) ,因此Derived类的主基类就是class Base1。
在多继承中,当最左边的类中没有虚函数时候,编译器会将第一个有虚函数的基类移到对象的开头,这样对象的开头总是有vptr。
首先看虚函数表的前半部分,如下:
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Derived)
16 (int (*)(...))Base1::f1
24 (int (*)(...))Derived::f2
正是因为编译器将class Base1作为Derived的主基类,并将自己的函数加入其中。从上述可以看出offset为0,也就是说Base1类的指针不需要偏移就可以直接访问Derived::f2()。
接着看虚函数表的下半部分:
32 (int (*)(...))-16
40 (int (*)(...))(& _ZTI7Derived)
48 (int (*)(...))Derived::_ZThn16_N7Derived2f2Ev
偏移值为-16,因为是多重继承,所以class Base1和class Base2类型的指针或者引用都可以指向class Derived对象,那么又是如何调用正确的成员函数呢?
Base2* b2 = new Derived;
b2->f2(); //最终调用Derived::f2();
lass Base2类型的指针或者引用都可以指向class Derived对象,那么又是如何调用正确的成员函数呢?
Base2* b2 = new Derived;
b2->f2(); //最终调用Derived::f2();
由于不同的基类起点可能处于不同的位置,因此当需要将它们转化为实际类型时,this指针的偏移量也不相同,且由于多态的特性,b2的实际类型在编译时期是无法确定的;那必然需要一个东西帮助我们在运行时期确定b2的实际类型,这个东西就是offset_to_top
。通过让this指针加上offset_to_top
的偏移量,就可以让this指针指向实际类型的起始地址。