一文彻底读懂C++虚函数的实现机制

Posted 彼 方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文彻底读懂C++虚函数的实现机制相关的知识,希望对你有一定的参考价值。

1、虚函数简介

C++中有两种方式实现多态,即重载和覆盖。

  • 重载:是指允许存在多个同名函数,而这些函数的参数表不同(参数个数不同、参数类型不同或者两者都不同)。
  • 覆盖:是指子类重新定义父类虚函数的做法,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针拥有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法,比如:模板元编程是在编译期完成的泛型技术,RTTI、虚函数则是在运行时完成的泛型技术。

关于虚函数的具体使用方法,建议大家先去阅读相关的C++的书籍,本文只剖析虚函数的实现机制,让大家对虚函数有一个更加清晰的认识,并不对虚函数的具体使用方法作过多介绍。本文是依据个人经验和查阅相关资料最终编写的,如有错漏,希望大家多多指正。

2、虚函数表简介

学过C++的人都应该知道虚函数(Virtual Function)是通过虚函数表(Virtual Table,简称为V-Table)来实现的。虚函数表主要存储的是指向一个类的虚函数地址的指针,通过使用虚函数表,继承、覆盖的问题都都得到了解决。假如一个类有虚函数,当我们构建这个类的实例时,将会额外分配一个指向该类虚函数表的指针,当我们用父类的指针来操作一个子类的时候,这个指向虚函数表的指针就派上用场了,它指明了此时应该使用哪个虚函数表,而虚函数表本身就像一个地图一样,为编译器指明了实际所应该调用的函数。指向虚函数表的指针是存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下),这就意味着理论上我们可以通过对象实例的地址得到这张虚函数表(实际上确实可以做到),然后对虚函数表进行遍历,并调用其中的函数。

前面说了一大堆理论,中看不中用,下面还是通过一个实际的例子验证一下前面讲的内容,首先定义一个Base类,该类有三个虚函数,代码如下:

#include <iostream>
#include <string>

typedef void (*Fun)(void);

class Base
{
public:
    virtual void f()
    {
        std::cout << "Base::f()" << std::endl;
    }

    virtual void g()
    {
        std::cout << "Base::g()" << std::endl;
    }

    virtual void h()
    {
        std::cout << "Base::h()" << std::endl;
    }
};

接下来按照前面的说法,我们通过Base类的实例对象base来获取虚函数表,代码如下:

int main(int argc, char* argv[])
{
    Base base;
    Fun fun = nullptr;

    std::cout << "指向虚函数表指针的地址:" << (long*)(&base) << std::endl;
    std::cout << "虚函数表的地址:" << (long*)*(long*)(&base) << std::endl;

    fun = (Fun)*((long*)*(long*)(&base));
    std::cout << "虚函数表中第一个函数的地址:" << (long*)fun << std::endl;
    fun();

    fun = (Fun)*((long*)*(long*)(&base) + 1);
    std::cout << "虚函数表中第二个函数的地址:" << (long*)fun << std::endl;
    fun();

    fun = (Fun)*((long*)*(long*)(&base) + 2);
    std::cout << "虚函数表中第三个函数的地址:" << (long*)fun << std::endl;
    fun();
}

运行结果图2-1所示(Linux 3.10.0 + GCC 4.8.5):

在这里插入图片描述

图2-1 程序运行结果


在上面的例子中我们通过把&base强制转换成long *,来取得指向虚函数表的指针的地址,然后对这个地址取值就可以得到对应的虚函数表了。得到对应虚函数表的首地址后,就可以通过不断偏移该地址,依次得到指向真实虚函数的指针了。这么说有点绕也有点晕,下面通过一幅图解释一下前面说的内容,详见图2-2

在这里插入图片描述

图2-2 基类虚函数表内存布局


当然,上述内容也可以在GDB中调试验证,后续的内容也将全部在GDB下直接验证,调试的示例见图2-3:

在这里插入图片描述

图2-3 GDB查看基类虚函数表内存布局

3、有继承关系的虚函数表剖析

