C++多态学习(一)单继承与多重继承
简介
最近在学习虚函数相关的知识,发现理解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

单继承与多重继承的小知识点,包括this指针调整,non-virtual thunk等
浙公网安备 33010602011771号