C++多态

面向对象语言存在三大特性,即:继承,封装和多态。C++这一门面向对象语言当然也具有如此的特性,并且对于其中的继承和封装(类和对象),我们在之前的博客中已经有所讨论过了。于是,在本篇博客中,我们来讨论面向对象语言的最后一个特性--多态。

目录

1.多态概念

2.多态实现条件

2.1重写

2.2同名隐藏和重写的区别

2.3虚函数重写的两个例外

2.3.1协变

2.3.2析构函数的重写

3.C++11中出现的两个重要关键字

3.1override

3.2final

4.抽象类

5.多态原理 

5.1对象模型

5.2虚表

5.2.1子类虚表的构建过程

5.2.2超出监视窗口的虚函数

5.3静态绑定和动态绑定

6.多继承中的虚函数表

6.1对象模型

6.2虚表

7.多态总结

1.多态概念

简单而言,多态便是具有多种形态,即针对某一种行为,不同的对象去处理会产生不同的状态。我们加以代码来理解:

#include<iostream>
#include<string>
using namespace std;class People {
public:People(const string& name, const string& gender, int age):_name(name), _gender(gender), _age(age){}virtual void Speak() {cout << "你好\n";}
protected:string _name;string _gender;int _age;
};class Teacher : public People {
public:Teacher(const string& name, const string& gender, int age, const string& subject):People(name, gender, age), _subject(subject){}void Speak() {cout << "上课\n";}
protected:string _subject;
};class Student : public People {
public:Student(const string& name, const string& gender, int age, const string& grade):People(name, gender, age), _grade(grade){}void Speak() {cout << "起立\n";}
protected:string _grade;
};void TestSpeak(People& p) {p.Speak();
}int main() {Teacher t("大明", "男", 24, "数学");TestSpeak(t);Student s("小明", "男", 16, "高一");TestSpeak(s);return 0;
}

执行结果如下:

定义继承体系中子类对象,其中不同对象调用同名成员函数时,便会产生不同效果。即,每个对象皆是调用本类中的函数。这样从设计代码的角度,结合代码执行的结果,十分明显的产生了多态的效果。(关键字:virtual)

2.多态实现条件

在上述代码中我们体现了多态的内容,并通过文字讲解了多态的概念。我们可以较为清晰的看出,在我们实现多态时,总是伴随着两个条件:

  • 多态必须在继承的前提之下,且子类必须重写基类的虚函数(被virtual关键字所修饰的函数成为虚函数);
  • 关于虚函数的调用,我们必须通过基类的指针或者引用来调用虚函数。

这两个条件缺一不可,只有满足这两个条件之后,我们才可以实现多态。其中,具体实现过程为:在程序运行过程中,根据基类的引用或者指针来指向不同类的对象,进而编译器会选择调用对应类中的成员函数。

2.1重写

在上述多态实现条件中的第一条中,我们讲到了子类重写基类的虚函数,对于重写的内容我们有必要展开讨论。

我们先来了解重写的概念:在C++当中,重写是子类中定义了一个与基类中同名,同参数列表且具有相同返回值类型的成员函数,从而覆盖(重写)了基类中的函数。重写允许子类根据自身需求重新实现基类中的函数。

然后我们来讲述重写中值得注意的信息:

  • 被重写的方法(函数)在基类当中必须作为虚函数出现,否则重写失败;
  • 基类虚函数和子类虚函数的原型必须完全一致,即二者的返回值类型,方法(函数)名称,以及参数列表必须相同;
  • 子类虚函数前的virtual关键字可以不用添加,建议添加表明方法身份;
  • 基类和子类的虚函数访问权限可以不相同,即可以二者在各自类中的访问限定修饰可以不一致,我们可以根据不同设计场景选择不同的访问权限。

2.2同名隐藏和重写的区别

了解二者区别之前,我们先来讲述一下同名隐藏的内容。在C++中的同名隐藏是指在子类中,如果子类的成员函数与基类的成员函数名称相同,那么基类的成员函数将被隐藏,无法通过子类对象直接访问到基类的同名函数。(我们可以使用作用域解析运算符“::”来显示的指定基类的作用域)

