"当人类悬浮到腐朽,有谁愿追随彗星漂流哦~"
一、多态原理
(1)虚函数表指针(虚表指针)
紧接上一篇sizeof(Base)这一小段说起。
class Base1
{
public:
void func(){}
private:
int _a;
};
class Base2
{
public:
virtual void func() {}
private:
int _a;
};
我们知道,两个Base虽然在成员上相差无几,但是因为虚函数的存在,Base2一定是 > Base1的。
前篇说了这里的原因在于,一旦有虚函数,类里面就会自动生成一个虚函数指针。那么什么是虚函数指针呢?
虚函数表指针是能够实现动态多态的根本原因,一个有虚函数的类里,会多出一个_ vfptr即虚函数表指针(虚表指针),它指向的 是一个函数指针数组_vtable,而这个指针数组,存储的是类里虚函数的地址。
(2)虚函数表
那么这个虚函数表在哪里呢?这个虚函数表有几份呢?
在图示中,我们清晰地看到,相同的类它的虚表指针是固定的,即它们共享一份虚函数表。不同的类,有不同的虚表指针。
但这些表存储在哪个地方呢?它们是在编译时生成还是在构造时生成呢?
原来虚函数表是存储在静态区、代码段区域。
虚函数表存储在常量、代码区域附近
虚函数表在编译时就已经存在。
虚函数表指针在构造函数初始化列表出初始化。
(3)重写覆盖
为什么说子类对基类虚函数的定义叫做重写,这个行为又被叫做覆盖呢?
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
private:
int _a;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
这份份代码只对基类的func1虚函数进行了重写。
因此,一定程度上,就是可以这么理解。建立_vftble子类虚函数表的时候,是把基类的虚函数表拷贝一份过去,完成重写的部分,则"覆盖"式地填写进子类的虚函数表中。重写是语法的叫法,覆盖是原理层的叫法。
(4)静态绑定vs动态绑定
从概念上这两个定义很简单。
静态绑定又称为前期绑定(早绑定), 在程序编译期间确定了程序的行为 ,也称为静态多态。
动态绑定又称后期绑定(晚绑定), 是在程序运行期间 ,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
我们从反汇编的角度来看看呢,
从指令的复杂程度,也就知道它们之间的差异其实挺大的。
对于静态的动态而言,如:函数重载。函数实现在代码区,编译期间就可以找到函数的地址,当执行到该函数时,直接call该保存的 地址即可(如图所示)。
但是对于动态的多态,如:虚函数重写,虽然虚函数表在代码段\静态区,但是你不知道调用子类的虚函数还是父类的虚函数。因为起决定的是,父类指针、引用接收的对象,由此当父类指针、引用接收到对象时,会根据该对象去虚函数表中找到适合的虚函数,再进行调用,从而实现动态的多态。
(5)子类虚函数
子类也定义一个虚函数,那是否会进入虚函数表呢?
我们写一个打印虚函数表的函数;
void PrintVFTable(VFPtr vft[])
{
for (int i = 0; vft[i] != nullptr; ++i)
{
printf("[%d]:%p->", i, vft[i]);
//我们拿到了函数的地址 就可以去调用函数
vft[i]();
}
cout << endl;
}
(6)经典题目
我们以一道面试题来开启这一小段。
class A{
public:
A(const char *s) { cout << s << endl; }
~A(){}
};
class B :virtual public A
{
public:
B(const char *s1,const char*s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:
C(const char *s1,const char*s2) :A(s1) { cout << s2 << endl; }
};
class D :public C, public B
{
public:
D(const char *s1,const char *s2,const char *s3,const char *s4) : B(s1, s2), C(s1, s3),A(s1)
{
cout << s4 << endl;
}
};
这段打印什么?
初始化列表初始化数据的顺序:
①声明顺序
②继承顺序
为什么这里需要D去显式调用A的构造?
因为在B、C中A的部分都是一个。因此,让B、C其中哪一个去构造都不太合适,因为两人都合适,并且A的部分是公共的。所以,这个任务就交由D一定要去显式调用A的构造。完成对那部分的初始化。
此时我们把虚继承去掉,此时也就是菱形继承了:
class A{
public:
A(const char *s) { cout << s << endl; }
~A(){}
};
class B :public A
{
public:
B(const char *s1,const char*s2) :A(s1) { cout << s2 << endl; }
};
class C :public A
{
public:
C(const char *s1,const char*s2) :A(s1) { cout << s2 << endl; }
};
class D :public C, public B
{
public:
D(const char *s1,const char *s2,const char *s3,const char *s4) : B(s1, s2), C(s1, s3),A(s1)
{
cout << s4 << endl;
}
};
这种题很考人,如果对多态与继承学得不是很扎实时,总不免会踩坑。
(7)为什么多态的条件要求是父类的指针或引用?
这也是为什么,多态的条件是基类的指针或者引用,而非对象。
二、多继承关系里的多态
需要注意的是,虚函数表与虚继承表无 任何关系,虽然它们都使用了一个 同样的关键字"virtual"。
(1)单继承中的虚函数表
从观察窗口看,我们看不见func3,这是vs调试窗口做了特殊处理进行了隐藏。那我们如何看到子类的func3虚函数呢? 我们就只好用之前写好的打印函数。
(2)多继承中的虚函数表
此外,多继承的派生类为重写的虚函数,会放在第一个继承的基类部分的虚函数表中。
菱形继承、菱形虚拟继承产出的虚函数表更加地吓人,本节不会对此做过多赘述。你要设计菱形继承又要以此设计多态出来,只能奉劝你 "耗子尾汁"。
三、经典面试问答
(1)重载、重写(覆盖)、重定义(隐藏)
(2)inline函数可以是虚函数吗?
inline函数就是在函数调用处给出展开,但是我们虚函数是需要写入虚函数表的。因此不适宜展开。
虽然这个在语法上来说编译器不会报错,但是一旦构成多态,那么内联就没什么用了。毕竟inline只是给编译器提"建议"。而如果是普通调用,那么内联展开也是行得通的。
(3)静态成员函数可以是虚函数吗
我们写出来编译器就立马报错。静态成员函数最显著的一个特征时,可以不需要类对象的创建,就可以调用的函数,也就是该函数没有this指针。没this指针你怎么访问虚函数表,调用虚表指针呢?
(4)构造函数可以是虚函数?
在前面也说过,虚函数表是在编译期间就已经存在了。但是虚表指针是在构造函数的初始化列表中完成初始化的。虚表指针都没有,你怎么让构造函数是虚函数。
(5)析构函数可以是虚函数?
父类指针、引用 子类对象时,如果父类的虚函数没有完成重写,那么它就只会去调用它自己的析构函数,而不会去调用子类的析构函数。只有将父类的析构函数变为虚函数,才能正确地析构子类对象。
(6)对象访问普通函数快还是虚函数更快?
如果是普通调用。两个一样的块。难道声明了virtual的虚函数,每次调用都会去查找虚表?
只要你不构成多态的条件,对类里虚函数的调用跟普通调用没什么区别。当然,构成多态从反汇编的都知道它要去虚表里面查找合适的虚函数,肯定对效率有一定的影响。
总结:
①类里一旦有虚函数,就会自动生成虚函数表指针。
②虚函数表指针在初始化列表初始化,虚函数表是在编译阶段就生成的,一般情况
下存在代码段(常量区)的。
③多态分为静态的多态和动态的多态。一个是在编译期间确定的,一个是运行期间才能确定的。
本篇到此结束,感谢你的阅读
祝你好运,向阳而生~