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

【C++】多态,要这样学

头像
🚀个人主页:奋斗的小羊
🚀所属专栏:C++
很荣幸您能阅读我的文章,诚请评论指点,欢迎欢迎 ~

动图描述

目录

  • 前言
  • 💥1、多态的定义和实现
    • 💥1.1 什么是多态
    • 💥1.2 多态的定义和实现
      • 💥1.2.1 虚函数
      • 💥1.2.2 构成多态的条件
      • 💥1.2.3 虚函数的重写(覆盖)
      • 💥1.2.4 为什么要重写析构函数
      • 💥1.2.5 c++11 override 和 final
      • 💥1.2.6 重载、重写(覆盖)、隐藏(重定义)
    • 💥1.3 抽象类
      • 💥1.3.1 定义
      • 💥1.3.2 实现继承和接口继承
  • 💥2、多态的原理
    • 💥2.1 虚函数表
    • 💥2.2 多态的原理
    • 💥2.3 动态绑定和静态绑定
  • 总结收尾


前言

本篇文章带你深入学习面向对象设计思想的重要体现之一——多态。
多态在面向对象编程(OOP)中具有深远的意义,它不仅是OOP的三大特性之一,还是实现代码复用、提高程序灵活性和可扩展性的重要手段。


💥1、多态的定义和实现

💥1.1 什么是多态

简单来说多态就是多种形态,细说就是当不同的对象去做同一个行为,得到的结果不同。
多态是面向对象编程中的一个核心概念,它允许我们以统一的接口来操作不同的对象。多态意味着 “多种形态”,即多种表现形式或类型。在编程中,多态通常指的是一个接口(或基类)可以有多种实现方式,或者一个对象可以在不同的情境下表现出不同的行为。

比如: 扫码支付,同样是扫码,当扫微信二维码是使用的是微信支付,当扫支付宝二维码时使用的是支付宝支付等。

在这里插入图片描述


💥1.2 多态的定义和实现

💥1.2.1 虚函数

virtual修饰的类成员函数就是虚函数。

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

💥1.2.2 构成多态的条件

两个条件缺一不可。

  1. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数重写(实现)
  2. 必须是基类的指针或引用调用虚函数

多态是在不同继承关系的类对象去调用同一个函数,产生了不同的行为。
比如同样买火车票,普通人是全价,学生是半价,军人的话是优先买票。这就是同一件事不同人去做得到的结果就不一样。

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

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

int main()
{
	Person pe;
	Func(pe);

	Student st;
	Func(st);

	Soldier so;
	Func(so);
	return 0;
}

💥1.2.3 虚函数的重写(覆盖)

派生类中有一个和基类完全相同的虚函数(函数名、参数列表、返回类型都相同),称子类的虚函数重写(也叫覆盖)了基类的虚函数。

  • 派生类的虚函数不加virtual,也可以构成重写(继承后基类的虚函数被继承下来了),但规范起见还是不建议省略
  • 虽然虚函数的实现可以重写,但接口只有一个

以下程序的输出结果是什么?

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->1

指针p指向B类类型对象,调用由A类继承下来的test函数,在test函数中再调用func函数,调用的是B类中的func函数,先输出“B->”,再打印后面的val,这道题最关键的点就是这里打印的val是A类中func函数参数列表中的val,还是B类中func函数参数列表中的val。
前面我们说过,多态是以统一的接口去操作不同的对象,所以这里即使B类中重写了A类中的func函数,但重写只是重写了函数的实现,接口用的还是统一的,所以这里虽然执行的是B类中的func函数,但val用的还是A类中func函数参数列表中的val

虚函数重写的两个例外:

  1. 协变(了解)
    派生类重写基类虚函数时,与基类虚函数的返回类型不同。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用时,称为协变。
class A {};
class B : public A {};

class Person
{
public:
	virtual A* func() { return new A; }
};
class Student : public Person
{
public:
	virtual B* func() { return new B; }
};
  1. 析构函数的重写
    如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。在继承一文中我们提到过,因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destructor()
class Person
{
public:
	virtual ~Person() { cout << "~Person" << endl;}
};
class Student : public Person
{
public:
	virtual ~Student() { cout << "~Student" << endl; }
};

int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;

	delete p1;
	delete p2;
	return 0;
}
  • 只有派生类的析构函数重写了基类的析构函数,delete对象调用析构函数才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数

💥1.2.4 为什么要重写析构函数

普通情况下析构子类对象:

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

int main()
{
	Student s;
	return 0;
}

在这里插入图片描述

我们知道子类对象析构清理先调用子类析构再调用父类析构,没什么问题。而当我们通过父类指针删除子类对象时,会出现问题:

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

int main()
{
	Person* p = new Student;
	delete p;
	return 0;
}

在这里插入图片描述

我们看到指针p指向的是子类对象,但最后却只调用了基类的析构函数,我们期望的是调用子类的析构函数,这里用多态处理,就可以正常了。

