【C++哲学】面向对象的三大特性之 多态

【C++哲学】面向对象的三大特性之 多态


                                                        🔥拾Ծ光:个人主页

👏👏👏欢迎来到我的专栏:《C++》《数据结构》《C语言》

目录

一、多态的概念及实现

1、什么是多态?

2、虚函数

• 虚函数的重写/覆盖

• override和final关键字

3、多态的实现⭐️

• 多态实现的条件

• 多态场景下的一个经典面试题💥

4、虚函数重写的特殊场景

• 析构函数的重写⭐️

• 重载/重写/隐藏的区别

• 协变(了解)

5、纯虚函数和抽象类

二、多态的原理‼️

1、虚函数表指针

2、虚函数表

3、多态的底层实现

4、动态绑定和静态绑定

三、总结

一、多态的概念及实现

1、什么是多态?

多态顾名思义就是有多种形态。多态是C++面向对象编程的最重要的特性之一,多态分为:编译时多态(静态多态)运行时多 态(动态多态),这里我们重点讲运行时多态。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过不同的参数类型来达到多种形态。而之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在 编译时完成的,我们把编译时一般归为静态,运行时归为动态。

运行时多态,具体点讲就是去完成某个行为(函数),可以传不同的对象来完成不同的行为,就达到了多种形态。比如买票这个行为,当对象是普通人时,是全价买票;对象是学生买时,是优惠买票(5折或75折);当对象是军人时,是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>^ω^<) 喵“,传狗对象过去,就是"汪汪"。

2、虚函数

