什么是虚函数表(virtual table)
C++语言有三大特性:封装、继承和多态。
多态是在继承基础上出现的需求,而虚函数表就是多态的底层实现原理。
正是因为虚函数表的存在,C++才能够实现以动态/后期绑定方式(dynamic/late binding manner)来解析函数。
虚函数表(virtual table)的其他别名: "vtable", "virtual function table", "virtual method table", 和"dispatch table"。
Note:如何理解多态?
多态即为"多种形态",同一个基类的不同派生类有不同的功能实现方式
就像诸葛亮的锦囊妙计,对应不同的情况打开不同的锦囊。(这是第一个参考的B站视频老师讲的,讲的非常幽默)
虚函数表原理解析
计算机是如何在底层实现多态的?
先来看看虚函数表的调用过程:
-
基类成员函数添加virtual修饰, 我们称之为虚函数。
-
编译器会向基类添加隐藏的指针,我们将其称之为
*__vptr
,*__vptr
是一个真实的指针, 所以基类大小会增加sizeof(*__vptr )
,*__vptr
指向一个函数 指针数组(这就是虚函数表),这个数组中保存所有虚函数的地址。实际上虚函数表是编译器在编译时设置的静态数组。 -
派生类继承基类,会继承基类的函数指针数组里的元素,注意,这里每个派生类都会有一个自己的虚函数表。
-
如果派生类有重写,那么重写后的函数地址会覆盖函数指针数组里对应的地址。所以每个类的虚函数表都指向
most-derived
的函数。 -
调用函数时,会去虚函数表中找函数。
Note:在VS中调试看到的是
*__vfptr
单继承虚函数表
下面来看一个简单的单继承例子:
#include <string>
#include <iostream>
using namespace std;
//基类:学生
class student{
public:
virtual void study() {cout<<"学生学习"<<endl;}
virtual void eat() {cout<<"学生需要吃饭"<<endl;}
private:
string name;
};
//派生类:小学生
class pupil:public student{
public:
virtual void study() {cout<<"小学生快乐学习"<<endl;} //重写了函数study()
private:
string name; //这里不小心多写了一个,可以忽略
};
int main()
{
student A;
A.study();
pupil B;
B.study();
getchar();
return 0;
}
运行输出结果为, 符合预期:
在VS调试时的局部变量窗口,从这里就可以看到,被重写的虚函数地址被覆盖了:
关系如下图所示:
多继承虚函数表
同样举一个多继承的例子(随手写一个emmm):
#include <string>
#include <iostream>
using namespace std;
//基类1:学生
class student{
public:
virtual void study() {cout<<"学生学习"<<endl;}
virtual void eat() {cout<<"学生需要吃饭"<<endl;}
private:
string name;
};
//基类2: 优秀
class good{
public:
virtual void Goodgrades() {cout<<"成绩很好"<<endl;}
virtual void polite() {cout<<"非常礼貌"<<endl;}
};
//派生类:优秀的小学生
class pupil:public student,public good{
public:
virtual void study() {cout<<"小学生快乐学习"<<endl;}
virtual void Goodgrades() {cout<<"小学生成绩很好"<<endl;}
};
int main()
{
student A;
A.study();
good X;
X.Goodgrades();
pupil B;
B.study();
B.Goodgrades();
getchar();
return 0;
}
同样看看VS调试时的局部变量窗口,可以看到同时继承了两个基类的虚函数表,有两个虚函数表指针:
相同类的虚函数表
还是多继承的那段代码,加一个一样的"优秀小学生"C:
pupil B;
pupil C;
可以看到B和C的两个__vfptr
都相同,也就是说相同的类会共用同样的虚函数表。
小结
-
派生类会继承基类的虚函数表,派生类和基类用不同的虚函数表
-
多继承会继承每个基类的表,有几个基类就继承几张表
-
相同的类共用同样的虚函数表
如何自己实现虚函数表功能
以下使用单继承的情况,主要代码如下:
typedef void (*pFUNC)();
pupil B;
//pvfptr指向pupil类对象B,强转为int*, 从以上实验可以知道单继承情况虚函数表指针在内存的前面一部分
int* pvfptr = (int*) &B;
printf("B对象的地址:%p\n", pvfptr);
//获得对象B中的虚函数表地址,强转为int*
int* vfptr = (int*) *pvfptr;
printf("虚函数表地址:%p\n", vfptr);
int* pfunc1 = (int*)* vfptr;
printf("虚函数1地址:%p\n", pfunc1);
int* pfunc2 = (int*)*(vfptr + 1);
printf("虚函数2地址:%p\n", pfunc2);
//调用函数
pFUNC pf1 = (pFUNC)pfunc1;
pf1();
pFUNC pf2 = (pFUNC)pfunc2;
pf2();
输出结果:
构造函数可以是虚函数吗
To construct an object, a constructor needs the exact type of the object it is to create. Consequently, a constructor cannot be virtual. Furthermore, a constructor is not quite an ordinary function, In particular, it interacts with memory management in ways ordinary member functions don't. Consequently, you cannot have a pointer to a constructor.
--- From 《The C++ Progamming Language》15.6.2
构造函数是特殊的,是没有虚函数的概念的。
-
构造函数是不继承的,创建子类对象时,将调用子类的构造函数,子类的构造函数将自动调用父类的构造函数。
-
构造一个对象的时候,必须知道对象的实际类型,而虚函数行为是在运行期间确定实际类型的。而在构造一个对象时,由于对象还未构造成功。编译器无法知道对象的实际类型,是该类本身,还是该类的一个派生类,或是更深层次的派生类。
-
虚函数的执行依赖于虚函数表。而虚函数表在构造函数中进行初始化工作,即初始化vptr,让他指向正确的虚函数表。而在构造对象期间,虚函数表还没有被初始化,将无法进行。
为什么要用虚析构函数
同样还是这个简单的例子, 先不使用虚析构函数,且用基类指针释放派生类:
#include <string>
#include <iostream>
using namespace std;
//基类:学生
class student{
public:
~student(){ cout << "Destroying student" << endl;}
virtual void study(){cout<<"学生学习"<<endl;}
private:
string name;
};
//派生类:小学生
class pupil:public student{
public:
~pupil(){ cout << "Destroying pupil" << endl;}
void study(){cout<<"小学生快乐学习"<<endl;}
};
int main()
{
student A;
pupil B;
student* p = new pupil();
delete p;
getchar();
return 0;
}
输出结果:
从结果可以看到,只执行了基类的析构函数,派生类的析构函数没有被调用。
改为虚析构函数:
virtual ~student(){ cout << "Destroying student" << endl;}
virtual ~pupil(){ cout << "Destroying pupil" << endl;}
由VS截图可以看到虚析构函数也是在虚函数表里的
改成虚析构函数后的结果符合预期,先调用了派生类的析构函数,再调用了基类的析构函数。内存得到正确的释放。
Note:派生类析构函数,一定会调用基类析构函数,释放父类对象,内存安全释放。具体可以看下图的《C++Primer》内容。
小结
-
为什么:析构函数执行时应先调用派生类的析构函数,其次才调用基类的析构函数。如果析构函数不是虚函数,而程序执行时又要通过基类的指针去销毁派生类的动态对象,那么用delete销毁对象时,只调用了基类的析构函数,未调用派生类的析构函数。这样会造成销毁对象不完全(或者说内存泄漏)。
-
什么时候:析构函数应是虚函数,除非类不用做基类。
注意:并不是要把所有类的析构函数都写成虚函数。因为当类里面有虚函数的时候,编译器会给类添加一个虚函数表,里面来存放虚函数指针,这样就会增加类的存储空间。所以,只有当一个类被用来作为基类的时候,才把析构函数写成虚函数。
将基类的析构函数设为virtual型,则基类的所有派生类的析构函数都会自动设置为virtual型,这保证了任何情况下,都不会出现由于析构函数没有被调用而导致的内存泄漏。