当前位置:首页 » 《休闲阅读》 » 正文

【c++】多态(多态的概念及实现、虚函数重写、纯虚函数和抽象类、虚函数表、多态的实现过程)

15 人参与  2024年12月13日 12:02  分类 : 《休闲阅读》  评论

点击全文阅读


??作者主页:ephemerals__
??所属专栏:C++

目录

前言

一、多态的概念

二、多态的实现

1. 多态的构成条件

2. 虚函数及其重写

2.1 概念

2.2 实现多态

2.3 一道选择题

2.4 虚函数重写的特例 

2.4.1 协变

2.4.2 析构函数的重写(重点)

2.5 c++11关键字:override和final

2.6 重载、重写、隐藏的对比

三、纯虚函数和抽象类

四、多态的实现原理

1. 虚函数表

2. 多态的实现过程

3. 动态绑定和静态绑定

总结


前言

        本篇文章是继继承之后,博主跟大家介绍面向对象三大特性的最后一个——多态

正文开始

一、多态的概念

        通俗地讲,多态就是“多种形态” 的意思,它的核心要义在于“一个接口,多种实现”,也就是说调用同一个接口,而产生不同的行为。多态可以分为编译时多态(静态多态)运行时多态(动态多态)。在之前的学习当中,我们已经接触过编译时多态,例如函数重载和函数模板。本篇文章,博主主要和大家分享运行时多态的相关知识。

本文所提到的多态皆表示运行时多态。

        举个例子,当我们提到汽车、飞机、轮船等交通工具时,它们虽然都属于“交通工具”这一大类,但在实际使用时,却有着不同的出行方式。也就是说,当我们说“乘坐xx出行”时,这些交通工具会以各自独特的方式行驶。这正好体现了多态性:我们调用相同的接口(“乘坐xx出行”),对于不同的对象(如汽车、飞机、轮船等)会根据它们各自的实现来展现出不同的行为。这样,我们就可以在不关心具体实现细节的情况下,灵活地处理和使用这些交通工具了。

二、多态的实现

1. 多态的构成条件

        要实现多态,就要满足以下三点构成条件:

1. 多态发生在有继承关系的类之间。

2. 必须用基类的指针或引用调用该接口。

3. 被调用的接口是虚函数,并且虚函数完成了重写(覆盖)

至于什么是虚函数,什么是重写(覆盖),接下来博主跟大家进行介绍。

2. 虚函数及其重写

2.1 概念

虚函数:指的是被virtual关键字修饰的成员函数

举个例子:

class A{public://虚函数virtual void fun(){//...}};

注意:非成员函数不能加virtual关键字。

虚函数的重写:也叫做覆盖,当派生类中有一个与基类完全相同返回值类型、参数列表、函数名都相同)的虚函数时(注意基类中的这个函数也是虚函数),称派生类的该虚函数重写(覆盖)了基类的相应虚函数。这样,派生类的虚函数就提供了一个基类虚函数的新实现。然后我们调用该虚函数时,编译器就会根据基类的指针/引用所表示的对象类型来调用相应的虚函数

注意:当派生类的某个成员函数(没有virtual关键字)与基类的虚函数完全相同时,也会构成虚函数的重写(因为基类虚函数被继承到了派生类)。但是为了保持代码规范,一般还是会加上virtual。

2.2 实现多态

接下来根据刚才的例子实现一个简单的多态。代码如下:

#include <iostream>using namespace std;//定义交通工具类class Transport{public://出行virtual void going_out() const {}private://...};//定义飞机类class Plane : public Transport{public://出行virtual void going_out() const{cout << "飞机:飞行" << endl;}private://...};//定义轮船类class Steamship: public Transport{public://出行virtual void going_out() const{cout << "轮船:航行" << endl;}private://...};//定义汽车类class Car : public Transport{public://出行virtual void going_out() const{cout << "汽车:地面行驶" << endl;}private://...};//中间函数,将派生类对象转化为基类的引用void func(const Transport& t){t.going_out();}int main(){Plane p;Steamship s;Car c;func(p);func(s);func(c);}

这里的going_out就是一个虚函数,各派生类的going_out都重写了基类的going_out。

运行结果:

不难发现,虽然我们传入的参数都是基类的引用,但程序却调用了不同的函数。这就说明编译器根据引用所表示的实际对象来执行相应代码。

 当然,这里的中间函数也可以设置为将对象的地址转化为基类的指针,使用时传入对象的地址。

