C++进阶-->多态(Polymorphism)

1. 多态的概念

多态,顾名思义多种形态;多态分为编译时多态静态多态 )和运行时多态动态多态 ),静态多态就是就是我们前面讲的函数重载和函数模板,可以通过传不同类型,然后在编译期间就确定好使用哪个的称为静态多态动态多态就是一般在程序运行时确定使用指定函数的就称作动态多态。

动态多态,举个生活中的例子,例如铁路12306中买票的时候,当成年且没有特殊情况的人是全价买票,学生买票有特定的学生价买票,军人买票时可以优先买票,而这个过程抽象来说就是传入不同的对象就会完成不同的行为。


2. 多态的定义及使用

2.1 多态的构成条件

多态是一个继承关系下的类对象,去调用同一函数,产生了不同行为。比如Student继承了Person。Person对象买票全价,Student对象优惠买票。

2.1.1 实现多态的两个必要条件

  1. 必须是基类的指针或引用调用函数。

  2. 被调用的函数必须是虚函数(virtual修饰的)。

解释一下为什么必须是基类的指针或引用调用函数,因为只有是基类的指针或引用才能做到既接收基类的地址又接收派生类的地址;

第二派生类必须对基类的虚函数进行重写/覆盖,派生类对基类的虚函数重写了才能做到传入不同的类执行不同的操作的效果,才能达到多态。


3. 虚函数

在继承那一章我们也讲到虚继承,虚继承是用来避免菱形继承带来代码的冗余和二义性的问题,关键字是virtual;虚函数的关键字也是virtual,只需要在类成员函数前面加上virtual修饰,那么这个成员函数就被称为虚函数。注意:如果不是成员函数是不能加virtual修饰的。如下代码所示:

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

3.1 虚函数的重写

派生类中有一个虚函数跟基类的虚函数完全一致的虚函数称为虚函数的重写。

完全一致指的是:函数名、参数列表、返回值类型。(注意!这里的参数列表指的是参数的类型,名字相同没有关系);如下代码所示:

cpp 复制代码
class Person
{
public:
	virtual void BuyTicket(int x)
	{
		cout << "买票--全价" << endl;
	}
};
	
class Student : public Person
{
public:
	virtual void BuyTicket(int y)
	{
		cout << "买票--半价" << endl;
	}
};

还有一点需要提一下的是:如果基类的虚函数加了virtual,那么派生类的虚函数可以不加virtual,这里我们可以直接按"在继承的时候就把基类的虚函数的属性继承下来了"理解,这个不太建议这样子用,但在笔试或者考试选择题可能会考。

下面是虚函数、虚函数的重写、实现多态的代码使用:

cpp 复制代码
#include<iostream>
using namespace std;

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

void BuyTicket(Person* ptr)
{
	ptr->BuyTicket(6);
}

int main()
{
	Person p1;
	Student s1;
	BuyTicket(&p1);
	BuyTicket(&s1);
	return 0;
}

👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇

运行结果为:

最后再补充一个重重重重要的知识:虚函数的重写本质上是重写虚函数的实现!!!!下面就有一道相关的练习题。(毕竟人教人教不会,事教人一脚就懂)

3.2 多态场景下的选择题

cpp 复制代码
#include<iostream>
using namespace std;

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

首先如果我们对重写的知识理解的不够全面的话,那么肯定会选择D, 但我们上面说了重写是重写函数的实现,函数的参数不会有变化,所以我们调用的func应该如下图所示:

所以正确的答案是B;

最后我们再来细致分析一下p->test()究竟怎么构成多态,我们都知道在类里面的成员函数会有一个隐藏的参数就是this指针,而test是在A里面的,所以完整的函数应该为 virtual void test(A* this)这里就构成重载了,基类做this指针的返回值,完全符合上面所说的2个多态的构成条件。


3.3 虚函数重写的一些问题

3.3.1 协变

协变:派生类重写基类的虚函数的时候返回值不同。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用,这个就成为协变。协变用处不多,仅需了解一下即可;如下代码所示:

cpp 复制代码
#include<iostream>
using namespace std;
//基类
class A
{

};
//派生类
class B :public A
{

};

class Person
{
public:
	//基类做返回值
	virtual A* BuyTicket()
	//virtual Person* BuyTicket()也可以
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};