前面分析虚函数表的场景是没有继承关系的,然而在实际开发中,没有继承关系的虚函数纯属浪费表情,所以接下来我们就来看看有继承关系下虚函数表会呈现出什么不一样的特点,分析的时候会分别就单继承无虚函数覆盖、单继承有虚函数覆盖、多重继承、多层继承这几个场景进行说明。

3.1、单继承无虚函数覆盖的情况

先定义一个Base类,再定义一个Derived类,Derived类继承于Base类,代码如下:

#include <iostream>
#include <string>

class Base
{
public:
    virtual void f()
    {
        std::cout << "Base::f()" << std::endl;
    }

    virtual void g()
    {
        std::cout << "Base::g()" << std::endl;
    }

    virtual void h()
    {
        std::cout << "Base::h()" << std::endl;
    }
};

class Derived : public Base
{
public:
    virtual void f1()
    {
        std::cout << "Derived::f1()" << std::endl;
    }

    virtual void g1()
    {
        std::cout << "Derived::g1()" << std::endl;
    }

    virtual void h1()
    {
        std::cout << "Derived::h1()" << std::endl;
    }
};

继承关系如图3-1所示:

在这里插入图片描述

图3-1 类继承关系UML图


测试的代码如下,因为等下要使用GDB来验证,所以就随便写点,定义个Derived类实例就行了

int main(int argc, char* argv[])
{
    Derived derived;
    derived.f();
}

派生类Derived的虚函数表内存布局如图3-2所示:

在这里插入图片描述

图3-2 单继承无虚函数覆盖情况下派生类虚函数表内存布局


接下来就用GDB调试一下,验证上图的内存布局是否正确,如图3-3所示:

在这里插入图片描述

图3-3 GDB查看单继承无虚函数覆盖情况下派生类虚函数表内存布局


从调试结果可以看出图3-2是正确的,Derived的虚函数表中先放Base的虚函数,再放Derived的虚函数。

3.2、单继承有虚函数覆盖的情况

派生类覆盖基类的虚函数是很有必要的事情,不这么做的话虚函数的存在将毫无意义。下面我们就来看一下如果派生类中有虚函数覆盖了基类的虚函数的话,对应的虚函数表会是一个什么样子。还是老规矩先定义两个有继承关系的类,注意一下我这里只覆盖了基类的g()

#include <iostream>
#include <string>

class Base
{
public:
    virtual void f()
    {
        std::cout << "Base::f()" << std::endl;
    }

    virtual void g()
    {
        std::cout << "Base::g()" << std::endl;
    }

    virtual void h()
    {
        std::cout << "Base::h()" << std::endl;
    }
};

class Derived : public Base
{
public:
    virtual void f1()
    {
        std::cout << "Derived::f1()" << std::endl;
    }

    virtual void g()
    {
        std::cout << "Derived::g()" << std::endl;
    }

    virtual void h1()
    {
        std::cout << "Derived::h1()" << std::endl;
    }
};

继承关系如图3-4所示:

在这里插入图片描述

图3-4 类继承关系UML图


测试的代码如下,因为等下要使用GDB来验证,所以就随便写点,定义个Derived类实例就行了

int main(int argc, char* argv[])
{
    Derived derived;
    derived.g();
}

派生类Derived的虚函数表内存布局如图3-5所示:

在这里插入图片描述

图3-5 单继承有虚函数覆盖情况下派生类虚函数表内存布局


接下来就用GDB调试一下,验证上图的内存布局是否正确,如图3-6所示:

在这里插入图片描述

图3-6 GDB查看单继承有虚函数覆盖情况下派生类虚函数表内存布局


从调试结果可以看出图3-5是正确的,并且可以得到以下几点信息:

  1. 覆盖的g()被放到了虚表中原来父类虚函数的位置
  2. 没有被覆盖的虚函数位置排序依旧不变

有了前面的理论基础,我们可以知道对于下面的代码,由base所指的内存中的虚函数表的Base::g()的位置已经被Derived::g()所取代,于是在实际调用发生时,调用的是Derived::g(),从而实现了多态

