详解C++中多态的底层原理

目录
  • 前言
  • 1.虚函数表
    • (1)虚函数表指针
    • (2)虚函数表
  • 2.虚函数表的继承–重写(覆盖)的原理
  • 3.观察虚表的方法
    • (1)内存观察
    • (2)打印虚表
    • (3)虚表的位置
  • 4.多态的底层过程
  • 5.几个原理性问题
  • 6.多继承中的虚表

前言

要了解C++多态的底层原理需要我们对C指针有着深入的了解,这个在打印虚表的时候就可以见功底,理解了多态的本质我们才能记忆的更牢,使用起来更加得心应手。

1.虚函数表

(1)虚函数表指针

首先我们在基类Base中定义一个虚函数,然后观察Base类型对象b的大小:

class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1" << endl;
    }
    virtual void Func2()
    {
        cout << "Func2" << endl;
    }
    void f()
    {
        cout << "f()" << endl;
    }
protected:
    int b = 1;
    char ch = 1;
};
int main()
{
    Base b;
    cout << sizeof(b);
    return 0;
}

我们发现,如果按照对齐数原则来计算b的大小时,得到的结果是8,而我们打印的结果是:

这说明带有虚函数的类所定义的对象中,除了成员变量之外还有其他的东西被加入进去了(成员函数默认不在对象内,在代码段)。

我们可以通过调试来观察b中的内容:

我们发现对象中多了一个__vfptr,即为虚函数表指针。简称为虚表指针。

(2)虚函数表

仍然看上图,我们发现虚函数表指针下方有两个地址,这两个地址分别对应的就是Base中两个虚函数的地址,构成了一个虚函数表。所以虚函数表本质是一个指针数组,数组中每一个元素是一个虚函数的地址。

VS2019封装更为严密,在底层的汇编代码中,虚函数表中的地址并不一定是虚函数的地址,可能存放的是跳转到虚函数的地址的指令的地址。这个在后面会加以演示。

因此当我们调用普通函数和虚函数时,它们的本质是不同的:

    Base* bb=nullptr;
    bb->f();
    bb->Func1();

其中bb调用f()的过程没有发生解引用操作,非虚函数在公共代码段中,直接对其进行调用即可。而bb调用Func1()的过程中,需要通过虚表指针来找到Func1(),而拿到虚表指针需要对bb进行解引用操作,而bb是空,因此程序会崩溃。

我们知道对象中只存储成员变量,成员函数存储在公共代码段中,其实虚函数也是一样存储在公共代码段,只不过寻找虚函数需要通过虚表来确定位置。普通函数直接就可以确定位置。

2.虚函数表的继承–重写(覆盖)的原理

还拿上一节中买票的例子举例,其中父类中有两个虚函数,子类重写了其中的一个,子类中还有自己的函数。

class Person
{
public:
    virtual void BuyTicket()
    {
        cout << "全价" << endl;
    }
    virtual void Func1()
    {
        cout << "Func1" << endl;
    }
protected:
    int _a;
};
class Student :public Person
{
public:
    virtual void BuyTicket()
    {
        cout << "半价" << endl;
    }
    virtual void Func2()
    {
        cout << "Func2" << endl;
    }
protected:
    int _b;
};
int main()
{
    Person a;
    Student b;
    return 0;
}

我们可以通过调试来观察一下他们的虚表和虚表指针。

显然父类对象__vfptr[0]中存放的是BuyTicket的地址,__vfptr[1]中存放的是Func1()的地址。子类对象中__vfptr[0]中存放的是继承并重写的BuyTicket的地址,__vfptr[1]中存放的是继承下来但没有进行重写的Func1()的地址。通过对比我们发现:对于没有进行重写的Func1()来说,子类中虚表中的地址和父类中的是一样的,可以说是直接拷贝下来的。而对于进行了重写的BuyTicket来说,子类中虚表的地址与父类中明显不一样,其实是在拷贝了父类的地址后又进行了覆盖的。因此重写从底层的角度来说又叫做覆盖。

同时我们又发现了一个问题,那就是子类对象的虚表中为什么没有写它自己的虚函数地址Func2()呢?

其实是写了的,只不过通过VS的监视窗口并不能看到,我们可以通过内存来进行观察:

3.观察虚表的方法

