🍁🍂🍃
🍁前言
开始前回顾一下构成多态的条件:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须为虚函数,且派生类必须对基类的虚函数进行重写(覆盖)
对虚函数还不熟悉的老铁可以先看看此篇文章:多态语法介绍
以下示例皆是在VS2022环境中所展现的,在其他编译器环境下可能有所出入,在这里若有错误可以在评论区提出,小编加以改正!
接下来进入正题:
🍁多态原理
先来卖个关子,结构体内存对齐问题对我们来说是再熟悉不过,下面来看看这样一道例题:
class Base
{
public:
virtual void Func1() //虚函数
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
char _a = 'a';
};
int main()
{
Base b;
cout << sizeof(b) << endl; //计算对象b的大小
}
正常计算一个类的大小是要看其类中成员变量按照结构体内存对齐规则进行计算;
大多数老铁会认为上面对象b计算结果会是8字节。
但是结果真的是8字节吗?
别忘了在Base类中的函数为虚函数,并不是普通函数。所以我们的思想要反其道而行之,结果肯定不会是简单8字节。
接着往下看:
不经让人疑惑,多出来4个字节是为谁准备?
在这里先给出结论:多出来4字节的内容是为虚函数表指针准备的。
🍂虚函数表指针
什么是虚函数表指针呢?接下来我们看看下面一段简单代码,还有在VS环境下的监视窗口所展示的内容:
class Base
{
public:
virtual void Func1() //虚函数
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
char _a = 'a';
};
int main()
{
Base b;
}
可以看到的是:b对象中除了两个内置类型的变量外,还有一个__vfptr
指针 ( 虚函数表指针 );
- 虚函数表指针(虚表指针)
__vfptr
指针存放位置跟平台有关,在VS平台下放到了对象的最前面;不同平台下存放位置有所不同__vfptr
指针中的 v 表示 virtual ;f 代表 function- 一个含有虚函数的类中都至少都有一个虚函数表指针,虚表指针指向的位置是虚函数表。虚函数表也简称虚表(虚表实质上是虚函数指针数组,里面存放着虚函数的地址)例如上面的存储虚函数Func1的地址数组就是虚表。
🍂多态原理(单继承)
有了上述知识铺垫后,再回过头来看看多态,要想到这样一些问题:
多态在编译器中是如何运作的,为什么不同对象在调用同一函数时会表现出不一样的结果呢?
下面来看两个示例:
- 示例一没有用基类的指针或是引用去调用虚函数,且派生类也没有去完成虚函数的重写
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
protected:
int _age = 18;
};
//派生类
class Student : public Person
{
//没有重写BuyTicket虚函数
protected:
size_t _id = 111111;
};
int main()
{
Person mike; //基类对象
mike.BuyTicket();
Student johnson; //派生类对象
johnson.BuyTicket();
return 0;
}
基类和派生类中虚函数表指针各自指向的一张虚函数表,且虚函数表内存储的
BuyTicket
虚函数地址都是一样的;
- 示例二基类的虚函数在派生类完成重写,且用基类的引用去调用虚函数,构成多态
//基类
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
protected:
int _age = 18;
};
//派生类
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
protected:
size_t _id = 111111;
};
void Func(Person* p) //基类的引用接收参数
{
p->BuyTicket(); //调用虚函数
}
int main()
{
Person mike; //基类对象
Func(&mike);
Student johnson; //派生类对象
Func(&johnson);
return 0;
}
基类和派生类中虚函数表指针各自指向的一张虚函数表,且虚函数表内存储的
BuyTicket
虚函数地址都不一样;
再来对比一下 示例一 的汇编代码 和 示例二 的汇编代码:
注意:以下构成汇编代码的不同,主要原因是看有没有用基类的指针或是引用去调用虚函数
示例一的汇编代码,对象在调用虚函数时是直接call到buyticket虚函数地址直接进行调用。
示例二的汇编代码明显多了很多内容,可以看到的是编译器是严格卡是不是符合多态的条件的。
符合多态条件情况下:
- 基类指针或是引用去调用虚函数的时候,如果 接收的对象是基类对象 ,那么此时基类的指针或引用会直接去找到基类对象的虚表指针,通过虚表指针所指向的虚表里面去找虚函数的地址进行调用
- 基类指针或是引用去调用虚函数的时候,如果 接收的对象是派生类对象 ,那么此时基类的指针或是引用会对派生类对象进行切割或是切片,完成对应操作后此时的基类的指针或是引用会找到派生类对象中的虚表指针,通过虚表指针所指向的虚表里面去找虚函数的地址进行调用
再来回想到这样一个问题:为什么说构成多态的第二个条件被称为虚函数的重写或是覆盖?
有了上述示例对比可以这样看待重写和覆盖二词:
- 重写(语法层上概念):派生类将基类的虚函数接口继承下来,如然后重写这个虚函数的实现
- 覆盖(原理层上概念):派生类继承基类的虚函数表后,如果派生类中的虚函数实现了重写,派生类对应虚函数表中的虚函数地址将会覆盖成新的虚函数地址,与基类对应虚函数表中的虚函数地址形成差异,好让基类指针或是引用去找到不同对象想要调用的虚函数
🍁虚函数表
在虚函数表指针中也简单介绍了虚函数表,虚函数表本质就是存储虚函数地址的函数指针数组(一个类中的虚表可以存储多个虚函数地址)。
class Base {
public :
virtual void func1()
{
cout<<"Base::func1" <<endl;
}
virtual void func2()
{
cout<<"Base::func2" <<endl;
}
private :
int a;
};
单单是研究虚表的意义是不大的,在这里主要介绍的是派生类对象的虚表模型。
🍂单继承的虚表
首先来介绍单继承中的虚表,派生类不仅仅继承了基类的虚函数,还含有属于自己的虚函数:
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;} //完成虚函数的重写
virtual void func3() {cout<<"Derive::func3" <<endl;}
virtual void func4() {cout<<"Derive::func4" <<endl;}
private :
int b;
};
int main()
{
Base b;
Derive d;
return 0;
}
在VS2022监视窗口看到的是,派生类对象虚表中只包含了重写虚函数fun1
和继承的虚函数fun2
,自己的两个虚函数成员却没有包含在其中。
func3
函数和func4
函数没有存储到虚表里面,难道消失不见了吗?
上面的监视窗口是没有体现,那么我们往内存窗口内部进行查看:
将派生类的虚函数表地址输入内存窗口
蓝色方框内所标记的地址只是猜想,要想搞清楚是不是func3
函数和func4
函数的地址还需要验证。
实践是检验真理的唯一标准,由于示例中的函数返回值参数都比较简单,在这里可以采用下面这样的程序来获得这些函数的地址:
🍃打印虚表
提示:该程序只支持在VS环境下进行测试
//声明函数指针,该函数指针类型名字为VF_PTR
typedef void(*VF_PTR)();
//打印虚函数表
void PrintVFTable(VF_PTR table[])
{
//VS环境下,VS会将虚函数表的最后一个位置置于空指针,G++环境下没有这样的处理
for(int i = 0; table[i] != nullptr; i++)
{
printf("[%d]:%p->", i, table[i]);
//获取虚函数地址
VF_PTR f = table[i];
//有了虚函数地址就可以调用虚函数
f();
}
cout << endl;
}
打印虚表的程序有了,那么如何去调用虚表呢?
回到前面说到的,一个类中实现了虚函数那么该类对象就会存在一个虚表指针,只要我们取到该虚表指针,就可以去打印虚函数表了。
在32为平台下虚表指针在对象大小的前4个字节;
在64为平台下虚表指针在对象大小的前8个字节;
如何取虚函数表地址呢?以基类对象b为示例,下面有两种方法:
方法一:
(VF_PTR*)(*(int*)&b)
取到对象的地址,用int*
类型强转后,解引用取到前4个字节的内容,再将其强制类型转换为VF_PTR*
即可
提示:方法一只能适用于32平台,如果更换64平台测试,需要将int*
换成double*
方法二:
(*(VF_PTR**)&b)
取到对象地址后将其强转类型转换为VF_PTR**
类型,解引用后取到虚函数表首元素的地址
这里对第二种方法进行解释:我们都知道虚函数表其实就是函数指针数组,在一个函数内部要去访问一个数组内容,就要将该数组的首元素地址进行传参,这样才能对该数组进行遍历。
谁能够保存函数指针数组的地址呢?那当然是函数指针数组的指针也就是VF_PTR**
,这也是为什么取到对象地址后需要强转为VF_PTR**
类型。最后为了取到地址还需要对其进行解引用,才能取到头四个字节的内容。
第二种方法适用于32位平台和64位平台。
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;} //完成虚函数的重写
virtual void func3() {cout<<"Derive::func3" <<endl;}
virtual void func4() {cout<<"Derive::func4" <<endl;}
private :
int b;
};
//声明函数指针,该函数指针类型名字为VF_PTR
typedef void(*VF_PTR)();
//打印虚函数表
void PrintVFTable(VF_PTR table[])
{
//VS环境下,VS会将虚函数表的最后一个位置置于空指针,G++环境下没有这样的处理
for(int i = 0; table[i] != nullptr; i++)
{
printf("[%d]:%p->", i, table[i]);
//获取虚函数地址
VF_PTR f = table[i];
//有了虚函数地址就可以调用虚函数
f();
}
cout << endl;
}
int main()
{
Base b;
Derive d;
//打印虚函数表中各个虚函数的地址的调用方法
//方法一:适用32位平台
PrintVFTable((VF_PTR*)(*(int*)&b));
PrintVFTable((VF_PTR*)(*(int*)&d));
//方法二:适用32位或64位平台
PrintVFTable((*(VF_PTR**)&b));
PrintVFTable((*(VF_PTR**)&d));
return 0;
}
这样我们就能将虚表打印出来观察了:
接下来我们得思考几个问题:
- 虚函数表什么时候开始生成的?
- 对象中的虚函数表指针什么时候初始化的?
- 虚函数表存储在什么地方?栈?堆?代码段?静态区?
针对第一个问题:虚函数表是在编译阶段生成的。
虚函数表是用于支持C++中的多态性(polymorphism)的一种机制。当类中定义了虚函数时,编译器会为该类生成一个虚函数表。虚函数表是一个存储类中所有虚函数地址的数据结构。
在编译阶段,编译器会扫描类的声明和定义,如果发现有虚函数存在,则会在类的元数据(metadata)中插入一项称为虚函数表指针(vptr)的成员变量。编译器会根据类的层次结构和关系,生成相应的虚函数表,并将该表的地址赋值给类的虚函数表指针。
每个类仅生成一个虚函数表,该表存储了该类以及其基类和派生类中所有虚函数的地址。每个类的对象都会在其内存布局中包含一个虚函数表指针,指向该类对应的虚函数表
针对第二个问题:虚表指针是在调用构造函数时的初始化列表进行初始化
在大多数编译器中,虚函数表指针是作为对象的第一个成员来存储的,其初始化是通过对象的构造函数来完成的。当对象被创建时,编译器会在构造函数中为对象分配内存,并将虚函数表指针初始化为指向相关的虚函数表
下面来推断一下虚函数表存储区域:
在这里我们要先排除前两个存储区域分别是栈区和堆区。
栈区是绝对不可能的,前面也说到了,每个类只会生成一张虚函数表,如果存在栈区,那不就矛盾了吗?出了作用域就销毁了怎么可能就只生成一张虚表。
对于堆区一般是给用户也就是程序员去动态申请开辟空间的,再此我们也没有去做任何操作虚表不也生成了吗?
也可以用类比推理方法来证明虚表存储的位置,这里我们可以打印不同地址来对比一下:
int main()
{
Base b;
Derive d;
int a = 0;
int* r = new int(1);
static int c = 2;
const char* str = "常量";
printf("栈区地址:%p\n", &a);
printf("堆区地址:%p\n", r);
printf("静态区地址:%p\n", &c);
printf("常量区地址:%p\n", str);
printf("b对象虚表地址:%p\n", *((int*)&b));
printf("d对象虚表地址:%p\n", *((int*)&d));
return 0;
}
结果显而易见,虚表存储区域大概率是在常量区也就是代码段。
其实我们早在监视窗口也看过虚表地址和虚函数地址大致类似的位置存储,一般函数定义好了都会存储在代码段,在这里我们也可以推测虚表存储的位置在代码段。
🍂多继承的虚表
单继承中的虚表内容倒是还行,不会特别绕。比较难的是多继承中虚表问题。接着往下看:
class Base1
{
public:
virtual void func1() {cout << "Base1::func1" << endl;}
virtual void func2() {cout << "Base1::func2" << endl;}
private:
int b1;
};
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;
};
int main()
{
Derive d;
return 0;
}
Derive
类同时继承了Base1
类和Base2
类,且Derive
类也都完成了func1函数的重写。
在这里思考一个问题,Derive
类中有几张虚表?
派生类完成虚函数的重写后,会将基类的虚表进行拷贝一份当作派生类的虚表,将重写虚函数的地址进行覆盖。这个知识点前面有提到过,那么在多继承中,继承两个基类的Derive
类自然而然也就有两份虚表。
上面监视窗口中很奇怪,又没有将Derive
派生类的func3
函数进行显示。在这里先猜一下func3
函数会保存在哪个虚表里面。
Base1基类虚表中?还是Base2基类虚表中?
上面单继承中我们用打印虚表的方式将属于派生类的虚函数进行了打印,在这里我们可以用同样的方式再来实践一遍。
注意:由于这里是多继承,d对象中会存在两个虚函数表指针。第一个虚表指针位置比较好找,但是要找第二个虚表指针的位置需要单独处理一下。
找第二个虚表指针有两个方法:
- 方法一: 取到对象d的地址强制转换成
char*
类型,然后计算Base1基类的大小。利用强转后的地址加上Base1基类的大小后找到对应虚表指针的偏移量;- 方法二:创建Base2基类的指针去切割对象d,自动帮我们找到第二个虚表指针的位置
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
cout << " 虚表地址>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Derive d;
PrintVFTable((VF_PTR*)(*(int*)&d));
//找到第二个虚表指针的位置
//方法一:
//PrintVFTable((VF_PTR*)(*(int*)((char*)&d + sizeof(Base1))));
//方法二:
Base2* ptr = &d;
PrintVFTable((VF_PTR*)(*(int*)ptr));
return 0;
}
由于VS编译器每次重新编译会导致内存的重新分配,因此终端显示的虚表内部函数地址会跟上面展示的监视窗口不同。
上面终端显示结果可以看到,Derive
派生类中func3
虚函数的地址会保存到第一张虚表中。
但是这里有一个很奇怪的现象,由于Base1
基类和Base2
基类中都有func1
虚函数,且Derive
派生类也都完成了重写,但是这两个虚表所展示的func1
虚函数的地址却是不同的。
多继承实现多态又是如何去调用func1
虚函数呢?面对疑问最好的解决方式就是动手检验。
🍂多态原理(多继承)
class Base1
{
public:
virtual void func1() {cout << "Base1::func1" << endl;}
virtual void func2() {cout << "Base1::func2" << endl;}
private:
int b1;
};
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;
};
int main()
{
Derive d;
Base1* ptr1 = &d;
Base2* ptr2 = &d;
ptr1->func1();
ptr2->func1();
return 0;
}
多态的原理我们前面提到过(前提是基类的指针或是引用去调用派生类的虚函数或是基类的虚函数,派生类完成虚函数的重写)通过虚函数表指针去虚表里面找到对应的虚函数进行调用。
上面我们也看到了多继承下,派生类重写的虚函数后,两个虚表内的func1虚函数的地址不一样。
但是为什么会不一样,还是很疑惑,下面来通过观察汇编调用来一探究竟:
- 左侧是
Base1
基类指针ptr1
去调用func1
虚函数的汇编 - 右侧是
Base2
基类指针ptr2
去调用func1
虚函数的汇编
上面汇编对比中可以明显看到 ptr1
基类指针的汇编比ptr2
基类指针的汇编少了两次jmp
指令,但是最终目的都是为了调用了func1
虚函数。
为什么ptr2指针要绕多两次jmp指令呢?一次到位难道不好吗?
编译器这样做是有一定道理的,细心的观察一下可以看到:这里的汇编在多次调用jmp
指令期间的同时又调用了sub
指令,这里的sub
指令的作用是为了修正this
指针的指向
这也就解释了为什么派生类完成func1
虚函数重写后,两张虚表内的func1
虚函数的地址会不一样。
C++多态原理就介绍到这里,感谢大家观看。