void func(const Transport* t){t->going_out();}

2.3 一道选择题

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

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

先看结果:

解释:

首先要知道的是:两个func函数是构成重写的。

p是一个B*类型的指针,它指向的对象是B类型的对象。p调用test函数,本质是将p传给了test函数的this指针。

注意:该test函数是由A继承到B中的,但是其参数类型仍然是A*。

test调用func函数,本质是将this指针传过去,该this指针的类型是A*,指向的对象是B类型。

由于实际对象是B类型,所以程序调用B的func函数

但是注意:在重写过程当中,派生类重写的只是基类虚函数的函数体。

所以说对于参数列表而言,由于this指针是A*类型,所以仍然使用A类的。

所以运行结果是:B->1

2.4 虚函数重写的特例

2.4.1 协变

        所谓协变,指的是派生类重写基类虚函数时,与基类虚函数返回值类型不同,此时需要满足:基类虚函数的返回值是基类对象的指针或引用,派生类虚函数的返回值是派生类对象的指针或引用

举个例子:

class A{public:virtual A* fun() {/*...*/ }};class B : public A{public:virtual B* fun() {/*...*/ }};

协变的意义并不大,作为了解即可。

2.4.2 析构函数的重写(重点)

        如果基类的析构函数为虚函数,那么派生类的析构函数只要定义,无论是否加virtual关键字,都会重写基类析构函数,这是因为编译之后析构函数的名称被统一处理成destructor()。

来看一段代码:

#include <iostream>using namespace std;class A {public: ~A() { cout << "~A()" << endl; }//...};class B : public A {public: ~B() { cout << "~B()" << endl; }//...};int main(){A* p1 = new A;A* p2 = new B;delete p1;cout << endl;delete p2;return 0;};

运行结果:

        上述代码当中,我们创建了分别为A、B类型的两个对象,将它们的地址赋值给两个A*指针p1和p2。但从运行结果中可以看出,这段代码出问题了:对于p1指针,它所指向的对象是A类型,对象销毁时直接调用A类型的析构函数,没毛病;但是对于p2指针,它所指向的对象是B类型,B是A的派生类,其在销毁时首先要调用派生类析构,然后调用基类析构,但是程序并没有调用派生类析构,导致内存泄漏这是由于指向它的指针是A*类型,所以只会调用A的析构函数

解决方法:将A类的析构函数设置为虚函数。

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

此时,两析构函数构成重写,这样程序在调用析构函数时,就会根据指向对象的类型进行调用(而不是根据指针),那么当p2指向的对象在销毁时,就会调用B的析构函数,进而在函数内部调用A的析构函数,完成派生类部分和基类部分的数据销毁。

运行结果:

结论:基类当中的析构函数建议设计为析构函数。

2.5 c++11关键字:override和final

        不难发现,c++对虚函数重写的要求十分严格。但是有时我们会因为疏忽(例如函数名/参数名写错)导致无法构成重写,并且这种错误在编译阶段并不会被发现。为了尽量避免这种疏忽,c++11提供了关键字override,用于检查派生类虚函数是否重写了基类某个函数,如果没有重写,则编译报错。

class B : public A{public:virtual void fun() override {/*...*/ }};

final关键字可以用于修饰虚函数,表示该虚函数不能被重写

class A{public:virtual void fun() final {/*...*/ }};

2.6 重载、重写、隐藏的对比

三、纯虚函数和抽象类

        当我们在虚函数的参数列表之后写一个“=0”,那么该函数就成为了“纯虚函数”。包含纯虚函数的类叫做抽象类

抽象类有以下特点:

1. 抽象类不能实例化

2. 抽象类的派生类如果不重写纯虚函数,那么派生类也是抽象类。

//抽象类class A{public://纯虚函数virtual void fun() = 0;};int main(){A a; //报错:无法实例化抽象类return 0;}

抽象类从某种程度上强制了派生类重写虚函数,因为虚函数无法实例化。 

四、多态的实现原理

1. 虚函数表

        首先看一段代码(x64平台):

#include <iostream>using namespace std;class A{public:virtual void fun(){//...}private:int _m;int _n;};int main(){A a;cout << sizeof(a) << endl;return 0;}

