面向对象语言存在三大特性,即:继承,封装和多态。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):动态绑定是在运行时决定要调用哪个函数的机制。通过使用虚函数和指针或引用,可以在运行时根据对象的实际类型来调用相应的虚函数,而不是根据指针或引用的静态类型。
- 多继承体系中的多态:仅需注意一点,即:在多继承中,子类新增的虚函数地址存储在第一份虚函数表中。
多态性使得代码更加灵活和可扩展,通过统一的接口处理不同类型的对象,提高了代码的可读性和可维护性。它是面向对象编程中的重要特性之一,可以提升程序的设计和开发效率。