首页 > 编程语言 > 详细

C++笔记:虚函数背后的虚表

时间:2019-08-03 14:14:48      阅读:122      评论:0      收藏:0      [点我收藏+]

1. 为什么需要虚表

  • 在我们学习C++的时候,几乎每书本都会告诉我们“想要实现多态就必须依赖虚函数”,非虚函数只能静态绑定而不具备多态性,只有虚函数才具有动态绑定的特性。而为了实现虚函数,C++则使用一种特殊的后期绑定(动态绑定)形式,称为 虚表(The virtual table)。虚表的就是一个函数查找表,用来解决函数的动态绑定问题。因此,虚表存在的意义就是——解决动态绑定问题,进而实现面向对象编程中的多态性。

2.静态绑定与动态绑定的简单理解

  • 静态绑定:就是在“编译期”就可以确定需要调用哪些函数,并将这些函数的入口地址直接固化到调用这个函数的指令中。如同下面的这段代码中base.function()调用就是一个“静态绑定”的函数调用:
// 静态绑定
#include <iostream>
#include <cstdlib>

using namespace std;

class Base {
public:
    void function() { cout << "Base::function" << endl; }
    virtual void sayhello() { cout << "Hello!" << endl; }
};

int main()
{
    Base *base = new Base();
    base->function(); // function在编译期间就知道是调用Base类中的function函数
    delete base;
    return EXIT_SUCCESS;
}
  • 从下面的主函数部分的汇编代码片段(g++编译器)中,我们不难发现编译器在编译的时候直接生成了一条名为call _ZN4Base8functionEv的指令,而_ZN4Base8functionEv正是base.function()函数的入口名称。这也就意味着程序在“执行前”,编译器就已经知道在此处应该调用是函数base->function(),而不是其他的函数,并将相关的指令固化下来。这就是“静态绑定”。
main:
.LFB1459:
    .loc 1 12 0
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    .loc 1 13 0
    movl    $1, %edi
    call    _Znwm@PLT
    movq    %rax, -8(%rbp)
    .loc 1 14 0
    movq    -8(%rbp), %rax
    movq    %rax, %rdi
    call    _ZN4Base8functionEv ; !! 这里 !! 直接使用call指令调用Base类中的function()成员函数
    .loc 1 15 0
    movq    -8(%rbp), %rax
    movl    $1, %esi
    movq    %rax, %rdi
    call    _ZdlPvm@PLT
    .loc 1 16 0
    movl    $0, %eax
    .loc 1 17 0
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
  • 动态绑定:与静态绑定恰恰相反,编译器无法在“编译期”知道需要调用哪些函数。也就没有办法像静态绑定那样在编译的时候把函数调用固化为类似call _ZN4Base8functionEv指令。那么,为什么实现多态就必须要动态绑定呢,静态绑定不行吗?我们一起来看下面这段代码:
// 动态绑定
#include <iostream>
#include <cstdlib>
#include <ctime>

using namespace std;

class Base {
public:
    virtual void function() { cout << "Base::function" << endl; }
    virtual void sayhello() { cout << "Hello!" << endl; }
};

class DeriveA : public Base{
public:
    virtual void function() { cout << "DeriveA::function" << endl; }
};

class DeriveB : public Base{
public:
    virtual void function() { cout << "DeriveB::function" << endl; }
};

int main()
{
    Base *ptr = nullptr;
    if (time(nullptr) % 2) // 这里time(nullptr)获取当前系统的unix时间戳, 它为奇数和偶数概率各为一半
        ptr = new DeriveA(); // 当时间戳为奇数时, 创建DeriveA
    else
        ptr = new DeriveB(); // 当时间戳为偶数时, 创建DeriveB

    ptr->function();

    delete ptr;
    return EXIT_SUCCESS;
}
  • 上面这段代码中time(nullptr)获取当前系统的unxi时间戳,当时间戳为奇数时ptr指向DeriveA类对象,而当时间戳为偶数时ptr指向DeriveB类对象。当编译器编译ptr->function()这条语句时,编译器不知道ptr的背后是一个DeriveA类还是一个DeriveB类,而由于要实现多态性,就必须保证当ptr指向DeriveA的时候调用DeriveA中的function(),当ptr指向DeriveB的时候调用DeriveB中的function(),而ptr指向谁这只能 在程序运行起来的时候才能知晓。因此,面向对象编程所需要的多态性就必须依赖“动态绑定”才能实现。当然,如果没有使用虚函数,不同函数不具备多态性,编译器在编译时就不会理睬ptr具体指向什么, 直接执行静态绑定。

