【C++】多态 — 多态的原理 (下篇)

📖 前言

上一章我们学习了多态的形式和如何使用多态,这一章我们将来讲一讲多态的原理,搬好小板凳准备开讲啦…

前情回顾:👉 认识多态 + 多态的条件及其性质


1. 虚函数表

1.1 虚函数表的引入:

先来一道笔试题:sizeof(Base)是多少?

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

	virtual void Func2()
	{
		cout << "Func3()" << endl;
	}

	virtual void Func3()
	{
		cout << "Func1()" << endl;
	}

private:
	int _b = 1;
	char _ch;
};

//有了虚函数之后对象中就有了一个表 -- 虚表(虚函数表)
int main()
{
	Base b;
	cout << sizeof(Base) << endl;

	return 0;
}

先看运行结果:(不同位数的系统下大小是不一样的)

在这里插入图片描述

按照内存对齐的结果应该是 8,而现在答案却是 12,这是怎么回事?

  • 有了虚函数之后对象中就有了一个表 – 虚表(虚函数表)
  • 虚函数都会放到虚表当中去,虚表中有虚函数的指针

如图所示:

在这里插入图片描述
只有虚函数才会进虚表,用来实现多态。

解释:

  • v - 是virtual的单词首字母
  • f - 是function的单词首字母
  • ptr - 是pointer的单词缩写

1.2 基类的虚表:

先看代码:

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

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

	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}

private:
	int _b = 1;
};

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

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

基类的虚表:
在这里插入图片描述

  • 一个对象的vfptr也是在构造函数的时候才初始化的
  • 虚表中只存虚函数的地址
  • 在对象里面间接存的
  • 普通函数和虚函数都是存在一个地方的,编译好了之后都是放在代码段。
  • 虚表本质上是函数指针数组,存的是虚函数的指针

1.3 派生类虚表:

先来看一下有虚函数的类实例化对象的大小:
在这里插入图片描述
结果:子类的大小是12。

综上小结:

  • 很明显和上述结果一样,子类对象中还是存在一个虚表。
  • 父类对象的虚表里面存的是父类的虚函数地址
  • 子类对象的虚表里面存的是子类的虚函数地址

并且我们发现:
在这里插入图片描述

  • 在我们重写了func1之后,两个虚表中的func1的地址不一样,但是func2的地址却是一样的。
  • 对于基类虚表的第一个位置存的是基类的虚函数地址,对于子类虚表的第一个位置存的是子类的虚函数地址。
  • 虚函数重写 – 语法层的概念 – 派生类对继承基类虚函数实现进行了重写
  • 虚函数覆盖 – 原理层的概念 – 子类的虚表,拷贝父类虚表进行了修改,覆盖重写那个虚函数(可以这样理解)

2. 多态的原理

2.1 多态虚函数的调用和普通函数的调用:

2.1 - 1 到底什么是多态(重点)
  • 多态是父类指针指向父类对象就调用父类的虚函数,指向子类的对象就去调用子类的虚函数。
  • 所以到底要去调用哪个函数不是按照指针的类型定的,而是去到指向的对象中去查表。
  • 指向谁就在谁的虚表中找虚函数对应的地址 – 这是多态

多态调用:
在这里插入图片描述

  • 第一个p看到的是父类对象
  • 第二个p看到的则是被切片的父类对象
2.1 - 2 父类的指针实现多态

普通调用和多态调用:

在这里插入图片描述
我们来看一下汇编:
在这里插入图片描述
所以,就引入了以下的结论:

  • 多态调用: 运行时决议 – 运行时确定调用函数的地址(去对象的虚表中找函数的地址)
  • 普通调用: 编译时决议 – 编译时确定调用函数的地址(普通函数地址放在符号表,方便链接)

多态能够实现的依赖基础是:虚表完成了覆盖:

  • 父类对象的虚表里面存的是父类的虚函数地址
  • 子类对象的虚表里面存的是子类的虚函数地址

注意:

  • 这些地址不是直接存在对象里的,是间接存的,对象里存的是一个指针,这个指针指向的表是虚表,虚表中存的是虚函数的地址。
  • 不要和继承中菱形虚拟继承中的虚基表弄混了,虚基表中存的是偏移量。
2.1 - 3 父类的引用实现多态

在这里插入图片描述

  • 因为引用和指针一样都能发生切片,指针和引用底层是一样的。