类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。
class Person { public: // 这里在成员函数BuyTicket前面加了virtual,则BuyTicket就是虚函数 virtual void BuyTicket() { cout << "买票-全价" << endl; } };
• 虚函数的重写/覆盖

虚函数的重写 (Override) - 发生在派生类,在基类中一定要有一个被virtual修饰的虚函数。

重写是实现多态的关键。它的定义是:在派生类中提供一个与基类中某个虚函数具有完全相同函数名、参数列表、返回值类型的函数。

虚函数重写的目就是为了在运行时,当通过基类指针或引用指向派生类对象时,能够调用派生类的版本,而不是基类的版本。这就是 “动态绑定”。

//---------------------Person类与Student类中的buyticket构成重写------------------------------ class Person { public: virtual void buyticket() { cout << "全价票" << endl; } }; class Student:public Person { public: virtual void buyticket() { cout << "半价票" << endl; } };
还需要注意一点:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也可以构成重写(因为子类继承基类后,基类的虚函数被继承下来了,在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样 使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
//---------------------A类与B类中的func构成重写------------------------------ class A { public: virtual void func() {} }; class B:public A { public: void func() {} };
• override和final关键字
从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。
class Person { public: virtual void buyticket() {} }; class Student:public Person { public: virtual void buyticket()override {} };
如果我们不想让 派生类重写这个虚函数,那么可以用final去修饰。

3、多态的实现⭐️

• 多态实现的条件
💦必须是基类的指针或者引用调用虚函数

💦被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖

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

//-------------------------------------基类---------------------------------------------- class Person { public: virtual void buyticket() { cout << "全价票" << endl; } }; //-------------------------------------派生类---------------------------------------------- class Student :public Person { public: virtual void buyticket() override { cout << "半价票" << endl; } }; void Func(Person* ptr) //基类的指针来接收参数 { ptr->buyticket(); // 用基类的指针调用虚函数 } int main() { Person p; // 基类对象 Student s; // 派生类对象 Func(&p); // 将基类对象作为实参,则调用基类的虚函数 Func(&s); // 将派生类对象作为实参,则调用派生类的虚函数 return 0; }
• 多态场景下的一个经典面试题💥
以下程序输出结果是什么()

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

答案:B

我们也许都会对答案感到不可思议,这不就应该从A或者D中选一个吗?为什么最后的答案是B呢,下面我们来分析:

首先题目中创建了一个指向B类类型对象的指针p,通过指针p来调用test函数,由于test函数是基类A的成员函数,所以隐含的参数this指针是A*类型的(即基类的指针),同时基类与子类中func函数构成虚函数的重写,虽然,派生类的func函数并没有加virtual关键字,但是由于继承的原因,任然构成重写,所以满足多态的条件,此时,就会根据真正的实参的类型来决定调用哪个虚函数(动态绑定),而指针p是派生类类型的,所以,就会调用派生类B中 的func函数,这时候我们肯定就会毫不犹豫的选择D选项,相信大多数人都能分析到这一步,

但是,最终的答案确选B,这就是这个题经典的原因之一,也是最坑的地方之一 。因为还有一点就是:

• 默认参数是静态绑定的(编译时根据调用者的类决定):test() 属于基类 A,因此 func() 的默认参数 val 会使用 A 中声明的 val = 1(而非 B 中声明的 val = 0)。

也可以理解为(形象),虚函数重写后,派生类中的虚函数其实是由基类虚函数的函数名和参数列表与派生类的虚函数的实现部分组成的。



所以,选择B:B -> 1

4、虚函数重写的特殊场景

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

💬那么问题就来了,为什么要这样设计呢?这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。

当一个基类的指针 ptr 指向派生类对象时,如果基类的析构函数不实现为虚函数,那么当delete ptr释放该对象时,由于静态绑定(ptr是什么类类型的指针就调用哪个类的析构函数),就只调用基类的析构函数,如果派生类对象中有额外的资源,这时候就会导致内存泄漏。所以,C++中只要基类的析构函数实现为虚函数,无论派生类析构函数加不加virtual关键字,都会与基类的析构函数构成重写,即此时满足多态编译器会根据ptr指针真正指向的类类型对象来调用相应的析构函数(动态绑定),而在前面的继承中已经讲到:子类的析构函数会自动调用基类的析构函数,所以,此时即使基类中有资源,也会被释放。
//-------------------------------------基类--------------------------------------------- class A { public: virtual ~A() // 虚函数 {} protected: int _a; }; //-------------------------------------子类--------------------------------------------- class B :public A { public: ~B() { delete[]_b; _b = nullptr; } protected: int* _b = new int[10]; // 派生类中有额外的资源 }; // 用一个基类的指针指向派生类对象 int main() { A* a1 = new B; delete a1; return 0; }

可视化分析:

• 重载/重写/隐藏的区别

💥这个问题面试中也有可能会考到:

• 协变(了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class Person { public: virtual Person* BuyTicket() // 返回值为基类的指针 { cout << "买票-全价" << endl; return nullptr; } }; class Student : public Person { public: virtual Student* BuyTicket() // 返回值为派生类的指针 { cout << "买票-打折" << endl; return nullptr; } }; void Func(Person* ptr) { ptr->BuyTicket(); } int main() { Person ps; Student st; Func(&ps); Func(&st); return 0; }

5、纯虚函数和抽象类

在虚函数的后面写上 =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; } }; int main() { //-----------------错误示范:编译报错:error C2259: “Car”: 无法实例化抽象类------------- // Car car; Car* pBenz = new Benz; pBenz->Drive(); Car* pBMW = new BMW; pBMW->Drive(); return 0; } 

二、多态的原理‼️

1、虚函数表指针

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

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

答案:D



看到这个题,我们肯定就会选C,因为一个int占4个字节,一个char占1个字节,然后对齐到4的整数倍,不就是8吗。但是,在b对象中不仅仅放了_b和_ch成员,还有一个_vfptr的指针在这两个成员的前面,我们知道在32位机器下一个指针占4个字节,所以最后b对象的大小就是12个字节。




一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。

2、虚函数表

基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
class A { public: virtual void func() {} }; class B :public A { public: virtual void func() {} }; class C :public A { public: virtual void func() {} }; int main() { A a1; // 基类 A a2; // 基类 B b1; // 派生类 B b2; // 派生类 C c; // 派生类 return 0; }
• 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表 指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,因为基类对象的成员和派生类对象中的基类对象成员是独立的
派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函 数地址。这也是为什么,当满足多态时编译器会根据真正的对象调用相应的虚函数的原因。就是因为,派生类与基类的虚函数表中放着各自独立的虚函数的指针。
• 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,派生类 自己的虚函数地址三个部分。
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() { Base b; Derive d; return 0; } 

• 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。

虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下。vs下是存在代码段(常量区)



可以看到,Person虚表地址与Student虚表地址更加接近常量区

3、多态的底层实现

满足多态条件后,底层 不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函 数。
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: int _codename; };

4、动态绑定和静态绑定

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

三、总结

多态是C++面向对象编程非常重要的一个特性,多态在我们处理一些具有相似特性的问题时,有着非常重要的作用,同时,在面试中也有许多的考点和细节。

Read more

最新电子电气架构(EEA)调研-3

而新一代的强实时性、高确定性,以及满足CAP定理的同步分布式协同技术(SDCT),可以实现替代TSN、DDS的应用,且此技术已经在无人车辆得到验证,同时其低成本学习曲线、无复杂二次开发工作,将开发人员的劳动强度、学习曲线极大降低,使开发人员更多的去完成算法、执行器功能完善。 五、各大车厂的EEA 我们调研策略是从公开信息中获得各大车厂的EEA信息,并在如下中进行展示。 我们集中了华为、特斯拉、大众、蔚来、小鹏、理想、东风(岚图)等有代表领先性的车辆电子电气架构厂商。        1、华为 图12 华为的CCA电子电气架构              (1)华为“计算+通信”CC架构的三个平台                         1)MDC智能驾驶平台;                         2)CDC智能座舱平台                         3)VDC整车控制平台。        联接指的是华为智能网联解决方案,解决车内、车外网络高速连接问题,云服务则是基于云计算提供的服务,如在线车主服务、娱乐和OTA等。 华

