当前位置: 首页 > news >正文

多态的原理

一:构成多态的两个条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
PersonPerson对象买票全价,Student对象买票半价。
在继承中要构成多态有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

一个多态的列子:

class Person {
public:
	virtual void BuyTicket() { cout << "Person-买票-全价" << endl; }
};

class Student : public Person {
public:
	virtual void BuyTicket() { cout << "Student-买票-半价" << endl; }
};

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{

	Person ps;//创建一个Person类的对象 ps
	Student st;//创建一个Student类的对象 st

	//Func函数参数不同 结果不同
	Func(ps);
	Func(st);

	return 0;
}

运行结果:

代码体现的多态的两个条件的地方:

易混淆点:
多态并不是都需要一个额外的函数 去通过基类的指针或者引用调用虚函数,如上面的Func函数
下面代码展示了也可以不需要额外的Func函数:
#include <iostream>
using namespace std;

class Person {
public:
	virtual void BuyTicket() {
		cout << "Person-买票-全价" << endl;
	}

	//直接在类中封装多态调用
	void PurchaseTicket() {
		this->BuyTicket();  // 通过 隐藏的this 指针调用虚函数,触发多态
							//this指针就是基类的指针            
	}
};

class Student : public Person {
public:
	virtual void BuyTicket() {
		cout << "Student-买票-半价" << endl;
	}
};

int main() {

	//两个类创建了各自的对象
	Person ps;
	Student st;

	// 直接调用对象的成员函数,内部自动处理多态
	ps.PurchaseTicket();  // 输出: Person-买票-全价
	st.PurchaseTicket();  // 输出: Student-买票-半价

	return 0;
}

运行结果:

总结:

之前用额外的Func函数去展示多态,因为可以显式的看见我们Func函数的参数是引用,可以更好的突出多态的条件;

而写进类中的时候,往往是通过基类指针调用,但this看不见,不太好观察

Q:多态是怎么实现这种不同继承关系的类对象,去调用同一函数,产生了不同的行为的?
A:请看下文

二:虚函数指针

// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
 virtual void Func1()
 {
 cout << "Func1()" << endl;
 }
private:
 int _b = 1;
};

int main()
{
	Base ba;
	cout << sizeof(ba);

	return 0;
}

运行结果:

Q:按照vs最大对齐数为8,再结合内存对齐的规则,这里怎么都该是4,为什么是8?

A:内存窗口,观察ba对象的存储的内容有哪些

 解释:

①:原来ba对象中还有一个指针,指针的大小是4字节,所以这里总大小是8字节

Q:那为什么会多存储一个指针

A:往下看

三:虚函数表

对象中的这个名为_vfptr的指针我们叫做虚函数表指针(vvirtualf代表function)

顾名思义该指针指向虚函数表,虚函数表(简称虚表)是一个数组,且是一个指针数组,也就是存放虚函数地址的数组。

提示:监视窗口中的红线的内容 在内存中不存在 仅仅是监视窗口方便我们观察而生成的 

Q:叽里咕噜的说这么多,所以多态的原理到底是什么?

A:请往下看

四:重写即覆盖

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;
	}
private:
	int _d = 2;
};
int main()
{
    //基类对象
	Base b;
    //派生类对象
	Derive d;

	return 0;
}
针对上面的求base对象大小的代码我们做出以下改造:
①:我们增加一个派生类Derive去继承Base
②:Derive中重写Func1
③:Base再增加一个虚函数Func2和一个普通函数Func3
此时再通过内存窗口去观察基类对象b 和派生类对象d:

发现:

  ①: 派生类对象d除开继承了基类对象b的成员变量_b,也存储了一个虚表指针
  ②: 基类 b 对象和派生类 d 对象虚表是不一样的,这里我们发现 Func1 完成了重写,所以d的虚表
