C++_多态(深入理解虚函数表)

Posted 楠c

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++_多态(深入理解虚函数表)相关的知识,希望对你有一定的参考价值。

1. 什么是多态

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票

2. 怎么构成多态

在这里插入图片描述

在这里插入图片描述
并没有构成多态,形参p对象,全部调用了Person类的成员函数。

在这里插入图片描述

2.1 多态与重写

这时候就需要使用虚函数来构成多态。
梳理一下,多态的条件:

  1. 继承类中,需要对虚函数进行重写。
  2. 基类的指针或者引用都去调用这个虚函数

而重写的条件
3. 父子类中的函数都是虚函数。
4. 函数名参数返回值都要相同(有一个例外,那就是协变,基类的虚函数返回基类指针或引用。派生类指针或引用返回派生类指针或引用)

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

原本都会指向Person类的成员函数,但是当继承类中对虚函数进行重写,基类的指针或引用去调用这个虚函数。此时这个指针或引用指向谁就调用谁的虚函数。这个基类是个相对基类。
即不满足多态,p调用函数,p是什么类型就调用哪个类型的函数。而满足多态,基类的指针或引用指向谁,就调用谁的虚函数。

但是注意,派生类中的虚函数可以不用写virtual。会继承下来,但是我觉得这是C++不严格的地方。显示的带上virtual更好。

2.2 虚函数重写的两个例外

2.2.1 协变

虚函数的返回值可以不相同,但是必须满足,基类的虚函数返回值为基类的指针或引用,派生类的虚函数返回值为派生类的指针或引用。我们称它为协变

2.2.2 析构函数

派生类的析构函数和基类的析构函数实际是构成隐藏的,是因为编译器将析构函数全部定义为destruct。析构函数没有返回值,没有参数,参数名相同。最好将基类的析构函数定义为虚函数,那么派生类的析构函数也会成为虚函数(最好加上virtual,不加也可以),构成重写。

为什么一定需要构成重写呢?

假如有这样一种场景
在这里插入图片描述
person类型的指针,指向一个student对象。那么就会发生内存泄漏

将函数声明为虚函数,子类进行重写。
在这里插入图片描述
此时不再看P的类型,而是看p指向的是什么对象,然后调用student的析构函数,然后自动调用基类的析构函数。

3. C++11中的两个关键字

3.1 final

修饰一个函数,表示该函数不能被重写(我们不写,子类会默认带上)
在这里插入图片描述
修饰一个类,表示该类不能被继承
在这里插入图片描述

3.2 override

检查派生类是否重写了某个虚函数,如果没有则报错

在这里插入图片描述

4. 对比重载,重写,重定义

在这里插入图片描述

5. 抽象类

5.1 抽象类无法实例化对象

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类,抽象类是不能实例化出对象的。
在这里插入图片描述
在这里插入图片描述

而派生类直接继承也不行(直接继承你也是抽象类),必须在对它纯虚函数进行重写之后才能实例化出对象。
什么适合被实例化出抽象类呢?人,植物,车,一些很宽泛的概念
,他们这个抽象类中有一些基本的概念,人的职业,植物的类别,车的品牌。然后让派生类继承,实现出具体的行为(人->教师,植物->牡丹,车->奔驰)。

5.2 抽象类可以定义指针或者引用。

在这里插入图片描述
而这样的行为也可以展现多态。因为派生类对函数完成了重写,基类的指针或者引用调用。
在这里插入图片描述

5.3 接口继承与实现继承

抽象类体现了接口继承,接口继承,当纯虚函数是一个声明的时候,我主要继承你的,返回值,函数名,参数。

而实现继承就是,你是一个完整的函数,我继承你就是为了你里面的实现,我不需要在写,直接复用你的。

6. 多态的底层实现

在这里插入图片描述
32位下,默认对齐数为4,此时类有一个虚函数表指针,还有一个int类型变量,所以sizeof大小为8。

6.1 虚函数表

6.1.1 父类中的虚函数表

在这里插入图片描述

此时vfptr数组指针,指向一个虚函数表,这个虚函数表实际上是一个数组,他是一个虚函数指针数组,即 数组里面存储着指针,指针指向一个个函数。

当对象没有初始化的时候,虚函数表也没有初始化,说明对象里面的虚函数表,是在对象初始化的地方才初始化的。他早早就已经创建,初始化就是把数组首元素的值给你就好了。
在这里插入图片描述
时刻记住,虚函数表是一个指针数组,一个个指针指向了代码段中的虚函数。func4不是虚函数,所以不在表内。
在这里插入图片描述
透过内存来看一下分布
在这里插入图片描述
跟前面菱形继承,没有关系!!!!
菱形继承是使用虚继承来解决数据冗余和二义性,使用虚基指针指向一个虚基表,虚基表里面存储着当前地址距离虚基类对象的偏移量,让原来的地址加偏移量就可以找到虚基类对象。
而这里的虚函数表指针,当对象初始化的时候,虚函数表也才会初始化,而且虚函数表只有一张。很显然他是和虚函数一样,都存在代码段中。

