C++虚函数和虚函数表



  • 什么是虚函数表(virtual table)

    C++语言有三大特性:封装继承多态

    多态是在继承基础上出现的需求,而虚函数表就是多态的底层实现原理。

    正是因为虚函数表的存在,C++才能够实现以动态/后期绑定方式(dynamic/late binding manner)来解析函数。

    虚函数表(virtual table)的其他别名: "vtable", "virtual function table", "virtual method table", 和"dispatch table"。

    Note:如何理解多态?

    多态即为"多种形态",同一个基类的不同派生类有不同的功能实现方式

    就像诸葛亮的锦囊妙计,对应不同的情况打开不同的锦囊。(这是第一个参考的B站视频老师讲的,讲的非常幽默)

    虚函数表原理解析

    计算机是如何在底层实现多态的?

    先来看看虚函数表的调用过程:

    1. 基类成员函数添加virtual修饰, 我们称之为虚函数。

    2. 编译器会向基类添加隐藏的指针,我们将其称之为*__vptr, *__vptr是一个真实的指针, 所以基类大小会增加 sizeof(*__vptr ), *__vptr指向一个函数 指针数组(这就是虚函数表),这个数组中保存所有虚函数的地址。实际上虚函数表是编译器在编译时设置的静态数组。

    3. 派生类继承基类,会继承基类的函数指针数组里的元素,注意,这里每个派生类都会有一个自己的虚函数表。

    4. 如果派生类有重写,那么重写后的函数地址会覆盖函数指针数组里对应的地址。所以每个类的虚函数表都指向most-derived的函数。

    5. 调用函数时,会去虚函数表中找函数。

    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;
    }
    

    运行输出结果为, 符合预期:

    0_1590300570464_7030d7aa-50bc-4e18-bdf3-b1a5bd08a2e7-image.png

    在VS调试时的局部变量窗口,从这里就可以看到,被重写的虚函数地址被覆盖了:
    0_1590300604091_66848f7c-43ca-4a59-a97a-a50a55c35a17-image.png

    关系如下图所示:

    0_1590300622801_b9baa45f-3c13-44d0-8463-edd6880584f9-image.png

    多继承虚函数表

    同样举一个多继承的例子(随手写一个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调试时的局部变量窗口,可以看到同时继承了两个基类的虚函数表,有两个虚函数表指针:
    0_1590300705086_7d70bb08-339c-4676-a7bc-a45e86e70232-image.png

    相同类的虚函数表

    还是多继承的那段代码,加一个一样的"优秀小学生"C:

    	pupil B;
    	pupil C;
    

    0_1590300847009_8b8ebd17-68f9-48ea-b5f2-6ea1e47ef731-image.png

    可以看到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();
    

    输出结果:
    0_1590300877080_f2b13631-f8b7-4edd-b85b-7a88bd4d3a41-image.png

    构造函数可以是虚函数吗

    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;
    }
    

    输出结果:

    0_1590300929372_868774e5-dc28-4c9e-9213-fe566d8c036b-image.png
    从结果可以看到,只执行了基类的析构函数,派生类的析构函数没有被调用。

    改为虚析构函数:

    virtual ~student(){ cout << "Destroying student" << endl;}
    virtual ~pupil(){ cout << "Destroying pupil" << endl;}
    

    由VS截图可以看到虚析构函数也是在虚函数表里的

    0_1590300952482_ef015b3d-027b-46ce-accb-a5665a34ec28-image.png

    改成虚析构函数后的结果符合预期,先调用了派生类的析构函数,再调用了基类的析构函数。内存得到正确的释放。

    0_1590300964097_ee905cab-eed9-4ace-996f-8f00e7e0797a-image.png

    Note:派生类析构函数,一定会调用基类析构函数,释放父类对象,内存安全释放。具体可以看下图的《C++Primer》内容。

    0_1590300998644_16ba5d07-f462-42ee-a198-030d02c75e1a-image.png

    小结

    • 为什么:析构函数执行时应先调用派生类的析构函数,其次才调用基类的析构函数。如果析构函数不是虚函数,而程序执行时又要通过基类的指针去销毁派生类的动态对象,那么用delete销毁对象时,只调用了基类的析构函数,未调用派生类的析构函数。这样会造成销毁对象不完全(或者说内存泄漏)。

    • 什么时候:析构函数应是虚函数,除非类不用做基类。

    注意:并不是要把所有类的析构函数都写成虚函数。因为当类里面有虚函数的时候,编译器会给类添加一个虚函数表,里面来存放虚函数指针,这样就会增加类的存储空间。所以,只有当一个类被用来作为基类的时候,才把析构函数写成虚函数。

    将基类的析构函数设为virtual型,则基类的所有派生类的析构函数都会自动设置为virtual型,这保证了任何情况下,都不会出现由于析构函数没有被调用而导致的内存泄漏。

    参考



  • 👍我上次面试还被问到怎么用c实现



  • @tangbin 面试问这么底层嘛,我上次就被问到他的工作原理



  • @liuyi 会问的😂


登录后回复
 

Copyright © 2018 bbs.dian.org.cn All rights reserved.

与 Dian 的连接断开,我们正在尝试重连,请耐心等待