深度解读《深度探索C++对象模型》之C++虚函数实现分析(二)

这篇具有很好参考价值的文章主要介绍了深度解读《深度探索C++对象模型》之C++虚函数实现分析(二)。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。

第一篇请从这里阅读:
深度解读《深度探索C++对象模型》之C++虚函数实现分析(一)

这一篇主要讲解多重继承情况下的虚函数实现分析。

在多重继承下支持虚函数,主要体现在对第二及其后继的基类的处理上,下面我们以一个具体的例子来讲解:

#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子对象开始的,会造成不对齐的问题而引起运行崩溃。

是否在多重继承下才会有这样的问题?其实不然,在单一继承下也会存在问题,虽然在单一继承下,对象中的父类的子对象和对象的起始地址是对齐的,释放内存不会造成程序崩溃,但是这时调用的是父类的析构函数而不是子类的析构函数,这将导致派生类真正想要的析构动作将不会被执行到,例如本来要在析构函数中释放资源的动作将没有被执行,将导致资源的泄露,如在构造函数中申请的内存等。文章来源地址https://www.toymoban.com/news/detail-856744.html

到了这里,关于深度解读《深度探索C++对象模型》之C++虚函数实现分析(二)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 深度解读《深度探索C++对象模型》之数据成员的存取效率分析(一)

    接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。 在《深度解读《深度探索C++对象模型》之C++对象的内存布局》这篇文章中已经详细分析过C++的对象在经过封装后,在各

    2024年04月22日
    浏览(44)
  • 深度解读《深度探索C++对象模型》之返回值优化

    接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。 当在函数的内部中返回一个局部的类对象时,是怎么返回对象的值的?请看下面的代码片段: 对于上面的代码,是否

    2024年04月22日
    浏览(57)
  • 深入分析C++对象模型之移动构造函数

    接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎关注!也可以关注公众号:iShare爱分享,自动获得推文和全部的文章列表。 C++11新标准中最重要的特性之一就是引入了支持对象移动的能力,为了支持移动的操作,新标准引入了一种新的引用类

    2024年04月22日
    浏览(48)
  • 【C++】继承 ⑦ ( 继承中的对象模型分析 | 继承中的构造函数和析构函数 )

    下面有 3 个类 , 分别是 A 类 , B 类 , C 类 ; A 类是 基类 ; B 类 公有继承 A 类 , 并定义了新的 成员变量 y ; C 类 公有继承 B 类 , 并定义了新的 成员变量 z ; 分别定义上述 3 个类的对象 , 上述 3 个对象的内存模型如下 : A 类对象 objA 中有一个成员 int x , 在内存中只有一个 int 类型的

    2024年02月08日
    浏览(55)
  • 深度探索 Elasticsearch 8.X:function_score 参数解读与实战案例分析

    在 Elasticsearch 中,function_score 可以让我们在查询的同时对搜索结果进行自定义评分。 function_score 提供了一系列的参数和函数让我们可以根据需求灵活地进行设置。 近期有同学反馈,function_score 的相关参数不好理解,本文将深入探讨 function_score 的核心参数和函数。 Elasticsear

    2024年02月14日
    浏览(44)
  • 【C++干货基地】面向对象核心概念与实践原理:拷贝构造函数的全面解读

    🎬 鸽芷咕 :个人主页  🔥 个人专栏 : 《C++干货基地》《粉丝福利》 ⛺️生活的理想,就是为了理想的生活!   哈喽各位铁汁们好啊,我是博主鸽芷咕《C++干货基地》是由我的襄阳家乡零食基地有感而发,不知道各位的城市有没有这种实惠又全面的零食基地呢?C++ 本身作

    2024年03月13日
    浏览(45)
  • C++奇迹之旅:探索类对象模型内存的存储猜想

    上回我们学习了类的定义,初步了解了什么是类?类的定义,以及类的三个访问限定符: public , private , protected ,本小节将讲解类的实例化,类对象模型的猜想存储,及三种简单类的计算。 在 C++ 中,类的实例化是指创建一个类的对象。当我们定义了一个类之后,就可以根据

    2024年04月12日
    浏览(39)
  • 【C++学习】类和对象 | 拷贝构造 | 探索拷贝构造函数为什么需要引用传参 | 深拷贝 | 初识运算符重载

    上一篇文章我们开始学习类内的默认成员函数, 这里是传送门,有兴趣可以去看看:http://t.csdn.cn/iXdpH 这篇文章我们继续来学习类和对象的知识。 目录 写在前面: 1. 拷贝构造 2. 拷贝构造函数为什么需要引用传参? 3. 深拷贝 4. 初识运算符重载 写在最后: 我们在创建一个对

    2024年02月11日
    浏览(53)
  • C++类和对象-C++对象模型和this指针->成员变量和成员函数分开存储、this指针概念、空指针访问成员函数、const修饰成员函数

    #includeiostream using namespace std; //成员变量 和 成员函数 分开储存的 class Person { public:     Person() {         mA = 0;     }     //非静态成员变量占对象空间     int mA;     //静态成员变量不占对象空间     static int mB;     //函数也不占对象空间,所有函数共享一个函数实例

    2024年02月20日
    浏览(47)
  • AI大模型探索之路-训练篇3:大语言模型全景解读

    大规模语言模型(Large Language Models,LLM),也称大语言模型或大型语言模型,是一种由包含数百亿以上参数的深度神经网络构建的语言模型,通常使用自监督学习方法通过大量无标注文本进行训练。 语言模型旨在对于人类语言的内在规律进行建模,从而准确预测词序列中未来

    2024年04月26日
    浏览(51)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包