可以通过打印地址,来验证
在这里插入图片描述
理论上可以 (int)b 但是不支持
在这里插入图片描述
可以看出显然是和代码段更加接近。
虽然他是在对象初始化的时候初始化,那么他是在什么时候生成的呢,在编译的时候生成的。

6.1.2 子类中的虚函数表

同一个类型用一张虚函数表,这个没有问题。所以子类也是独有一个虚函数表。需要注意的是,假如你多继承,那就是继承多张。继承是复用,而不是共用。
在这里插入图片描述
所以可以这么理解,子类直接将整个虚函数表深拷贝下来。当有虚函数被重写,直接在上面覆盖掉原来的虚函数地址。没有被覆盖的就留下。所以重写是语法上的概念,而覆盖是系统底层的概念。那假如子类一个虚函数都没有重写呢?虽然虚函数的地址都没变,但是还会单独生成一个虚函数表。

梳理一下,派生类虚表的生成过程,父类中有虚函数,所以子类先会单独生成一个虚函数表,然后深拷贝下来,假如子类重写了某个虚函数,将重写后的虚函数地址覆盖原来的地址。没有重写的地址就不变。

7. 多态的原理

怎么实现的多态呢?子类中重写了父类的虚函数,指向或引用子类对象的父类的指针或引用调用这个虚函数。

为什么么非得是指针或引用呢?

当你是一个普通对象。那肯定是什么类型就调用哪个类型的函数。

A a 或 B b

即使发生切片 ,由于a对象里面永远是基类的虚函数表,他想实现多态都没处实现

A a=b

而基类类型指针或引用,指向一个子类对象,这时看到的就是子类对象的虚函数表。这样就能调用子类的虚函数。

透过汇编查看
在这里插入图片描述

在这里插入图片描述
那么假如有多个虚函数,我调用了不同的虚函数,底层是怎么实现的呢?
也很简单,按照顺序+4字节即可找到。而他的地址就是按声明的顺序放着的。
在这里插入图片描述

8. 静态绑定与动态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

所以什么是多态呢?

多态分为静态多态和动态多态,静态多态编译时就确定,动态多态是运行时在确定。

8.1 静态多态

函数重载,例如

int i=1;
double j=1.1;
cout<<i;
cout<<j;

函数重载了double1类型和int类型,所以可以输出不同类型。(当然,先实现了运算符重载)。程序编译期间确定了行为。

8.2 动态多态

子类对父类的虚函数进行重写,父类的指针或引用调用这个虚函数。当程序运行起来时,才通过虚函数表来调用虚函数。

9. 多继承中的虚函数表

单继承中,刚才研究了,子类继承父类的虚函数表,假如有重写了某个虚函数,会直接覆盖掉地址,假如没有重写就保留。那么假如子类自己新增了呢。

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

private:
	int _b = 1;
	
};

class Derive:public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}

	virtual void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}
	void Func4()
	{
		cout << "Derive::Func4()" << endl;
	}
private:
	int _d = 2;

};

func1覆盖,func2保留,自己写的func3,func4在哪呢?
通过监视窗口看一下,好家伙,func3,func4影子都没有。
在这里插入图片描述
不用动脑子都知道,肯定是存在的,从内存角度看一下。
在这里插入图片描述
打印一下他的地址,但是该怎么传参呢。
在这里插入图片描述
然后main函数中可以调用,注意强转
在这里插入图片描述

在这里插入图片描述
不太形象,怎么能看出这是哪个函数呢?

其实我们既然拿到了函数的地址,那么就可以突破限制,直接调用这个函数。(不在像以前一样只有对象,或者其指针才能调用,那是语法上的概念)
在这里插入图片描述
两个对象都调用一下,做个对比
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
这是子类新增的。

说清楚后,再来看多继承下的虚函数表

#include<iostream>
using namespace std;
class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()";
	}
	virtual void Func2()
	{
		cout << "Base::Func2()";
	}

private:
	int _b = 1;

};

class Base1
{
public:
	virtual void Func1()
	{
		cout << "Base1::Func1()";
	}
	virtual void Func2()
	{
		cout << "Base1::Func2()";
	}

private:
	int _b1 = 2;
};
class Derive :public Base,public Base1
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()";
	}

	virtual void Func3()
	{
		cout << "Derive::Func3()";
	}

private:
	int _d = 2;

};

int main()
{
	Base b;

	Base1 b1;

	Derive d;

	cout << sizeof(d) << endl;
	
	return 0;
}

先口算一下,d有多大。d继承,b和b1的两个虚函数表,8+8+4=20,对齐数为4所以直接放。
在这里插入图片描述

