【C++进阶】多态

在这里插入图片描述

​👻内容专栏: C/C++编程
🐨本文概括: 多态的概念、多态的定义及实现、抽象类、多态的原理、单继承和多继承关系的虚函数表、继承和多态常见的面试题。
🐼本文作者: 阿四啊
🐸发布时间:2023.11.14

一、多态的概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态
🌰举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
🌰再举个栗子: 最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包-支付-给奖励金的活动。那么大家想想为什么有人扫的红包又大又新鲜8块、10块…,而有人扫的红包都是1毛,5毛…。其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码金额 = random()%99;比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你扫码金额 = random()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。ps:支付宝红包问题纯属瞎编,大家仅供娱乐。

二、多态的定义及实现

2.1 多态构成的条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了PersonPerson对象买票全价,Student对象买票半价。
那么在继承中要构成多态有两个条件

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

2.2 虚函数

虚函数:即被virtual修饰的成员函数称为虚函数。

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

2.3 虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型函数名参数列表完全相同),称派生类的虚函数重写了基类的虚函数

class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
	class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
	/*注意:在重写基类虚函数时,C++规定允许派生类的虚函数在不加virtual关键字时,
	虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),
	但是该种写法不是很规范,不建议这样使用*/
	/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
void Func(Person& p)
{
	 p.BuyTicket(); 
}
int main()
{
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

虚函数重写的两个例外

  1. 协变(基类与派生类虚函数返回值类型不同)
    派生类与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class A{};
class B : public A {};
class Person {
public:
	virtual A* f() {return new A;}
};
class Student : public Person {
public:
	virtual B* f() {return new B;}
};
  1. 析构函数的重写(基类与派生类析构函数名字不相同)
    如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,表面看起来违背了重写的规则,其实不然,在内存管理章节我们学过,编译器对析构函数的名称做了特殊处理,编译后析构函数的名称都统一处理成destructor这个名字

事先引入两个调用概念,我们通过下面的例子进行具体阐释。

普通调用:调用函数的类型是谁,就去调用这个对象类型的函数。
多态调用:调用指针或者引用指向的对象。指向父类就调用父类的函数,指向子类调用子类的函数。

class Person {
public:
	//~Person() { cout << "~Person()" << endl; }
	virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
	//~Student() { cout << "~Student()" << endl; }
	virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函
//数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	Person* ptr = new Person;
	delete ptr;
	ptr = new Student;
	delete ptr;
	return 0;
}

验证以上代码,假设派生类Student的析构函数没有重写Person的析构函数,也就是子父类析构函数不构成虚函数的重写,那么delete ptr释放对象,两次调用的都是Person类的析构函数,这种情况称为普通调用。所以结果如下:

 ~Person()
 ~Person()

只有派生类Student的析构函数重写了Person的析构函数,delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。这种情况属于多态调用。所以结果如下:

~Person()
~Student()
~Person()

2.4 C++11 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

  1. final:修饰虚函数,表示该虚函数不能再被重写
class A
{
public:
	virtual void func() final {}
};
class B :public A
{
public:
	virtual void func() {}
};
  1. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class A
{
public:
	virtual void func() {}
};
class B :public A
{
public:
	virtual void func() override{}
};

2.5 重载、重写(覆盖)、隐藏(重定义)的对比

在这里插入图片描述

三、抽象类

3.1 概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数包含纯虚函数的类叫做抽象类(也叫接口类)抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

//抽象类
//包含纯虚函数的类叫做抽象类
//抽象类不能实例化出对象
//间接强制地在派生类重写纯虚函数
class Car
{
public:
	virtual void Drive() = 0;
};
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};
void Test()
{
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
}

3.2 接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

下面来看一道面试题:

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

以上程序的输出结果是什么?
在这里插入图片描述
解释:故选B
在这里插入图片描述

四、多态的原理

4.1 虚函数表与多态原理

// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
	char ch = 'x';
};
int main()
{
	Base b;
	//x86环境下(32位平台)占12Byte
	cout << sizeof Base << endl;
}
//不要误以为仅仅考察的是内存对齐,在vs平台通过调试观察发现还多了一个__vfptr指针
//b对象中的__vfptr指针我们叫做虚函数表指针(v代表virtual,f代表function)
//一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数
//的地址要被放到虚函数表中,虚函数表也简称虚表。