2.1 - 4 父类的对象实现多态(懂原理)

1.子类对象赋值给父类对象,也可以切片,为什么实现不了多态?
在这里插入图片描述
(1)从汇编语法编译器的角度分析:

  • 编译器的做法非常的死板,就是去检查是否符合多态的条件
  • 符合多态的条件就是运行时决议,要去虚表中找
  • 不符合就是编译时决议,这时候就是用类型确定,是什么类型就去调用谁的

(2)从最底层来分析为什么不支持对象实现多态:

  • 对象切片的时候,是将子类中父类的那一部分拷贝

2.问题来了,切片拷贝的时候,子类对象那一部分的虚表指针要拷贝过去给父类对象吗?

答案:

  • 子类对象只会拷贝成员给父类对象,不会拷贝虚表指针

3.为什么呢?

  • 因为拷贝虚表指针之后就混乱了
  • 父类对象中到底是父类的虚表指针还是子类虚表指针?
  • —— 都有可能。
  • 那下面调用是调用父类的虚函数还是子类虛函数?
  • —— 就不确定。

在这里插入图片描述

4.如果虚表拷贝了的话:

在这里插入图片描述
深入理解:(重点)

  • 因为虚表如果被拷贝过去的话,ptr此时指向父类,此时ptr去调用Func1的时候,按照多态来说是要调用父类的Func1,但是此时调用的却是子类的Func1,乱套了。
  • 此时就是指向子类调子类,指向父类调用的也是子类了,就不是多态了!!
    在这里插入图片描述
  • 那么这时候有个指针指向父类对象,那么调用的是父类还是子类呢?
  • 指向父类却有可能调用子类也有可能调用父类,此时就分不清了!!
  • 所以不能拷贝虚表!!
  • 所以对象不能实现多态,想实现也不能 – 乱套了(重点)
  • 对象无法实现多态 – 对象要实现多态必然要拷贝虚表,一拷贝虚表就乱了
  • 一个父类对象如果被切片过,就指向子类,没切片过就指向父类

同一个类型都是指向一张表的,同一个类型的不同对象它们的虚表都是一样的。(重点)

5.显然编译器也没有拷贝虚表:

在这里插入图片描述

2.2 动态绑定与静态绑定:

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态
  • C++中:动态是运行时,静态是编译时
  • 静态库: 链接阶段去链接的
  • 动态库: 程序运行起来才会去加载动态库

动静态多态:

  • 编译时 – 静态的多态: 函数重载·
  • 运行时 – 动态的多态: 本节内容讲的这个

3. 探索虚表

3.1 虚函数重写的过程:

下看代码:

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

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

	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}

private:
	int _b = 1;
};

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

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

int main()
{
	Derive d;

	return 0;
}

通过监视窗口看:

在这里插入图片描述

  • 子类的虚表是继承了父类的虚表
  • 子类的虚表可以认为是将父类的虚表拷贝过来,然后将自己重写的虚函数Func1进行覆盖
  • Func2是从父类虚表留下来的,Func1是子类在虚表中重写的

我们先来看新增的虚函数Func4是否在虚表中:

在这里插入图片描述

  • 通过监视窗口我们发现,新增的虚函数并不能通过监视窗口看到其在虚表中
  • 和继承那一章节一样,Vs的监视窗口在复杂的情况下被处理过,看到的就不准了
  • 此时就需要我们看内存窗口了
  • VS窗口看到的虚函数表不一定是真实的,可能被处理过
  • 虚函数的地址不在对象里面,而是在虚表指针指向的虚表里面

通过内存窗口看:

在这里插入图片描述
问题:

  • 我们上文所述,vfptr是一个函数指针数组首元素的地址(指针),那么我们通过该指针就能找到虚表(函数指针数组)中的元素(函数指针)
  • 我们通过内存窗口发现了vfptr指向的空间前两个元素(函数指针)都是可以从监视窗口中的虚表中核对
  • 唯独第三个虚函数也就是图中圈出来的那一串不知道是不是虚函数Func4的指针?

3.2 单继承 - 打印虚表:

下面我们来验证一下:(目的是确认Func4的指针在不在虚表)

  • 取内存值,打印并调用,确认是否是func4

