c++ 虚函数表
在c++中,实现多态有多种方式,其中动态多态的核心就是虚函数表。每一个拥有虚函数的类,都有一个虚函数表。
虚函数表
类的虚函数表
对于虚函数表,我们首先可以记住一些基本的知识:
- 每个包含了虚函数的类,都会有虚函数表;
- 每个类只有一个虚函数表,每个类的对象内会有一个虚函数指针指向这个类的虚函数表
- 一个类继承另一个类时,会继承这个类的虚函数,所以一个类继承了包含虚函数的基类,那么这个类也会有虚函数,也会有虚函数表
- 虚函数表内的指针赋值发生在编译器
下面这张图也基本描述了一些虚函数表和虚函数指针的信息,虚函数表是一个指针数组,其元素是虚函数的指针,虚函数和虚函数表内的指针一一对应;而该类的非虚函数,在调用时不需要经过虚函数表。
1 | class A{ |
类的对象的虚函数表指针
对于类A的每一个对象来说,都会有一个虚函数表指针指向这个类的虚函数表。虚函数表指针是编译器在编译时创建的。
同一个类的所有对象都使用同一个虚函数表,下图中A的对象中虚函数表指针都指向同一个虚函数表。
虚函数表和动态绑定
继承中的虚函数表
虚函数表时如何实现动态绑定的呢?我们研究一个简单的案例,类A是基类,类B继承于类A,可以使用下图来描述其对象的虚函数表关系。
1 | class B : public A{ |
此时,类A和类B的继承关系和虚函数表关系如图所示,由于类B继承了类A,并且重写了虚函数vfunc2
。对于类A的对象来说,其虚函数表中,指向类A的的两个虚函数A::vfunc1
()和A::vfunc2()
。而类B的虚函数表,指向重写后的B::vfunc1
和继承于类A的虚函数A::vfunc2()
。
总而言之,一个基本结论:对象的虚函数表指针都是指向对象自己的虚函数表。一般来说,虚函数表指针占用4个字节的大小。
虚函数实现的多态
对于上面的A和B,通过以下程序来认识通过虚函数实现的多态。为了说明虚函数和多态,我们观察以下程序的输出。
1 | class A{ |
程序的输入如下
1 | A::func1 |
在程序中,我们定义了一个B的对象bObject
,然后声明一个类A的指针a
指向bObject
和一个类B的指针b
指向bObject
。
当指针
a
调用func1
时,非虚函数不需要经过虚函数表,a
调用了自身的func1
,输出A::func1
;而当指针a调用虚函数
vfun1
时,此时需要使用虚函数表,而此时的对象bObject
的虚函数指针指向的虚函数表中的vfun1
指向的是B::vfunc1
,输出了B::vfunc1
;- 当指针
b
调用func1
时,b
调用了自身的func1
,输出B::func1
; - 当指针
b
调用vfunc1
时,此时需要使用虚函数表,所以调用了B::vfunc1
,输出B::vfunc1
。
这种通过虚函数表调用虚函数的过程称之为动态绑定,表现出来就是运行时的动态。
如果此时还有一个类C继承于类B,代码如下,又会输出什么呢?
1 | class C : public B |
代码输出如下
1 | B::vfunc1 |
对于a->vfunc2();b->vfunc2();c->vfunc2();
这三个调用,通过前面地例子地学习,此处应该通过虚函数表来调用虚函数,可以很清楚地知道会调用C::vfunc2()
。需要关注的是c->vfunc1()
,此时类C的虚函数表里面对应的vfunc1()
是B::vfunc1()
。所以可以总结得到,虚函数的调用,调用的是指向的对象的最近的一个类虚函数表的该虚函数
通过观察可以看到,实现这种多态有几个关键的条件:
- 需要通过指针来调用函数
- 调用的是虚函数
- 有继承类向基类的转化,例如上面的通过基类指针指向继承类的对象。
对于这种多态,我们可以总结出来:
非虚函数,谁的指针调用谁的方法
虚函数,调用指向的对象的最近的一个类虚函数表的函数
多重继承中的虚函数表
考虑以下场景,如果增加了一个类D,一个类E继承于A和D,其虚函数表会是什么样的构成呢?这里就涉及到了多重继承的问题。
1 | class D { |
首先,需要注意的是如果D和A有相同的函数,并且没有被e重写过,使用e的指针调用该函数是会出现调用不明确的问题,无法编译通过的,但是可以通过e->A::vfunc2
来调用。
在多重继承中:
1) 每个父类都有对应的虚函数表。
2) 子类的成员函数调用,如果没有被重写,是按声明继承的顺序,如果被重写就调用重写后的。依然是调用指向的对象的最近的一个类虚函数表的函数
虚函数表相关的其他问题
虚函数默认值问题
纯虚函数中有默认值,如下面的程序之中,基类和派生类的虚函数vfunc1()
里面都有一个默认值name
。
1 | class A { |
在实际调用时,我们可以知道的是a->vfunc1()
和b->vfunc1()
实际上都调用的是B::vfunc1
,这一点毫无疑问,所以函数的输出结果是
1 | B::vfunc1 A |
前面的B::vfunc1
没有问题,有趣的后面的默认参数name
,a->vfunc1()
使用的默认参数是`std::string name =" A "
,b->vfunc1()
使用的默认参数是std::string name =" B "
,这是因为参数值是在编译期就已经决定的,所以类A的指针调用时,使用的时类A的默认参数。类B的指针调用时使用的时类B的默认参数。
虚函数与构造函数和析构函数
- 构造函数不能是虚函数,如果构造函数是虚函数,此时对象开始还未分配内存空间,也就不会有什么虚函数表了,所以根本就无法找到虚函数表,也就无法调用构造函数了,所以构造函数是不能成为虚函数。
- 析构函数可以是虚函数,并且类有继承的时候,析构函数常常必须为虚函数。当使用基类指针指向派生类对象时,基类指针调用析构函数,因为析构函数是虚函数,基类和子类都会被释放。如果析构函数不是虚函数的时候,只能调用基类析构函数,只有基类会被释放,而派生类没有被释放。
对于如下代码
1 | class A { |
因为A的构造函数是虚函数,所以会输出1
2
3
4~B()
~A()
~B()
~A()`
如果A的析构函数不是虚函数,那么只能是
1 | ~A() |
此时,指针a的对象派生类内存没有释放。
此时还有一个问题,如果构造函数里面有虚函数呢?例如下面这个例子。
1 | class A { |
这里输出是
1 | A::vfunc1 |
这里初始化类B的对象,先调用B的构造函数,会调用A的构造函数,先按照A对象的内存布局进行初始化,初始化了类A的对象的虚函数表指针,所以此时的vfunc1
指向的是A::vfunc1
,虚函数表指针还没有指向B的虚函数表。之后对B的对象内存布局进行初始化,虚函数表指针才会指向B的虚函数表,然后才会调用B的B::vfunc1
。如果是析构函数里面有虚函数,那么顺序就是相反的,因为析构的顺序和构造函数的顺序是相反的。
多态与类型转换
考虑如下函数的输出,还是前面的类A类B,构造函数和析构函数里的调用和输出注释掉。
1 | void test1(A &a){ |
对于函数test1
来说,其输入的是该对象的引用,而test2
输入的是该对象的拷贝赋值。所以在test1
中调用虚函数vfunc1
时,调用的依然是B::vfunc1()
,而在test2
里调用的是A::vufnc2()
,所以输出如下:
1 | B::vfunc1 |