中存的是重写的Derive::Func1;所以虚函数的重写也叫作覆盖,覆盖就是指派生类虚表中虚函数
的地址把基类原本的虚函数的地址覆盖了。重写是语法的叫法,覆盖是原理层的叫法。
③: 另外 Func2 继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函
数,所以不会放进虚表。
④:总结一下派生类的虚表生成
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。   
至此 我们就可以知道多态的原理了!

五:多态的原理解析

上面分析了这个半天了那么多态的原理到底是什么?
还记得最开始的多态例子吗? Func 函数传 Person 调用的 Person::BuyTicket,传 Student 调用的是 Student::BuyTicket ,如下图:

class Person {
public:
 virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

class Student : public Person {
public:
 virtual void BuyTicket() { cout << "买票-半价" << endl; }
};

void Func(Person& p)
{
p.BuyTicket();
}

int main()
{
    //基类调用函数
    Person Mike;
    Func(Mike);

    //派生类调用函数
    Student Johnson;
    Func(Johnson);

    return 0;
}

多态的原理如下:

①:观察上图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚
函数是Person::BuyTicket。
②: 观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中
找到虚函数是Student::BuyTicket。
③: 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

也就是说,编译器根本不管你传过来的是基类指针/引用,还是派生类的指针/引用

它只会直接去找传过来的对象中的虚表(vptr),然后通过虚表找到正确的虚函数进行调用。

而此时因为虚函数已经被重写了,所以就实现了多态的“不同对象不同行为”。

反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调
用虚函数。反思一下为什么?
①:虚函数覆盖(重写)的条件的原因:
确保子类能提供自己的实现,替换父类的默认行为。

如果没有虚函数覆盖,所有对象调用的都是父类的版本,无法实现“不同对象不同行为”。

Q:那必须通过基类的指针或者引用调用虚函数的原因是什么?

A:

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

②:要求对象的指针或引用调用虚函数原因是:

让程序在运行时决定调用哪个版本的函数(动态绑定)。为什么需要?

如果直接用对象调用(如 obj.BuyTicket()),编译器在编译时就确定了调用哪个函数(静态绑定),无法实现多态。

如果用指针或引用(如 Person* p = &student; p->BuyTicket()),由于指针/引用可以指向不同的对象,程序在运行时才能确定实际调用的函数(动态绑定)。

六:验证虚函数表中存储的是虚函数的地址

需要知道一些知识:

①:__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),博主是vs所以放在最前面的

②:虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。

③:先暂且理解为__vfptr指向了一个指针数组,且其是首元素的地址(后面会拓展虚函数表的真实内存布局)

所以现在我们的思路是:

①:获取对象的前四个节点的地址 进行解引用 得到的就是__vfptrd的内容,也就是数组的起始地址

②:通过__vfptrd的内容去遍历打印整个数组的元素(虚函数的地址),终止条件为nullptr

③:每次遍历得到的是虚函数的地址,在通过得到的地址验证它确实指向相应的虚函数,因为我们的虚函数里面会打印内容,此时运行结果有虚函数内部打印的内容,则代表验证成功!!

思路化作代码如下:

#include<iostream>
using namespace std;

class Base
{
public:
	Base()
		:_b(2)
	{
	}

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

	virtual void Func3()//新增的虚函数
	{
		cout << "Derive::Func3()" << endl;
	}
private:
	int _d = 2;
};


typedef void(*VF_PTR)();
//打印虚表,本质打印指针数组,每个元素都是虚函数的地址
void PrintVFT(VF_PTR* vft)
{
	for (size_t i = 0; vft[i] != nullptr; i++)
	{
		printf("虚函数表的下标元素[%d]对应的地址是:%p->", i, vft[i]);

		//回调该地址验证是否为虚函数
		cout << "调用函数地址产生的内容是:";
		VF_PTR f = vft[i];
		f();


		//(*f)();这种调用也可以
	}
	cout << endl << endl;
}

int main()
{
	//派生类的对象d
	Derive d;

	//取到对象d中的前四个字节 给PrintVFT函数
	PrintVFT((VF_PTR*)(*((int*)&d)));

	return 0;
}