class Student :public Person
{
public:
	//派生类做返回值
	virtual B* BuyTicket()
	//virtual Student* BuyTicket()也可以
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

3.3.2 析构函数的重写

基类的析构函数只要定义为虚函数后,无论派生类的析构函数写不写virtual,都会与基类的析构函数构成重写。

前面继承有提到一嘴,就是析构函数最后都会被编译器转换为destructor(),那么这时候函数名(destructor)相同构成隐藏(这个是继承那边的)。

所以只要基类的析构加了virtual,派生类的就构成了重写;函数名一样都是destructor、参数一样都没有、返回值类型都没有。

接下来就谈谈为什么要实现析构函数的重写(重点!!!!!)

在思考为什么的时候我们就可以通过反推来证明即如果没有实现会发生什么情况;我们先看一下下面的代码:

cpp 复制代码
#include<iostream>
using namespace std;

class A
{
public:
	 ~A()
	{
		cout << "~A()" << endl;
	}
};

class B :public A
{
public:
	~B()
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};

int main()
{
	A* p1 = new A;
	A* p2 = new B;

	delete p1;
	delete p2;
	return 0;
}

对代码的解析:我们用基类A指针实例化了两个对象p1接收基类A的地址p2接收派生类B的地址 ,如果说我们没有实现析构函数的重写delete p1和delete p2 是直接走基类的析构函数 ,因为没有构成多态 ,也就不会产生不同的行为 而只走基类A的析构函数,如果派生类B里面有动态申请空间,那么就会出现内存泄漏。如下图:

但如果我们让基类A的析构函数定义为虚函数,那么就构成多态 ,从而会根据传入不同的类型调用不同对象的析构函数。

这里为什么会还会调用一次基类A的析构函数呢?前面继承有提到,派生类的析构函数里面其实包含了基类的析构函数,主要是为了达到先子后父的析构顺序。

最后总结一下:C++的这些实现其实都是有关联的,他们为了处理上面的这种情况而实现了析构函数的重写,为了实现析构函数的重写而实现了让析构函数无论函数名为什么最后都会转换成destructor。真的很妙。


3.3.3 override和final关键字

override是用来检查你是否重写错误;

final在继承那里也提到过,如果想实现一个类不被继承就用final,而这里的final是用来实现一个不能被重写的虚函数;


3.3.4 重载/重写/隐藏的对比


3.3.5 纯虚函数和抽象类

纯虚函数只要在虚函数后面加个"=0"即可;纯虚函数不需要定义实现,没有很大作用,但是在语法上是支持实现的 。 而包含纯虚函数的类称作抽象类,抽象类不能实例化出对象,但是派生类可以继承抽象类,如果派生类不重写纯虚函数,那么派生类也称为抽象类。纯虚函数其实就是间接强制了派生类要重写纯虚函数。

干说有点难理解,举个实际例子,狗和猫都是动物,狗的叫声是"旺旺",猫的叫声是"喵喵",狗和猫都是动物都有动物的特征所以可以继承动物的属性,但是动物是怎么叫的我们不知道,而动物就可以称作是抽象类,"叫"这一个动作可以用纯虚函数实现,猫和狗则是动物的派生类,所以需要重写这个纯虚函数,狗重写"叫"函数为"旺旺",猫则是"喵喵"。如下代码所示:

cpp 复制代码
#include<iostream>

class Animal
{
public:
	virtual void talk() = 0;
};
class Dog : public Animal
{
public:
	virtual void talk() 
	{
		std::cout << "汪汪" << std::endl;
	}
};

class Cat : public Animal
{
public:
	virtual void talk() 
	{
		std::cout << "(>^ω^<)喵" << std::endl;
	}
};
int main()
{
	Cat cat;
	Dog dog;
	cat.talk();
	dog.talk();
	return 0;
}

👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇

运行结果为:


4. 多态的原理

首先我们看一道题,用一道题引出下面的知识;

cpp 复制代码
#include<iostream>
using namespace std;

class Base {
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
protected:
	int _b = 1;
	char _ch = 'x';
};

int main()
{
	Base b;
	cout << sizeof(b) << endl;
	return 0;
}

我们知道,计算类的大小是使用对齐的方式计算的,那么计算出来的结果是8,可能我们的答案就选择了C,实则并不然,答案选的是D,首先我们通过调试看一下里面究竟多了什么玩意,如下图所示:

我们发现里面除了成员变量_b和_char,还有一个指针_vfptr,这个指针我们称作虚函数表指针(virtual function pointer);

4.1 虚函数表指针

每个含有虚函数的类中都会有一个虚函数表指针,这个指针是指向一块存着虚函数地址的空间,虚函数表也称作虚表。所以我们传入不同类去调用该类的虚函数的时候就是通过这个指针去调用不同类中的虚函数的。

4.2 多态的实现

首先我们先来看一串代码

cpp 复制代码
#include<iostream>
using namespace std;
class Person {
public:
	virtual void BuyTicket() 
	{
		cout << "买票-全价" << endl;
	}
protected:
	string _name;
};

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

protected:
	int _id;
};

class Soldier : public Person 
{
public:
	virtual void BuyTicket() 
	{ 
		cout << "买票-优先" << endl;
	}

protected:
	string _codename; // 代号
};

void Func(Person* ptr)
{
	// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
	// 但是跟ptr没关系,而是由ptr指向的对象决定的。
	ptr->BuyTicket();
}

int main()
{
	// 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后
	// 多态也会发生在多个派生类之间。
	Student st;
	Soldier sr;
	Person pr;
	Func(&st);
	Func(&sr);
	Func(&pr);
	return 0;
}

我们可能会好奇,Func内的ptr是如何做到传入不同的类对象的地址然后走不同的虚函数执行不同操作的,下面就来讲解一下。