在认识到同名隐藏的内容之后,我们来阐述二者内容上的异同。

两者具有一定的相同点,即:两个函数都位于继承体系当中,一个在基类另一个在子类;并且两个函数的名称必须相同。当这两个条件满足之后,则一定是同名隐藏,但不一定是函数重写。

不过重写在同名隐藏的基础上要求更加的严格,重写中基类的函数必须是虚函数,但同名隐藏中没有要求;并且重写要求基类和子类函数中的原型必须完全一致(析构和斜变除外)。

同名隐藏只要求方法名称相同即可,和返回值以及参数列表是否相同并未做要求。

2.3虚函数重写的两个例外

2.3.1协变

C++当中的斜变是指:当子类重写基类虚函数时,与基类虚函数的返回值类型不同。具体而言,当基类中的虚函数返回一个指向基类的指针或者引用时,子类可以重写该函数并且返回指向子类的指针和引用。如下代码:

class A{};
class B : public A {};
class C {
public:virtual A* fun() { return new A; }
};
class D : public C {
public:virtual B* fun() { return new B; }
};

2.3.2析构函数的重写

在C++当中,析构函数是一个特殊的成员函数,它用于在对象销毁时清理空间。当我们在继承体系当中,将基类的析构函数定义为虚函数。此时只要子类中的析构函数只要定义,无论是否添加virtual关键字,子类析构函数都与基类析构函数构成重写。虽然二者名字并不相同,违背了重写的规则,但是在实际编译过程中,编译器会自动处理成destructor的同名函数。如下代码:

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

得到结果如下:

我们发现p2对象会被销毁两次,这是因为子类继承基类,子类需要对基类中的内容进行涵盖。

3.C++11中出现的两个重要关键字

3.1override

override用于修饰子类中的虚函数,作用是:当编译器编译代码过程中,帮助检测是否重写了基类中的某个虚函数。如果重写则编译通过,否则编译失败。

override可以保证我们设计代码中,完成对重写内容的检查,让问题出现在编译阶段(便于发现和修正)而非代码执行结果阶段。如下代码:

class A {
public:virtual ~A() { cout << "A class destructor\n"; }
};
class B : public A {
public:~B() override { cout << "B class destructor\n"; }
};

上述代码是可以执行通过的,这也从侧面说明了析构函数的重写不关注函数名称的一致。

3.2final

final用于修饰类、成员函数和虚函数,用来指示它们不可被继承、重写或是覆盖。

  • final修饰类,表示该类时最终类,不允许其他类来继承它;;
  • final修饰成员函数,表示该函数不可被派生类重写;
  • final修饰虚函数,表示该虚函数不可再被进一步的派生类重写。

我们在此提供一个样例供大家参考使用final关键字,用final修饰虚函数代码样例:

class A {
public:virtual void show() final {}
};
class B : public A {
public://错误"A::show" : 声明为 "final" 的函数不能由 "B::show" 重写virtual void show() { cout << "This is B\n"; }
};

4.抽象类

当我们在虚函数的后面加上=0,则该虚函数被声明为纯虚函数,包含纯虚函数的类我们成为抽象类。抽象类的目的是为了其余派生类的重写,它本身并不能被实例化,而只能作为其他类的基类来进行使用。代码如下:

class People {
public:People(const string& name, const string& gender, int age):_name(name), _gender(gender), _age(age){}virtual void Speak() = 0;//纯虚函数声明,无法实例化对象,所以不用关注方法的实现。
protected:string _name;string _gender;int _age;
};class Teacher : public People{public:Teacher(const string & name, const string & gender, int age, const string & subject):People(name, gender, age),_subject(subject){}void Speak() {cout << "上课\n";}protected:string _subject;
};class Student : public People{public:Student(const string & name, const string & gender, int age, const string & grade):People(name, gender, age),_grade(grade){}void Speak() {cout << "起立\n";}protected:string _grade;
};

