• C/C++多态/虚函数的原理(实现过程)


    多态

    面向对象编程有三大特性:继承、封装和多态。

    其中,多态又分为编译时多态和运行时多态。编译多态是通过重载函数体现的,运行多态是通过虚函数体现的。

    多态是如何实现的呢?下面举个例子:

    #include <iostream>
    
    using namespace std;
    
    class Base {
    public:
    	virtual void fun() {
    		cout << " Base::func()" << endl;
    	}
    	void fun1(int a) {
    		cout << "Base::func1()" << endl;
    	}
    	void fun2(int a, int b) {
    		cout << "Base::func2()" << endl;
    	}
    };
    class Son1 : public Base {
    public:
    	virtual void fun() override {
    		cout << " Son1::func()" << endl;
    	}
    };
    class Son2 : public Base {
    
    };
    int main()
    {
    
    	cout << "编译时多态" << endl;
    	Base* base1 = new Base;
    	base1->fun1(1);
    	base1->fun2(1,1);
    	cout << "运行时多态" << endl;
    	Base* base = new Son1;
    	base->fun();
    	base = new Son2;
    	base->fun();
    	delete base;
    	base = NULL;
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    结果:
    在这里插入图片描述

    在例子中

    • 由于Base类中 fun1 和 fun2 函数签名不同(其中,函数后面是否有const 也是签名的一部分),从结果分析实现重载,体现了多态性。
    • Base为基类,其中的函数为虚函数。子类1继承并重写了基类的函数,子类2继承基类但没有重写基类的函数,从结果分析子类体现了多态性。

    那么为什么会出现多态性,其底层的原理是什么?这里需要引出一些相关的概念来进行解释。

    虚表和虚表指针

    • 虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表
    • 虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针

    父类对象模型:
    在这里插入图片描述
    子类对象模型:
    在这里插入图片描述
    上图中展示了虚表和虚表指针在基类对象和派生类对象中的模型,下面阐述实现多态的过程:

    (1)编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址

    (2)编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数

    (3)所谓的合适时机,在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表

    (4)当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。

    下面在VS2019环境下,通过程序展现

    代码部分:

    #include <iostream>
    
    using namespace std;
    
    
    class A {
    public:
    	virtual void vfunc1() {
    		cout << "A::vfunc1() -> ";
    	}
    	virtual void vfunc2() {
    		cout << "A::vfunc2() -> " ;
    	}
    	void func1() {
    		cout << "A::func1() -> " ;
    	}
    	void func2() {
    		cout << "A::func2() -> " ;
    	}
    	int m_data1, m_data2;
    };
    class B : public A {
    public:
    	virtual void vfunc1() {
    		cout << "B::vfunc1() -> " ;
    	}
    	void func2() {
    		cout << "B::func2() -> " ;
    	}
    	int m_data3;
    };
    class C : public B {
    public:
    	virtual void vfunc1() {
    		cout << "C::vfunc1() -> " ;
    	}
    	void func2() {
    		cout << "C::func2() -> " ;
    	}
    	int m_data1, m_data4;
    };
    int main()
    {
    	//  这里指针操作比较混乱,在此稍微解析下:
    
    	//  *****printf("虚表地址:%p\n", *(int *)&b); 解析*****:
    	//  1.&b代表对象b的起始地址
    	//  2.(int *)&b 强转成int *类型,为了后面取b对象的前四个字节,前四个字节是虚表指针
    	//  3.*(int *)&b 取前四个字节,即vptr虚表地址
    	//
    
    	//  *****printf("第一个虚函数地址:%p\n", *(int *)*(int *)&b);*****:
    	//  根据上面的解析我们知道*(int *)&b是vptr,即虚表指针.并且虚表是存放虚函数指针的
    	//  所以虚表中每个元素(虚函数指针)在32位编译器下是4个字节,因此(int *)*(int *)&b
    	//  这样强转后为了后面的取四个字节.所以*(int *)*(int *)&b就是虚表的第一个元素.
    	//  即f()的地址.
    	//  那么接下来的取第二个虚函数地址也就依次类推.  始终记着vptr指向的是一块内存,
    	//  这块内存存放着虚函数地址,这块内存就是我们所说的虚表.
    
    	cout << "class A 成员函数、成员变量的地址::" << endl;
    	A a;
    	cout << "A::vptr 地址 :" << *(int*)&a << endl;
    	cout << "A::vtbl 地址 :" << *(int*)*(int*)&a << endl;
    	cout << "A::vtbl 地址 :" << *((int*)*(int*)(&a) + 1) << endl;
    	union {
    		void* pv;
    		void(A::* pfn)();
    	} u;
    	u.pfn = &A::vfunc1;
    	(a.*u.pfn)();
    	cout << u.pv << endl;
    	u.pfn = &A::vfunc2;
    	(a.*u.pfn)();
    	cout << u.pv << endl;
    	u.pfn = &A::func1;
    	(a.*u.pfn)();
    	cout << u.pv << endl;
    	u.pfn = &A::func2;
    	(a.*u.pfn)();
    	cout << u.pv << endl;
    	cout << "class B 成员函数、成员变量的地址::" << endl;
    	B b;
    	cout << "B::vptr 地址 :" << *(int*)&b << endl;
    	cout << "B::vtbl 地址 :" << *(int*)*(int*)&b << endl;
    	cout << "B::vtbl 地址 :" << *((int*)*(int*)(&b) + 1) << endl;
    	union {
    		void* pv;
    		void(B::* pfn)();
    	} m;
    	m.pfn = &B::vfunc1;
    	(b.*m.pfn)();
    	cout << m.pv << endl;
    	m.pfn = &B::vfunc2;
    	(b.*m.pfn)();
    	cout << m.pv << endl;
    	m.pfn = &B::func1;
    	(b.*m.pfn)();
    	cout << m.pv << endl;
    	m.pfn = &B::func2;
    	(b.*m.pfn)();
    	cout << m.pv << endl;
    	cout << "class C 成员函数、成员变量的地址::" << endl;
    	C c;
    	cout << "C::vptr 地址 :" << *(int*)&c << endl;
    	cout << "C::vtbl 地址 :" << *(int*)*(int*)&c << endl;
    	cout << "C::vtbl 地址 :" << *((int*)*(int*)(&c) + 1) << endl;
    	union {
    		void* pv;
    		void(C::* pfn)();
    	} n;
    	n.pfn = &C::vfunc1;
    	(c.*n.pfn)();
    	cout << n.pv << endl;
    	n.pfn = &C::vfunc2;
    	(c.*n.pfn)();
    	cout << n.pv << endl;
    	n.pfn = &C::func1;
    	(c.*n.pfn)();
    	cout << n.pv << endl;
    	n.pfn = &C::func2;
    	(c.*n.pfn)();
    	cout << n.pv << endl;
    }	
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123

    运行结果:
    请添加图片描述
    整个程序图示:
    在这里插入图片描述
    通过图示我们可以看出,函数在构造后,通过vptr寻找到vtbl,进而得到所对应的成员函数。而它是怎么做到寻找到所需要的是父类还是子类的成员函数呢?

    这里就要提到另一个隐藏的指针,this指针

    this指针是隐藏在类里面的一个指针,它指向当前对象,通过它可以访问当前对象的所有成员。

    如程序中如果出现:

    	C c;
    	c.vfunc1();
    
    • 1
    • 2

    其实编译器会对其进行处理,从直观上可以将 vfunc1() 看作是下面形式(不知编译器是否这样转换):

    	c.A::vfunc1(&c);
    
    • 1

    其中,&c就是隐藏的this指针,通过this指针,进而得到c对象需要的成员函数。

    同时,这里面还包括另一个C++语法:动态绑定和静态绑定

    • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
    • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

    从上面的定义也可以看出,非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。

    所以,我们在上面代码中加入一些代码如下:

    	B bb;
    	A aa = (A)bb;
    	aa.vfunc1();
    
    • 1
    • 2
    • 3

    同时,加入断点,进行调试,通过vs2019窗口查看反汇编代码,我们得到如下代码:

    	B bb;
    00B63237  lea         ecx,[bb]  
    00B6323D  call        B::B (0B6129Eh)  
    	A aa = (A)bb;
    00B63242  lea         eax,[bb]  
    00B63248  push        eax  
    00B63249  lea         ecx,[aa]  
    00B6324F  call        A::A (0B6128Ah)  
    	aa.vfunc1();
    00B63254  lea         ecx,[aa]  
    00B6325A  call        A::vfunc1 (0B6111Dh)  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    由于,aa是一个A的对象而非指针,即使a内容是B对象强制转换而来,aa.vfunc1()调用的是静态绑定的A::vfunc1()。同时,在汇编中我们得到,在调用时,直接call xxxx,call后面是一个固定的地址,从这里依旧可以看出是静态绑定。

    同时,我们继续运行下面代码:

    	A* pa = new B;
    	pa->vfunc1();
    
    	pa = &b;
    	pa->vfunc1();
    
    • 1
    • 2
    • 3
    • 4
    • 5

    得到如下反汇编:

    	A* pa = new B;
    00B6325F  push        10h  
    00B63261  call        operator new (0B6114Fh)  
    00B63266  add         esp,4  
    00B63269  mov         dword ptr [ebp-174h],eax  
    00B6326F  cmp         dword ptr [ebp-174h],0  
    00B63276  je          __$EncStackInitStart+68Fh (0B6328Bh)  
    00B63278  mov         ecx,dword ptr [ebp-174h]  
    00B6327E  call        B::B (0B6129Eh)  
    00B63283  mov         dword ptr [ebp-17Ch],eax  
    00B63289  jmp         __$EncStackInitStart+699h (0B63295h)  
    00B6328B  mov         dword ptr [ebp-17Ch],0  
    00B63295  mov         eax,dword ptr [ebp-17Ch]  
    00B6329B  mov         dword ptr [pa],eax  
    	pa->vfunc1();
    00B632A1  mov         eax,dword ptr [pa]  
    00B632A7  mov         edx,dword ptr [eax]  
    00B632A9  mov         esi,esp  
    00B632AB  mov         ecx,dword ptr [pa]  
    00B632B1  mov         eax,dword ptr [edx]  
    00B632B3  call        eax  
    00B632B5  cmp         esi,esp  
    00B632B7  call        __RTC_CheckEsp (0B61316h)    //并非固定地址
    
    	pa = &b;
    00B632BC  lea         eax,[b]  
    00B632BF  mov         dword ptr [pa],eax  
    	pa->vfunc1();
    00B632C5  mov         eax,dword ptr [pa]  
    00B632CB  mov         edx,dword ptr [eax]  
    00B632CD  mov         esi,esp  
    00B632CF  mov         ecx,dword ptr [pa]  
    00B632D5  mov         eax,dword ptr [edx]  
    00B632D7  call        eax  
    00B632D9  cmp         esi,esp  
    00B632DB  call        __RTC_CheckEsp (0B61316h)  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    在下面这段程序中,我们可以看到,指针pa指向一个B对象,有一个向上转型操作,可以确定,这应该是动态绑定。同时,在汇编代码中,call后面并不是一个固定的地址,从这里我们也可以看出pa调用了B::vfunc1()。

  • 相关阅读:
    Spring声明式基于注解的缓存(3-精进篇)
    第5章 网络编程
    Linux--互斥锁
    Java工程师面试题
    【pen200-lab】10.11.1.217
    qgis 将县区的数据转成市区的边界数据
    【nvm】
    软件开发模型和软件开发模型的区别
    前后端数据互传问题
    Unity 手动编译 Reload脚本 减少等待时间
  • 原文地址:https://blog.csdn.net/qq_43142509/article/details/125433115