运行结果:

代码解释:

①:如何在main函数取到虚函数表起始地址

int main()
{
	//派生类的对象d
	Derive d;

	//取到对象d中的前四个字节 给PrintVFT函数
	PrintVFT((VF_PTR*)(*((int*)&d)));

	return 0;
}

a:&d获取对象d的地址

b:(int*)&d将其转换为int指针(此处指针大小和int相同,都是4字节)

c:*((int*)&d)解引用获取vptr的值(虚函数表起始地址)

d:(VF_PTR*)将vptr转换为函数指针数组类型

②:如何遍历打印虚函数表的元素(虚函数的地址)

typedef void(*VF_PTR)();
//打印虚表,本质打印指针数组,每个元素都是虚函数的地址
void PrintVFT(VF_PTR* vft)
{
	for (size_t i = 0; vft[i] != nullptr; i++)
	{
		printf("虚函数表的下标元素[%d]对应的地址是:%p->", i, vft[i]);

		//回调该地址验证是否为虚函数
		cout << "调用函数地址产生的内容是:";
		VF_PTR f = vft[i];
		f();


		//(*f)();这种调用也可以
	}
	cout << endl << endl;
}

解释:

①:typedef void(*VF_PTR)();

  • 这是一个函数指针类型的定义

  • 它定义了一个名为VF_PTR的类型,表示"指向无参数、无返回值函数的指针"

  • 分解:

    • void():函数类型(无参数,返回void)

    • (*VF_PTR):声明一个指针类型

    • typedef:为这个指针类型创建别名

等效的现代C++写法:

using VF_PTR = void(*)();

注意:我们平时的typedef 给其他类型 换一个新的名字 是分开的,很好观察

 

但是typedef给函数指针类型起一个名字 是揉在一起的 不易观察 明白是C++写法那种就OK了

②:此时main函数传过来一个数组的首地址,咱们PrintVFT函数也接收到了,然后就是遍历打印了,截止条件是nullptr

③:重点来了,我们取到了每个元素,也就是每个虚函数的地址,咱们如何回调验证是否就是虚函数的地址呢?

VF_PTR f = vft[i];
f();
// 或者 (*f)();
  • 将函数指针赋值给f

  • 通过函数指针调用函数

  • 两种调用方式等价:

    • f():直接调用

    • (*f)():先解引用再调用

所以运行结果为:

好的。现在再来回顾需要知道的知识中的第③点:

③:先暂且理解为__vfptr指向了一个指针数组,且其是首元素的地址(后面会拓展虚函数表的真实内存布局)

在之前的很多截图中,我们都能发现__vfptr的内容和数组的首元素地址并不一致,如图

Q:那你的_vfptr都不是一个数组的首地址,为什么能通过这个直接打印数组的元素呢??

A:vptr(虚函数表指针)的值和第一个虚函数的地址确实不同,这是由虚函数表的内存结构决定的。

1. 虚函数表的真实内存布局

虚函数表(vtable)并不只存储虚函数指针,它的完整结构是:

  • vptr指向的是整个虚函数表的起始地址(即type_info的位置)

  • 第一个虚函数实际是表的第1项(索引1)

2. 验证代码的问题所在

实际上跳过了type_info项,因为:

  1. 当强制转换为VF_PTR*时,编译器认为这是"纯函数指针数组"

  2. 但实际内存中,虚函数表前面还有隐藏的RTTI数据

了解到这里就可以了,跳过了type_info项,也方便我们更简单理解!

七:单继承/多继承的虚函数表的区别

①:单继承的虚函数表

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};
 
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 b;
};


int main()
{
	Base b;
	Derive d;
}

和我们预想的一致

