C++多态

目录

[1. 多态的概念](#1. 多态的概念)

[2. 多态的定义及实现](#2. 多态的定义及实现)

[2.1 多态的构成条件](#2.1 多态的构成条件)

[2.1.1 实现多态还有两个必须重要条件:](#2.1.1 实现多态还有两个必须重要条件:)

[2.1.2 虚函数](#2.1.2 虚函数)

[2.1.3 虚函数的重写/覆盖](#2.1.3 虚函数的重写/覆盖)

[2.1.4 多态场景的⼀个选择题](#2.1.4 多态场景的⼀个选择题)

[2.1.5 虚函数重写的⼀些其他问题](#2.1.5 虚函数重写的⼀些其他问题)

[2.1.6 override 和final关键字](#2.1.6 override 和final关键字)

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

[3. 纯虚函数和抽象类](#3. 纯虚函数和抽象类)

[4. 多态的原理](#4. 多态的原理)

[4.1 虚函数表指针](#4.1 虚函数表指针)

[4.2 多态的原理](#4.2 多态的原理)

[4.2.1 多态是如何实现的](#4.2.1 多态是如何实现的)

[4.2.2 动态绑定与静态绑定](#4.2.2 动态绑定与静态绑定)

[4.2.3 虚函数表](#4.2.3 虚函数表)


1. 多态的概念

多态(polymorphism)的概念:​ ​ 通俗来说,就是多种形态。多态分为编译时多态(静态多态)​运行时多态(动态多态)​。这里我们重点讲运行时多态。

编译时多态(静态多态)主要就是我们前面讲的函数重载函数模板 。它们通过传递不同类型的参数来调用不同的函数,从而达到多种形态。之所以叫编译时多态,是因为实参传递给形参的参数匹配工作是在编译时 完成的。通常,我们将编译时发生的归类为静态 ,运行时发生的归类为动态

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。

比如买票这个行为:

  • 当普通人买票时,是全价买票;
  • 学生买票时,是优惠买票(5折或75折);
  • 军人买票时是优先买票。

再比如,同样是动物叫的一个行为(函数):

  • 传猫对象过去,就是"(>^ω^<)喵";
  • 传狗对象过去,就是"汪汪"。

2. 多态的定义及实现

2.1 多态的构成条件

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

2.1.1 实现多态还有两个必须重要条件:

  • 必须是基类的指针或者引用调用虚函数
  • 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。

说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象;第二派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。

2.1.2 虚函数

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

cpp 复制代码
class Person
{
public:
	//被virtual修饰的类成员函数
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};

2.1.3 虚函数的重写/覆盖

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

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

cpp 复制代码
//基类
class Animal
{
public:
	virtual void talk() const
	{}
};

//派生类
class Dog : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "汪汪" << std::endl;
	}
};

//派生类
class Cat : public Animal
{
public:
	virtual void talk() const
	{
		std::cout << "(>^ω^<)喵" << std::endl;
	}
};

//基类的引用
void letsHear(const Animal& animal)
{
	animal.talk();
}

int main()
{
	Cat cat;
	Dog dog;
	letsHear(cat);
	letsHear(dog);

	return 0;
}

现在我们就可以通过父类Animal的指针或者引用调用虚函数talk,此时不同类型的对象,调用的就是不同的函数,产生的也是不同的结果,进而实现了函数调用的多种形态。

2.1.4 多态场景的⼀个选择题

以下程序输出结果是什么()

A: A->0 B:B->1 C:A->1 D:B->0 E:编译出错 F:以上都不正确

cpp 复制代码
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;
}

我敢说第一次做这个题目的人99%的都做不出来,这是一道考官故意为难我们的题目

答案是:B

通过这个题目可以加深我们对多态的理解

1、我们创建了一个派生类B的指针p,然后通过这个指针调用test函数

2、p在调用test函数时会先在自己(class B)中找有没有这个函数,没有的话再向基类中寻找如果还没有就会编译报错

3、p在A类中找到了test函数,注:调用test函数的是什么?是*this指针,它属于A基类的指针。调用test函数实质上就是调用func函数

4、派生类B的func函数虽然没有加virture但它还是构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),这样我们就满足了多态的2个条件------1基类指针的调用 2被调用的函数属于虚函数

5、p是派生类B的对象,所以调用B中的func函数所以主体是"B->"那么val的缺省值是0还是1呢?

我都这么说了,这个题第二坑的就是这里,这里的缺省值是1,是继承基类A的

注:val的缺省值是1,是继承基类A的。因为默认参数是静态绑定的,而不是动态绑定的。在C++中,默认参数是根据静态类型(编译时类型)决定的,而不是动态类型(运行时类型)。这意味着,当函数被调用时,默认参数基于调用表达式中使用的类型。

2.1.5 虚函数重写的⼀些其他问题

1、协变(基类与派生类虚函数的返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们 了解⼀下即可。

cpp 复制代码
class A {};
class B : public A {};

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

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

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

int main()
{
	Person ps;
	Student st;

	Func(&ps);  //"买票-全价"
	Func(&st);  //"买票-打折"

	return 0;
}

2、析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。因此,只要基类的析构函数加了virtual修饰,派生类的析构函数就自动构成重写。

下面的代码可以说明问题:

cpp 复制代码
class A
{
public:
	virtual ~A()
	{
		cout << "~A()" << endl;
	}
};

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

// 只有派生类B的析构函数重写了A的析构函数,
// 下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	A* p1 = new A;
	A* p2 = new B;

	delete p1;
	delete p2;

	return 0;
}

在这种场景下,若是基类和派生类的析构函数没有构成重写就可能会导致内存泄漏,因为此时delete p1和delete p2都是调用的基类的析构函数,而我们所期望的是p1调用基类的析构函数,p2调用派生类的析构函数,即我们期望的是一种多态行为。

此时只有基类和派生类的析构函数构成了重写,才能使得delete按照我们的预期进行析构函数的调用,才能实现多态。因此,为了避免出现这种情况,比较建议将基类的析构函数定义为虚函数。

2.1.6 override 和final关键字

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

在上篇博客我们还谈到如果不想一个类被继承,可以在类名后+final关键字这里是它的另一个用法

final:修饰虚函数,表示该虚函数不能再被重写。

cpp 复制代码
//基类
class Person
{
public:
	//被final修饰,该虚函数不能再被重写
	virtual void BuyTicket() final
	{
		cout << "买票-全价" << endl;
	}
};
//派生类
class Student : public Person
{
public:
	//重写,编译报错
	virtual void BuyTicket()
	{
		cout << "买票-半价" << endl;
	}
};
//派生类
class Soldier : public Person
{
public:
	//重写,编译报错
	virtual void BuyTicket()
	{
		cout << "优先-买票" << endl;
	}
};

override:检查派生类虚函数是否重写了基类的某个虚函数,如果没有重写则编译报错。

例如,子类Student和Soldier的虚函数BuyTicket被override修饰,编译时就会检查子类的这两个BuyTicket函数是否重写了父类的虚函数,如果没有则会编译报错。

cpp 复制代码
//父类
class Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-全价" << endl;
	}
};
//子类
class Student : public Person
{
public:
	//子类完成了父类虚函数的重写,编译通过
	virtual void BuyTicket() override
	{
		cout << "买票-半价" << endl;
	}
};
//子类
class Soldier : public Person
{
public:
	//子类没有完成了父类虚函数的重写,编译报错
	virtual void BuyTicket(int i) override
	{
		cout << "优先-买票" << endl;
	}
};

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

3. 纯虚函数和抽象类

在虚函数的后面写上=0,则这个函数为纯虚函数。纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象。如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

cpp 复制代码
//抽象类
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;
	}
};

int main()
{
	// 编译报错:error C2259: "Car": 无法实例化抽象类
	//Car car;

	Car* pBenz = new Benz;
	pBenz->Drive();

	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}

抽象类既然不能实例化出对象,那抽象类存在的意义是什么?

  1. 抽象类可以更好的去表示现实世界中,没有实例对象对应的抽象类型,比如:植物、人、动物等。
  2. 抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去重写纯虚函数,因为子类若是不重写从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。

4. 多态的原理

4.1 虚函数表指针

下面编译为32位程序的运行结果是什么()

A. 编译报错 B.运行报错 C.8 D.12

cpp 复制代码
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;
}

这跟我们在结构体大小学的有一点不同,我们通常认为这里b是int类型占4个字节,char占1个字节,和字节数对齐后总大小应该是4的两倍------8。但这里不是

上面题目运行结果12bytes,除了_b_ch成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关)。对象中的这个指针我们叫做虚函数表指针 ​(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表

4.2 多态的原理

4.2.1 多态是如何实现的

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

protected:
	string _name;
	int _age;
};

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

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

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

	Func(&ps);
	Func(&st);

	return 0;
}

从底层的角度来说,在 Func函数中,ptr->BuyTicket()是如何做到当 ptr指向 Person对象时就调用 Person::BuyTicket,而指向 Student对象时就调用 Student::BuyTicket的呢?通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。第一张图,ptr指向的 Person对象,调用的是 Person的虚函数;第二张图,ptr指向的 Student对象,调用的是 Student的虚函数。

4.2.2 动态绑定与静态绑定

  • 对不满足多态条件(即调用方式不是**"**基类的指针或者引用"来调用的虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
  • 对满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就叫做动态绑定。

4.2.3 虚函数表

  • 基类虚表:​​ 基类对象的虚函数表中存放基类所有虚函数的地址。

  • 虚表独立性:​ 同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表。所以,基类和派生类有各自独立的虚表。

  • 派生类构成与虚表指针:​ 派生类由两部分构成:继承下来的基类部分和自己的成员。一般情况下,继承下来的基类部分中已经包含了虚函数表指针,派生类自己的部分就不会再生成一个虚函数表指针。但要注意,派生类对象中继承下来的基类部分的虚函数表指针,与基类对象本身的虚函数表指针不是同一个指针(就像派生类对象中的基类成员和基类对象本身的成员也是独立的)。

  • 虚函数覆盖:​ 派生类中重写了基类的虚函数后,派生类的虚函数表中对应的虚函数地址就会被覆盖成派生类重写的虚函数地址。

  • 派生类虚表内容:​ 派生类的虚函数表中包含三部分:

    1. 基类的虚函数地址(未被重写的)。
    2. 派生类重写的虚函数地址(覆盖了基类对应的虚函数地址)。
    3. 派生类自己的虚函数地址(新增的虚函数)。
  • 虚表本质:​ 虚函数表本质是一个存储虚函数指针的指针数组。一般情况下,这个数组最后面会放一个标记(如 0x00000000)。注意:​ 这个标记是编译器自行定义的(C++标准没有规定),VS系列编译器通常会放,g++系列编译器则可能不会放。

  • 虚函数存储位置:​ 虚函数和普通函数一样,编译后是一段指令代码,都存储在代码段(text segment)。虚函数表里存储的只是指向这些虚函数代码的指针(地址)。

  • 虚表存储位置:​ 虚函数表本身存储在哪里?这个问题严格来说没有标准答案(C++标准没有规定)。可以通过写代码对比验证。在VS编译器下,虚表是存放在代码段(常量区)的。

相关推荐
FirstFrost --sy5 分钟前
map和set的使⽤
c++·set·map
黑客影儿6 分钟前
在Godot中为您的游戏添加并控制游戏角色的完整技术指南
开发语言·游戏·游戏引擎·godot·gdscript·游戏开发·3d游戏
不午睡的探索者9 分钟前
FFmpeg + WebRTC:音视频开发的两大核心利器
c++·github·音视频开发
愚润求学15 分钟前
【贪心算法】day3
c++·算法·leetcode·贪心算法
SimpleUmbrella23 分钟前
windows下配置lua环境
c++·lua
yaoxin5211231 小时前
168. Java Lambda 表达式 - 专用比较器
java·开发语言
shylyly_2 小时前
Linux->多线程3
java·linux·开发语言·阻塞队列·生产者消费者模型
yw00yw2 小时前
常见的设计模式
开发语言·javascript·设计模式
我不是星海3 小时前
RabbitMQ基础入门实战
java·开发语言
重启的码农3 小时前
Windows虚拟显示器MttVDD源码分析 (6) 高级色彩与HDR管理
c++·windows·操作系统