int main(int argc, char* argv[])
{
    Base* base = new Derived();
    base->f();
    base->g();
    base->h();
}

输出结果如图3-7所示:

在这里插入图片描述

图3-7 程序运行结果

注意:在前面的例子中,我们分配内存的实例对象的类型是Derived,但是却用Base的指针去引用它,这个过程中数据并没有发生任何的转换,实例的真实类型依旧是Derived,但是由于我们使用时用的是Base类型,所以函数调用要依据Base类来,不能胡乱调用,比如说我们此时是无法调用Derivedf1()h1()的。由于这个是个单继承,不存在虚函数表选择问题,相对比较简单。

3.3、多重继承的情况

多重继承就不分开讲有覆盖和无覆盖的情况了,其实结合前面讲的就差不多知道是什么个情况了,下面的例子中会设计成派生类既有自己的虚函数,又有用于覆盖基类的虚函数,这样就能兼顾有覆盖和无覆盖的情况了。类的设计如下:

#include <iostream>
#include <string>

class Base1
{
public:
    virtual void f()
    {
        std::cout << "Base1::f()" << std::endl;
    }

    virtual void g()
    {
        std::cout << "Base1::g()" << std::endl;
    }

    virtual void h()
    {
        std::cout << "Base1::h()" << std::endl;
    }
};

class Base2
{
public:
    virtual void f()
    {
        std::cout << "Base2::f()" << std::endl;
    }

    virtual void g()
    {
        std::cout << "Base2::g()" << std::endl;
    }

    virtual void h()
    {
        std::cout << "Base2::h()" << std::endl;
    }
};

class Base3
{
public:
    virtual void f()
    {
        std::cout << "Base3::f()" << std::endl;
    }

    virtual void g()
    {
        std::cout << "Base3::g()" << std::endl;
    }

    virtual void h()
    {
        std::cout << "Base3::h()" << std::endl;
    }
};

class Derived : public Base1, public Base2, public Base3
{
public:
    virtual void f()
    {
        std::cout << "Derived::f()" << std::endl;
    }

    virtual void g1()
    {
        std::cout << "Derived::g1()" << std::endl;
    }

    virtual void h1()
    {
        std::cout << "Derived::h1()" << std::endl;
    }
};

继承关系如图3-8所示:

在这里插入图片描述

图3-8 类继承关系UML图


测试的代码如下:

int main(int argc, char* argv[])
{
    Derived* d = new Derived();
    Base1* b1 = d;
    Base2* b2 = d;
    Base3* b3 = d;
    std::cout << (long*)(*(long*)b1) << std::endl;
    std::cout << (long*)(*(long*)b2) << std::endl;
    std::cout << (long*)(*(long*)b3) << std::endl;
}

输出结果如图3-9所示:

在这里插入图片描述

图3-9 程序运行结果


输出信息非常有趣,明明b1b2b3指向的都是d,但是它们各自取出来的虚函数表的地址却完全不同,按理来说不是应该相同吗?别急,下面我们通过图3-10来看一看多继承下派生类虚函数表的内存布局是什么样的

在这里插入图片描述

图3-10 多重继承情况下派生类虚函数表内存布局


从图3-10中可以看出以下几点信息:

  1. 在派生类中,每个基类都有一个属于自己的虚函数表
  2. 派生类自己特有的虚函数被放到了第一个基类的表中(第一个基类是按照继承顺序来确定的)

这里我们就会得出一个新问题了,对于上面例子中的b1,这个没啥问题,因为它的类型Base1就是第一个被继承的,所以我们当然可以认为这个不会出任何问题,但是对于b2呢,它被继承的位置可不是第一个啊,运行时要怎么确定它的虚函数表呢?它有没有可能一不小心找到Base1的虚函数去?恰好这个例子中几个基类的虚函数名字和参数又都是完全相同的。这里其实就涉及到编译器的处理了,当我们执行赋值操作Base2* b2 = d;时,编译器会自动把b2的虚函数表指针指向正确的位置,这个过程应该是编译器做的,所以虚函数所实现的多态应该是“静动结合”的,有部分工作需要在编译时期完成的。