在上述继承体系当中,我们并不关注抽象类Person的具体实例化对象,我们的重心在于继承于它的Teacher和Student类中具体对象所展示的多态内容。

抽象类的出现让我们的代码更加符合逻辑,即存在不具体的类我们便不应该让其创建对象;并且抽象类让我们不必再考虑其中的纯虚函数如下设计,节约了设计成本;最后抽象类书写的纯虚函数在一定程度上规范了后续子类的虚函数原型(接口规范化)。

5.多态原理 

在了解原理之前,我们使用的环境是vs编译器中(x86),大家后续验证尝试的话,请注意程序运行环境的一致性。

5.1对象模型

此处我们讨论的对象模型是具有虚函数的类定义的对象,我们先来看代码设计:

class A {
public:void func() { cout << "A::func()\n"; }int _a;
};
class B : public A {
public:void func() { cout << "B::func()\n"; }virtual void func1() { cout << "B::func1\n"; }virtual void func2() { cout << "B::func2\n"; }int _b;
};

从上述代码的执行结果中我们可以看出,子类B 的大小为12字节,其中存在整形数据_b占据4个字节,继承得到的整形数据_a占据4个字节,那么其余4字节内容从何而来?

其中存在两个虚函数,说明多余4字节内容和虚函数个数无关(一个虚函数应是4字节),那么我们来创立B类对象b,并打开监视窗口来对对象b的内容进行分析,如下图:

我们可以很清晰的看出多余4字节内容存储的内容是一份地址,该地址映射到的内容为虚函数的地址。这意味着这一份地址它为虚函数的入口地址,我们可以根据这份地址定位到B类中的虚函数位置。

5.2虚表

我们将这份多余4字节内容的地址称作“虚表指针”,将虚表指针指向的虚函数内容称作“虚函数表”(虚表),并且虚表中虚函数地址存放顺序和类中虚函数生成次序一致。

 当我们加以定义B类对象b2,并再来查看监视窗口:

我们可以看出二者对象存储的虚表入口地址(虚表指针)内容完全一致,这说明在同一个类的对象共享同一张虚表。

5.2.1子类虚表的构建过程

子类虚表在构建过程中和基类的虚函数存在一定联系,当子类不存在虚函数时,子类会将基类虚函数内容原封不动的拷贝到子类的虚表当中。如果其中存在子类重写了基类中的某个虚函数,则在子类虚表中会将对应位置(相同偏移量)的基类虚函数内容进行覆盖。

我们需要注意的内容是:虽然可能存在子类虚表内容和基类虚表内容一致的情况,但是二者虚表指针从不会相同。因为无论怎样,这仍是两份虚表,所以入口地址不会相同。

了解完子类虚函数继承和重写的情况后,我们来讲述子类新增虚函数的情况。当我们在子类中新增加虚函数时,新增虚函数会根据我们在子类中的生成顺序,依次加入到子类虚表当中。

5.2.2超出监视窗口的虚函数

我们可以设计如下代码,并调用它的监视窗口:

class A {
public:virtual void func() { cout << "A::func()\n"; }virtual void func1() { cout << "A::func1()\n"; }virtual void func2() { cout << "A::func2()\n"; }
};
class B : public A {
public:virtual void func() { cout << "B::func()\n"; }virtual void func1() { cout << "B::func1()\n"; }virtual void func2() { cout << "B::func2()\n"; }virtual void func3() { cout << "B::func3()\n"; }virtual void func4() { cout << "B::func4()\n"; }int _b;
};

从上述代码运行过程中的监视窗口中我们可以看出,监视窗口中仅是呈现了子类B中的中前3份虚函数内容,剩余2份虚函数内容并没有展示。此时我们根据监视窗口中提供的虚表指针来访问内存,让我们来查看内存中的虚表存储内容:

我们可以发现其中后续仍有2份地址,于是我们不妨设想这2份地址是否为监视窗口中,未曾呈现的子类B中虚函数地址。我们来设计如下代码进行验证:

