本节是我们面向对象内容的最终篇章,不是说我们的C++就学到这里。如果有一些面向对象的基础知识没有讲到,后面会发布在知识点补充专栏,全都是干货满满的。
初识:多态特性
多态的基本概念
多态是C++面向对象的三大特性之一。其实早在运算符重载那一章节我们就已经在接触多态了。只不过我们当时还不认识,看完下文你就知道,本节你已经是有基础傍身的"大白"了(反正不是小白)
运算符重载链接:C++入门day3-面向对象编程(中)-CSDN博客
多态分为两种:
静态多态:函数重载 和 运算符重载属于静态多态,复用函数名
动态多态:派生类和虚函数实现运行时多态
二者的区别:静态多态:函数地址早绑定,编译时确定
动态多态:函数地址晚绑定,运行时确定
virtual关键字
C++中的virtual关键字主要有这样几种使用场景:第一,修饰父类中的函数 ;第二,修饰继承性。注意:友元函数、构造函数、static静态函数不能用virtual关键字修饰。普通成员函数和析构函数可以用virtual关键字修饰。
virtual具有继承性:父类中定义为virtual的函数在子类中重写的函数也自动成为虚函数。
一定要注意: 只有子类的虚函数和父类的虚函数定义完全一样才被认为是虚函数,比如父类后面加了const,如果子类不加的话就是隐藏了,不是覆盖.
函数重写(覆盖)
定义:子类重新定义父类中有相同名称、返回值、参数的虚函数
cpp
class father{
public:
virtual void speak(){
cout<<"我是父亲"<<endl;
}
};
class son:public father{
public:
/*virtual*/ void speak(){
cout<<"我是儿子"<<endl;
}
}
基本条件:
1.被重写的函数必须为vitual函数,并位于父类中
2.重写的函数与被重写的函数除了函数体可以不一样,其余的函数名、返回值、参数及类型都必须完全一致
如果我们不适用virtual关键字,分别在父类与子类写两个函数:查看son的内存布局
我们看不到任何东西存在。
我们先在父类函数中加上virtual关键字,如上段代码,然后利用终端查看son类的内存布局情况:
在这里我们看到子类那里只有一个来自父类的虚函数表指针(virtual function-table ptr),而下面还附带一个son域内的虚函数表(virtual function table),里面有son::speak函数名。运行时自动检测是哪个类创建的对象调用的函数,这个过程就是根据虚函数表指针访问虚表然后找到被调函数的函数地址的过程。
当然,我们仍然可以通过加作用域的方式进行子类访问父类函数:
函数隐藏
1.对于上文,如果父类子类之间有函数的函数名一致,其它不一定一致,那么会发生函数隐藏,此时子类创建的对象会优先匹配子类本身的函数。
2.如果函数要素完全一致:双方都没有virtual修饰,是函数隐藏。
多态案例分析
cpp
class father {
public:
virtual void speak() {
cout << "我是father" << endl;
}
void work() {
cout << "上班" << endl;
}
};
class son :public father {
public:
void speak() {
cout << "我是son" << endl;
}
void work() {
cout << "上学" << endl;
}
};
class daughter :public father {
public:
void speak() {
cout << "我是daughter" << endl;
}
void work() {
cout << "嫁人" << endl;
}
};
多态的基础 :需要有重写:子类重写父类的返回值、函数名、参数列表完全一致的虚函数。
(只要父类的函数是虚函数即可)
cpp
int main(){
father *f1=new son;
f1->speak();
f1->work();
father *f2=new daughter;
f2->speak();
f2->work();
return 0;
}
动态多态:父类指针类型的变量或父类引用类型的变量,使用子类类型进行new创建。(即父指针指向子对象。)并通过该指针或引用调用子类的重写出来的虚函数的现象是动态多态
动态的过程体现在,函数传参时,只要形参是父类指针或引用类型,那么传入子类时,就会自动使用子类类型的一系列重写的成员函数。其实就有点类似于局限版的模板了。
对于重写的函数,f会调用子类的重写函数,对于隐藏的函数,f会调用父类本身的函数。
小结
总结:
一、多态满足条件:
1.有继承关系
2.子类重写父类虚函数
二、多态使用条件:
父类指针或引用指向子类对象
多态的实现
C++为了实现多态,使用了一种动态绑定的技术,这个技术的核心内容就是虚函数表
虚函数表我们在上文也提到过,在这里我再放一下图大家有个基础的认识:
类的虚函数表
当子类中重写一个或多个父类的虚函数时,这些虚函数不会直接存在类内,而是添加一个数组--虚函数表,数组内存放的是函数的一个个虚函数指针 。
虚函数表:简称虚表
【⚪】虚表是一个存放指针的数组,内部元素是虚函数的指针。普通函数(非虚函数)调用不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。
【⚪】虚表内的条目--即虚函数指针,指针的赋值发生在编译阶段。也就是说在代码的编译阶段,虚表就已经构建出来了
- 每个包含了虚函数的类都包含一个虚表(存放虚函数指针的数组)
2.当子类继承父类时,子类会继承父类的函数的调用权。所以说如果一个父类包含了虚函数,那么子类也可以调用这些虚函数,(即上文提到的作用域指定访问)。换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有了自己的虚表。
【⚪】虚表是属于类的,而不是属于某个具体的对象,一个类只有一个虚表,虚表这个数组就相当于static修饰的静态成员一样。同一个类的所有成员都使用同一个虚表。
虚表指针
虚表指针:即上文提到的虚函数表指针,用于访问类的虚表,一定程度上相当于隐藏的静态成员指针
类创建的对象通过虚表指针来访问类的虚表。简单来讲就是将数组的标准形式改为了指针形式。
为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个静态成员指针变量:* vfptr,用来指向虚表,这样,当类的成员在创建时就拥有了这样的指针,且这个指针的值会自动被设置为指向类的虚表。
验证vfptr指针的方法(_vfptr不可访问),就是用sizeof()先求一个普通类占的字节数大小,然后将类中的某一个函数前使用virtual修饰使其变为虚函数,再求该类占的字节数的大小,会发现多了四个字节,这就验证了vfptr的存在。当然也可以使用终端查看,方法如下
动态绑定
动态绑定我们会单独讲解,有需要可以到主页找一找,或者是在知识点补充专栏查找。如果没有找到请等待一两天,博主会加紧把文章码出来的。(专栏链接在文章开头)
纯虚函数与抽象类
纯虚函数
纯虚函数的语法:(当类中有了纯虚函数,这个类也被称为抽象类。)
cpp
virtual 返回值类型 函数名 (参数列表) = 0;
抽象类的特点:
1.无法实例化对象
2.子类必须重写抽象类的纯虚函数,否则也属于抽象类
Tips:虚函数在虚表中存放的是函数地址,而纯虚函数在虚表中存放的是0。
抽象类(接口)
接口是为了描述类的行为和功能,不需要完成类的特定实现。C++的接口就是使用抽象类来实现的,抽象类与数据抽象互不混淆,数据抽象是一个把细节与相关数据分离的过程的这样一个概念。
如果类中至少有一个纯虚函数,那么这个类就称为抽象类。语法同上。
设计抽象类(Abstract-Class,ABC) 的目的是为了给派生类提供一个行为的约束,必须要完成重写这些虚函数的功能才能自行拓展自身特殊行为。抽象类不能被实例化为对象,这一点使得抽象类可以很好的作为接口 使用。相对的,非抽象类即为具体类。
抽象类的设计策略:面向对象的系统可能会使用一个抽象基类为所有的外部应用程序提供一个适当的、通用的、标准化的接口。然后,派生类通过继承抽象基类,把所有操作继承下来。
这种架构具有很强的可拓展性。
虚析构与纯虚析构
回顾析构
在学习虚析构与纯虚析构之前。我们想一下普通的析构能解决什么问题?
防止对象的成员指针被重复释放时导致的无法释放nullptr的问题。
虚析构
虚析构是为了解决父类指针无法释放子类对象的问题的。
我们先来看一段代码:
cpp
#include<iostream>
using namespace std;
class father abstract{
public:
virtual void speak() = 0;
virtual void work() = 0;
virtual void show() = 0;
~father() {
cout << "father _destruct" << endl;
}
};
class son :public father {
public:
void speak() {
cout << "我是son" << endl;
}
void work() {
cout << "上学" << endl;
}
void show() {
}
~son() {
cout << "son _destruct" << endl;
}
};
int main() {
father* fs = new son;
delete fs;
return 0;
}
乍一看没啥问题,我们看一下运行结果:
显然,只调用了父类的析构函数,子类的析构函数没有被调用。这就导致子类的资源得不到释放,这就造成了内存泄露的问题。
当我们在父类的析构函数之前加上virtual关键字后:
这时候二者的资源都被释放了。这才是我们想看到的结果。
虚析构:virtual ~类名(){}
纯虚析构:virtual ~类名()=0;
虚析构与纯虚析构的区别:一旦类内有纯虚析构,类就是抽象类了,无法进行实例化了就。
总结
1.虚析构或纯虚析构就是为了解决父类释放子类对象的问题的
2.如果子类中没有堆区数据,也可以不写虚析构或纯虚析构
3.拥有纯虚析构的类也属于抽象类
共性:都需要具体的函数实现;都可以解决父类指针释放子类对象的问题
区别:有纯虚析构的类是抽象类,无法实例化对象
遗留问题
我们上节课遗留了一个问题,就是菱形继承问题。什么是菱形继承呢,简单说:你画个菱形,菱形的每个顶点都代表着一个类,其中第一层一个顶点,作为基类,第二层两个顶点,均由基类派生,第三层一个顶点,这个类继承第二层的两个类。继承关系构成了一个菱形,所以我们形象的称之为菱形继承。
菱形继承
上述的情况就是简单的菱形继承,代码如下:
cpp
class Animal{
public:
int _age;
void eat(){
cout<<"eat"<<endl;
}
};
class Wolf:public Animal{
public:
int w_num;
void speak(){
cout<<"嗷呜~"<<endl;
}
};
class Dog:public Animal{
public:
int d_num;
void speak(){
cout<<"汪汪~"<<endl;
}
};
class WolfDog:public Wolf,public Dog{
public:
int _xxx;
void show(){
cout<<"我是狼狗"<<endl;
}
};
此时,我们的WolfDog的成员有哪些东西呢?首先wolf类继承了animal的_age属性,dog类也继承了_age属性,那么WolfDog继承Wolf和Dog两类时,同时继承了来自二者的_age属性。这样的话就会导致我们访问_age时,出现访问不明确的问题,我们要通过作用域限制明确访问。
此时,菱形继承带来了一个问题:二义性,还有数据冗余的问题。致使我们使用时非常不方便,因此C++提供了虚拟继承的技术来解决菱形继承带来的问题。
虚拟继承
根据下图我们可以看出来,它的底层对象模型的布局与我们分析的相一致。这就是为什么普通的菱形继承会带来二义性及数据冗余的问题。
普通菱形继承的底层对象模型
虚拟继承的语法:
cpp
class A{};
class B:virtual public A{};
运用虚拟继承:
cpp
class Animal {
public:
int _age;
void eat() {
cout << "eat" << endl;
}
};
class Wolf :virtual public Animal {
public:
int w_num;
void speak() {
cout << "嗷呜~" << endl;
}
};
class Dog :virtual public Animal {
public:
int d_num;
void speak() {
cout << "汪汪~" << endl;
}
};
class WolfDog :public Wolf, public Dog {
public:
int _xxx;
void show() {
cout << "我是狼狗" << endl;
}
};
查看底层模型我们可以知道,此时WolfDog类只有一个_age,直接继承来自Animal类的_age,第二层的两个类都是虚拟继承的Animal,可以理解为不算真正拥有。那它们怎么访问_age属性呢,这时候哦我们又看到了一个vbptr和vbtable,之前我们看到的是vfptr和vftable。后面这个我们认识,那这个vb到底是什么意思呢?
vbptr(virtual base-class-table pointer)虚基类表指针
vbtable(virtual base-class table)虚基类表
虚拟菱形继承的底层对象模型
实际运用时很少用多继承语法,基本上不会遇到菱形继承问题,但需要我们理解底层,有助于你的实力提升。我们大多会使用 组合的技术,即类内定义对象成员。
感谢观看,如果有需要互3的小伙伴可以关注+私信,看到必回哦。