By Ne0inhk
Apache IoTDB 架构特性与 Prometheus+Grafana 监控体系部署实践

Apache IoTDB 架构特性与 Prometheus+Grafana 监控体系部署实践

Apache IoTDB 架构特性与 Prometheus+Grafana 监控体系部署实践 文章目录 * Apache IoTDB 架构特性与 Prometheus+Grafana 监控体系部署实践 * Apache IoTDB 核心特性与价值 * Apache IoTDB 监控面板完整部署方案 * 安装步骤 * 步骤一:IoTDB开启监控指标采集 * 步骤二:安装、配置Prometheus * 步骤三:安装grafana并配置数据源 * 步骤四:导入IoTDB Grafana看板 * TimechoDB(基于 Apache IoTDB)增强特性 * 总结与应用场景建议 Apache IoTDB 核心特性与价值 Apache IoTDB 专为物联网场景打造的高性能轻量级时序数据库,以 “设备 - 测点” 原生数据模型贴合物理设备与传感器关系,通过高压缩算法、百万级并发写入能力和毫秒级查询响应优化海量时序数据存储成本与处理效率,同时支持边缘轻量部署、

By Ne0inhk
SQL Server 2019安装教程(超详细图文)

SQL Server 2019安装教程(超详细图文)

SQL Server 介绍) SQL Server 是由 微软(Microsoft) 开发的一款 关系型数据库管理系统(RDBMS),支持结构化查询语言(SQL)进行数据存储、管理和分析。自1989年首次发布以来,SQL Server 已成为企业级数据管理的核心解决方案,广泛应用于金融、电商、ERP、CRM 等业务系统。它提供高可用性、安全性、事务处理(ACID)和商业智能(BI)支持,并支持 Windows 和 Linux 跨平台部署。 一、获取 SQL Server 2019 安装包 1. 官方下载方式 前往微软官网注册账号后,即可下载 SQL Server Developer 版本(

By Ne0inhk