typedef void(*PVF) (); //定义函数指针类型,便于我们访问虚表内容
//虚表中存放的每个虚函数类型都为:PVF类型
void TestVirtual() {B b;b._b = 1;int* p = (int*)&b;//获取子类B对象b中的前4字节内容,即得到虚报指针内容int data = *(int*)&b;//整形数字data和虚表地址在数值上一致PVF* pvf = (PVF*)*(int*)&b;//将整形数字转化为入口地址//设计循环来不断向下调用pvf指向的虚函数for (int i = 0; i < 5; i++) {(*pvf)();//调用pvf指向位置的虚函数pvf++;//向下调用函数}
} 

上述代码的设计逻辑为:我们创建子类B的对象b,通过b来获取虚表指针,然后根据虚表指针指向的虚表入口地址,来得到剩余2份虚函数的执行结果:

 从上述结果中我们可以看出,后续子类B中虚函数执行结果依次呈现,这说明后续地址仍为子类B中虚函数地址,只是监视窗口未显示完全。

从反汇编可以看出是对同一份地址不断加4,即代码设计中的指针不断先后偏移,得以运行后续全部代码。

5.3静态绑定和动态绑定

  • 静态绑定又称为前期绑定,在程序编译期间便已经确定了程序的行为,也称为静态多态,代表案例为:函数重载。
  • 动态绑定又称为后期绑定,是在程序运行期间根据函数调用的动态类型(运行时确定的类型),来调用对应的具体函数,也称为动态多态。

动态绑定便是适用于虚函数调用的多态情况,也是我们本节内容一直所讲述的内容:通过在基类指针或引用上来调用虚函数,可以根据指针或引用实际传递的对象类型来动态的决定调用哪个函数。

6.多继承中的虚函数表

在了解完单继承中的虚函数情况之后,我们来讲述多继承中的虚函数表内容。我们首先来设计一段简单的代码,来对多继承中的虚函数情况进行展示:

class A1 {
public:virtual void funcA1() { cout << "A1::funcA1()\n"; }int _a1;
};class A2 {
public:virtual void funcA2() { cout << "A2::funcA2()\n"; }int _a2;
};class B : public A1, public A2 {
public:virtual void funcA1() { cout << "B::funcA1()\n"; }virtual void funcB() { cout << "B::funcB()\n"; }int _b;
};

6.1对象模型

我们设计子类B来继承A1和A2,并对A1中的虚函数重写,接下来我们定义子类B对象b,通过sizeof求出b的大小。

int main() {B b;b._a1 = 0;b._a2 = 1;b._b = 2;cout << sizeof(b) << endl;return 0;
}

并打开监视窗口和内存窗口来查看它的模型。 

通过上述结果结果展示我们可以明显看出,子类B中对象b继承了A1和A1中的内容:虚表地址和数据成员(_a1和_a2),并且自己新增数据成员,最终大小为8+8+4=20字节大小。

6.2虚表

按照重写和继承和规则,我们明白:子类B中重写的虚函数会在b中覆盖基类虚函数,未重写的虚函数则会直接继承基类虚函数。在多继承中,子类B中对象b分别存在从A1和A2中继承得到的虚表地址,上述两种的操作便会根据这份虚表地址,来进行实际的虚函数操作。

对于继承得到的虚函数我们有它们各自的虚表地址用来存储和重写,那么当我们在子类B中新增的虚函数会存储在这两封地址对应的哪一份虚函数表中呢?还是说二者都有所存储?

我们可以形如5.2.2中的代码,来调用二者虚函数表中的虚函数,代码如下:

typedef void(*PVF) ();void TestVirtualA1(A1& a1) {cout << "This is A1 Vtable\n";int data = *(int*)&a1;PVF* pvf = (PVF*)*(int*)&a1;while (*pvf) {(*pvf)();pvf++;}
}void TestVirtualA2(A2& a2) {cout << "This is A2 Vtable\n";int data = *(int*)&a2;PVF* pvf = (PVF*)*(int*)&a2;while (*pvf) {(*pvf)();pvf++;}
}