class Person
{
public:
	virtual ~Person() { cout << "~Person" << endl;}
};
class Student : public Person
{
public:
	virtual ~Student() { cout << "~Student" << endl; }
};

int main()
{
	Person* p = new Student;
	delete p;
	return 0;
}

请添加图片描述

从这里我们可以知道,普通调用多态调用的区别,普通调用看调用者类型,多态调用看指向对象的类型

析构函数是一个特殊的成员函数,它在对象的生命周期结束时自动被调用,用于执行清理工作,如释放对象所占用的资源。在某些情况下,我们可能需要重写基类中的析构函数:

  1. 资源管理:如果基类负责管理某些资源(如动态分配的内存、文件句柄等),而派生类需要扩展或修改这些资源的管理方式,那么派生类需要重写析构函数来确保这些资源被正确释放。
  2. 多态删除:在使用多态时(即基类指针指向派生类对象),如果通过基类指针删除派生类对象,并且基类析构函数没有被声明为虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类对象中的资源没有被正确释放,从而造成内存泄漏等问题。因此,在多态基类中,通常会将析构函数声明为虚函数,并可能需要在派生类中重写它以执行特定的清理工作。
  3. 依赖关系:如果派生类依赖于基类析构函数的某些行为(比如基类析构函数中的某些资源释放逻辑),但需要在这些操作之前或之后执行额外的操作,那么派生类需要重写析构函数来实现这一点。
  4. 异常安全:在析构函数中处理异常需要特别小心,因为异常在析构函数中抛出时可能导致程序异常终止(除非在析构函数中捕获了所有异常)。如果基类析构函数中有可能导致异常的代码,并且派生类需要以一种特殊的方式处理这些异常,那么派生类可能需要重写析构函数来提供异常安全的清理逻辑。

需要注意的是,即使你不需要在派生类的析构函数中执行任何特定的清理工作,如果你打算通过基类指针来删除派生类对象,并且想要确保派生类对象中的资源被正确释放,你也应该将基类的析构函数声明为虚函数。这样做可以确保当通过基类指针删除派生类对象时,派生类的析构函数也会被调用。


💥1.2.5 c++11 override 和 final

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

  1. final:修饰虚函数,表示该虚函数不能被重写
class Person
{
public:
	virtual ~Person() final { cout << "~Person" << endl;}
};
class Student : public Person
{
public:
	virtual ~Student() { cout << "~Student" << endl; }
};
  1. override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写则编译报错
class Person
{
public:
	virtual ~Person()  { cout << "~Person" << endl;}
};
class Student : public Person
{
public:
	virtual ~Student() override { cout << "~Student" << endl; }
};

💥1.2.6 重载、重写(覆盖)、隐藏(重定义)

  • 重载: 两个函数在同一作用域,函数名相同,参数不同
  • 重写: 两个函数分别在基类和派生类的作用域,函数名、参数、返回类型都相同(协变除外),两个函数必须是虚函数
  • 隐藏: 两个函数分别在基类和派生类的作用域,函数名相同,两个基类和派生类的同名函数不构成重写就是隐藏

重写也可以调用隐藏,重写是一种特殊的隐藏。


💥1.3 抽象类

💥1.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();
}

抽象类在哪些场景下会使用呢?
假设有一个动物系统,其中包含多种动物如狗、猫、鸟等。这些动物都具有一些共同的行为,如吃和睡。此时,可以定义一个动物抽象类,其中包含eat和sleep方法的声明(其中eat可能为抽象方法,因为不同动物的吃法可能不同;而sleep方法则可能已经在Animal类中给出了具体实现)。然后,狗、猫、鸟等类继承自Animal类,并实现各自的eat方法。
抽象类是实现多态的一种重要手段。通过抽象类和接口,可以实现父类类型的引用指向子类对象,调用方法时根据对象的实际类型执行相应的实现。这种方式可以增加程序的灵活性和可扩展性。


💥1.3.2 实现继承和接口继承

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

💥2、多态的原理

💥2.1 虚函数表

我们通过一道例题来初步认识虚函数表。
在下面的代码中,sizeof(b)的值是多少?

class Base
{
public:
	virtual void func()
	{
		cout << "func()" << endl;
	}
private:
	int _a = 1;
};

int main()
{
	Base b;
	
	return 0;
}

在不了解虚函数表前,我们可能会根据以往的知识判断sizeof(b)的值是4,事实上sizeof(b)的值是8(32位环境下)。

请添加图片描述