通过观察测试我们发现b对象是12bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表
在这里插入图片描述
那么派生类中这个表放了些什么呢?我们接着往下分析。


class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
	virtual void func(){}
private:
	int a = 0;

};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
private:
	int b = 1;
};
void Func(Person* p)
{
	p->BuyTicket();
}
int main()
{
	Person p;
	Student s;
	Func(&p);
	Func(&s);
	return 0;
}

通过观察和测试,我们发现:

  1. 派生类s中也有一个虚表指针,s对象由两部分构成,一部分是父类继承下来的成员,另一部分是自己的成员。
  2. 基类p与子类s对象基表是不一样的,这里我们发现BuyTicket完成了重写,所以s的虚表中存放的是BuyTicket,所以观察底层其地址发生了改变,其本质是拷贝给子类对象,子类对象覆盖了,所以虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  3. func函数被继承下来后是虚函数,所以放进了s子类对象的虚表。
  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
  5. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

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

在这里插入图片描述
这里我们就更加深刻认识了多态调用的具体含义:父类对象的指针或者引用指向父类对象调用父类的虚函数,指向子类对象调用子类的虚函数或者"切割"出来父类那一部分。

那么为什么只有父类的指针或者引用去调用才会形成多态,Person p = s,用父类的对象调用却不能形成多态呢?这里表示的是将子类对象拷贝给父类,而不会构成多态,将切割出子类对象中父类那一部分成员拷贝给父类,但是并不会拷贝虚函数表指针否则,构成多态,引发一系列不可预知的结果!
例如:假如构成多态,那么以下代码,假如s重写了析构函数,然后delete p就会释放Student对象,而不是Person对象。
在这里插入图片描述
以上就是对象调用却不能形成多态的原因。

  1. 虚函数与虚函数表存放在哪里?
    虚函数和普通成员函数一样存放在代码段,同时把虚函数地址存了一份到虚函数表。虚函数表也属于代码段(常量区)。

下面我们通过一段程序验证一下:

//程序验证,虚函数表与虚函数存放在内存哪个区域??
void funct()
{}
int main()
{
	static int a = 10;
	int b = 20;
	int* p = new int;
	const char* str = "hello world";
	Person ps;
	printf("堆区:%p\n", p);
	printf("栈区:%p\n", &b);
	printf("静态区:%p\n", &a);
	printf("代码段:%p\n", str);
	printf("虚函数表: %p\n", *((int*)&ps));//取对象头4个byte,即为指向虚表的指针
	printf("虚函数地址: %p\n", &Person::func);
	printf("普通函数地址: %p\n", funct);

	return 0;
}

在这里插入图片描述

  1. 满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象中去找的。不满足多态的函数调用是编译时确认好的。

4.2 动态绑定与静态绑定

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

五、单继承和多继承关系的虚函数表

需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的。

5.1 单继承中的虚函数表

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; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
	void func5() { cout << "void func5()" << endl; }
private:
	int b;
};
class X : public Derive
{
public:
	virtual void func3() { cout << "X::func3" << endl; }
private:
	int c;
};
int main()
{
	Base b;
	Derive d;
	X x;
	return 0;
}

将以上程序在vs平台运行起来,调试观察监视窗口发现只能看到基类的func1func2函数,那么其他的虚函数是不是就与之前讲的冲突了呢?这不禁让我们产生了质疑:
虚函数的地址一定会被放进类的虚函数表吗?
答案是一定的,只不过这里为什么没有显示出来呢?因为编译器可视化的窗口是会欺骗人的O(∩_∩)O哈哈,开玩笑啦,真实原因取决于编译器的设计而已,监视窗口有时会隐藏一些信息,导致一些bug,我们不妨自己去验证,查看一下虚表,那么有什么方式打印虚表呢?
我们继续往下看,

