@liuyi 会问的
lyy
@lyy
Posts made by lyy
-
C++虚函数和虚函数表
什么是虚函数表(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型,这保证了任何情况下,都不会出现由于析构函数没有被调用而导致的内存泄漏。
参考
-