简介
最近在学习虚函数相关的知识,发现理解C++继承在内存中的表现以及多态性在底层的实现原理还是有点必要的,故在此写个小笔记,记录一些小知识点。
本文相关测试的机器环境:
Linux Qcumber 5.4.0-84-generic #94~18.04.1-Ubuntu SMP Thu Aug 26 23:17:46 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
gcc版本:
gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)
单一继承
首先从单一继承开始
class Base
{
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
int b;
};
class Derived : public Base
{
public:
void func1() override { cout << "Derived::func1" << endl; }
virtual void func3() { cout << "Derived::func3" << endl; }
int d;
};
内存布局
Derived
继承了Base
所有成员变量和函数,并且重写了func1()
,Derived
对象内存布局应该是这样的:
对虚函数的调用,比如像这样:
Derived* d_ptr = new Derived();
d_ptr->func1();
其实是等价于*((d_ptr->vptr)[0])(d_ptr)
:
d_ptr->vptr
:获取虚表地址;(d_ptr->vptr)[0]
:获取虚表第一个槽的地址;*((d_ptr->vptr)[0])
:解引用,获取Derived虚表上的第一个元素,里面存着Derived::func1()
的地址;*((d_ptr->vptr)[0])(d_ptr)
:调用Derived::func1()
,并隐式地将this指针传递给Derived::func1()
。
当然,我们无法访问vptr
,因此这种等价只是理论上的,有助于理解底层的原理。
指针向上转型
在C++多态中,支持父类的指针指向子类的对象:
Derived d;
Base* b_ptr = &d;
这是因为所有派生类对象都可以视作基类的对象(因为派生类继承了基类的函数和变量),但并非所有基类的对象都可以视作派生类的对象。
如果你有一个Base
指针,你可以调用在Base中声明的函数。而如果有一个Derived
指针,由于Derived
继承了Base
的所有函数和变量,因此Derived
也能够访问Base
的函数和变量。
当使用基类的指针指向派生类对象时,基类指针可访问的部分如下图红框:
注意:
- 虚表第一个槽中函数地址已经被替换为
Derived::func1()
的地址,因此无论是b_ptr->func1()
还是d_ptr->func1()
都会调用Derived::func1()
。
多重继承
当多重继承时,内存模型就变得稍微复杂一点了;
class Base1 {
public:
Base1()
{
printf("Base1的this指针是:%p!\n", this);
}
//虚表指针8字节
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
int b1; //4字节
};
class Base2 {
public:
Base2()
{
printf("Base2的this指针是:%p!\n", this);
}
virtual void func3() { cout << "Base2::func3" << endl; }
virtual void func4() { cout << "Base2::func4" << endl; }
int b2;
};
class Derived : public Base1, public Base2 {
public:
Derived()
{
printf("Derive的this指针是:%p!\n", this);
}
void func1() override { cout << "Derived::func1" << endl; }
void func3() override { cout << "Derived::func3" << endl; }
virtual void func5() { cout << "Derived::func5" << endl; }
int d;
};
多重继承中主要关注两个方面:内存布局和虚表
内存布局
Derived
对象内存布局如下:
可以观察到Derived
对象的内存布局由上到下依次是:Base1,Base2,Derived
。因为在内存布局中,首先是基类按照它们在继承列表中的顺序由上到下排列,然后是派生类。
尾部填充(tail padding)
待补充。
this指针调整
在C++中,一个对象的 this 指针默认指向该对象的起始地址。this指针可以使成员函数能够知道它们是在为哪个具体的对象实例工作,从而可以访问和修改该对象的成员变量。
然而在多重继承中会涉及到this指针的偏移:
Derived d;
Base1* pb1 = &d;
Base2* pb2 = &d;
cout<<"d的地址"<<&d<<endl;
cout<<"pb1指向的地址"<<pb1<<endl;
cout<<"pb2指向的地址"<<pb2<<endl;
输出的结果是:
可以看到静态类型为Base2*
的pb2
指向的地址与d的地址并不相同。这是因为在指针向上转换时,对于继承列表中非首位的基类,编译器会自动将对象的this指针进行偏移,然后赋值给基类的指针。在上述例子中,this指针的偏移量为16字节(正好等于sizeof(Base1)
),然后将偏移后的地址赋予了pb2
。
值得注意的是这种指针偏移现象也会出现在调用函数时:
Derived* d = new Derived();
d->func4();
当通过指向Derived
对象的指针调用func4()
时,传入的this指针也会被调整。
虚表指针
多重继承中的虚表指针和虚表也是要特别注意的。
可以观察到内存布局中有两个虚表指针,数量与Derived
的直接基类数量相等。
为什么上述例子中要有两个虚表指针呢?
因为与单继承不同,Base1
和Base2
完全独立,他们的虚函数没有顺序关系,即func1()
和func3()
有着相同的对虚表起始位置的偏移量。不可以按序排在一起。而且Base1
和Base2
中的成员变量也是无关的。所以使得Base1
和Base2
在Derived
中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数进行索引。
non-virtual thunk
现在我们关注func3()
,Derived
中重写了Base2
的func3()
。根据内存布局图,在虚表中,Base2
部分的func3()
并没有被Derived::func3()
覆盖,而是产生了一个non-virtual thunk,真正的Derived::func3()
地址被放在了虚表中Derived
的部分下。这个non-virtual thunk的本质是根据top_offset调整this指针,然后调用真正的函数。
下面解释一下原因,考虑以下情况:
Base2* p = new Derived();
p->func3();
我们主要关注的就是两点:①调用正确版本的func3()
②传入正确的this指针
若仅是要调用正确的func3()
,那我们完全可以不用生成non-virtual thunk,直接把将Base2中的&Base2::func3()
覆盖为&Derived::func3()
即可,但是由于我们期望传入的是指向Derived
起始地址的this指针,因此就还需要对this指针进行调整。
由上面的this指针讨论结果可知p
指向Derived
的Base2
部分的起始地址。通过反汇编发现,non-virtual thunk正好将this指针向上调整了16B(sizeof(Base1)
),使其指向了正确的位置(Derived
的起始地址),然后调用了’真正’的func3()
。
小结
本文简单讨论了
1.单继承和多重继承下类对象的内存布局
2.多态下指针的行为
3.non-virtual thunk的实现机制
参考文章
1.C++ vtables - Part 2 - Multiple Inheritance
2.VTable Notes on Multiple Inheritance in GCC C++ Compiler v4.0.1
3.C++ Inheritance Memory Model