C++多态

Posted 山舟

tags:

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

文章目录


声明:本文中的代码及运行结果均是在32位平台下、VS2022中的测试结果。


概念

通俗来说就是多种形态,具体一些就是对于某个行为,当不同的对象去完成时会产生出不同的状态。

再进一步到C++中,就是函数调用的多种形态,这一特性可以让我们在调用函数时更加灵活。

多态又分为静态多态和动态多态:

  • 静态多态:主要指函数重载。
  • 动态多态:主要指父类的指针或引用调用、重写虚函数。如果父类的指针或引用指向父类,就调用父类的虚函数;如果父类的指针或引用指向某个子类,就调用那个子类的虚函数。

静态多条在前面讲过,本篇主要讲动态多态,从其定义中就可看出,这里和继承关系非常密切,所以如果对继承不太熟的读者,可以到【C++】继承 了解一下。


一、多态的定义及实现

1.多态的构成条件

多态是指不同继承关系的类对象,调用同一函数时产生了不同的行为。比如Student(学生)继承了Person(承认),Person对象在买票这一行为上需要全价,而Student对象买票半价。

在继承中要构成多态还有两个条件:

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

2.virtual关键字和虚函数

成员函数前加virtual关键字后,该函数就变为虚函数(注意一般的函数前不可以加virtual)。

示例代码如下:

class Person

public:
	virtual void BuyTickets()
	
		cout << "全价票" << endl;
	
;

virtual void func()

编译结果如下:


注意:

  1. 只有类的非静态成员函数才可以成为虚函数。
  2. 虚函数和虚继承都用到了virtual关键字,但二者之间没有任何关系。虚函数使用virtual是为了实现多态,而虚继承是为了解决菱形继承中产生的数据冗余和二义性问题,它们之间没有关联。

3.虚函数重写

派生类中有一个跟基类完全相同的虚函数,即派生类虚函数与基类虚函数的返回值类型、函数名、参数列表完全相同,这时称子类的虚函数重写了基类的虚函数。

示例代码如下:

class Person //父类

public:
	virtual void BuyTickets()
	
		cout << "全价票" << endl;
	
;

class Student : public Person //派生类继承父类

public:
	virtual void BuyTickets() //虚函数重写
	
		cout << "半价票" << endl;
	
;

void func(Person& p) //代码以引用为例,指针同理

	p.BuyTickets(); // 调用"同一个"成员函数(实际不是同一个)


int main()

	Person p;
	Student s;

	//父类的引用接收不同类型的对象,实现了多态
	func(p);
	func(s);

	return 0;

运行结果如下:

上面代码中func函数的参数p是父类的引用,接收不同类型的对象时调用虚函数产生了不同的结果,如果将参数改为父类的指针也可以实现同样的效果,但是如果是父类的对象则不可以。


下面的代码中,func1以父类的引用为参数,func2以父类的指针为参数,func3以父类的对象为参数。

示例代码如下:

class Person //父类

public:
	virtual void BuyTickets()
	
		cout << "全价票" << endl;
	
;

class Student : public Person //派生类继承父类

public:
	virtual void BuyTickets() //虚函数重写
	
		cout << "半价票" << endl;
	
;

void func1(Person& p) //父类引用

	p.BuyTickets(); // 调用"同一个"成员函数(实际不是同一个)


void func2(Person* p) //父类指针

	p->BuyTickets();


void func3(Person p) //父类对象

	p.BuyTickets();


int main()

	Person p;
	Student s;

	//父类的引用接收不同类型的对象,实现了多态
	func1(p);
	func1(s);
	cout << endl;

	//父类的指针接收不同类型的对象,实现了多态
	func2(&p);
	func2(&s);
	cout << endl;

	//父类的对象接收不同类型的对象,无法实现多态
	func3(p);
	func3(s);

	return 0;

很显然以父类的对象为参数无法实现多态,这也印证了最开始提到的,只有父类的引用或指针才可以实现多态。

运行结果如下:


而如果父类的函数都不是virtual修饰的虚函数,那么就更无法实现多态了,很容易理解,这里就不做演示了。


4.虚函数重写的三个例外

(1)协变

子类重写父类虚函数时,与父类虚函数仅有返回值类型不同,且父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用时,称为协变。