3.虚表是如何实现动态绑定进而实现多态性的?

  • 在讨论完静态绑定与动态绑定之后,我们终于进入正题——虚表是怎么样实现动态绑定进而实现多态性的?
  • 这里我们依然以“动态绑定”中的代码为例子,上面的代码中一共有3个类分别是BaseDeriveADeriveB
    • 首先,编译器在编译每个类的时候都会为其生成一张“虚表”,这张虚表其实就是一个编译器在编译时设置的一个“静态数组”。虚表的每一个元素都对应一个虚函数,这些虚函数可以被类的对象来调用。
    • 之后,编译器会为每一个类添加一个“隐藏的是指针”,我们把它叫做*__vptr。这个*__vptr指针是类的实例被创建的时候,编译器自动为其设置的。如果我们把这个指针显示地展现出来,大概就像这样:
// 动态绑定
#include <iostream>
#include <cstdlib>
#include <ctime>

using namespace std;

class Base {
public:
    FunctionPointer *__vptr; //!隐藏指针! 指向虚表
    virtual void function() { cout << "Base::function" << endl; }
    virtual void sayhello() { cout << "Hello!" << endl; }
};

class DeriveA : public Base {
public:
    FunctionPointer *__vptr; //!隐藏指针! 指向虚表
    virtual void function() { cout << "DeriveA::function" << endl; }
};

class DeriveB : public Base {
public:
    FunctionPointer *__vptr; //!隐藏指针! 指向虚表
    virtual void function() { cout << "DeriveB::function" << endl; }
};
  • 我们把上面的每个类及其虚表画出来,并用箭头表示指针的指向关系,就可得到下面这张图:

技术分享图片

在上图中,每个类中的* __vptr指向该类的虚拟表。对于被子类覆写了虚函数,在虚表中对应指向该函数的条目将指向该子类中的该函数(如DeriveA类中的function函数);如果子类没有覆写父类的虚函数,在虚表中对应指向该函数的条目将指向该子类的父类中的该函数(如DeriveA类中的sayhello函数)

  • 现在,让我们考虑一下——当把一个基类指针指向派生类对象的时候发生了什么:
    1. 首先,我们创建一个DeriveA子类对象da

      int main()
      {
          DeriverA da ;
          //...
      }
    2. 之后,我们让基类指针指向该对象:

      int main()
      {
          DeriverA da;
          Base *bda = &da;
          //...
      }

      我们注意到由于bda是一个Base类型的指针,因此通过bda指针我们只能访问DeriverA类中的“基类部分”(比如function函数)。然而,我们还要注意* __vptr也是属于这个所谓的“基类部分”的,因此通过bda也可以访问这个指针。但是,bda->__vptr指向的是DeriverA的虚表!因此,即使bda指针是Base类型,它仍然可以访问DeriverA的虚表(通过__vptr 指针)。

    3. 最后,我们来分析一下使用bda来调用function()的过程中发生了什么。首先,程序认识到function()是一个虚拟函数。其次,程序使用bda->__vptr来访问DeriverA的虚表。最终,通过DeriverA的虚表找到需要调用的function()——即DeriverA::function()。因此,bda->function()解析为DeriverA::function()

      int main()
      {
          DeriverA da;
          Base *bda = &da;
          bda->function();
          //...
      }
  • 如果我们使用图来表示就是:

    技术分享图片

4.对虚表的一点思考

  • 调用虚函数相比调用非虚函数,有一定的效率上的损失,这主要来自一下的几个原因:
    1. 首先,我们必须使用* __vptr来获得恰当的虚拟表;
    2. 其次,我们必须查找虚拟表,以找到要调用的正确函数。只有这样我们才能调用这个函数。
  • 因此,我们必须执行3个操作(1.对对象指针解引用; 2.对__vptr指针进行解引用得到虚表;3. 索引虚表中的函数,找到需要调用的函数)来找到要调用的函数,而不是对普通的间接函数调用执行2个操作(1.对对象指针解引用,2.找到需要调用的函数),或对直接函数调用执行1个操作。
  • 尽管虚函数有一定效率上的损失,但是对于现代计算机来说,这些效率上的损失几乎微不足道。另外,任何使用虚函数的类都有一个编译器为我们生成__vptr指针。因此该类的每个对象会多占用一个指针变量的内存空间。所以说,虚函数在空间上也有一定的成本。

5.参考资料

[1] www.learncpp.com

另外博主目前也只是个学生,如果博文中有任何错误,希望各位朋友帮忙指出!!谢谢!!

C++笔记:虚函数背后的虚表

原文:https://www.cnblogs.com/Silgm/p/11290693.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!