C++ 虚函数和虚表

几篇写的不错的文章,本文是整合了这几篇文章,感谢这些大佬

https://www.jianshu.com/p/00dc0d939119

https://www.cnblogs.com/hushpa/p/5707475.html

https://www.jianshu.com/p/91227e99dfd7

多态:

多态是面相对象语言一个重要的特性,多态即让同一个用户自定义类型的对象在不同的决策时机呈现不同的行为实现
C++中的多态就分为

  • 编译时多态:就包括类成员函数重写operator函数重载
  • 运行时多态:C++编译器在运行时,根据决策逻辑判断传入所对象的类型,然后查找并根据该类虚表中的虚成员函数的地址,以进行动态调度目标类中的成员函数。

接下来就说下运行时多态的核心,虚函数和其背后的虚表。

虚函数

用virtual关键字修饰的函数就叫虚函数

因为vTable(虚表)是C++利用runtime来实现多态的工具,所以我们需要借助virtual关键字将函数代码地址存入vTable来躲开静态编译期。这里我们先不深入探究,后面我会细说。

首先我们先来看一个没有虚函数,即没有用到vTable的例子:

#include <iostream>
#include <ctime>
using std::cout;
using std::endl;

struct Animal { void makeSound() { cout << "动物叫了" << endl; } };

struct Cow : public Animal { void makeSound() { cout << "牛叫了" << endl; } };
struct Pig : public Animal { void makeSound() { cout << "猪叫了" << endl; } };
struct Donkey : public Animal { void makeSound() { cout << "驴叫了" << endl; } };

int main(int argc, const char * argv[])
{
    srand((unsigned)time(0));
    int count = 4;
    while (count --) {
        Animal *animal = nullptr;
        switch (rand() % 3) {
            case 0:
                animal = new Cow;
                break;
            case 1:
                animal = new Pig;
                break;
            case 2:
                animal = new Donkey;
                break;
        }
        animal->makeSound();
        delete animal;
    }
    return 0;
}

程序中有一个基类Animal,它有一个makeSound()函数。有三个继承自Animal的子类,分别是牛、猪、驴,并且实现了自己的makeSound()方法。很简单的代码,是吧。

我们运行程序,你觉得输出结果会是什么呢?不错,这里会连续执行4次Animal的makeSound()方法,结果如下:

为什么?因为我们的基类Animal的makeSound()方法没有使用Virtual修饰,所以在静态编译时就makeSound()的实现就定死了。调用makeSound()方法时,编译器发现这是Animal指针,就会直接jump到makeSound()的代码段地址进行调用。

ok,那么我们把Animal的makeSound()改为虚函数,如下:

struct Animal { virtual void makeSound() { cout << "动物叫了" << endl; } };

运行会是怎样?如你所料,多态已经成功实现:

 

接下来就是大家最关心的部分,这是怎么回事?编译器到底做了什么?

虚表

为了说明方便,我们需要修改一下基类Animal的代码,不改变其他子类,修改如下:

struct Animal {
    virtual void makeSound() { cout << "动物叫了" << endl; }
    virtual void walk() {}
    void sleep() {}
};

struct Cow : public Animal { void makeSound() { cout << "牛叫了" << endl; } };
struct Pig : public Animal { void makeSound() { cout << "猪叫了" << endl; } };
struct Donkey : public Animal { void makeSound() { cout << "驴叫了" << endl; } };

首先我们需要知道几个关键点:

  1. 函数只要有virtual,我们就需要把它添加进vTable。
  2. 每个类(而不是类实例)都有自己的虚表,因此vTable就变成了vTables。
  3. 虚表存放的位置一般存放在模块的常量段中,从始至终都只有一份。详情可在此参考

我们怎么理解?从本例来看,我们的Animal、Cow、Pig、Donkey类都有自己的虚表,并且虚表里都有两个地址指针指向makeSound()和walk()的函数地址。一个指针4个字节,因此每个vTable的大小都是8个字节。如图:

 

他们的虚表中记录着不同函数的地址值。可以看到Cow、Pig、Donkey重写了makeSound()函数但是没有重写walk()函数。因此在调用makeSound()时,就会直接jump到自己实现的code Address。而调用walk()时,则会jump到Animal父类walk的Code Address。

虚指针

