这一篇主要讲解多重继承情况下的虚函数实现分析。
在多重继承下支持虚函数,主要体现在对第二及其后继的基类的处理上,下面我们以一个具体的例子来讲解:
#include <cstdio>
class Base1 {
public:
virtual ~Base1() = default;
virtual void virtual_func1() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual void virtual_func2() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual Base1* clone() { return new Base1; }
int b1 = 0;
};
class Base2 {
public:
virtual ~Base2() = default;
virtual void virtual_func3() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual void virtual_func4() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual Base2* clone() { return new Base2; }
int b2 = 0;
};
class Derived: public Base1, public Base2 {
public:
virtual ~Derived() = default;
void virtual_func1() override { printf("%s\n", __PRETTY_FUNCTION__); }
void virtual_func3() override { printf("%s\n", __PRETTY_FUNCTION__); }
virtual void virtual_func5() { printf("%s\n", __PRETTY_FUNCTION__); }
virtual Derived* clone() override { return new Derived; }
int d = 0;
};
int mAIn() {
Derived* pd = new Derived;
pd->virtual_func1();
pd->virtual_func2();
pd->virtual_func3();
pd->virtual_func4();
Base1* pb1 = pd;
pb1->virtual_func1();
pb1->virtual_func2();
Base2* pb2 = pd;
Base2* pb = pb2->clone();
pb->virtual_func3();
pb->virtual_func4();
delete pd;
delete pb;
return 0;
}
多重继承下围绕第二及后继的基类的问题主要表现在虚函数表的处理、this指针的调整,虚析构函数的调用,下面将一一展开来分析。
多重继承下虚函数表的问题
每个类主要有虚函数,编译器将会为这个类生成虚函数表,子类会继承基类的虚函数表,这是我们已经知道的事情。但是在多重继承下,将会有两个以上的基类,那么子类将会继承到多个虚函数表,如果多重继承中,有N个基类有虚函数表,子类中也将会有N个虚函数表。编译器将如何处理这种情况?不同的编译器可能有不同的处理方式,Clang和Gcc编译器是将多个虚函数表合并在一起,每个子表仍然是包含RTTI信息和子对象的虚函数地址,具体看一下实际汇编代码中的虚函数表:
vtable for Derived:
.quad 0
.quad typeinfo for Derived
.quad Derived::~Derived() [base object destructor]
.quad Derived::~Derived() [deleting destructor]
.quad Derived::virtual_func1()
.quad Base1::virtual_func2()
.quad Derived::clone()
.quad Derived::virtual_func3()
.quad Derived::virtual_func5()
.quad -16
.quad typeinfo for Derived
.quad non-virtual thunk to Derived::~Derived() [complete object destructor]
.quad non-virtual thunk to Derived::~Derived() [deleting destructor]
.quad non-virtual thunk to Derived::virtual_func3()
.quad Base2::virtual_func4()
.quad covariant return thunk to Derived::clone()
Base1类和Base2类的虚函数表跟普通情况下的一样,就不贴出来了。上面表中的第2到第10行是Base1子对象的虚函数表,它和Derived类的对象共用同一个,称为主表,第11到第17行是Base2子对象的虚函数表,也称为次表。对应有两个虚函数表指针,一个是在对象的起始地址(也是Base1子对象的起始地址),另一个是在Base2子对象的起始地址(对象首地址加上大小为Base1子对象大小的偏移量)。这两个虚函数表指针是在对象构造时,在构造函数中由编译器生成的汇编代码设置的,Base1子对象的虚函数表指针被设置为指向表中第4行的第一个虚函数的位置,Base2子对象的虚函数表指针被设置为指向表中第13行次表的第一个虚函数的位置,具体的代码就不分析了,详见另一篇《深度解读《深度探索C++对象模型》之默认构造函数》。
继续分析上面虚函数表的内容,表中有两个析构函数,第一个是完整的析构函数,完成主要的析构动作,用于局部对象、临时对象等释放时被调用,第二个析构函数是给在堆空间中申请的对象释放时调用的,也就是用new函数申请的内存空间,在这个析构函数里会先调用第一个析构函数,然后再调用delete函数释放申请的内存空间。主表中有两个(第4、5行),次表也有两个(第13、14行),次表中的两个最终也是调用主表中的析构函数,这里涉及到thunk技术,稍后再细讲。
主表继承了Base1基类的虚函数表,按顺序是虚析构函数、virtual_func1、virtual_func2和clone函数,其中只有virtual_func2没有改写,直接拷贝了基类的虚函数的地址,之后virtual_func3和virtual_func5是Derived子类新增的虚函数,virtual_func3虽然是对Base2基类中的虚函数的改写,但对于Base1基类来说相当于是新增的,它和Base2子对象中virtual_func3是共用一个函数,在稍后详细讲解。
判定一个虚函数是否被改写的规则是函数名称、参数个数和类型以及返回类型都必须相同,但有两个例外的地方,第一个是虚析构函数,只要基类中定义了虚析构函数,子类就一定继承了虚析构函数,即使代码中没有定义,编译器也会为它生成一个,而且名称也不要求相同,当然也不可能相同。第二个是类似上面的clone函数,在基类中返回类型是基类类型,在派生类中返回的是派生类的类型时,规则允许例外,它也会被当做是重写。
用派生类指针调用第二及后继基类的虚函数
通过派生类指针调用第二及后继基类中一个继承而来的虚函数,主要的工作在于调整this指针,如C++代码中使用Derived类型的指针pd调用virtual_func4虚函数,virtual_func4是Base2基类定义的虚函数,Derived类没有改写它,直接继承它的实现,因此它只存在于Base2子对象的虚函数表中,调用virtual_func4函数,需要把this指针调整到Base2子对象的起始位置,它和Derived对象的起始地址相差Base1子对象的大小,汇编代码中调用virtual_func4函数的实现:
mov rax, qword ptr [rbp - 16]
mov rdi, rax
add rdi, 16
mov rax, qword ptr [rax + 16]
call qword ptr [rax + 24]
[rbp - 16]是存放Derived对象的起始地址,把它加载到rdi寄存器后再加上16的偏移量(第2、3行),16就是Base1子对象的大小,偏移后还是保存在rdi寄存器,rdi寄存器作为第5行调用函数时的参数,也即是this指针,这时它是指向Base2子对象,第4行中的[rax + 16]是将Derived对象的起始地址加上16的偏移量,也就是指向Base2子对象的起始地址,这里保存着指向Base2子对象的虚函数表的指针,对其取值后就是Base2子对象的虚函数表的起始地址,在第5行的调用中,[rax + 24]就是在虚函数表的起始地址偏移24,相当于跳过3个虚函数(每个虚函数的地址占用8字节),也就是上面虚函数表中的第16行virtual_func4函数(请参考上表),对其取值即virtual_func4虚函数的地址,然后调用之。
用第二及后继基类的指针调用派生类的虚函数
通过第二及后继基类的指针调用派生类中的虚函数,主要围绕在几方面上:派生类Derived类改写的Base2基类的虚函数如virtual_func3虚函数,调用clone函数的问题,虚析构函数的问题。
通过第二基类如Base2基类的指针调用virtual_func3函数的问题体现在:因为Derived类中对virtual_func3虚函数进行改写,所以virtual_func3也被添加到Base1子对象的虚函数表中(相当于新增函数),同时它也是对继承自Base2基类的virtual_func3虚函数的改写,所以它也必然存在于Base2子对象的虚函数表中,因此在两个表格中占了两个条目,但实际的函数实例只有一个。在Base1子对象的虚函数表中存放的是真实的virtual_func3虚函数的地址,而在Base2子对象的虚函数表中存放的是一个辅助函数的地址,这个辅助函数是由编译器实现的,就是一段汇编代码,主要的工作就是去调整this指针,调整后再去调用真正的virtual_func3函数,这就是thunk技术。来看看汇编代码中的实现:
# pb->virtual_func3();
mov rdi, qword ptr [rbp - 40]
mov rax, qword ptr [rdi]
call qword ptr [rax + 16]
non-virtual thunk to Derived::virtual_func3(): # @non-virtual thunk to Derived::virtual_func3()
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
add rdi, -16
pop rbp
jmp Derived::virtual_func3() # TAILCALL
上面几行的汇编代码是通过Base2类型的指针调用virtual_func3函数,做法就是通过Base2子对象的虚函数表找到virtual_func3虚函数的地址然后调用它,但是这里的virtual_func3的地址不是真实的virtual_func3函数实例的地址,而是我们上面分析的辅助函数,即thunk技术,是编译器实现的一段汇编代码。在这汇编代码里,首先将参数rdi寄存器(保存着Base2子对象的地址,即Base2子对象的this指针)取出来保存到栈空间[rbp - 8]中,然后减去16的偏移量,16是Base1子对象的大小,也就是调整到Derived类对象的起始的地址,然后保存到rdi寄存器作为调用virtual_func3函数的参数,最后跳转到真正的virtual_func3函数去执行(第13行)。
对clone函数的调用也存在同样的问题,clone函数在Base1基类和Base2基类中都有定义,在Derived类中进行改写,因此在Base1子对象和Base2子对象的虚函数表中都各自占了一个条目,主表中存放的是真正的clone函数的实现,次表中存放的是thunk技术实现的辅助函数,但它比对virtual_func3函数的调用要更复杂一些。看一下这段汇编代码的实现:
# Base2* pb = pb2->clone();
mov rdi, qword ptr [rbp - 32]
mov rax, qword ptr [rdi]
call qword ptr [rax + 32]
mov qword ptr [rbp - 40], rax
covariant return thunk to Derived::clone(): # @covariant return thunk to Derived::clone()
# 略...
add rdi, -16
call Derived::clone()
mov qword ptr [rbp - 16], rax # 8-byte Spill
cmp rax, 0
je .LBB13_2
mov rax, qword ptr [rbp - 16] # 8-byte Reload
add rax, 16
mov qword ptr [rbp - 24], rax # 8-byte Spill
jmp .LBB13_3
.LBB13_2:
# 略...
.LBB13_3:
# 略...
上面汇编代码的前面几行是调用虚函数的常规做法,只不过这时调用到的是下面这个thunk技术实现的clone函数。它比调用virtual_func3函数麻烦的地方在于,在调用真正的clone函数之前要先调整this指针,即上面汇编代码的第9行,这时将this指针调整为指向Derived对象的起始地址,然后调用真正的clone函数(第10行)。调用完clone函数之后还得再调整一次this指针,因为clone函数返回的是Derived对象的起始地址,我们要把它赋值给Base2类型的指针,所以要把this指针调整到指向Base2子对象的起始地址,不然通过它返回的指针(即pb指针)调用函数或者存取数据成员时将引起错误,首先判断返回的指针是否为0(第12行),不为0的话就加上16的偏移量(第15行),即指向Base2子对象,然后返回。
虚析构函数的问题和实现手法跟上面两种情况类似,同样存在两种类型的虚析构函数,一个为真正的实例,一个是thunk技术实现的。有两种调用到虚析构函数的情况,第一种是new出来的Derived对象赋值给Base1类型的指针,最后再通过Base1类型的指针delete掉,如:
Base1* pb1 = new Derived;
...
delete pd1;
这种情况下跟直接使用Derived类型的指针是一样的,因为Base1子对象的起始地址和Derived对象的起始地址是对齐的,不需要调整this指针,这时将调用的是Base1子对象的虚函数表中真正的析构函数,完成析构动作。
第二种情况是通过Base2类型的指针来操作,如:
Base2* pb2 = new Derived;
...
delete pb2;
这时因为Base2子对象和Derived的起始地址不对齐,需要调整this指针,所以这时先调用thunk技术实现的析构函数,在析构函数里完成this指针调整后再调用真正的析构函数,下面是汇编代码:
non-virtual thunk to Derived::~Derived() [deleting destructor]: # @non-virtual thunk to Derived::~Derived() [deleting destructor]
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
add rdi, -16
pop rbp
jmp Derived::~Derived() [deleting destructor]
代码的意思跟上面的汇编代码差不多,就不详细解释了。
为什么多态时需要虚析构函数
最后来谈谈在多态时为什么需要将析构函数声明为虚函数。假如在上面的例子中,我们没有将析构函数声明为虚函数,那么析构函数将没有多态的行为。当Base2类型的指针指向一个Derived对象时,这时通过Base2类型的指针来释放对象,调用的将是Base2类的析构函数,它将只会释放掉Base2子对象部分的内存,这将会引起程序的崩溃,因为申请的内存的起始地址是Derived对象开始的,释放时是从Base2子对象开始的,会造成不对齐的问题而引起运行崩溃。
是否在多重继承下才会有这样的问题?其实不然,在单一继承下也会存在问题,虽然在单一继承下,对象中的父类的子对象和对象的起始地址是对齐的,释放内存不会造成程序崩溃,但是这时调用的是父类的析构函数而不是子类的析构函数,这将导致派生类真正想要的析构动作将不会被执行到,例如本来要在析构函数中释放资源的动作将没有被执行,将导致资源的泄露,如在构造函数中申请的内存等。