大家好!上一篇文章,主要是说了多态的概念和使用。这篇文章就会说一下多态的底层原理,如果对多态的使用和概念不清的可以看一下上篇文章(多态概念)。
文章目录
- 1. 多态的原理
- 1.1 虚函数表
- 1.2 多态的原理
- 1.3 动态绑定与静态绑定
- 2. 多继承关系的虚函数表
- 3. 一些其余问题
1. 多态的原理
1.1 虚函数表
首先,我们先看下面的例子:
可能有的同学会很疑惑,成员函数不是不在类里面吗?为什么这里不是4个字节,而是8个字节。我们调试来看一下:
除了_a成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function),其实是一个指针数组,里面存的是虚函数指针。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
那么派生类中这个表放了些什么呢?我们接着往下分析。
从上图,我们可以看出:
1. 派生类对象中也有一个虚表指针,派生类对象由两部分构成,一部分是父类继承下来的成员,一部分是自己的成员。
子类和父类间Func2函数的地址是一样的,但是Func1函数的地址是不一样的,原因是子类把Func1虚函数重写了。
2. 基类对象和派生类对象虚表是不一样的,Func1完成了重写,所以子类的虚表中存的是重写的B::Func1,所以虚函数的重写也叫作覆盖。覆盖就是指虚表中虚函数的覆盖。
重写是语法的叫法(派生类对继承基类虚函数实现进行了重写),覆盖是原理层的叫法(子类的虚表,拷贝父类的虚表进行修改,覆盖那个虚函数)。
3.另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
派生类自己新增加的虚函数放在哪里呢?
我们调试来看一下:
你会发现找不到虚函数Func3,我们可以看一下内存窗口:
我们可以看到第三个是有一个地址的,但是我们不确定它是不是Func3。我们可以这样去确定:取内存值,打印并调用,确认是否是func3
那么我们该如何找到这个虚表呢?
首先,为了找到这个虚表,我们必须先找到vfptr。而这个vfptr是一个指向函数数组的指针。那么现在就有一个问题:如何去取出这个地址呢?
因为这个vfptr地址在vs编译器下是在类里面首位,而且地址是4个字节,我们可以先这样:
B b;
(int*)&b)
这样的话我们就能取出4个字节,但这里我们不能强转成int,因为B类型和int类型没有任何关系。但地址间它们都是一串数字,是可以相互强转的。
然后我们在解引用:
B b;
*((int*)&b)
这样就能取出4个字节了。但是它解引用是一个int,我们需要再次强转成函数数组指针类型。
当我们取到这个vfptr(函数指针数组),我们就可以打印这个虚表了。由于在VS下数组最后面放了一个nullptr,我们可以这样:
需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。
然后,有了函数地址,我们再去调用一下:
然后,我们来看一下结果:
所以,在VS编译器监视窗口下,它是优化过的现象,有点不准确。因为Func3没有形成多态,它就没有显示出来。但通过验证,虚函数一定是放在虚表里的。
可能有的同学会问:虚函数存在哪的?虚表存在哪的?
虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。
注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是它的指针存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。而虚表是存在代码段的。
我们可以来验证一下:
这里的虚函数地址,首先函数名就是函数地址,我们需要指定类域,然后要加上这个&,这是规定。从上图可以看出,虚表和虚函数比较接近代码段。
1.2 多态的原理
那么多态又是如何实现的呢?
我们来看一下这个运行结果:
虚函数的调用产生了多态的效果,而普通函数的调用只调用基类的函数。这是为什么呢?
原因是:
多态调用:运行时决议——运行时,去指向的对象虚表中确定调用函数的地址。
普通调用:编译时决议——编译时,确定调用函数的地址。
我们可以看一下反汇编:
看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
那么引用行不行呢?
引用也可以实现多态。
那么对象赋值也可以切片,为什么不能形成多态呢?
原因是:对象切片的时候,子类只会拷贝成员给父类对象,不会拷贝虚表指针。
如果拷贝的话,会怎么样呢?
那么子类和父类都会指向子类的虚表。如果我们在遇到这样的代码:
此时,ptr去找Func1,它就不知道找的是父类的虚函数,还是找子类的虚函数了。就会发生混乱了。所以,它不允许对象赋值实现多态。
1.3 动态绑定与静态绑定
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态。比如:函数重载,模板。
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
2. 多继承关系的虚函数表
上面我们已经说过单继承关系的虚函数表,下面我们就说一下多继承的虚函数表吧。
看下面的例子:
因为是多继承,那么肯定会继承Base1一个,Base2一个。然后我们看一下调试结果:
我们可以看到,是存在两个Base,每个Base都存在一个虚表。但现在有一个问题:子类的Func3存在那个虚表里呢?
我们现在需要把两个虚表里的内容都打印出来:那么Base1我们已经会打印了,Base2我们该如何打印呢?
这里我们不能直接加8个字节,因为可能会有内存对齐啥的。我们可以直接加一个sizeof(Base1)。
但是这样还不行,因为&d加1是加上整个d,我们需要强转成char*类型。
然后我们在看一下运行结果:
可以看到多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中
但是这里存在一个问题:
Func1函数的地址不一样。原因是可能不是真正的函数,它是被封装过的。
这里我们用printf,因为cout不能识别函数指针,所以无法配对。但是,我们发现这里的函数地址和两个都不一样。这是为什么呢?
原因是:在VS下,它进行了一些封装,我们看到的虚表里的地址,并不是真正的函数地址,而是另外一些指令的地址,而通过这些调用指令去完成这个函数的内容,可能都不会直接去调用函数地址去使用。至于到底为什么这样做,很复杂,感兴趣可以研究。
3. 一些其余问题
1. inline函数可以是虚函数吗?
可以,不过在多态调用时,编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去
注意:一定是在多态调用时,才会忽略内联函数,其它情况下调用都还是内联。
2. 静态成员可以是虚函数吗?
不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数,所以静态成员函数无法放进虚函数表。
3. 构造函数可以是虚函数吗?
不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
注意:虚表是在编译的时候就出现了,但是虚函数表指针还是一个随机值。而虚函数的意义是多态,多态调用时到虚函数表里找,但构造函数之前还没初始化,如何去找呢?
4. 析构函数可以是虚函数吗?
可以,并且最好把基类的析构函数定义成虚函数。
5. 对象访问普通函数快还是虚函数更快?
首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
6. 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
7. C++菱形继承的问题?虚继承的原理?
注意这里不要把虚函数表和虚基表搞混了。虚函数表存的是虚函数地址是为了实现多态,虚基表存的是偏移量是为了解决数据冗余和二义性。