打印出来确实是20.

子类重写了,func1,继承了func2,自己写的一个虚函数func3。

在这里插入图片描述
透过监视窗口看到

  1. func2处于下标1位置,一个是Base类型,一个是Base1类型,在两个虚函数表中地址不同,这是正常的。

  2. 在前面我们知道由于子类自己写的虚函数,但是监视窗口不显示,继承的func3,但是内存中是有对应地址的,第一个疑惑的点,那么这个自己写的虚函数地址会在这两个虚函数表当中哪一个?还是都有呢?

  3. 第二个疑惑的点?子类重写了继承下来的func1,为啥是两个地址,难道不该是一个地址直接覆盖两个虚函数表吗?最诡异的是两个地址,他还调用的是相同的函数
    在这里插入图片描述

先解决第一个问题,在前面我们写了一个打印虚函数表的函数,这里在复用一下,看看他在哪?
在这里插入图片描述
可以看到,自己实现的那个虚函数,地址是放到了Base类的虚函数表之中。
而与此同时,第二个问题还是没能得到解答,因为在内存中,重写之后的Func1仍旧是两个地址,但调用同一个。

然后在经过F10调试,执行的时候也确实是进入了同一个函数。

那在直接对它取地址,好家伙,不得了了,3套地址。
在这里插入图片描述
所以这里我们可以推理得,虽然虚函数表里的地址不一样,但是在汇编层面他们会jmp到一个地址处完成对同一个函数的调用。

练习

第一题

在这里插入图片描述
B:虚函数表简称虚表,虚基表是为了解决菱形继承引入的,里面存的是偏移量
D正确,父类和子类,甚至子类中没有重写任何虚函数,都会生成不同的虚函数表。

第二题

在这里插入图片描述
参数列表初始化的顺序是声明的顺序,因为是先继承的B,在继承的C。
假如先继承的C,在继承B,那么就会打印 A C B D

第三题

在这里插入图片描述
p为B类型,当p去调用test,由于test没有重写,是直接继承下来,所以里面的函数原封不动,this指针类型仍然是A,此时把p赋值给A*的this,就相当于

在这里插入图片描述
那不是应该打印B->0吗,错,其实虚函数是一种接口继承,他将你的参数,返回值,形参列表继承下来所以B类中,形参的缺省参数不起作用,还是A中的val=1,所以打印的是B->1。

问答题

  1. 内联函数可以是虚函数吗?
    可以,虽然内联函数编译时展开,他没有地址。
    但是不好验证的是,在默认debug版本不会展开,因为展开的话就不能调试了,内联相比于宏的优点,就是可以调试,所以debug是有地址的,但是release版本下会展开,但又因为内联只是一种建议,编译器会取消内联。通过汇编可以看到release版本来应该展开的函数,却有了地址。所以肯定是舍弃了优化。但是实际选择题中还是根据其他选项的对错,再来选择。
  2. 静态成员函数可以是虚函数吗?
    这肯定是不可以的,虚函数是为多态而生,多态的其中之一条件就是,父类的指针或引用去调用这个虚函数,连this指针都没有,没法调用。
  3. 构造函数可以是虚函数吗?
    不可以,虚函数表在构造函数的参数列表初始化。虚函数为多态而生,你想调用这个构造函数,但是对象都没有初始化,虚函数表指针也没有初始化,不能调用。
  4. 析构函数可以是虚函数吗?
    析构函数尽量写成虚函数,因为普通定义场景没有问题,假如定义一个基类指针,指向一个子类对象A* a=new B;delete a时不构成多态就只会调用A的析构函数,从而造成内存泄漏。定义成虚函数,由于构成重写(析构函数名destructor),基类的指针调用,所以构成多态,从而去调用B的析构函数,而B的析构函数又会自动调用A的析构函数。所以就解决了内存泄漏问题。
  5. 虚函数表是在哪个阶段生成?
    编译阶段生成,存储在代码段中,构造函数参数列表中初始化。
  6. 隐藏,子类体现了实现继承,多态中的重写,子类体现了接口继承。
  7. 在构造函数和析构函数中调用虚函数不会呈现多态性
    假如满足多态的条件,也不会呈现多态性,因为,在基类构造的时候,子类还没有初始化,那么子类的虚函数表也就没有初始化。而在析构函数中调用虚函数,父类析构函数什么时候执行呢,子类析构函数执行后,那么子类已经执行析构函数了,虚函数表也不在了,所以都不会呈现多态。

以上是关于C++_多态(深入理解虚函数表)的主要内容,如果未能解决你的问题,请参考以下文章

c++ 深入理解虚函数

深入C++对象模型&虚函数表

C++_多态详谈

硬核两万字带你理解C++之多态

多态的深入理解

多态的深入理解