现在我们已经知道虚表的数据结构了,那么我们在堆里实例化类对象时是怎么样调用到相应的函数的呢?这就要借助到虚指针了(vPointer)。

虚指针是类实例对象指向虚表的指针,存在于对象头部,大小为4个字节,比如我们的Donkey类的实例化对象数据结构就如下:

 

我们修改main函数里的代码,如下:

int main(int argc, const char * argv[])
{
    int count = 2;
    while (count --) {
        Animal *animal = new Donkey;
        animal->makeSound();
        delete animal;
    }
    return 0;
}

我们在堆中生成了两个Donkey实例,运行结果如下:

驴叫了
驴叫了
Program ended with exit code: 0

 

没问题。然后我们再来看看堆里的结构,就变成了这样:

 进一步探究虚表的内存布局

#include <iostream>
class Employee{
public:
    bool iService=true;
    virtual ~Employee(){};
    virtual void add_salary(){
       std::cout<<"add_salary method in Employee"<<std::endl;
    }
};

class Teamer:public Employee{
public:
    int idNo=1000;
    virtual ~Teamer(){}
    void add_salary(){
         std::cout<<"add_salary method in Teamer"<<std::endl;
    }

    virtual void info(){
       std::cout<<"Teamer info for Teamer"<<std::endl;
    }

    void show(){
       std::cout<<"show method in Teamer"<<std::endl;
    }
};
int main(void){
    Employee *tm1=new Teamer();
    Employee *tm2=new Teamer();
    Employee *pp1=new Employee();
    Employee *pp2=new Employee();
    delete tm1,tm2,pp1,pp2;
}

在这里我们可以尝试打印*tm1,*tm2,*pp1和 *pp2,如下图所示

从上图的输出中,我们要引入一个虚指针(_vptr)的概念

  • 虚类的对象初始化时会自动创建一个隐藏的数据成员_vptr指针指向虚表,此前声明该虚类的对象编译器也创建了该虚类的虚表
  • 后续同一个虚类所有对象实例共享同一个虚表,截图中的tm1和tm2的隐藏指针指向同一个地址0x400cf0,pp1和pp2的虚表是同理如是.
  • 虚表表当前的地址是一个已经+16字节偏移后的内存地址

另外我们还打印出所有Teamer对象和Employee对象,他们获得内存分配都为16个字节。因此我们不妨在查看我们刚才实例化的所有对象。

查看对象的内存数据

现在我们不妨看看刚才实例化的各个对象的内存布局,使用x命令,因为每个对象的堆内存块尺寸都为16个字节,因此我们使用x/16xb将他们的内存数据转存到屏幕中,如下图所示。

  • _vptr在虚类的对象中就占用8个字节,该_vptr存储了指向该虚类的虚表的内存地址值。
  • iService是一个bool类型仅占用1个字节,另外高位的3个字节空间由于内存对齐的原因都以0填充。
  • idNo是一个4字节的int类型,对于Teamer的对象0x03e8的值就是十进制的1000,对于Employee的对象这里的4个字节由于按8字节内存对齐,仅作为填充位之用。

     

备注:这里我们回顾了内存对齐的相关知识。

探究虚表的内存布局

我们从前文打印的第一个Teamer对象 tm1的信息中,可以知道其_vptr指针指向0x400cf0,你是否发现“<虚表 for Teamer+16>”的字样。这个其实表明0x400cf0是已经+16字节偏移后的地址值

我们已经在前文提到在首个的新的虚类对象且初始化时,编译器会该类动态创建一个虚表,但为什么每个不同虚类的虚表都要额外偏移16个字节呢? 在本示例中,我们不妨减去这个偏移量,也即得到0x400ce0这个地址,然后使用x命令,该命令将300字节的内存数据转储到屏幕。

(gdb) x/300xb 0x400ce0

上面的命令以十六进制格式打印300字节,从0x400d00开始。 为什么要这个地址? 因为在上面我们看到类Teamer的虚表指针指向0x400d10,该地址已经偏移0x10个字节,即减去0x10就能得到原本虚表的地址。

下图中_ZTV是虚表的前缀,_ZTS是type-string(名称)的前缀,_ZTI是type-info的前缀。

