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

结果:

在例子中

  • 由于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;
}

运行结果:

整个程序图示:

通过图示我们可以看出,函数在构造后,通过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()。

(0)

相关推荐

  • C/C++使用C语言实现多态

    目录 1.多态的概念 1.1什么是多态? 1.2为什么要用多态呢? 1.3多态有什么好处? 2.多态的定义及实现 2.1继承中构成多态的条件 2.2虚函数 2.3虚函数的重写 2.4C++11 override 和 final 2.5 重载.覆盖(重写).隐藏(重定义)的对比 3.抽象类 3.1概念 3.2实现继承和接口继承 4.多态的原理 4.1虚函数表 4.2多态的原理 4.3 动态绑定与静态绑定 5.单继承和多继承关系的虚函数表 5.1 单继承中的虚函数表 5.2 多继承中的虚函数表 总结

  • C语言模拟实现C++的继承与多态示例

    一.面向过程编程与面向对象编程的区别 众所周知,C语言是一种典型的面向过程编程语言,而C++确实在它的基础上改进的一款面向对象编程语言,那么,面向过程与面向对象到底有什么样的区别呢? [从设计方法角度看] 面向过程程序设计方法采用函数(或过程)来描述对数据的操作,但又将函数与其操作的数据分离开来. 面向对象程序设计方法是将数据和对象的操作封装在一起,作为一个整体来处理. [从维护角度看] 面向过程程序设计以过程为中心,难于维护. 面向对象程序设计以数据为中心,数据相对功能而言,有较强的稳定性,因

  • C语言模式实现C++继承和多态的实例代码

    这个问题主要考察的是C和C++的区别,以及C++中继承和多态的概念. C和C++的区别 C语言是面向过程的语言,而C++是面向对象的过程. 什么是面向对象和面向过程? 面向过程就是分析解决问题的步骤,然后用函数把这些步骤一步一步的进行实现,在使用的时候进行一一调用就行了,注重的是对于过程的分析.面向对象则是把构成问题的事进行分成各个对象,建立对象的目的也不仅仅是完成这一个个步骤,而是描述各个问题在解决的过程中所发生的行为. 面向对象和面向过程的区别? 面向过程的设计方法采用函数来描述数据的操作,

  • C语言实现C++继承和多态的代码分享

    这个问题主要考察的是C和C++的区别,以及C++中继承和多态的概念. C和C++的区别 C语言是面向过程的语言,而C++是面向对象的过程. 什么是面向对象和面向过程? 面向过程就是分析解决问题的步骤,然后用函数把这些步骤一步一步的进行实现,在使用的时候进行一一调用就行了,注重的是对于过程的分析.面向对象则是把构成问题的事进行分成各个对象,建立对象的目的也不仅仅是完成这一个个步骤,而是描述各个问题在解决的过程中所发生的行为. 面向对象和面向过程的区别? 面向过程的设计方法采用函数来描述数据的操作,

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

    目录 多态 虚表和虚表指针 多态 面向对象编程有三大特性:继承.封装和多态. 其中,多态又分为编译时多态和运行时多态.编译多态是通过重载函数体现的,运行多态是通过虚函数体现的. 多态是如何实现的呢?下面举个例子: #include <iostream> using namespace std; class Base { public: virtual void fun() { cout << " Base::func()" << endl; } vo

  • Java多态中动态绑定原理解析

    这篇文章主要介绍了Java多态中动态绑定原理解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 多态是面向对象程序设计非常重要的特性,它让程序拥有 更好的可读性和可扩展性. 发生在继承关系中. 需要子类重写父类的方法. 父类类型的引用指向子类类型的对象. 自始至终,多态都是对于方法而言,对于类中的成员变量,没有多态的说法. 一个基类的引用变量接收不同子类的对象将会调用子类对应的方法,这其实就是动态绑定的过程.在理解动态绑定之前,先补充一些概念.

  • 虚函数表-C++多态的实现原理解析

    参考:http://c.biancheng.net/view/267.html 1.说明 我们都知道多态指的是父类的指针在运行中指向子类,那么它的实现原理是什么呢?答案是虚函数表 在 关于virtual 一文中,我们详细了解了C++多态的使用方式,我们知道没有 virtual 关键子就没法使用多态 2.虚函数表 我们看一下下面的代码 class A { public: int i; virtual void func() { cout << "A func" <<

  • 一篇文章带你了解C++多态的实现原理

    目录 虚函数和多态 多态的作用 多态的一个例子 构造函数和析构函数中存在多态吗? 多态的实现原理 虚函数表 虚析构函数 纯虚函数和抽象类 总结 虚函数和多态 虚函数: 在类的定义中,前面有 virtual 关键字的成员函数称为虚函数 virtual 关键字只用在类定义里的函数声明中,写函数体时不用 比如: class Base { virtual int Fun() ; // 虚函数 }; int Base::Fun() // virtual 字段不用在函数体时定义 { } 多态的表现形式 派生

  • 详解C++中多态的底层原理

    目录 前言 1.虚函数表 (1)虚函数表指针 (2)虚函数表 2.虚函数表的继承–重写(覆盖)的原理 3.观察虚表的方法 (1)内存观察 (2)打印虚表 (3)虚表的位置 4.多态的底层过程 5.几个原理性问题 6.多继承中的虚表 前言 要了解C++多态的底层原理需要我们对C指针有着深入的了解,这个在打印虚表的时候就可以见功底,理解了多态的本质我们才能记忆的更牢,使用起来更加得心应手. 1.虚函数表 (1)虚函数表指针 首先我们在基类Base中定义一个虚函数,然后观察Base类型对象b的大小:

  • SpringMvc定制化深入探究原理

    目录 一.SpringBoot 自动配置套路 二.定制化常见方式 @EnableWebMvc 原理 三.使用 @EnableWebMvc 案例 一.SpringBoot 自动配置套路 引入场景 starter —— xxxxAutoConfiguration —— 导入 xxxx组件 —— 绑定 xxxxProperties —— 绑定配置文件项 因此,需要修改时只需要修改配置文件项 二.定制化常见方式 使用 @Bean + 编写自定义配置类 ,增加或替换容器中的一些组件 (常用) 修改配置文件

  • JavaScript 继承 封装 多态实现及原理详解

    面向对象的三大特性 封装 所谓封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏.封装是面向对象的特征之一,是对象和类概念的主要特性. 简单的说,一个类就是一个封装了数据以及操作这些数据的代码的逻辑实体.在一个对象内部,某些代码或某些数据可以是私有的,不能被外界访问.通过这种方式,对象对内部数据提供了不同级别的保护,以防止程序中无关的部分意外的改变或错误的使用了对象的私有部分. 我们在vue项目中使用混入将公有代码提出来,混入到每个

  • java多态机制原理特点详解

    java多态机制是什么 java中实现多态的机制是依靠父类或接口的引用指向子类.从而实现了一个对象多种形态的特性.其中父类的引用是在程序运行时动态的指向具体的实例,调用该引用的方法时,不是根据引用变量的类型中定义的方法来运行,而是根据具体的实例的方法. 概念 多态就是指一个引用变量倒底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须在由程序运行期间才能决定. 因为在程序运行时才确定具体的类,这样,不用修改源程序代码,就可以让引用变量绑定到各种不同的类实现上,从而导致

  • Java面试问题知识点总结

    本篇文章会对面试中常遇到的Java技术点进行全面深入的总结(阅读本文需要有一定的Java基础:若您初涉Java,可以通过这些问题建立起对Java初步的印象,待有了一定基础后再后过头来看收获会更大),喜欢的朋友可以参考下. 1. Java中的原始数据类型都有哪些,它们的大小及对应的封装类是什么? (1)boolean boolean数据类型非true即false.这个数据类型表示1 bit的信息,但是它的大小并没有精确定义. <Java虚拟机规范>中如是说:"虽然定义了boolean这

  • C++面试常见问题整理汇总

    本文总结讲述了C++面试常见问题.分享给大家供大家参考,具体如下: 1. 继承方式 public     父类的访问级别不变 protected    父类的public成员在派生类编程protected,其余的不变 private        父类的所有成员变成private #include <iostream> using namespace std; class base { public: void printa() { cout <<"base"&

随机推荐