②:多继承的虚函数表

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;
};
 
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[]) {
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
 
int main()
{
	Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

  

Q:此时的func函数放在那一个表中?是两张都放一份,还是选择一份放呢?

A:

 

八:多态面试大题

Q:以下程序输出结果是什么?
class A
{
public:
	virtual void func(int val = 1)
	{
		std::cout << "A->" << val << std::endl;
	}

	virtual void test()
	{
		func();
	}
};

class B : public A
{
public:
	void func(int val = 0)
	{
		std::cout << "B->" << val << std::endl;
	}
};

int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}
A:
Q:你要么B->0 要么A->1  怎么会有B->1的情况??
A:我们要理解,虚函数的重写,是对虚函数的定义部分进行了重写,而虚函数的声明部分不重写

这里题目很有心机的利用了缺省函数这一点,当你虚函数重写的时候,因为声明部分不重写,所以用的val的值还是基类中的虚函数的声明中的 1,所以结果为B->1  可以说这是一道为了考而考的题

这个例子很好地展示了为什么在C++中建议:

  • 避免在虚函数中使用默认参数(缺省参数)

  • 确保派生类和基类的虚函数使用相同的默认参数,以避免混淆。

九:多态面试问答题

①:  inline函数可以是虚函数吗?
答: 可以,不过编译器就忽略inline属性,这个函数就不再是 inline,因为虚函数要放到虚表中去。
②:  静态成员可以是虚函数吗?
答: 不能,因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
③:  构造函数可以是虚函数吗?
答: 不能,因为对象中的虚函数表指针是在构造函数初始化列表 阶段才初始化的。
④:  析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答: 可以,并且最好把基类的析构函数定义成虚函数。
⑤:  对象访问普通函数快还是虚函数更快?
答: 首先如果是普通对象,是一样快的。如果是指针 对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函 数表中去查找。
⑥:  虚函数表是在什么阶段生成的,存在哪的?
答: 虚函数表是在编译阶段就生成的,一般情况 下存在代码段(常量区)的。

十:虚表的存储区域

这里还有一个童鞋们很容易混淆的问题: 虚函数存在哪的?虚表存在哪的?
答:虚函数存在 虚表,虚表存在对象中。
注意上面的回答的错的 但是很多童鞋都是这样深以为然的。
正确答案:
虚表存的是虚函数指针,不是虚函数 ,虚函数和普通函数一样的,都是存在代码段的,只是
他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么 虚表存在哪的
呢?实际我们去验证一下会发现 vs 下是存在代码段的
vs验证如下:

相关文章:

  • 个人学习编程(3-26) leetcode刷题
  • 三个串口同时打开并指定数据包控制指令思想
  • 高效内存管理:x86-64架构中的分页机制
  • RK3568 驱动和设备匹配的几种方法
  • 小区团购管理设计与实现(代码+数据库+LW)
  • Rust 与 FFmpeg 实现视频水印添加:技术解析与应用实践
  • AI作为学术评审专家有哪些优缺点?
  • Redis 常用数据结构及其对应的业务场景(总结)
  • R --- Error in library(***) : there is no package called ‘***’ (服务器非root用户)
  • 接口自动化进阶 —— Pytest全局配置pytest.ini文件详解!
  • 浏览器存储 IndexedDB
  • 蓝桥杯算法实战分享
  • CDN节点对网络安全扫描的影响:挑战与应对策略
  • 【Tauri2】004——run函数的简单介绍(2)
  • 【leetcode hot 100 84】柱状图中最大的矩形
  • LeetCode热题100题|1.两数之和,49.字母异位词分组
  • [WEB开发] Mybatis
  • CSP历年题解
  • Android 启动流程详解:从上电到桌面的全流程解析
  • Netty源码—7.ByteBuf原理四
  • 陕西旱情实探:大型灌区农业供水有保障,大旱之年无旱象
  • 西北大学副校长范代娣成陕西首富?系家庭财富,本人已从上市公司退出
  • 多地警务新媒体整合:关停交警等系统账号,统一信息发布渠道
  • 高适配算力、行业大模型与智能体平台重塑工业城市
  • 他站在当代思想的地平线上,眺望浪漫主义的余晖
  • 前四月全国铁路完成固定资产投资1947亿元,同比增长5.3%