运行结果:

根据类成员的内存对其规则,原本类A的内存大小应该是8字节。但为什么运行结果是16呢?

我们看看调试窗口

可以看到,对象的成员当中,除了两个变量_m、_n,还有一个变量叫做__vfptr。 __vfptr是一个指针类型,在x64环境下,它的大小是8个字节,这就能够说明为什么输出结果是16了。

那么__vfptr到底是什么呢?

实际上,__vfptr叫做虚函数表指针。对于一个含有虚函数的类,它的所有虚函数的地址都被存放在一个虚函数表当中,而这个虚函数表指针存放的是虚函数表的地址。所以一个含有虚函数的类中都至少拥有一个虚函数表指针。

那么,这里的 “ [0] ” 中,存放的就是fun()的地址了。

看看更复杂的情况:

class A{public:virtual void fun1(){//...}virtual void fun2(){//...}void fun3(){//...}private:int _m;int _n;};class B : public A{public:virtual void fun1() override{//...}private:int _q;};int main(){A a;B b;return 0;}

在类A当中,我们定义了两个成员变量已经三个成员函数,其中fun1和fun2是虚函数,fun3是普通函数;类B继承了类A,定义了一个成员变量,并且重写了fun1函数。

调试窗口:

a对象的虚函数表当中,我们可以看到有两个变量,它们分别是fun1和fun2的地址。由于fun3并非虚函数,所以它的地址并不在虚函数表当中。


b对象继承了a对象的虚函数表指针。值得注意的是,其中的“ [0] ”的值与a对象中“ [0] ”的值不同,但是“ [1] ”的值相同。这是由于b对象重写了fun1,而没有重写fun2,所以“ [1] ”还是同一个函数fun2的地址

总结一下虚函数表的相关知识

1. 虚函数表的本质是一个函数指针数组,在编译阶段生成,基类的虚函数表存放的是基类所有虚函数的地址同类型的对象共用同一张虚函数表,不同类型对象有各自的虚函数表,例如基类和派生类。

2. 一般情况下,派生类当中有继承得到的__vfptr,自己就不会再生成__vfptr。但要注意派生类中的__vfptr与基类的不是同一个。

3. 如果是多继承的情况,那么派生类继承了多少个带有虚函数的基类,该派生类就有多少张虚函数表

3. 如果派生类重写了基类的虚函数,那么派生类的虚函数表中对应的虚函数地址就会被覆盖成重写的新虚函数地址。

4. 派生类的虚函数表包含三个部分基类的虚函数地址、由于派生类重写而覆盖的虚函数地址、派生类自己的虚函数地址

5. c++标准并没有规定虚函数表位于内存哪一个区域,vs下默认位于代码段

2. 多态的实现过程

        总的来说,多态的实现过程是:首先根据规则创建虚函数表,在虚函数表中存储相应的虚函数地址,然后在基类和派生类中添加虚函数表指针,最后通过指向的虚函数表来调用相应的虚函数或重写函数

        我们通过基类的指针或引用调用虚函数时,若该指针或引用指向的是父类,运行时就到指向父类对象的虚函数表中找到对应的虚函数进行调用;若指向的是子类,运行时就到指向子类对象的虚函数表中找到对应的虚函数进行调用

        如此,即便都是通过基类的指针或引用进行调用,但只要访问所表示对象的虚函数表,就能确定调用的重写函数是哪一个,也就做到了根据基类的指针/引用所表示的对象类型来调用相应的虚函数。

        由于访问虚函数表、通过虚函数表访问函数等一系列行为都是在程序运行时完成的,所以该过程也被称为运行时多态

3. 动态绑定和静态绑定

        最后,我们根据多态的实现原理,总结编译时多态运行时多态的区别,由此引申出静态绑定和动态绑定的概念: 

静态绑定,指的是在程序编译期间确定了程序的行为,也就是编译时多态,比如:函数重载,函数模板。

动态绑定,是在程序运行期间,根据具体类型来确定程序的具体行为,调用具体的函数,也就是运行时多态。

总结

        本篇文章,我们学习了面向对象编程的最后一点特性——多态。从多态的概念、多态的构成条件到多态的实现、多态的原理,以及多态在编程中的应用,我们进行了全面而深入的学习。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤


点击全文阅读


本文链接:http://zhangshiyu.com/post/200565.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1