服务器之家:专注于VPS、云服务器配置技术及软件下载分享
分类导航

PHP教程|ASP.NET教程|Java教程|ASP教程|编程技术|正则表达式|C/C++|IOS|C#|Swift|Android|VB|R语言|JavaScript|易语言|vb.net|

服务器之家 - 编程语言 - C/C++ - C/C++多态深入探究原理

C/C++多态深入探究原理

2023-02-14 15:44温逗死 C/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;
}

结果:

C/C++多态深入探究原理

在例子中

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

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

 

虚表和虚表指针

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

父类对象模型:

C/C++多态深入探究原理

子类对象模型:

C/C++多态深入探究原理

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

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

运行结果:

C/C++多态深入探究原理

整个程序图示:

C/C++多态深入探究原理

通过图示我们可以看出,函数在构造后,通过vptr寻找到vtbl,进而得到所对应的成员函数。而它是怎么做到寻找到所需要的是父类还是子类的成员函数呢?

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

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

如程序中如果出现:

C c;
c.vfunc1();

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

c.A::vfunc1(&c);

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

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

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

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

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

B bb;
A aa = (A)bb;
aa.vfunc1();

同时,加入断点,进行调试,通过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)

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

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

A* pa = new B;
pa->vfunc1();

pa = &b;
pa->vfunc1();

得到如下反汇编:

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)

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

到此这篇关于C/C++多态深入探究原理的文章就介绍到这了,更多相关C语言多态内容请搜索服务器之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持服务器之家!

原文链接:https://blog.csdn.net/qq_43142509/article/details/125433115

延伸 · 阅读

精彩推荐
  • C/C++C++中strtok()函数的用法介绍

    C++中strtok()函数的用法介绍

    以下是对C++中strtok()函数的使用方法进行了详细的分析介绍,需要的朋友可以过来参考下...

    C++教程网9272020-12-25
  • C/C++介绍C语言程序中的注释等辅助语句如何使用

    介绍C语言程序中的注释等辅助语句如何使用

    C语言中分为单行注释和多行注释:单行注释 以 // 开始的这一行文本,可能不被编译器所支持;多行注释从 /* 到 */之间的所有文本,不支持嵌套,下面让我...

    清风自在 流水潺潺6092022-11-13
  • C/C++C语言编程入门之程序头文件的简要解析

    C语言编程入门之程序头文件的简要解析

    这篇文章主要介绍了C语言编程入门之程序头文件的简要解析,包括头文件重复包含问题等方面的说明,需要的朋友可以参考下...

    fushiqianxun10622021-03-18
  • C/C++C/C++ Qt 数据库与Chart历史数据展示

    C/C++ Qt 数据库与Chart历史数据展示

    这篇文章主要介绍了Qt利用Qchart组件展示数据库中的历史数据。文中的示例代码讲解清晰,具有一定的学习和工作价值,感兴趣的小伙伴可以学习一下...

    LyShark12022022-07-08
  • C/C++C语言结构体中内存对齐的问题理解

    C语言结构体中内存对齐的问题理解

    内存对齐”应该是编译器的“管辖范围”。编译器为程序中的每个“数据单元”安排在适当的位置上。但是C语言的一个特点就是太灵活,太强大,它允许你...

    诚挚的乔治8592022-09-16
  • C/C++C语言实现简单计算器功能(2)

    C语言实现简单计算器功能(2)

    这篇文章主要为大家详细介绍了C语言实现简单计算器功能的第二部分,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一...

    xiaocaidayong9172021-08-20
  • C/C++c语言stack(栈)和heap(堆)的使用详解

    c语言stack(栈)和heap(堆)的使用详解

    这篇文章主要介绍了c语言stack(栈)和heap(堆)的使用详解,需要的朋友可以参考下...

    C语言程序设计4002021-01-18
  • C/C++C++中this指针用法详解及实例

    C++中this指针用法详解及实例

    这篇文章主要介绍了C++中this指针用法详解及实例的相关资料,需要的朋友可以参考下...

    开源中国6822021-05-07