在这里插入图片描述
前面我们讲过打印虚函数表验证虚函数表存放在内存哪个区域,我们只要取出对象的前4个字节,就是虚表指针。
思路:以Derive对象d为例,&d即为Derive对象指针,那么如何拿到虚函数表指针,我们需要先转为int类型的指针,再解引用就可以拿到头四个字节的值,这个值就是指向虚表的指针。
在这里插入图片描述
下面我们实现一段代码,将虚函数表和虚函数地址打印。
前面我们说虚函数表本质是一个存虚函数指针的数组,这个数组最后面放了一个nullptr,我们可以写一个打印虚函数表的函数,用一个函数指针接收参数。

//打印虚表
typedef void(*VFptr)();//重命名void (*)()函数类型为VFptr

void Print_VFT(VFptr a[])
//void Print_VFT(VFptr* a)
{
	cout << "__vfptr地址:" << a << endl;
	for (size_t i = 0; a[i] != nullptr; i++)
	{
		printf("[%d] : %p ->", i, a[i]);

		VFptr f = a[i];//调用虚函数
		f();
		//以下写法等价
		//a[i]();
	}
	printf("\n");
}
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; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
	void func5() { cout << "void func5()" << endl; }
private:
	int b;
};
class X : public Derive
{
public:
	virtual void func3() { cout << "X::func3" << endl; }
private:
	int c;
};
int main()
{
	Base b;
	Derive d;
	X x;
	//不同的类型需要进行强制类型转换
	//需要再强转成VFptr*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
	Print_VFT((VFptr*)*((int*)&b));
	Print_VFT((VFptr*)*((int*)&d));
	Print_VFT((VFptr*)*((int*)&x));
	return 0;
}

需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再次编译就好了!
🌷打印结果如下:
在这里插入图片描述
所以,虚函数的地址一定会被放进类的虚函数表。同时我们也再一次深刻认识到了虚函数表。

5.2 多继承中的虚函数表

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 (*vf_ptr)();
void Print_VFT(vf_ptr a[])
{
	for (size_t i = 0; a[i] != nullptr; i++)
	{
		printf("[%d] : %p ->", i, a[i]);
		a[i]();
	}
	cout << endl;
}
int main()
{
	Derive d;
	//打印第一个虚表
	Print_VFT((vf_ptr*)*(int*)&d);

	//打印第二个虚表
	// 需要找偏移量,向后偏移sizeof Base1个字节
	//Print_VFT((vf_ptr*)*(int*)((char*)&d + sizeof(Base1)));

	//当然利用切片来写更好
	Base2 b = d;
	Print_VFT((vf_ptr*)*(int*)&b);

	return 0;
}

观察发现,多继承中,有多少个基类有虚函数,那么就有几张虚表,多继承派生类的未重写的虚函数放第一个继承基类的虚函数表中
在这里插入图片描述

六、继承和多态常见的面试题。

  1. 什么是多态?
    即多种形态,不同对象去完成会产生出不同的状态。静态的多态:函数重载。动态的多态:a. 父类的指针或引用去调用虚函数,b. 虚函数完成重写. 指向谁就调用谁的虚函数,实现多种形态。
  2. 什么是重载、重写(覆盖)、重定义(隐藏)?参考2.5 图中内容。
  3. 多态的实现原理? 参考4.1 的内容。
  4. inline函数可以是虚函数吗?
    可以,普通调用,inline内联函数直接完成替换,多态调用,inline不起作用,call的是虚函数的地址。
  5. 静态成员可以是虚函数吗?
    不可以,编译报错,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
  6. 构造函数可以是虚函数吗?
    不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。因为虚函数多态调用,要到虚表中去寻找,虚表指针都还没有初始化。
  7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
    可以,最好是虚函数,参考2.3 虚函数重写的例外。
  8. 对象访问普通函数快还是虚函数更快?
    如果是普通对象,构成普通调用,两者是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找,虚函数要慢一些。
  9. 虚函数表是在什么阶段生成的,存在哪的?
    虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
  10. C++菱形继承的问题?虚继承的原理?
    参考上一章节C++继承的内容👉C++进阶 继承
  11. 什么是抽象类?抽象类的作用?
    参考(三、抽象类),抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

阿四啊

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

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

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

打赏作者

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

抵扣说明:

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

余额充值