如下图

通过上图我们看到,满足多态后,不再是编译时通过调用对象确定函数的地址,而是运行时到指定的对象内找到对象的__vfptr(虚表)然后确定对应的虚函数的地址,然后执行,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类的虚函数。


4.3 动态绑定和静态绑定

动态绑定 则是满足多态条件的函数调用是在运行时绑定,说简单点就是在运行时去虚函数表内找到函数地址然后调用。

静态绑定则是在编译期间确定函数的地址然后调用。

这个可以通过汇编层看一下。

动态绑定👇看着挺复杂的,挺多操作,因为他是在运行时先去找到__vptr这个指针,然后找到虚函数表,再通过虚函数表内的地址找到虚函数,再调用该函数。

静态绑定👇编译器直接确定函数地址然后直接调用。


4.4 虚函数表

  1. 基类对象的虚函数表中存放基类所有虚函数的地址。如果同类型实例化对象的话则虚表共用,不同类型虚表各自独立。

  2. 派生类由两部分构成,一部分是派生类自己的成员,还有一部分是基类的成员,还有基类的虚表指针,继承下来的基类中有虚表指针的话就不会自己再生成一个新的虚表指针,但继承下来的和虚表指针和基类的虚表指针不是同一个,他们的地址不同。

  3. 如果派生类中有虚函数的重写,那派生类虚表中对应的虚函数就会被重写的虚函数覆盖。

  4. 派生类的虚表中包含基类的虚函数地址,派生类重写的虚函数地址,还有派生类自己的虚函数地址。

  5. 虚函数的本质是一个存虚函数指针的指针数组,一般情况下这个数组后面会放一个0x00000000的标记 (这个C++并没有进⾏规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000 标记,g++系列编译不会放)

  6. 虚函数和普通函数一样,编译好后是一段指令,都是存在代码段的,只是虚函数的地址存在虚表中。

  7. 虚函数表的存在位置是看编译器的,C++并没有规定,下面将来看一下vs的在哪里;如下代码所示:

cpp 复制代码
#include<iostream>
using namespace std;

class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	void func5() { cout << "Base::func5" << endl; }
protected:
	int a = 1;
};

class Derive : public Base
{
public:
	// 重写基类的func1
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func1" << endl; }
	void func4() { cout << "Derive::func4" << endl; }

protected:
	int b = 2;
};

int main()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);

	Base b;
	Derive d;
	Base* p3 = &b;
	Derive* p4 = &d;
	printf("Person虚表地址:%p\n", *(int*)p3);
	printf("Student虚表地址:%p\n", *(int*)p4);
	printf("虚函数地址:%p\n", &Base::func1);
	printf("普通函数地址:%p\n", &Base::func5);
	return 0;
}

👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇 👇

运行结果为:

由此可见,虚表的地址与常量区的地址很接近,那就说明虚表是存在常量区的。


5. 额外补充的知识

首先我们先来看一个代码。

cpp 复制代码
#include<iostream>
using namespace std;

class A 
{ 
public: 
	void test(float a) 
	{
		cout << a; 
	} 
}; class B :public A 
{
public: 
	void test(int b) 
	{
		cout << b; 
	} 
}; void main() 
{
	A* a = new A;
	B* b = new B;
	a = b; 
	a->test(1.1); 
}

按照我们的思路,a赋值给b之后,那么A的test的隐式指针this就应该转换成B类的this,应该调用的是cout出b的值为1。

但是编译器不是这么做的,编译器 虽然通过指针的指向访问成员变量 ,但是不能通过指针的指向访问成员函数 ,而是通过**指针的类型来访问成员函数。**也就是说无论a的指针指向了谁,都只能访问到A类内的成员函数。(引用也是一样的!!!!!)


派生类有几个父类,且那几个父类都有虚函数的话,那么派生类就会有几张虚表。而如果在派生类中增加虚函数的话只会放在第一张虚表的最后。

相关推荐
----云烟----21 分钟前
QT中QString类的各种使用
开发语言·qt
lsx20240625 分钟前
SQL SELECT 语句:基础与进阶应用
开发语言
开心工作室_kaic1 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
向宇it1 小时前
【unity小技巧】unity 什么是反射?反射的作用?反射的使用场景?反射的缺点?常用的反射操作?反射常见示例
开发语言·游戏·unity·c#·游戏引擎
武子康1 小时前
Java-06 深入浅出 MyBatis - 一对一模型 SqlMapConfig 与 Mapper 详细讲解测试
java·开发语言·数据仓库·sql·mybatis·springboot·springcloud
转世成为计算机大神1 小时前
易考八股文之Java中的设计模式?
java·开发语言·设计模式
机器视觉知识推荐、就业指导2 小时前
C++设计模式:建造者模式(Builder) 房屋建造案例
c++
宅小海2 小时前
scala String
大数据·开发语言·scala
qq_327342732 小时前
Java实现离线身份证号码OCR识别
java·开发语言
锅包肉的九珍2 小时前
Scala的Array数组
开发语言·后端·scala