(1)内存观察

我们可以通过观察内存来观察虚函数表的情况,这里观察的是父类对象,会发现在虚函数指针的地址存放的是父类对象中两个虚函数的地址。

我们也可以观察一下子类对象:

与父类对象中存储的相同,唯一有区别的地方就是紫色的部分,存放的其实是子类虚函数Func2()的地址。这说明Func2()也在虚表中只不过在监视窗口没有看不到而已。

(2)打印虚表

虚表的地址

通过观察内存,对于单继承来说,我们只需要打印对象的首元素的地址即可找到虚表,并进行打印。

我们发现对象的前四个字节存储的就是虚表的地址。可以通过这一点来打印虚表。

我们关闭一下调试来重新写一下代码(关闭调试后再进行运行地址会发生变化,但是规律是不变的)

typedef void(*vfptr)();
void Printvfptr(vfptr* table)
{
    for (int i = 0; table[i] != nullptr; i++)
    {
        printf("%d:%p\n",i,table[i]);
    }
    cout << endl;
}
int main()
{
    Person a;
    Student b;
    Printvfptr((vfptr*)*(void**)&a);
    Printvfptr((vfptr*)*(void**)&b);
    return 0;
}

下面来解释一下如何打印的虚表,分为两部分,一部分是函数,一部分是传参:

函数

首先我们明确,虚函数指针是一个函数指针,因此为了简便我们可以将函数指针重命名为vfptr。

通过接收虚表指针,并依次打印指针数组中的内容(虚函数的地址)。

传参

拿父类对象a举例,我们要找到a的前四个字节的内容,即为虚表指针,然后再传入函数中。

首先使用(void**)对a的地址进行强制类型转换,这其中发生了切割。使用(void**)的原因在于,由于不知道是使用的32位还是64位系统,但我们可以通过指针的大小来判断。首先将&a转换成一个指针,再将其转换成一个指针类型,再进行解引用就得到了a的前4或者8个字节。但同时我们需要传递的是一个vfptr类型的函数指针,所以还需要进行(vfptr*)类型的强制转换。

有了前面的解释,我们就可以理解打印虚表的原理了,我们把这段代码运行一下:

发现分别打印出了a和b的虚函数表。

如果打印的虚函数数量不对,这是VS编译器的bug,我们可以重新生成解决方案,再重新运行代码。

(3)虚表的位置

我们还可以观察一下虚表的位置,在哪个区域:

使用其他区域的变量进行对比:

    Person per;
    Student std;
    int* p = (int*)malloc(4);
    printf("堆:%p\n", p);
    int a = 0;
    printf("栈:%p\n", &a);
    static int b = 1;
    printf("数据段:%p\n", &b);
    const char* c = "aaa";
    printf("常量区:%p\n", &c);
    printf("虚表:%p\n", *(void**)&std);

打印的结果是:

我们发现虚表的位置在数据段和常量区之间。大致属于数据段。

4.多态的底层过程

class Person
{
public:
    virtual void BuyTicket()
    {
        cout << "全价" << endl;
    }
    virtual void Func1()
    {
        cout << "Func1" << endl;
    }
protected:
    int _a;
};
class Student :public Person
{
public:
    virtual void BuyTicket()
    {
        cout << "半价" << endl;
    }
    virtual void Func2()
    {
        cout << "Func2" << endl;
    }
protected:
    int _b;
};
void F(Person& p)
{
    p.BuyTicket();
}
int main()
{
    Person per;
    Student std;
    F(per);
    F(std);
    return 0;
}

我们还使用这一段代码来举例,首先复习一下多态:使用父类的指针或者引用去接收子类或者父类的对象,使用该指针或者引用调用虚函数,调用的是父类或子类中不同的虚函数。

下面来分析原理:

父类对象原理:

首先用父类引用p来接收父类对象per,此时p中的虚表和per中的虚表一模一样,只需要访问__vfptr中的BuyTicket()的地址即可调用该函数。

子类对象的原理:

用p来接收子类对象std,发生切片处理,会将子类中的虚表内容拷贝到父类引用p中,然后再调用其中的__vfptr中的BuyTicket地址。此时的p不是新创建了一个父类对象,而是子类对象std切片后构成的,其中就将重写之后的BuyTicket()的地址也随之切入了p。可以把p看成原std的包含__vfptr的一部分。