补充:

  • Vs平台下,虚表最后末尾统一放了一个空指针
  • g++平台下不会,g++就得写死
  • 知道几个虚函数,就要尝试打印几个虚函数地址
  • 如何打印虚表:
  • 我们前面也提到了,vfptr是一个函数指针数组首元素(函数指针)的地址(指针),而虚表则是一个函数指针数组
  • 因为函数指针的指针(二级指针)不好定义,我们先typedef一下方便后续使用:
  • 正常的typedef:typedef void(*)() V_FUNC; – 不支持,定义不出来
  • 函数指针有要求,定义变量或者进行typedef都得放在中之间
  • 正确定义:
  • typedef void( * V_FUNC)( );

打印子类的虚表,见下述代码:

//正确定义:
typedef void(*V_FUNC)();

//void PrintVFTable(V_FUNC a[]) -- 数组在传参的时候都会退化成了指针

//void PrintVFTable(void(**a)())-- 不用typedef的写法
void PrintVFTable(V_FUNC* a)
{
	printf("vfptr:%p\n", a);

	//**切记这里要记得清理解决方案** -- 不然会有非法访问
	//g++的话在这里就要写死,因为它的虚表中不存在空指针
	for (size_t i = 0; a[i] != nullptr; i++)
	{
		//printf("[%d]:%p\n", i, a[i]);

		printf("[%d]:%p->", i, a[i]);

		//用函数的地址直接去调用函数 -- 通过函数打印出结果便于观察
		V_FUNC f = a[i];
		f();
	}
	cout << endl;
}
  • 我们只需要取到虚表首元素的地址就可以打印虚表了
  • 虚表指针一般是存在对象头上的,也就是前四个字节

我们该如何取对象头上【四个字节】呢?

  • 取子类对象头四个字节是不可以通过强转的:
  • 不相近的类型强转也转不了 – 没有一定关联性的类型不能直接转
  • int* p = (int)d; – 这样是不行的

解决办法(重点理解):

  • 可以将指针强转,先取对象的地址,再强转成int*
  • 指针之间是可以互相转换的 – 任何类型之间的指针都可以互相强转
  • 解引用就拿到了子类对象的前四个字节的地址
  • 再将该地址的类型强转成 函数地址的指针(二级指针) 类型 – 这样才能传的过去
int main()
{
	Base b;
	Derive d;

	//函数指针数组的地址指针

	PrintVFTable((V_FUNC*)(*((int*)&d)));//取到对象头4byte的虚表指针

	return 0;
}

运行窗口,监视和内存窗口看一看:

在这里插入图片描述

  • 见图,结果和我们预想的一样,监视窗口将Func4给隐藏掉了
  • 同时我们还可以直接通过函数指针调用虚函数。

3.3 虚表存在哪个区域:

虚表存在哪个区域?

  • 虚表应该是一个类型共用一个虚表,所有这个类型对象都存这个虚表指针
  • Base b1;Base b2;Base b3;Base b4;这几个虚表应该是一样的
  • 所以虚表应该存在一个长期存储的区域

我们来验证一下:

int main()
{
	Base b1;
	Base b2;
	Base b3;
	Base b4;

	//打印虚表
	PrintVFTable((V_FUNC*)(*((int*)&b1)));
	PrintVFTable((V_FUNC*)(*((int*)&b2)));
	PrintVFTable((V_FUNC*)(*((int*)&b3)));
	PrintVFTable((V_FUNC*)(*((int*)&b4)));

	return 0;
}
  • 同一个类型都是指向一张表的,同一个类型的不同对象它们的虚表都是一样的。
  • 同一个类型的对象共用一份虚表,没必要搞多个
  • 虚表最好能够永久存储。
    在这里插入图片描述

深入理解:(重点)

按理来说在编译的时候就建好了虚表,对象在构造的时候才初始化虚表,其实不是初始化虚表,而是把这个类型的虚表找到,虚表的地址放在对象的头四个字节上。而是在对象初始化列表的时候挨个给vfptr。

首先我们排除虚表是存在栈上的:

  • 因为栈是用来建立栈帧的
  • 栈帧运行结束就销毁了
  • 那么虚表也是时而创建,时而销毁吗
  • 显然不可能

其次我们再排除虚表是存在堆上的:

  • 因为堆区是空间是动态申请的
  • 那么是在什么时候申请,什么时候释放呢
  • 第一个对象申请吗,最后一个对象释放吗?
  • 很显然会很麻烦,可能性也不大
  • 剩下的我们只能猜两个区域:静态区/数据段 常量区/代码段。

