多态的原理
虚函数表指针
(简称虚表指针)
(引子)
下面编译为32位程序的运行结果是什么()
A.编译报错 B.运行报错 C.8 D.12
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
我们说过C++计算大小时只考虑成员变量不考虑成员函数。
在思考这道题目是否会有坑时,我们要看到比较特殊的地方在于virtual的存在。
要对多态的原理有一定的了解,才能解决这道题目。
因为这题的结果是12,并不是我们正常计算下得到的8.
我们可以在监视窗口看到:
b里面不只有_b和 _ch,还有 _vfptr。我们更具体来看一下这个成员的类型:
vftable:v就是virtual,f就是function,t就是table
虚函数表指针,是指针,所以多占了4个字节。
所以最后变成12个字节。
虚函数表里面到底是个什么东西呢?我们再通过监视窗口进行查看:
从这个[0]可以看出,这个所谓的虚函数表其实是一个数组,另一方面“表”如顺序表,我们也可以推测出含义为数组。
这个数组存放的是什么类型的数据呢?可以看到,是void*,也就是虚函数的指针。
所以虚函数表实质上是一个函数指针数组。
所以我们的虚函数表指针是一个指向函数指针数组的数组指针。
现在我们可以多放几个虚函数,感受一下:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func1()" << endl;
}
virtual void Func3()
{
cout << "Func1()" << endl;
}
virtual void Func4()
{
cout << "Func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
可以看到,虚函数的指针都被放进了虚函数表。
多态的原理
要了解多态的原理,就要借助虚函数表。
class Person {
public:
virtual void BuyTicket() {
cout << "买票-全价" << endl; }
protected:
string _name;
};
class Student : public Person {
public:
virtual void BuyTicket() {
cout << "买票-打折" << endl; }
protected:
string id;
};
class Soldier : public Person {
public:
virtual void BuyTicket() {
cout << "买票-优先" << endl; }
protected:
string _codename;
};
void Func(Person* ptr)
{
// 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket
// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
ptr->BuyTicket();
}
这段代码的对象模型是怎么样的呢?
如果画图,就是这个样子:
那么怎么实现指向谁调用谁的呢?
我们知道所有的代码语句都会被编译成汇编指令,那么从汇编指令的角度来看,怎么实现指向谁调用谁的呢?
ptr->BuyTicket();
编译器会去看满不满足多态,满足的话就会去指针指向对象的虚函数表里去找。从底层来说,无论传什么对象,ptr看到的都是一个父类
因为会切片,所以无论什么场景,ptr看到的都是一个Person对象:
指向哪个对象,运行时,到指向对象的虚函数表中找到对应虚函数的地址,进行调用。
机械式地,看到满足多态,就把这段代码编译成到指向对象的虚函数表中找到对应虚函数的地址,进行调用。
(满足多态情况下的一个汇编)
(符号版:)
不满足多态版本的:
可以看到只剩下两句代码。
满足多态和不满足多态的两组汇编的真正差异是什么呢?
不满足多态,就跟指向的对象没有关系了:看到是Person的指针,所以就去调用Person的虚函数,编译时就确定好了。
动态绑定与静态绑定
• 对不满足多态条件(指针或者引⽤+调⽤虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
• 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
从运行效率的角度,静态绑定的效率更高。从汇编的指令数量也能看出来。
虚函数表
class Base {
public:
virtual void func1() {
cout << "Base::func1" << endl; }
virtual void func2() {
cout << "Base::func2" << endl; }
void func5() {
cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base
{
public:
// 重写基类的func1
virtual void func1() {
cout << "Derive::func1" << endl; }
virtual void func3() {
cout << "Derive::func1" << endl; }
void func4() {
cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
• 基类对象的虚函数表中存放基类所有虚函数的地址。
父类和子类的虚函数表不一样,但是同类型的对象的虚函数表是一样的,因为它们的虚函数是一样的。这也就是为什么要拿一个虚函数表来存放类的虚函数,方便不同对象里只要存这个虚函数表的指针就行了,而不用每个对象都去存储一份这些虚函数的指针(见上图),这样做节省了空间资源。
可以看到,b1和b2的_vfptr都是0x00339b34。
• 派⽣类由两部分构成,继承下来的基类和⾃⼰的成员,⼀般情况下,继承下来的基类中有虚函数表指针,⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派⽣类对象中的基类对象成员也独⽴的。
在这张监视窗口的图中可以看到,d1的父类部分继承了父类的虚函数表指针,但不是同一个,指向的表并不是同一张。
• 派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函数地址。
• 派⽣类的虚函数表中包含,基类的虚函数地址,派⽣类重写的虚函数地址,派⽣类⾃⼰的虚函数地址三个部分。
• 虚函数表本质是⼀个存虚函数指针的指针数组,⼀般情况这个数组最后⾯放了⼀个0x00000000标记。(这个C++并没有进⾏规定,各个编译器⾃⾏定义的,vs系列编译器会再后⾯放个0x00000000 标记,g++系列编译不会放)
• 虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段的,只是虚函数的地址⼜存到了虚表中。
• 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下⾯的代码可以对⽐验证⼀下。vs下是存在代码段(常量区)
int main()
{
//这一部分我们可以对栈、静态区、堆、常量区的位置进行一个大致了解
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);
//这一部分我们通过这种方式取到Person与Derive对象各自的前四个字节,而这前四个字节就是其各自的虚表地址
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
//强转为int*的指针再解引用得到的就是四个字节的内容
printf("Person虚表地址:%p\n", *(int*)p3);
printf("Student虚表地址:%p\n", *(int*)p4);
//通过上面两部分进行对比,可以看出虚表地址更应该是属于哪一块区域
//可以验证虚函数和普通函数一样存在代码段
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
可以看到,和虚表地址 最接近的是常量区的地址。可以推测VS下虚表应该存储在常量区。
然后我们可以看到虚函数地址和普通函数地址非常接近,可以看出都是存在一个区域的,也就是代码区。
补充:
在 C++ 中,内存存储主要分为以下几个区域:
一、栈区(Stack)
由编译器自动分配和释放。
存储局部变量、函数参数等。
空间相对较小,但是存取速度快。例如,当你在一个函数内部定义一个 int 类型的变量时,这个变量通常就存储在栈区。二、堆区(Heap)
由程序员手动分配和释放(使用new、malloc等操作符进行分配,使用delete、free等进行释放)。
空间较大,可存储较大的数据结构。比如,当你需要动态分配一个大型数组或者复杂的数据结构时,可以在堆区进行分配。三、全局 / 静态存储区
存储全局变量和静态变量。
全局变量在整个程序的生命周期内都存在,而静态变量根据其作用域在特定的范围内保持其值。四、常量存储区
存储常量,如字符串常量等。
这个区域的内容在程序运行期间不能被修改。五、代码区
存储程序的二进制代码。
函数体的二进制代码就存放在这个区域。
本文结束。