👀樊梓慕:个人主页****
🎥个人专栏:《C语言》** 《数据结构》 《蓝桥杯试题》 《LeetCode刷题笔记》 《实训项目》 《C++》 《Linux》《算法》**
🌝每一个不曾起舞的日子,都是对生命的辜负
目录
[2.4『 final』和『 override』关键字(C++11)](#2.4『 final』和『 override』关键字(C++11))
[2.4.1『 final』](#2.4.1『 final』)
[2.4.2『 override』](#2.4.2『 override』)
[3.3『 接口继承』和实现继承](#3.3『 接口继承』和实现继承)
前言
本篇文章博主将与大家共同学习多态的相关内容,并且会对之前继承的学习作补充。
欢迎大家📂收藏📂以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。
=========================================================================
**GITEE相关代码:**🌟樊飞 (fanfei_c) - Gitee.com🌟
=========================================================================
1.多态的概念
通俗来说,就是多种形态, 具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态 。
举个例子:比如买票这个行为 ,当普通人买票时,是全价买票;学生买票时,是半价买票;
如:
2.多态的定义及细节
在继承中要构成多态需要两个条件:
- 必须通过『 父类』的『 指针』或者『 引用』调用虚函数;
- 被调用的函数必须是『 虚函数』,且子类必须对父类的虚函数进行『 重写』;
对于上面新出现的两个概念做解释:
2.1虚函数
- 被virtual修饰的类『 成员函数』称为虚函数。
2.2虚函数的重写
- 子类中有一个跟父类『 完全相同』的虚函数(即子类虚函数与父类虚函数的返回值类型 、函数名、**参数『 类型』**完全相同),称子类的虚函数重写了父类的虚函数。
注意:在重写父类虚函数时,子类的虚函数在不加virtual关键字时,虽然也可以构成重写**(因为继承后,父类的虚函数被继承下来了,所以在子类依旧保持虚函数属性)**,但是该种写法不是很规范,不建议这样使用。
2.2.1虚函数重写的两个例外
(1)协变(父类与子类函数返回值类型不同)
协变是子类虚函数与父类虚函数返回值类型不同,但子类和父类的返回值类型也必须是父子关系指针和引用。
如:
cpp
class A {};
class B : public A {};
class Person
{
public:
virtual A* f() { return new A; }
};
class Student : public Person
{
public:
virtual B* f() { return new B; }
};
所以如果有人问:有关虚函数的重写,两个虚函数的返回值是必须相同的么?
你就知道这是个坑了,因为有『 协变』这一特殊情况,返回值类型不相同也可能满足虚函数重写。
(2)析构函数的重写(父类与子类析构函数名字不同)
我们知道,编译器对析构函数的名称会做特殊处理,编译后析构函数的名称统一处理成destructor(),这样就变相的满足了函数名相同了。
所以我们一般都给析构函数前加上virtual关键字,这样如果有子类继承,析构重写正好,没有子类继承也不影响。
有的同学可能会有疑问:为什么一定要重写父类的析构函数呢?
cpp
class Person {
public:
~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
~Student() { cout << "~Student()" << endl; }
};
int main()
{
Person* ptr1 = new Person;
Person* ptr2 = new Student;
delete ptr1;
delete ptr2;
return 0;
}
我们发现如果这里子类没有重写父类析构,就会导致子类对象的析构函数没有调用。
原因是什么呢?
我们知道delete的组成如下:
由于ptr指针的类型都是父类Person,所以当执行delete时,prt1和ptr2调用的都是父类的析构。
所以我们需要『 多态』,来让不同的对象调用它们对应的析构。
还记得构成多态的两个条件么?
- 必须通过『 父类』的『 指针』或者『 引用』调用虚函数;
- 被调用的函数必须是『 虚函数』,且子类必须对父类的虚函数进行『 重写』;
所以我们需要将父子类的析构函数设为虚函数,从而满足构成多态的条件。
2.3普通调用和多态调用的区别
通过上面析构函数的例子,相信大家已经体会到了多态的妙用,不同的对象调用不同的函数。
这里我们就来总结一下普通调用和多态调用的区别。
普通调用:根据指针、引用、对象的类型调用对应的函数;
多态调用:根据指针、引用指向的对象调用对应的函数。
2.4『 final』和『 override』关键字(C++11)
2.4.1『 final』
(1)修饰虚函数,表示该虚函数不能再被重写;
cpp
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() { cout << "Benz-舒适" << endl; } //err
};
(2)修饰类,该类不能被继承
2.4.2『 override』
override的作用是让编译器帮助用户检查子类虚函数是否重写了父类某个虚函数,如果没有重写编译报错,override作用发生在编译时。
cpp
class Car {
public:
virtual void Drive() {}
};
class Benz :public Car
{
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
2.5重载、重写(覆盖)、隐藏(重定义)的对比
3.抽象类
3.1概念
在虚函数的后面写上=0,则这个函数为纯虚函数;
包含纯虚函数的类叫做抽象类(也叫接口类)。
(1)抽象类不能实例化出对象:
cpp
#include <iostream>
using namespace std;
//抽象类(接口类)
class Car
{
public:
//纯虚函数
virtual void Drive() = 0;
};
int main()
{
Car c; //抽象类不能实例化出对象,error
return 0;
}
(2)子类继承抽象类必须重写纯虚函数,否则不能实例化出对象:
cpp
#include <iostream>
using namespace std;
//抽象类(接口类)
class Car
{
public:
//纯虚函数
virtual void Drive() = 0;
};
//派生类
class Benz : public Car
{
public:
//重写纯虚函数
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
//派生类
class BMV : public Car
{
public:
//重写纯虚函数
virtual void Drive()
{
cout << "BMV-操控" << endl;
}
};
int main()
{
//派生类重写了纯虚函数,可以实例化出对象
Benz b1;
BMV b2;
//不同对象用基类指针调用Drive函数,完成不同的行为
Car* p1 = &b1;
Car* p2 = &b2;
p1->Drive(); //Benz-舒适
p2->Drive(); //BMV-操控
return 0;
}
3.2意义
- 抽象类可以更好的去表示现实世界中,没有实例对象对应的抽象类型,比如:植物、人、动物等。
- 抽象类很好的体现了虚函数的继承是一种『 接口继承』,要求子类必须重写纯虚函数,因为子类若是不重写从父类继承下来的纯虚函数,那么子类也是抽象类就不能实例化出对象。
3.3『 接口继承』和实现继承
- 接口继承(虚函数继承):子类继承父类的函数『 声明』,但不继承其『 实现』。这种继承方式主要用于实现『 多态性』,即通过父类指针或引用调用子类实现的函数。
- 实现继承(普通函数继承):子类继承了父类的函数声明和实现。这种继承方式主要用于代码重用,即子类可以重用父类的代码。
4.多态的原理
4.1虚函数表
下面是一道常考的笔试题:Base类实例化出对象的大小是多少?
cpp
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
也就是说除了成员变量_b还有另外要存储的内容,这部分内容也就是实现多态的核心:『 虚函数表指针』。
b对象当中除了_b成员外,实际上还有一个『 _vfptr』放在对象的前面(不同平台会有不同设计)。
『 _vfptr』叫做虚函数表指针,简称虚表指针,虚表指针指向一个虚函数表,简称虚表,『 每一个』含有虚函数的类中『 都至少有一个虚表指针』。
那放在继承的框架下虚表指针会有什么样的设计呢?我们继续往下看。
针对上面的代码我们做出以下改造:
- 我们增加一个派生类Derive去继承Base
- Derive中重写Func1
- Base再增加一个虚函数Func2和一个普通函数Func3
cpp
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
通过监视窗口观察:
当然我这样画可能会有歧义,注意对象里存储的不是函数指针数组 。而是指向该函数指针数组的指针 ,本质是指针,大概模型应为下图所示:
观察得出以下结论:
(1)父类b对象和子类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1。
所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数地址的覆盖。
重写是语法的叫法,覆盖是原理层的叫法。
(2)Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函
数,所以不会放进虚表。
(3)此外,虚函数表本质是一个存虚函数指针的指针数组,一般情况下会在这个数组最后放一个nullptr。(如果你在调试时发现结尾不是nullptr,需要重新生成解决方案即可)。
总结一下,子类的虚表生成步骤如下
- 先将父类中的虚表内容拷贝一份到子类的虚表。
- 如果子类重写了父类中的某个虚函数,则用子类自己的虚函数地址覆盖虚表中父类的虚函数地址。
- 子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后。
注意
- 虚表实际上是在构造函数初始化列表阶段进行初始化的;
- 虚表当中存的是虚函数的地址不是虚函数,虚函数和普通函数一样,都是存在代码段的,只是他的地址存到了虚表当中;
- 对象中存的不是虚表而是指向虚表的『 指针』。
4.2多态的原理
为什么父类指针指向不同的对象就能实现多态呢?
cpp
#include <iostream>
using namespace std;
//父类
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
private:
int _b;
};
//子类
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
private:
int _d;
};
int main()
{
Person Mike;
Student Johnson;
Person* p1 = &Mike;
Person* p2 = &Johnson;
p1->BuyTicket(); //买票-全价
p2->BuyTicket(); //买票-半价
return 0;
}
通过监视窗口我们得到以下关系:
两个父类指针分别指向对应的Mike与Johnson对象,找到对应的虚表,调用对应的函数,即:
- 父类指针p1指向Mike对象,p1->BuyTicket在Mike的虚表中找到的虚函数就是Person::BuyTicket。
- 父类指针p2指向Johnson对象,p2>BuyTicket在Johnson的虚表中找到的虚函数就是Student::BuyTicket。
4.3动态绑定和静态绑定
- 静态绑定: 静态绑定又称为前期绑定(早绑定),在程序『 编译』期间确定了程序的行为,也成为静态多态,比如:函数重载。
- 动态绑定: 动态绑定又称为后期绑定(晚绑定),在程序『 运行』期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
我们可以通过以下代码进一步理解静态绑定和动态绑定:
cpp
//父类
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
//子类
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
4.3.1静态绑定(不构成多态)
cpp
int main()
{
Student Johnson;
Person p = Johnson; //不构成多态
p.BuyTicket();
return 0;
}
不构成多态,函数的调用是在编译时确定的。
查看汇编代码,发现直接调用函数:
4.3.2动态绑定(构成多态)
cpp
int main()
{
Student Johnson;
Person& p = Johnson; //构成多态
p.BuyTicket();
return 0;
}
构成多态,函数的调用是在运行时确定的。
查看汇编代码,发现需要经历一系列操作访问虚表:
4.4单继承和多继承关系的虚函数表
4.4.1单继承关系的虚函数表
构建单继承模型方便研究:
cpp
//父类
class Base
{
public:
virtual void func1() { cout << "Base::func1()" << endl; }
virtual void func2() { cout << "Base::func2()" << endl; }
private:
int _b;
};
//子类
class Derive : public Base
{
public:
virtual void func1() { cout << "Derive::func1()" << endl; }
virtual void func3() { cout << "Derive::func3()" << endl; }
virtual void func4() { cout << "Derive::func4()" << endl; }
private:
int _d;
};
通过监视窗口观察:
注意:这里有一个非常奇怪的现象,为什么监视窗口中d对象中没有func3和func4??
这其实可以认为是vs编译器的一个bug,实际上是有的,只不过监视窗口并没有显示出来,我们可以通过虚表指针在内存窗口中找到该虚表:
所以我们可以得到如下关系:
结论:在单继承关系当中,子类的虚表生成过程如下
- 继承父类的虚表内容到子类的虚表。
- 对子类重写了的虚函数地址进行覆盖,比如func1。
- 虚表当中新增子类当中新的虚函数地址,比如func3和func4。
4.4.2多继承关系的虚函数表
构建多继承模型方便研究:
cpp
//父类1
class Base1
{
public:
virtual void func1() { cout << "Base1::func1()" << endl; }
virtual void func2() { cout << "Base1::func2()" << endl; }
private:
int _b1;
};
//父类2
class Base2
{
public:
virtual void func1() { cout << "Base2::func1()" << endl; }
virtual void func2() { cout << "Base2::func2()" << endl; }
private:
int _b2;
};
//多继承子类
class Derive : public Base1, public Base2
{
public:
virtual void func1() { cout << "Derive::func1()" << endl; }
virtual void func3() { cout << "Derive::func3()" << endl; }
private:
int _d1;
};
通过监视窗口观察:
同样的疑问:监视窗口中d对象中的func3去哪了?
根据单继承关系给我们的启示,是vs编译器的一个bug,可是问题又来了,d对象有两个父类,对应着两张虚函数表,那么d中的虚函数放在了哪张表中呢?
找到了!红框内存储的函数指针无人认领,那必然是剩下的func3咯。
所以我们可以得到如下关系:
结论:在多继承关系当中,子类的虚表生成过程如下
- 分别继承各个父类的虚表内容到子类的各个虚表当中。
- 对子类重写了的虚函数地址进行覆盖,比如func1。
- 在子类『 第一个』继承基类部分的虚表当中新增子类当中新的虚函数地址,比如func3。
4.4.3利用代码打印出虚函数表
如何在终端输出虚函数表呢?
以单继承关系模型的场景为例:
前提:使用VS编译器,因为在VS平台中虚表结尾设置为nullptr,我们就可以利用该空指针作边界检测,然后输出对应的虚函数表。
cpp
typedef void(*VFPTR)(); //tepedef虚函数指针类型
void PrintVFT(VFPTR* ptr)
{
printf("虚表地址:%p\n", ptr);
for (int i = 0; ptr[i] != nullptr; i++)//利用结尾的空指针作边界检测
{
printf("ptr[%d]:%p-->", i, ptr[i]); //打印虚表当中的虚函数地址
ptr[i](); //使用虚函数地址调用虚函数
//函数指针+()即可调用该函数指针指向的函数
//或者*函数指针+()调用
}
printf("\n");
}
int main()
{
Base b;
PrintVFT((VFPTR*)(*(int*)&b)); //打印基类对象b的虚表地址及其内容
Derive d;
PrintVFT((VFPTR*)(*(int*)&d)); //打印派生类对象d的虚表地址及其内容
return 0;
}
思路:取出b、d对象的头字节,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr。
- 先取b的地址,强转成一个int*的指针,就得到了一个前四个字节的地址;
- 再解引用取值,就取到了b对象头4个字节的值,这个值就是指向虚表的指针,但此时这个值为int类型。
- 所以我们需要再强转成VFPTR*才能进行传参,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
- 虚表指针传递给PrintVTable进行打印虚表。
- 需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要重新生成解决方案即可。
有关『 菱形继承』模型这里就不讨论了,因为菱形继承本来就是一种非常危险的行为,不建议大家设计出菱形继承,实际中也很少会使用,所以大家只需要掌握单继承和多继承模型即可。
=========================================================================
如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容
🍎博主很需要大家的支持,你的支持是我创作的不竭动力🍎
🌟**~ 点赞收藏+关注 ~**🌟
=========================================================================