示例代码如下:

class Person //父类

public:
	virtual Person* pointer()  //返回值为Person*
	
		cout << "Person* pointer()" << endl;
		return new Person;
	
;

class Student : public Person //派生类继承父类

public:
	virtual Student* pointer()  //返回值为Student*
	
		cout << "Student* pointer()" << endl;
		return new Student;
	
;

int main()

	Person p;
	Student s;
	Person* ptr;
	
	ptr = &p;
	ptr->pointer(); //调用父类的

	ptr = &s;
	ptr->pointer(); //调用子类的
	return 0;

运行结果如下,可以看到虽然pointer()函数的返回值不同,但依然实现了多态。


(2)析构函数重写

由于析构函数的函数名是有要求的,所以从代码角度看,子类和父类析构函数的函数名不同,破坏了多态的要求。但实际上,在【C++】继承 提到过,编译器对所有析构函数的函数名都做了特殊处理,编译后析构函数的名称统一处理成destructor。

但是析构函数重写是必要的吗?

下面列举两种情况:

①不需要重写虚构函数

示例代码如下:

class Person //父类

public:
	~Person()
	
		cout << "~Person()" << endl;
	
;

class Student : public Person //派生类继承父类

public:
	~Student()
	
		cout << "~Student()" << endl;
	
;

int main()

	Person p;
	Student s;

	return 0;

运行结果如下:

这时两个类中都没有什么需要在析构函数中处理的东西,所以不重写析构函数没有什么影响。

这里再解释一下打印的结果:临时变量储存在栈中,符合后进先出的规则,所以先析构s,后析构p。而析构s时根据继承的规则,先调用子类的析构函数、再调用父类的析构函数,这时s的析构结束了。之后再析构p。


②需要重写析构函数

示例代码如下:

class Person //父类

public:
	~Person()
	
		cout << "~Person()" << endl;
	
;

class Student : public Person //派生类继承父类

public:
	~Student()
	
		cout << "~Student()" << endl;
	
;

int main()

	Person* p = new Person;
	Person* s = new Student;

	delete p;
	delete s;

	return 0;

运行结果如下:

从打印结果来看,s指针在delete时仅调用了父类的析构函数,而他在new时申请的是Student的空间,这样就可能造成内存泄漏。这种场景下就需要对Student类的析构函数进行重写。


下面代码将两个类的析构函数定义为虚函数,这样就可以达到析构的目的。

示例代码如下:

class Person //父类

public:
	virtual ~Person()
	
		cout << "~Person()" << endl;
	
;

class Student : public Person //派生类继承父类

public:
	virtual ~Student()
	
		cout << "~Student()" << endl;
	
;

int main()

	Person* p = new Person;
	Person* s = new Student;

	delete p;
	delete s;

	return 0;

运行结果如下,通过重写析构函数,指针在delete时就可以释放对应的资源,不会出现内存泄漏的问题。


(3)子类的虚函数可以不用virtual修饰

如果父类的某一个函数已经定义为虚函数,那么子类与之相同函数不需要用virtual修饰,也认为是完成了重写。但实际上这也是C++的一个小坑,为了方便代码的维护,建议还是只要需要用到虚函数的位置全部都用virtual修饰。


5.final

修饰父类的虚函数,使该虚函数无法被重写。

示例代码如下:

class Person //父类

public:
	virtual void BuyTickets() final
	
		cout << "全价票" << endl;
	
;

class Student : public Person //派生类继承父类

public:
	virtual void BuyTickets() //虚函数重写
	
		cout << "半价票" << endl;
	
;

可以看到,在编译阶段就报错,子类中无法对父类的BuyTickets()进行重写。

编译结果如下:


6.override

修饰子类的虚函数,检查该虚函数是否重写,若没有重写则编译报错。

示例代码如下:

class Person //父类

public:
	virtual void BuyTickets()
	
		cout << "全价票" << endl;
	
;

class Student : public Person //派生类继承父类

public:
	virtual void BuyTickets(int i) override//虚函数重写
	
		cout << "半价票" << endl;
	
;

代码中子类的BuyTickets函数与父类的参数列表不同,没有构成重写,override直接在编译阶段就报错。

