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

Julie ·
更新时间:2024-11-01
· 1068 次阅读

目录

前言

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++多态原理的资料请关注软件开发网其它相关文章!



c+ C++ 多态

需要 登录 后方可回复, 如果你还没有账号请 注册新账号