盲猜放在 常量区/代码段 更合理,因为 常量区/代码段 放的是全局数据和静态数据,因为函数指针数组放在静态区不正常,放在 常量区/代码段 相对来说就很合理。

我们写个程序反向验证一下:

int c = 2;

int main()
{
	Base b1;
	Base b2;
	Base b3;
	Base b4;

	//打印虚表
	PrintVFTable((V_FUNC*)(*((int*)&b1)));
	PrintVFTable((V_FUNC*)(*((int*)&b2)));
	PrintVFTable((V_FUNC*)(*((int*)&b3)));
	PrintVFTable((V_FUNC*)(*((int*)&b4)));

	//方向验证 -- 对比验证
	int a = 0;
	static int b = 1;
	const char* str = "hello world";
	int* p = new int[10];

	printf("栈:%p\n", &a);
	printf("静态区/数据段:%p\n", &b);
	printf("静态区/数据段:%p\n", &c);
	printf("常量区/代码段:%p\n", str);
	printf("堆:%p\n", p);
	cout << endl;

	printf("虚表:%p\n", (*((int*)&b4)));
	cout << endl;

	//成员函数取地址都得这么玩
	//函数编译完了是一段指令,第一句指令的地址就可以认为是函数的地址
	printf("函数地址:%p\n", &Derive::Func3);
	printf("函数地址:%p\n", &Derive::Func2);
	printf("函数地址:%p\n", &Derive::Func1);
	
	return 0;
}
  • str在栈,但是str指向的空间在 常量区/代码段
  • p在栈,但是p指向的空间在
  • 函数编译完了是一段指令,第一句指令的地址就可以认为是函数的地址!!

运行结果:
在这里插入图片描述
结合上图几个地址来看,充分的说明了虚表是存在 常量区/代码段 中的:

  • 由结果可得,虚函数和普通函数的地址都差不多
  • 说明虚函数和普通函数的地址都是放在一起的
  • 只不过虚函数要把地址放到虚表里去

3.4 多继承 - 虚表打印:

多继承代码:

class Base1 
{
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};

class Base2 
{
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};

class Derive : public Base1, public Base2 
{
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};

我们再来通过监视窗口看一下子类对象:

在这里插入图片描述

在之前多继承和菱形继承的基础上,我们再来理解这里:

  • Derive类中继承了两个类Base1和Base2
  • 那么Derive对象中应该就有两张虚表

问题来了,Derive中的fucn3放在哪一张虚表中呢?

  • 显然通过监视窗口观察还是遇到了和上述同样的问题,虚表中内容看不全

我们来打印一下子类中的两个虚表:

  • 我们该如何打印虚表呢?
  • 在之前的多继承中,我们是能知道子类对象的内存结构的
  • Derive对象中Base1对象在前,Base2对象在后,然后是d1成员变量
  • 所以我们和上述办法一样,取到Derive对象头四个字节,就可以打印Base1的虚表

Base2的虚表我们该如何打印呢?

  • 根据Derive对象中内存布局,我们可以知道,Base1中的vfptr后面是Base1的成员变量b1,紧接着就是Base2对象中的vfptr,然后紧接着的是Base2的成员变量b2。
  • 所以我们只需要跳过Base1对象中vfptr指针之后的成员,就可以找到Base2对象中的vfptr了
  • Base2的虚表我们可以通过如下的操作找到其vfptr:
    *
  • 去掉红框框出来的,我们取到的是红色箭头指向的Base1中的vfptr,是我们之前取到头四个字节的办法
  • 而下面我们是先取到d的地址,加一整个Base1对象大小个字节就能指向Base2中的vfptr了
  • 因为&d是Base1的指针,Base1的指针加减是跳过一整个对象大小的字节
  • 我们需要先将&d强转成char* 类型的指针,这样指针加减就是跳过一个字节了

见去下代码:

typedef void(*VFPTR)();

//vTable是指向函数指针数组首元素的指针
void PrintVFTable(VFPTR* a)
{
	printf("vfptr:%p\n", a);

	for (size_t i = 0; a[i] != nullptr; i++)
	{
		printf("[%d]:%p->", i, a[i]);

		//有函数的地址调用函数 -- 通过函数打印出结果便于观察
		VFPTR f = a[i];
		f();
	}
	cout << endl;
}