编译结果如下:


二、抽象类

1.概念

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

下图中用抽象类创建对象失败,证明抽象类无法实例化出对象。


2.接口继承和实现继承

普通函数的继承是一种实现继承,子类继承了父类函数,继承的是函数的实现。而虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。尤其是抽象类的产生,更是强制子类重写父类(否则子类也无法实例化出对象,类的功能大打折扣)。


三、多态的原理

1.引出虚函数表指针

定义一个如下的类,类成员有一个int类型的变量和一个char类型的变量,成员函数有一个虚函数,计算它的大小。

class A

public:
	virtual void Func()
	
		cout << "virtual void Func()" << endl;
	
private:
	int _b = 0;
	char _ch = '\\0';
;

int main()

	A a;
	cout << sizeof(a) << endl;
	return 0;

根据自定义类型(一):结构体 中内存对齐的规则可得该类定义出的对象的大小为8字节,但下面的运行结果是12字节(运行环境是32位平台、VS2022,后文中不再解释)。

通过调试来看对象a中具体有什么,为什么它的大小会是12字节?

通过调试看到对象a中不仅仅有类中定义的两个变量,还有一个void类型的指针,这个指针是虚函数表指针**,对象a大小为12字节正是因为多了这一个指针变量。

只要类中有虚函数,就一定会存在虚函数表指针,它就是用来实现多态的。


2.虚函数表

一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表(本质是一个数组)中,虚表指针指向的就是这个数组的起始地址。

虚函数表也简称虚表,虚函数表指针简称为虚表指针。


如下代码的类中定义了两个虚函数,一个普通函数,观察通过它定义的对象中虚表指针的情况。

class A

public:
	virtual void Func() //虚函数
	
		cout << "virtual void Func()" << endl;
	
	
	virtual void Func2() //虚函数
	

	void Func3() //普通函数
	
private:
	int _b = 0;
	char _ch = '\\0';
;

int main()

	A a;
	cout << sizeof(a) << endl;
	return 0;

可以看到,类中定义了两个虚函数,则对象中含有虚表指针指向的数组中有两个元素,而普通函数则没有对应的虚表指针。

再次提醒:多态和虚拟继承都用到了virtual关键字,但二者毫无关联。具体到这里,多态中的virtual定义虚函数,由此引出了虚函数表和虚函数指针;而虚拟继承中的虚基表存放的是虚基类的偏移量,二者同样无关,注意不要混淆。


3.多态的原理

再回到前面的代码,通过解释其逻辑来说明多态的原理:

class Person //父类

public:
	virtual void BuyTickets()
	
		cout << "全价票" << endl;
	
;

class Student : public Person //派生类继承父类

public:
	virtual void BuyTickets()//虚函数重写
	
		cout << "半价票" << endl;
	
;

int main()

	Person p;
	p.BuyTickets();
	Student s;
	s.BuyTickets();

	return 0;

通过调试可以看到,p和s中的虚表指针指向的地址不同,也就是说两个类中定义的BuyTickets()不是同一个,所以在调用时调用各自定义的函数,实现了多态。

满足多态条件、构成多态后,父类的指针或引用调用哪个虚函数,不是在编译时确定的,而是运行到指定位置、需要从指针或引用指向的对象的虚表中找虚函数地址时才确定的。所以如果指向的是父类对象,则调用父类的虚函数;指向的是子类对象,则调用子类的虚函数。

但如果不满足多态条件,那么调用哪个函数是在编译是就可以确定的,调用函数的对象是什么类型,就调用哪个类型定义的虚函数,与传什么类型的参数无关。


四、静态绑定和动态绑定

  • 静态绑定又称为前期绑定(早绑定),即在程序编译期间就确定了程序的行为,也称为静态多态,比如本文最开始提到的函数重载。
  • 动态绑定又称后期绑定(晚绑定),即在程序运行期间根据具体的类型来确定程序的具体行为,并确定具体调用哪个函数,也称为动态多态。

感谢阅读,如有错误请批评指正

以上是关于C++多态的主要内容,如果未能解决你的问题,请参考以下文章

Java—多态

C++多态

C++多态

C++多态

开心档之C++ 多态

C++多态