下面我们依然借助GDB来看一下实际的内存布局,详见图3-11,从调试信息中可以看出此时确实有三张虚函数表,对应三个基类

在这里插入图片描述

图3-11 GDB查看多重继承情况下派生类虚函数表内存布局


第一张表的数据如图3-12所示,可以看到和图3-10描述的内容是一致的,Derived自己特有的虚函数确实被加入到了第一张表中了,这里指示虚函数表结束的表示好像是那个0xfffffffffffffff8,不知道是不是固定的,有知道的小伙伴麻烦评论区告诉我一下谢谢

在这里插入图片描述

图3-12 派生类第一张虚函数表


第二张表的数据如图3-13所示,这里的结束符变成了0xfffffffffffffff0,搞不懂

在这里插入图片描述

图3-13 派生类第二张虚函数表


第三张表的数据如图3-14所示,这里的结束符终于是0x0

在这里插入图片描述

图3-14 派生类第三张虚函数表


补充说明:如果继承的某个类没有虚函数的话,比如说将上面的Base2修改为以下格式:

class Base2
{
public:
    void f()
    {
        std::cout << "Base2::f()" << std::endl;
    }

    void g()
    {
        std::cout << "Base2::g()" << std::endl;
    }

    void h()
    {
        std::cout << "Base2::h()" << std::endl;
    }
};

main函数不变,再运行以下程序,输出结果如图3-15所示,说明此时就没有指向Base2虚函数表的指针了,因为它本来就没有虚函数表

在这里插入图片描述

图3-15 程序运行结果

3.4、多层继承的情况

多层继承的在有前面的基础上来理解就非常简单了,测试程序如下:

#include <iostream>
#include <string>

class Base
{
public:
    virtual void f()
    {
        std::cout << "Base::f()" << std::endl;
    }

    virtual void g()
    {
        std::cout << "Base::g()" << std::endl;
    }

    virtual void h()
    {
        std::cout << "Base::h()" << std::endl;
    }
};

class Derived : public Base
{
public:
    virtual void f()
    {
        std::cout << "Derived::f()" << std::endl;
    }

    virtual void g1()
    {
        std::cout << "Derived::g1()" << std::endl;
    }
};

class DDerived : public Derived
{
public:
    virtual void f()
    {
        std::cout << "DDerived::f()" << std::endl;
    }

    virtual void h()
    {
        std::cout << "DDerived::h()" << std::endl;
    }

    virtual void g2()
    {
        std::cout << "DDerived::g2()" << std::endl;
    }
};

int main(int argc, char* argv[])
{
    DDerived dd;
    dd.f();
}

继承关系如图3-16所示:

在这里插入图片描述

图3-16 类继承关系UML图


派生类DDerived的虚函数表内存布局如图3-17所示:

在这里插入图片描述

图3-17 多层继承情况下派生类虚函数表内存布局


多层继承的情况这里就不使用GDB去看内存布局了,比较简单,大家可以自行去测试一下。

4、总结

本文先对虚函数的概念进行了简单介绍,引出了虚函数表这个实现虚函数的关键要素,然后对不同继承案例下虚函数表的内存布局进行说明,并使用GDB进行实战验证。相信看完这篇文章后聪明的你会对虚函数有更加深刻的理解了。

最后,如果大家觉得本文写得好的话麻烦点赞收藏关注一下谢谢,也可以关注该专栏,以后会有更多优质文章输出的。

以上是关于一文彻底读懂C++虚函数的实现机制的主要内容,如果未能解决你的问题,请参考以下文章

一文详解C++类的内存布局和虚函数底层实现机制

一文读懂C++虚继承的内存模型

一文读懂C++虚继承的内存模型

一文读懂C++虚继承的内存模型

几秒读懂C++虚函数调用的汇编代码实现

一文彻底读懂Python装饰器