我们编译并执行代码得到结果如下:

我们可以发现,子类新增的虚函数内容打印展示在A1虚函数表中。这样我们便得到了一个结论:在多继承中,子类新增加的虚函数地址存储在第一份虚函数表最后。

7.多态总结

C++中的多态是一种面向对象编程的重要概念,它通过虚函数和动态绑定机制实现。多态性允许以通用的方式操作不同类型的对象,使得程序在运行时能够根据对象的实际类型来调用相应的函数。

我们来对本节讲述到的C++多态内容进行一些总结:

  • 虚函数(Virtual Functions):虚函数是在基类中声明为虚函数的成员函数。通过在基类中声明为虚函数,可以在派生类中进行重写(覆盖),从而实现多态性。虚函数通过使用关键字`virtual`进行声明。
  • 基类指针和引用:使用基类的指针或引用可以指向派生类的对象,从而实现多态性。通过基类指针或引用调用虚函数时,实际调用的是对象的实际类型所对应的虚函数。
  • 虚函数表(Virtual Function Table):虚函数表是一种用于存储和管理虚函数地址的机制。每个包含虚函数的类都有一个对应的虚函数表。虚函数表中存储了该类及其基类的虚函数的地址,通过虚函数表来实现动态绑定。
  • 虚析构函数(Virtual Destructors):当使用基类指针指向派生类对象,并通过该指针删除对象时,应该将基类的析构函数声明为虚析构函数。这样可以确保通过基类指针删除对象时,会调用正确的派生类析构函数,防止内存泄漏。
  • 协变(Covariance):是指子类中的返回类型可以和基类中的返回类型有相关性,即可以是基类同样的返回类型,也可以是不同的返回类型。协变允许子类中的返回比基类更具体的类型,一边更好的符号对象的多态性和继承关系。
  • 纯虚函数(Pure Virtual Functions)和抽象类(Abstract Classes):纯虚函数是在基类中声明但没有实现的虚函数,通过在函数声明后加上`= 0`来表示。包含纯虚函数的类称为抽象类,抽象类不能被实例化,只能作为基类来派生新的类。纯虚函数为派生类提供了接口规范,要求派生类必须实现这些函数。
  • 动态绑定(Dynamic Binding):动态绑定是在运行时决定要调用哪个函数的机制。通过使用虚函数和指针或引用,可以在运行时根据对象的实际类型来调用相应的虚函数,而不是根据指针或引用的静态类型。
  • 多继承体系中的多态:仅需注意一点,即:在多继承中,子类新增的虚函数地址存储在第一份虚函数表中。

多态性使得代码更加灵活和可扩展,通过统一的接口处理不同类型的对象,提高了代码的可读性和可维护性。它是面向对象编程中的重要特性之一,可以提升程序的设计和开发效率。

 

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/506.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Altium Designer 使用总结

文章目录 1.1 原理图模式下1.1.1 元件统一命名1.1.2 原理图右下方信息框更改1.1.3 快速复制元件1.1.4 查找元器件1.1.5 垂直、水平镜像翻转元件1.1.6 查找相同的网络标号 1.2 PCB模式下1.2.1 mil 与 mm之间的单位转换1.2.2 测量1.2.3 批量修改丝印层文字大小1.2.4 属性更改1.2.…

想要让视频素材格式快速调整转换的方法分享

有时候有些视频播放软件不支持播放某些格式的视频文件&#xff1f;那要怎么解决呢&#xff1f;换一个播放软件&#xff1f;不妨试试批量转换视频格式&#xff0c;简单的几步操作就能快速解决烦恼&#xff0c;跟着小编一起来看看具体的操作环节吧。 首先先进入“固乔科技”的官网…

Docker Compose基础与实战

一、是什么 Compose 项目是 Docker 官方的开源项目&#xff0c;负责实现对 Docker 容器集群的快速编排。 二、能做什么 Compose允许用户通过一个单独的docker-compose.yml模板文件&#xff08;YAML 格式&#xff09;来定义一组相关联的应用容器为一个项目&#xff08;project&…