通过观察我们发现b对象的大小是8字节,除了_a成员,还多一个__vfptr放在对象的前面(有些平台可能会放到对象的最后面),__vfptr是一个指针(函数指针),我们叫做虚函数表指针(v代表virtua,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?

class Base
{
public:
	virtual void func1()
	{
		cout << "func1()" << endl;
	}
	virtual void func2()
	{
		cout << "func2()" << endl;
	}
	void func3()
	{
		cout << "func3()" << endl;
	}
private:
	int _a = 1;
};

class Derive : public Base
{
public:
	virtual void func1()
	{
		cout << "Derive::func1()" << endl;
	}
private:
	int _b = 1;
};

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

上面我们在Base中继续增加了一个虚函数和一个普通函数,然后让Derive继承Base,在Derive中只重写func1函数。

请添加图片描述

通过观察,可以总结出:

  1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
  2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  3. 另外func2继承下来后是虚函数,所以放进了虚表,func3也继承下来了,但是不是虚函数,所以不会放进虚表。
  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr
  5. 派生类的虚表生成是:先将基类中的虚表内容拷贝一份到派生类虚表中,如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数,派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
  6. 当通过基类指针或引用调用虚函数时,程序会首先查找该指针所指向的虚函数表,然后根据表中存储的地址来调用相应的函数。

| 最后有一个问题:虚函数存在哪里?虚表存在哪里?

虚函数不是存在虚表,虚表也不是存在对象中。
虚表存的只是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪里?VS下虚表是存在代码段的。


💥2.2 多态的原理

这里我们再简单总结一下多态的原理:

  1. 多态是不同对象去完成同一个行为,展现出不同的形态
  2. 多态有两个必须条件:虚函数覆盖和对象的指针或引用调用虚函数
  3. 对象切片: 需要注意的是,多态性仅适用于通过基类指针或引用来调用虚函数。如果尝试将派生类对象赋值给基类对象(非引用或指针),则会发生对象切片,即只保留基类部分的数据,派生类特有的部分会被丢弃。这会导致多态性的失效。
  4. 满足多态以后的函数调用,不是在编译时确定的,是运行起来后到对象中去找的,不满足多态的函数调用是编译时确定好的。
  5. 当通过基类指针或引用调用虚函数时,会根据指针或引用实际指向的对象的类型来调用相应的函数版本,这称为动态绑定。

在这里插入图片描述


💥2.3 动态绑定和静态绑定

  1. 静态绑定,也称为早期绑定,指的是函数调用在编译时期就已经确定。这种绑定方式主要适用于非虚函数,比如函数重载。
  2. 动态绑定,也称为晚期绑定,是指函数调用在运行时确定。这通常是通过虚函数机制来实现的。当一个函数在基类中被声明为虚函数后,派生类可以重写这个函数,创建自己的实现。如果通过基类的指针或引用调用这个函数,那么实际调用的版本将根据对象的实际类型(运行时的类型)来确定。

总结收尾

  • 多态允许使用统一的接口来处理不同的对象类型,这意味着我们可以编写能够处理多种类型的通用代码,而无需为每种类型编写特定的代码。这种能力极大地提高了代码的复用性,减少了重复代码,使得程序更加简洁和易于维护。
  • 多态是面向对象编程中一个非常强大的特性,它使得我们能够编写出更加模块化、灵活和可扩展的代码。然而,使用多态时也需要注意一些潜在的问题,比如虚函数调用的性能开销和虚析构函数的正确使用等。

本篇文章的学习就到这了,如果您觉得在本文中有所收获,还请留下您的三连支持哦~

头像

http://www.kler.cn/a/306803.html

相关文章:

  • Stored procedures in PostgreSQL
  • 搭建监控系统Prometheus + Grafana
  • 在C++上实现反射用法
  • pycharm快速更换虚拟环境
  • git之 revert和rebase
  • 河南省的一级科技查新机构有哪些?
  • PFC理论基础与Matlab仿真模型学习笔记(1)--PFC电路概述
  • requests-html的具体使用方法有哪些?
  • Docker安装mysql安装nginx安装Redis
  • 蓝桥杯18小白第5题
  • labview串口大数据量报错的一种解决思路(通过tcp进行写入和读取串口数据)
  • 最小二乘估计
  • ubuntu使用wireshark抓取数据
  • 【Scala入门学习】Scala的方法和函数
  • 【Go】十五、分布式系统、Consul服务注册发现、Nacos配置中心搭建
  • Rust 赋能前端: 视频抽帧
  • 【Python】基本使用
  • 构建高效入学审核系统:Spring Boot解决方案
  • 开源模型应用落地-qwen2-7b-instruct-LoRA微调-unsloth(让微调起飞)-单机单卡-V100(十六)
  • visual studio给项目增加eigen库 手把手教程
  • AI客服机器人开启企业客户服务新纪元
  • 外网(公网)访问VMware workstation 虚拟机内web网站的配置方法---端口转发总是不成功的原因
  • 动手学深度学习(pytorch)学习记录29-网络中的网络(NiN)[学习记录]
  • wpf触发与模板的使用示例:批量生产工具
  • git 你要如何打开这个文件
  • 【nginx】缓存配置文件