int main()
{
	Derive d;
	PrintVFTable((VFPTR*)(*((int*)&d)));
	PrintVFTable((VFPTR*)(*(int*)((char*)&d + sizeof(Base1))));

	return 0;
}

在这里插入图片描述
没有重写的虚函数放在第一张虚表,第二张虚表不放。

多继承子类对象模型:
在这里插入图片描述


补充:
在这里插入图片描述

  • 首先这三个指针的值是不一样的
  • 其次这三个指针的意义也是不一样的
  • 这里发生了切片
    在这里插入图片描述
  • ptr1和ptr2之间差了8个字节,正好是一个Base1的大小
  • ptr1和ptr3指向同一个位置并且大小一样,但是意义不一样
  • ptr1向后“看”的是Base1,ptr3向后看的是Derive

我们此时发现一个问题:
在这里插入图片描述

  • Base1的虚表和Base2的虚表中第一个位置都被子类对象重写了才对
  • 那也就是说,两个虚表的第一个位置都应该是同一个函数的指针才对
  • 但是根据打印虚表的结果来看,并不相同

我们直接将Derive中虚函数func1的地址打印出来:

在这里插入图片描述

  • 我们将地址打印出来之后
  • 惊奇的发现和两个虚表中的函数地址,没一个一样的

原因:

  • 这是Windows的自己的机制,多了几层封装
  • 因为它们都不是真函数的地址

4. 通过汇编深度探索底层调用

我们通过汇编逐步探索整个过程:

Base1虚表调用Derive::func1:

在这里插入图片描述

Base2虚表调用Derive::func1:

在这里插入图片描述
通过汇编逐层调用的结果来看:

  • 虽然地址不一样但是都调用到了同一个函数
  • 说明它们虽然表面不一样,但是都最终调转到了同一个地址去调用同一个函数
  • 最终都调用到了 “ 006528A0 ” 这个地址!!

我们上述过程是直接取到虚表中的内容直接通过虚表中存储的函数指针去调用(函数名就是函数地址),我们直接搞一个多态调用如下图:
在这里插入图片描述
经过本人验证,和上面两个图的过程一样!!

为什么在调用Base2::func1()的时候会比调用Base1::func1()的时候多跳了几层?(重点)

  • Derive对象Base2虚表中func1时, 是Base2指针ptr2去调用
  • 但是这时ptr2发生切片指针偏移(指向Derive对象中Base2那一部分),就需要修正
  • 中途需要修正存储this指针的ecx的值(ecx寄存器存的是this指针)
  • 因为现在调用的是Derive对象的func1那么传给func1的this指针应该是指向Derive对象 “头部” 的指针!!

修正图:
在这里插入图片描述

4.1 菱形虚拟继承的虚表:

我们首先来看一下普通的菱形继承:

class A
{
public:
	virtual void func()
	{}

public:
	int _a;
};

class B : public A
{
public:
	virtual void func()
	{}

public:
	int _b;
};

class C : public A
{
public:
	virtual void func()
	{}

public:
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};

int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	return 0;
}
  • 编译是可以通过的。

对象模型图:
在这里插入图片描述

但是一旦加上虚继承就会编译不通过:

在这里插入图片描述
为什么编译报错了?

虚继承对象模型:
在这里插入图片描述
在这里插入图片描述

  • B类中虚函数也重写了
  • C类中虚函数也重写了
  • 这时虚继承B和C共享一个A
  • 那么A的虚表中是放B的虚函数还是放C的虚函数? – 分不清楚了!!

解决办法:

—— 在D类中重写虚函数func
在这里插入图片描述
那么B类和C类的虚表指针都放在哪里?

下面我们通过内存窗口来看一下(先给B类增加一个虚函数):

在这里插入图片描述

在这里插入图片描述
—— 如图可见:B类有自己的虚表指针。

这里为之前讲的菱形虚拟继承对象模型中,虚基表中,第一行为0做了解释:

  • 第一个位置存的是距离虚表指针的偏移量
  • 第二个位置存的是距离A的偏移量

重写D中的func之后,就解决了之前的分不清楚的问题,但是B类指针和C类指针都是调用的D重写的虚函数…
在这里插入图片描述
在这里插入图片描述

总结:

别搞多继承,上面那么多繁琐的事情都是多继承之后可能出现的后果…

  • 41
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 31
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 31
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yy_上上谦

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值