总结:基类的指针或者引用,指向谁就去谁的虚函数表中找到对应位置的虚函数进行调用。

5.几个原理性问题

了解了多态原理之后,就可以分析出在上一节中出现的一些现象规律。

(1)虚表中函数是公用的吗?

虚表中的函数和类中的普通函数一样是放在代码段的,只是虚函数还需要将地址存一份到虚表,方便实现多态。这也就说明同一类型的不同对象的虚表指针是相同的,我们还可以通过调试观察:

    Person per;
    Person pper;

(2)为什么必须传入指针或引用而不能使用对象?

当我们使用父类对象去接收时,父类对象本身就具有一个虚表了,当子类对象传给父类对象的时候,其他内容会发生拷贝,但是虚表不会,C++这样处理的原因在于,如果虚表也会发生拷贝的话,那么该父类对象的虚表就存了子类对象的虚表,这是不合理的。

我们同样可以通过调试来进行观察:

void F(Person p)
{
    p.BuyTicket();
}
int main()
{
    Person per;
    Student std;
    F(std);
}

这是std中的虚表内容。

这是p中的虚表内容,而且在调试过程中,程序是进入父类中进行调用函数的。

(3)为什么私有虚函数也能实现多态?

这是因为编译器调用了父类的public接口,由于是父类的引用或者指针,因此编译器发现是public之后就不再进行检查了,只要在虚表中可以找到就能调用函数。

(4)VS中的虚表中存的是指令地址?

在VS2019中,为了封装严密,其实虚表中存入的是跳转指令,我们可以通过反汇编进行观察:

我们将虚表中的地址输入反汇编,看到的是这样的一条语句:

这是一条跳转指令,会跳转到BuyTicket()的实际地址处。

6.多继承中的虚表

谈到多继承就要谈到菱形虚拟继承,这是一个庞大而复杂的问题,需要更大的大佬来解释。

这里只介绍多继承中虚表的内容:

class Base1
{
public:
    virtual void Func1()
    {
        cout << "Func1" << endl;
    }
    virtual void Func2()
    {
        cout << "Func2" << endl;
    }
protected:
    int _a;
};
class Base2
{
public:
    virtual void Func3()
    {
        cout << "Func3" << endl;
    }
    virtual void Func4()
    {
        cout << "Func4" << endl;
    }
};
class Derive :public Base1, Base2
{
public:
    virtual void Func5()
    {
        cout << "Func5" << endl;
    }
};
int main()
{
    Derive a;
}

我们可以使用调试来观察a中的虚表内容:

通过调试我们可以看到a中有两个虚表指针分别存放的是Base1中虚函数的地址和Base2中虚函数的地址,那么a中特有的类Func5()存在哪个虚表呢?这需要通过内存进行观察:

我们发现它被存放在了第一个虚表指针指向的虚表中。

我们知道打印第一个虚表指针指向虚表的方法,那么第二个虚表指针的该怎样进行处理呢:

Printvfptr((vfptr*)*(void**)((char*)&a+sizeof(Base1));

注意需要先将&a转换成char*类型,这样对其加一,才代表加一个字节。

以上就是详解C++中多态的底层原理的详细内容,更多关于C++多态原理的资料请关注我们其它相关文章!

(0)

相关推荐

  • 虚函数表-C++多态的实现原理解析

    参考:http://c.biancheng.net/view/267.html 1.说明 我们都知道多态指的是父类的指针在运行中指向子类,那么它的实现原理是什么呢?答案是虚函数表 在 关于virtual 一文中,我们详细了解了C++多态的使用方式,我们知道没有 virtual 关键子就没法使用多态 2.虚函数表 我们看一下下面的代码 class A { public: int i; virtual void func() { cout << "A func" <<

  • C++ 超详细分析多态的原理与实现

    目录 多态的定义及实现 多态的构成条件 虚函数重写 C++11的override和final 抽象类 多态的原理 虚函数表 动态绑定与静态绑定 单继承和多继承关系的虚函数表 单继承中的虚函数表 多继承中的虚函数表 常见问题 多态的定义及实现 多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态. 比如买票这个行为,当普通人买票时,是全价买票:学生买票时,是半价买票:军人买票时是优先买票. 多态的构成条件 多态是在不同继承关系的类对象,去调用同一函数

  • C++多重继承及多态性原理实例详解

    一.多重继承的二义性问题 举例: #include <iostream> using namespace std; class BaseA { public: void fun() { cout << "A.fun" << endl; } }; class BaseB { public: void fun() { cout << "B.fun" << endl; } void tun() { cout &l

  • 一篇文章带你了解C++多态的实现原理

    目录 虚函数和多态 多态的作用 多态的一个例子 构造函数和析构函数中存在多态吗? 多态的实现原理 虚函数表 虚析构函数 纯虚函数和抽象类 总结 虚函数和多态 虚函数: 在类的定义中,前面有 virtual 关键字的成员函数称为虚函数 virtual 关键字只用在类定义里的函数声明中,写函数体时不用 比如: class Base { virtual int Fun() ; // 虚函数 }; int Base::Fun() // virtual 字段不用在函数体时定义 { } 多态的表现形式 派生

  • C++多态的实现及原理详细解析

    1. 用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数.2. 存在虚函数的类都有一个一维的虚函数表叫做虚表.类的对象有一个指向虚表开始的虚指针.虚表是和类对应的,虚表指针是和对象对应的.3. 多态性是一个接口多种实现,是面向对象的核心.分为类的多态性和函数的多态性.4. 多态用虚函数来实现,结合动态绑定.5. 纯虚函数是虚函数再加上= 0.6. 抽象类是指包括至少一个纯虚函数的类. 纯虚函数:virtual void breathe()=0:即抽象类!必须在子类实现这个函数!

  • 详解C++ 多态的实现及原理

    C++的多态性用一句话概括就是:在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数.如果对象类型是派生类,就调用派生类的函数:如果对象类型是基类,就调用基类的函数 1:用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数. 2:存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针.虚表是和类对应的,虚表指针是和对象对应的. 3:多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态

  • c++语言中虚函数实现多态的原理详解

    前言 自上一个帖子之间跳过了一篇总结性的帖子,之后再发,今天主要研究了c++语言当中虚函数对多态的实现,感叹于c++设计者的精妙绝伦 c++中虚函数表的作用主要是实现了多态的机制.首先先解释一下多态的概念,多态是c++的特点之一,关于多态,简而言之就是 用父类的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数,这种方法呢,可以让父类的指针具有多种形态,也就是说不需要改动很多的代码就可以让父类这一种指针,干一些很多子类指针的事情,这里是从虚函数的实现机制层面进行研究 在写这篇帖子之前

  • 详解C++中多态的底层原理

    目录 前言 1.虚函数表 (1)虚函数表指针 (2)虚函数表 2.虚函数表的继承–重写(覆盖)的原理 3.观察虚表的方法 (1)内存观察 (2)打印虚表 (3)虚表的位置 4.多态的底层过程 5.几个原理性问题 6.多继承中的虚表 前言 要了解C++多态的底层原理需要我们对C指针有着深入的了解,这个在打印虚表的时候就可以见功底,理解了多态的本质我们才能记忆的更牢,使用起来更加得心应手. 1.虚函数表 (1)虚函数表指针 首先我们在基类Base中定义一个虚函数,然后观察Base类型对象b的大小:

  • 详解ES6中class的实现原理

    一.在ES6以前实现类和继承 实现类的代码如下: function Person(name, age) { this.name = name; this.age = age; } Person.prototype.speakSomething = function () { console.log("I can speek chinese"); }; 实现继承的代码如下:一般使用原型链继承和call继承混合的形式 function Person(name) { this.name =

  • 详解Java 中泛型的实现原理

    泛型是 Java 开发中常用的技术,了解泛型的几种形式和实现泛型的基本原理,有助于写出更优质的代码.本文总结了 Java 泛型的三种形式以及泛型实现原理. 泛型 泛型的本质是对类型进行参数化,在代码逻辑不关注具体的数据类型时使用.例如:实现一个通用的排序算法,此时关注的是算法本身,而非排序的对象的类型. 泛型方法 如下定义了一个泛型方法, 声明了一个类型变量,它可以应用于参数,返回值,和方法内的代码逻辑. class GenericMethod{ public <T> T[] sort(T[]

  • 详解Java volatile 内存屏障底层原理语义

    目录 一.volatile关键字介绍及底层原理 1.volatile的特性(内存语义) 2.volatile底层原理 二.volatile--可见性 三.volatile--无法保证原子性 四.volatile--禁止指令重排 1.指令重排 2.as-if-serial语义 五.volatile与内存屏障(Memory Barrier) 1.内存屏障(Memory Barrier) 2.volatile的内存语义实现 六.JMM对volatile的特殊规则定义 一.volatile关键字介绍及底

  • 详解Java中跳跃表的原理和实现

    目录 一.跳跃表的引入 二.算法分析 1.时间复杂度 2.空间复杂度 三.跳跃表介绍 四.跳跃表的实现 1.数据结构定义 2.查找 3.插入 4.删除 五.实战 1.代码 2.测试结果 一.跳跃表的引入 对有序顺序表可以采用二分查找,查找的时间复杂度为O (logn),插入.删除的时间复杂度为 O(n ).但是对有序链表不可以采用二分查找,查找.插入和删除的时间复杂度均为O (n). 有序链表如下图所示,若查找 8,则必须从第 1 个节点开始,依次比较 8 次才能查找成功. 如何利用链表的有序性

  • 详解Java中AC自动机的原理与实现

    目录 简介 工作过程 数据结构 初始化 构建字典树 构建失败指针 匹配 执行结果 简介 AC自动机是一个多模式匹配算法,在模式匹配领域被广泛应用,举一个经典的例子,违禁词查找并替换为***.AC自动机其实是Trie树和KMP 算法的结合,首先将多模式串建立一个Tire树,然后结合KMP算法前缀与后缀匹配可以减少不必要比较的思想达到高效找到字符串中出现的匹配串. 如果不知道什么是Tire树,可以先查看:详解Java中字典树(Trie树)的图解与实现 如果不知道KMP算法,可以先查看:详解Java中

  • 详解Android中Handler的实现原理

    在 Android 中,只有主线程才能操作 UI,但是主线程不能进行耗时操作,否则会阻塞线程,产生 ANR 异常,所以常常把耗时操作放到其它子线程进行.如果在子线程中需要更新 UI,一般是通过 Handler 发送消息,主线程接受消息并且进行相应的逻辑处理.除了直接使用 Handler,还可以通过 View 的 post 方法以及 Activity 的 runOnUiThread 方法来更新 UI,它们内部也是利用了 Handler .在上一篇文章 Android AsyncTask源码分析 中

  • 详解Android中Handler的内部实现原理

    本文主要是对Handler和消息循环的实现原理进行源码分析,如果不熟悉Handler可以参见博文<详解Android中Handler的使用方法>,里面对Android为何以引入Handler机制以及如何使用Handler做了讲解. 概括来说,Handler是Android中引入的一种让开发者参与处理线程中消息循环的机制.我们在使用Handler的时候与Message打交道最多,Message是Hanlder机制向开发人员暴露出来的相关类,可以通过Message类完成大部分操作Handler的功

  • 详解Python中迭代器和生成器的原理与使用

    目录 1.可迭代对象.迭代器 1.1概念简介 1.2可迭代对象 1.3迭代器 1.4区分可迭代对象和迭代器 1.5可迭代对象和迭代器的关系 1.6可迭代对象和迭代器的工作机制 1.7自己动手创建可迭代对象和迭代器 1.8迭代器的优势 1.9迭代器的缺点和误区 1.10python自带的迭代器工具itertools 2.生成器 2.1生成器的创建方法 2.2生成器方法 2.3生成器的优势 2.4生成器应用场景 3.生成器节省内存.迭代器不节省内存 3.1可迭代对象 3.2迭代器 3.3生成器 3.

  • 一文详解C#中方法重载的底层玩法

    目录 一:为什么 C 不支持 二:C++ 符号表突破 三:C#如何实现突破 最近在看 C++ 的方法重载,我就在想 C# 中的重载底层是怎么玩的,很多朋友应该知道 C 是不支持重载的,比如下面的代码就会报错. #include <stdio.h> int say() { return 1; } int say(int i) { return i; } int main() { say(10); return 0; } 从错误信息看,它说 say 方法已经存在了,尴尬... 一:为什么 C 不支

随机推荐