👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨
多态是面向对象三大基本特征中的最后一个。概念:通俗来说,就是 多种形态,具体点就是 不同的对象去完成某个行为,就会产生出不同的结果。
比如在购买高铁票时,成人原价,学生半价,而军人可以优先购票,对于购票这一相同的动作,需要根据不同的对象提供不同的函数
【代码样例】
【输出结果】
可以看到,不同对象调用同一函数,结果是不同。
在继承中要构成多态还有两个条件:
注意:上述两个构成多态的条件缺一不可!
由此可以看出,多态调用看的是指向的对象,指向父类调父类,指向子类调子类;普通对象调用看的是当前对象的类型。
在类的成员函数前加上关键字virtual
称为虚函数。
子类中有一个跟父类完全相同的虚函数。完全相同指的是:返回值类型、函数名字、参数类型完全相同,则称子类的虚函数重写了父类的虚函数。
virtual
修饰。因为重写的本质是:重写子类虚函数的实现。虽然在语法上是支持的,但是建议不要省略,因为会破坏代码的可阅读性,可能无法让别人一眼看出多态。
返回值类型可以不同,但要求返回值必须是父子关系的指针和引用。
【返回类型为各自对象的指针】
【返回对象的引用】
注意:父子类关系的指针/引用,不是必须是自己的,也可以是其他类的,但是要对应匹配子类和父类。还有一点:必须同时是指针,或者同时是引用。
问题引入:析构函数加上virtual
是不是虚函数重写?
答案:是。为什么是呢?函数名不相同,就不满足重写的条件啊。其实这里可以理解为编译器对析构函数的函数名做了特殊处理,编译后析构函数的名称都被统一处理成destructor
【输出结果】
接下来就是面试官的连续”攻击”
为什么要这样处理呢?
— 那肯定是要构成重写
为什么要构成重写?好像父类不加virtual
关键字也是可以的
如果不对析构重写的话,那么下面有一个场景是过不了的(记住此场景)
【输出结果】
以上代码缺少了子类的析构函数!!!发生了内存泄漏。说明没有调用子类的析构函数,这是为什么呢?
原因如下:
delete
对于自定义类型的原理是:
operator delete
函数释放对象的空间(operator delete
本质就是调用free
函数)。即对于最后一个delete a
,先调用了析构函数a->destructor()
,然后再调用operator delete
函数释放a
指向的空间。
虽然析构函数名相同(统一处理成destructor
),但是函数并没有用virtual
修饰,因此并不构成重写,只能构成了重定义(隐藏)。所以,对于普通对象的调用,看的是当前调用者的类型。因此上述代码a
的类型为A
,调用的是父类的析构函数。
而我们期望的是指向什么对象,就调用对应对象的函数,因此就需要多态了。所以需要在父类的析构函数中加上virtual
修饰(子类可加可不加)
总结:只要一个类被继承,都要在其父类的析构函数前加上virtual
C++11
提供了override
和final
两个关键字,可以帮助用户检测是否重写。
作用:修饰子类的虚函数(写在子类函数括号的后面),检查是否构成重写,若不构成,则报错,反之什么事也不发生
作用:修饰父类的虚函数,不允许子类重写父类的虚函数,即不构成多态
对父类的虚函数加上final
:无法构成重写
注:final
可以修饰子类的虚函数,因为子类也有可能成为父类;但override
无法修饰父类的虚函数,因为父类之上可能没有父类了,自然无法构成重写。
final
还可以修饰父类,表示:父类不可被继承。
函数重载:同一个作用域中,函数名相同,参数个数不同 or 参数类型顺序不同 or 参数类型不同。
重定义:又称隐藏。子类和父类可能会出现同名成员(函数名/变量名相同,都构成重定义,与返回值类型和参数列表无关),若出现这种情况,默认会将父类的同名成员隐藏,进而执行子类的同名成员。若想访问父类的同名成员,可以加域作用访问限定符。
重写:又称覆盖。父类有虚函数,并且子类也存在完全相同的虚函数。完全相同指的是:返回值类型相同、函数名相同、参数类型完全相同。
= 0
,则这个函数为纯虚函数,只要包含纯虚函数的类叫做抽象类(也叫接口类)=0
。抽象类既然不能实例化出对象,那抽象类存在的意义是什么? -> 强制子类去重写纯虚函数。
【实现继承】 普通函数的继承是一种实现继承,派生类继承了基类函数的实现,可以使用该函数。
【接口继承】 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。
【建议】如果不实现多态,就不要把函数定义成虚函数。
多态究竟是如何实现的?先来看一段简单的代码,同时也是一道笔试题。
通过分析:类中只有一个虚函数,而对象是不存储函数。因此,大小只算上成员变应该是1
【运行结果】
可以通过【监视窗口】观察
我们发现:c
对象还多了一个名为_vfptr
,从名字上分析:v
代表virtual
,f
代表function
,ptr
代表pointer
,因此对象中的这个指针我们叫做虚函数表指针,也称作虚表指针。
【监视窗口】
通过观察:子类对象除了有自己的成员变量,还继承了父类的成员变量和虚函数表指针(对象中存的不是虚表,存的是虚表指针,指向虚表)。
实际上虚表当中存储的就是虚函数的地址,而不是虚函数,虚函数和普通函数一样的,都是存在代码段的。虽然子类继承父类的虚函数Func1
,但是子类对父类的虚函数Func1
进行了重写,因此,子类对象的虚表当中存储的是父类重写的虚函数Func1
的地址。
当然了,如果子类没有重写父类的虚函数,那么虚函数表里的虚函数的地址都是相同的
这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
总结一下子类的虚表生成:
根据以上分析,就可以解释多态是怎么做到指向父类调父类,指向子类调子类?
现在想想多态构成的两个条件:
必须完成虚函数的重写是因为我们需要完成子类虚表当中虚函数地址的覆盖来达到不同对象调用会产生不同的结果。那为什么必须使用父类的指针或者引用去调用虚函数呢?为什么子类的指针或引用去调用虚函数达不到多态的效果呢?
原因是:
又有一个问题:为什么不通过父类对象去调用虚函数呢?原因是:对象切片和指针/引用切片是由差距的。对象赋值不会拷贝虚表,如果拷贝虚表,那么如果指向对象是父类,调用的就是子类的虚函数,就达不到不同对象调用会产生不同的结果。
总结一下前面的:
那么问题来了,虚表存在哪?
A. 栈
B. 堆
C. 数据段(静态区)
D. 代码段(常量区)
因为堆是给用户手动申请和释放的,编译器不可能自己new
或者malloc
。
栈都是伴随的栈帧走的,假设存在栈帧上,那么同类型的对象在不同栈帧上,就会创建不同的虚表,我们可以来验证一下:
因此,可以得出结论:同类型的对象共用虚表。因此虚表不可能在栈上。
可以打印地址对比,因为同区域的地址是不会偏离太远的
因此,虚表是存在代码段(常量区)。
【代码】
可以通过查看汇编的方式进一步理解静态绑定和动态绑定:
首先Func1
虽然是虚函数,但是不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call
地址
相比不构成多态时的代码,构成多态时调用函数的那句代码翻译成汇编后比不构成多态的情况多,主要原因就是我们需要在运行时,先到指定对象的虚表中找到要调用的虚函数,然后才能进行函数的调用。
在前头讲过:子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后。观察下图中的监视窗口中我们发现看不见func3
和func4
。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug
。那么我们如何查看d
的虚表呢?下面我们使用代码打印出虚表中的函数
我们可以使用以下代码,打印上述基类和派生类对象的虚表内容,在打印过程中可以顺便用虚函数地址调用对应的虚函数,从而打印出虚函数的函数名,这样可以进一步确定虚表当中存储的是哪一个函数的地址。
运行结果如下:
以下列多继承关系为例,我们来看看基类和派生类的虚表模型。
其中,两个基类的虚表模型如下:
子类的虚表模型如下:
观察上图中的监视窗口中我们发现看不见func3
。同理的,使用代码打印出虚表中的函数来验证:
打印函数还是上面那个不变,下面是主函数部分
在多继承关系当中,派生类的虚表生成过程如下:
Func1
)#include
using namespace std;
class A
{
public:
A(char* s) { cout << s << endl; }
~A() {};
};
class B : virtual public A
{
public:
B(char* s1, char* s2)
:A(s1)
{
cout << s2 << endl;
}
};
class C : virtual public A
{
public:
C(char* s1, char* s2)
:A(s1)
{
cout << s2 << endl;
}
};
class D : public B, public C
{
public:
D(char* s1, char* s2, char* s3, char* s4)
:B(s1, s2)
, C(s1, s3)
, A(s1)
{
cout << s4 << endl;
}
};
int main()
{
D* p = new D("class A", "class B", "class C", "class D");
delete p;
return 0;
}
A.class A class B class C class D
B.class D class B class C class A
C.class D class C class B class A
D.class A class C class C class D
class Base1
{
public:
int _b1;
};
class Base2
{
public:
int _b2;
};
class Derive : public Base1, public Base2
{
public: int _d;
};
int main()
{
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}
A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3
#include
using namespace std;
class A
{
public:
virtual void func(int val = 1)
{
cout << "A->" << val << endl;
}
virtual void test()
{
func();
}
};
class B : public A
{
public:
void func(int val = 0)
{
std::cout << "B->" << val << std::endl;
}
};
int main()
{
B* p = new B;
p->test();
return 0;
}
A.A->0 B.B->1 C.A->1 D.B->0 E.编译错误 F.以上都不正确
多态又分为静态多态和动态多态。
- 静态多态:函数重载。
- 动态多态:继承中虚函数重写 + 父类指针调用。 -> 不同对象,去调用同一函数,产生了不同的行为。
可以。我们知道内联函数是会在调用的地方展开的,也就是说内联函数是没有地址的。当我们把内联函数定义虚函数后,编译器就忽略了该函数的内联属性,这个函数就不再是内联函数了,因为需要将虚函数的地址放到虚表中去。
- 静态成员函数不能是虚函数。因为静态成员函数没有
this
指针,使用类型::成员函数
的调用方式无法访问虚表,所以静态成员函数无法放进虚表。static
和virtual
是不能同时使用的- 静态成员函数与具体对象无关,属于整个类,核心关键是没有隐藏的
this
指针,可以通过类名::成员函数名 直接调用,此时没有this
无法拿到虚表,就无法实现多态,因此不能设置为虚函数
友元函数不属于成员函数,不能成为虚函数.
构造函数不能是虚函数,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
对象访问普通函数比访问虚函数更快,若我们访问的是一个普通函数,那直接访问就行了,但当我们访问的是虚函数时,我们需要先找到虚表指针,然后在虚表当中找到对应的虚函数,最后才能调用到虚函数。
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。点击跳转
- 菱形继承的问题:菱形虚拟继承因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。
- 虚继承的原理点击跳转:虚继承对于相同的虚基类在对象当中只会存储一份,若要访问虚基类的成员需要通过虚基表获取到偏移量,进而找到对应的虚基类成员,从而解决了数据冗余和二义性的问题。