前端web入门-CSS-day07

(创作不易&#xff0c;感谢有你&#xff0c;你的支持&#xff0c;就是我前行的最大动力&#xff0c;如果看完对你有帮助&#xff0c;请留下您的足迹&#xff09; 目录 定位 相对定位 绝对定位 定位居中 固定定位 堆叠层级 z-index 定位-总结 高级技巧 CSS 精灵 字…

将图像2D转换为3D--LeiaPix Converter

LeiaPix Converter是一款免费的在线图像处理工具&#xff0c;可帮助用户将2D图像实时转换为精美的3D光场图像。 它使用由Leia开发的专有算法&#xff0c;为照片、插画和艺术作品等2D图像添加深度和立体感&#xff0c;目前是完全免费的。 LeiaPix Converter 的特点 多格式转换…

【Spring Clound】Nacos高可用集群搭建与使用

文章目录 一、Nacos 简介二、Nacos 安装2.1、Nacos 环境依赖2.2、Nacos 服务端安装 三、Nacos 部署3.1、单实例部署3.2、 集群部署3.2.1、集群架构3.2.2、模拟部署 四、微服务集成Nacos4.1、依赖组件版本选型4.2、注册中心4.2.1、服务提供者4.2.2、服务消费者4.2.3、服务调用4.…

Linux上安装matlab

首先需要下载文件&#xff0c;微人大正版软件下载里有 然后直接点击&#xff0c;就可以就可以安装&#xff0c;不需要使用挂载命令&#xff0c;然后使用 ./install就可以进行安装了&#xff0c;这里记住是得登录自己的人大邮箱&#xff0c;否则无法激活&#xff0c;然后修改安…

HBase(9):过滤器

1 简介 在HBase中,如果要对海量的数据来进行查询,此时基本的操作是比较无力的。此时,需要借助HBase中的高级语法——Filter来进行查询。Filter可以根据列簇、列、版本等条件来对数据进行过滤查询。因为在HBase中,主键、列、版本都是有序存储的,所以借助Filter,可以高效地…

<Linux开发>驱动开发 -之- Linux RTC 驱动

&#xff1c;Linux开发&#xff1e;驱动开发 -之- Linux RTC 驱动 交叉编译环境搭建&#xff1a; &#xff1c;Linux开发&#xff1e; linux开发工具-之-交叉编译环境搭建 uboot移植可参考以下&#xff1a; &#xff1c;Linux开发&#xff1e; -之-系统移植 uboot移植过程详细…

macOS Big Sur 11.7.8 (20G1351) 正式版 ISO、PKG、DMG、IPSW 下载

macOS Big Sur 11.7.8 (20G1351) 正式版 ISO、PKG、DMG、IPSW 下载 本站下载的 macOS 软件包&#xff0c;既可以拖拽到 Applications&#xff08;应用程序&#xff09;下直接安装&#xff0c;也可以制作启动 U 盘安装&#xff0c;或者在虚拟机中启动安装。另外也支持在 Window…

RabbitMQ学习笔记6(小滴课堂)路由,主题模式

我们去修改我们的生产者代码&#xff1a; 我们去修改我们的消费者&#xff1a; 第一个节点&#xff1a; 我们还要去创建其它更多的节点&#xff1a; 这里第二个节点我们只绑定一个交换机队列。 我们去分别启动消费者和生产者&#xff1a; 我们可以看到第一个交换机只绑定了一…

Linux学习之进程的通信方式信号:kill命令

kill -l可以看到可以使用信号量。 把下边的内容使用编辑器&#xff0c;比如vim写到./a.sh。 #!/bin/bashecho $$ while : # 无限循环 do: donecat a.sh看一下文件里边的内容。 chmod ur,ux a.sh给当前用户赋予a.sh文件的写和执行权限。 在第一个端口里边&#xff0c;使用./a…