我们从下图可以得到很多虚表的内存细节。

  • 每个Teamer虚表存在一个虚表表头占用16个字节,前8个字节0填充,后8个字节包含一个指向与该类对应的typeinfo表的地址(没必要理会,只需知道他们占用16个字节即可)。
  • 每个typeinfo表的前面也包含一个typeinfo name的信息(没必要理会,l罗列出来只是让你知道有这么一个描述字段)
  • 绿色的部分就是不同虚类的虚表,虚表就是包含了该类定义的所有virtual成员函数的函数地址。

     

我们可以从上图中绿色部分的内存数据中即每行冒号之后的8字节空间提取有用的数据,例如

  • 0x400cf0到0x400d08的内存区域中的内存数据,对应的是Teamer类类虚表中virtual成员函数地址的条目。
  • 0x400d30到0x400d40的内存区域中的内存数据,对应的是Employee类虚表中virtual成员函数地址的条目

我们这两个内存区域的数据分别整理成如下表,注意写本文时使用的是CentOS 7的x64小端机器,因此读取图中的内存数据时,是从右向左读取,因此整理下表每个内存位置对应的值,并且分别是有info symbol命令 再次查看每个内存位置的值对应的具体含义。

结合整理如下表可知:虚表中的地址值分别代表虚拟类中对应虚函数的地址

虚表内存布局

 

更简单获取虚类的虚表条目的另外一条命令就是info vtbl,这里就不展示了,我们看到上图的虚表中的虚解构函数都成对地出现,我们先暂不讨论为什么会这样,因为我日后会令起一文再阐述该问题。

  • 第一个解构函数,称为完整对象解构函数(complete object destructor),执行销毁操作时无需在对象上调用delete()。
  • 第二个解构函数称为删除析构函数( deleting destructor),在销毁对象后调用delete()。
  • 两者都摧毁了任何虚拟基类.一个独立的非虚函数称为基类对象解构函数(base object destructor)执行对象的销毁操作,但不执行其虚拟基类子对象的销毁操作,并且不调用delete()。
  • 非虚函数是静态绑定的(编译时绑定),因此在虚表中不存在任何非虚函数。

虚表构建细节

我们仍然使用上文的调用示例代码

int main(void){
    //
    Employee *tm1=new Teamer();
    Employee *tm2=new Teamer();
    Employee *pp1=new Employee();
    Employee *pp2=new Employee();
    delete tm1,tm2,pp1,pp2;
}

从上面的示例代码中我们已经知道

 

  • 首先,每个使用虚函数的类或从基类派生的虚函数的类都被赋予自己的虚表。该表只是C++编译器在“编译时”设置的静态数组。虚表包含当前类中所有虚成员函数的函数指针的相关条目,那么填入虚表的虚成员函数指针有四种来源。
    1. 派生类本身原创定义的虚函数,例如上图的Teamer::info()函数。

    2. 从父类继承的虚成员函数,且该函数未被派生类重写

    3. 从父类继承的虚成员函数,但该函数已被派生类重写。值的注意的是,虚表的虚成员函数指针始终指向该类中的最新的派生版本的虚成员函数。理解这句话非常重要!举个例子Teamer类从Employee类继承了add_salary()函数,但Teamer类重写(注意:不是重载)了该add_salary()函数,对于Teamer虚表来说,填入表中的add_salary()函数的地址是0x400b3e,而不是父类的add_salary()的地址0x400ab4。

    4. 若当前类定义了虚解构函数,那么该类的虚解构函数的解构函数的地址会“成双成对”地填入虚表中。按照惯例,由于定义类时优先定义解构函数,再实现其他成员函数,因此该虚解构函数对的地址通常会出现在表中头两行,上图是很好的例证。

  • 然后,当类对象实例化时会将*_vptr设置为指向该类的虚表。例如,当创建类型为Teamer的对象时*_vptr设置为指向Teamer的虚表。构造类型为Employee对象时,*_vptr设置为指向的Employee的虚表。我们这里先不讨论virtual解构函数,目前只针对其他虚函数进行讨论。
  • 对于基类Employee类型的对象,它只能访问Employee的成员,Employee类型的对象无法访问Teamer类的的成员函数,因为地址为0x400ab4的地址仅指向Employee::salary()
  • 同理,Teamer类型的对象也只能访问Teamer::add_salary()和Teamer::info()。

总结:

用一张图说明一切

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值