编译期多态和运行期多态
又或者可以称之为静态多态和动态多态
通俗的来讲这两者的区别就是:应该调用哪一个重载?和 应该绑定哪一个虚函数?
编译期多态是指不同的模板参数具现化导致调用不同的重载函数,STL就是静态多态一个应用的例子,泛型编程,效率较高(指运行时效率比调用虚函数高)
- 函数重载
- 函数模板
运行期多态指利用查虚函数表实现的基类对象调用派生类的成员函数,运行时动态绑定,效率较低
- 虚函数表
静态绑定与动态绑定
静态类型指的是变量声明时指定的类型,动态类型指的是变量在运行期实际的类型
静态绑定在编译期间完成,绑定的是静态类型;动态绑定发生在运行期,绑定的是动态类型。虚函数是动态绑定的,非虚函数是静态绑定的,缺省函数参数是静态绑定的
注意以下多态语境,将会调用到Son::func
,但是由于缺省函数参数是静态绑定的,所以会输出10
struct Father
{
virtual void func(int data = 10) { std::cout << data << std::endl; }
};
struct Son : public Father
{
void func(int data = 20) override { std::cout << data << std::endl; }
};
int main()
{
Father* pFather = new Son();
pFather->func();
delete pFather;
}
虚函数杂谈
为什么多态需要虚析构函数
由于对虚函数的调用都是通过查虚表完成的,那么在以下语境下,假设基类不具备虚析构函数。那么由于pFather
是基类类型,那么在调用析构函数的时候,并不会去查虚函数表,而是只会调用基类的析构函数
Father* pFather = new Son();
delete pFather;
那么派生类的析构函数就不会被调用,进而导致析构不完全,可能会发生内存泄漏。所以为了让编译器能够完成查虚表这么一件事,我们需要创建虚析构函数。有了虚析构函数,各部分才能被正确析构
为什么构造函数不是虚函数
创建对象实例的时候,首先需要调用构造函数。如果是虚构造函数,那么应该通过查虚表来完成调用。为了查虚表首先需要有虚表指针,而虚表指针是在进入构造函数之后,在初始化列表之前被初始化的。这就成了一个悖论,因此如果将构造函数定义为虚函数,编译也会出错
在构造析构函数中调用虚函数会如何
首先在编译不会出错,程序能够正常运行,只是并不能达到我们想要的效果
设想以下情景(省略虚析构函数),我们想要让不同的动物在被初始化时能够调用虚函数,不同的子类产生不同的效果
class Animal
{
public:
Animal() { PrepareToBeCreate(); }
virtual void PrepareToBeCreate() { std::cout << "Animal prepare to be create" << std::endl; }
};
class Dog : public Animal
{
public:
Dog() {}
void PrepareToBeCreate() override { std::cout << "Dog prepare to be create" << std::endl; }
};
// 由于是Dog的实例化对象 因此理想调用是Dog::PrepareToBeCreate
Animal* pAnimal = new Dog();
delete pAnimal;
但是非常可惜,结果将会调用Animal::PrepareToBeCreate
,这是因为在创建Dog
对象的时候,会先执行基类的构造函数,再执行派生类的构造函数
因此在执行Animal()
中的PrepareToBeCreate()
时,派生类部分还没被初始化,也就是说明此时的虚表指针是指向基类的虚表的,那么PrepareToBeCreate()
也肯定会调用到基类中的版本。只有在执行Dog()
的构造函数体时,此时的虚表指针才是指向Dog
的虚表
哪些函数不能是虚函数
- 静态函数不能是虚函数
- 构造函数不能是虚函数
- inline函数不能是虚函数:因为内联函数会在编译期间替换,而虚函数是运行时确定调用对象
- 友元函数:友元函数不属于这个类,也没有虚函数的说法
虚表指针与虚表概述
首先虚函数表是属于类的,每个带有虚函数的类都会有一张虚函数表,这张表可以看作是存放虚函数指针的数组,这张虚表存储在常量区(.rdata)只读数据段;虚表指针是属于每个类的实例化对象的,同理如果类中有虚函数的话,虚表指针将存在于对象内存的首部
虚函数表在编译时生成,其中记录的虚函数也是在编译期时确定;因为类成员的初始化顺序按照声明的顺序排列,而虚表指针位于内存首部,因此虚表指针是在类构造函数的初始化列表之前被创建的
虚函数的调用
虚函数实现的机理是每个类实例的虚表指针。以武器攻击的多态场景为例,首先拿到对象的this
指针,然后通过访问首部得到虚表指针,进而通过指针指向的数据访问到虚函数表,再通过查表找到对应的虚函数进行调用
pWeapon->attack();
虚表的建立
如果是基类 ,那么虚表在编译阶段被创建,虚函数指针以虚函数声明的顺序依次添加到虚表中
如果是派生类,以多重继承为例(为了简单起见,不设置虚析构函数)
class Father
{
public:
int fatherData = 1;
virtual void father_func() {}
};
class Mother
{
public:
int motherData = 2;
virtual void mother_func() {}
};
class Son : public Father, public Mother
{
public:
int sonData = 3;
void father_func() override {}
virtual void son_func() {}
};
首先Father和Mother的内存布局如下图所示,它们的首部都有一个虚表指针指向虚函数表
由于Son
类同时继承了Father
和Mother
,因此在编译阶段编译期会分别拷贝Father
和Mother
的虚函数表
由于Son
中重写了Father
中的方法,因此第一个虚表中的虚函数指针将被替换,同时又因为Father
是主父类,且Son
中添加了自己的虚函数,因此会在第一个虚表中添加void son_func()
。从Mother
处拷贝来的虚函数表则保持不变
那么在运行期,Son
的内存布局为
通过虚表访问类中虚函数
上文中提到过虚表可以看作是虚函数指针数组,那么既然如此我们就可以通过解析虚表指针来调用类中的虚函数。本小节中的内容将类成员指针转换成了普通函数指针,因此在调用时其实成员函数内的this
指针为nullptr
还是以上文中的继承结构为例,64位环境下
int main() {
Son s;
}
由下图得,s
一共有两个虚函数表,虚表的地址为e0 6e 75 1d f6 7f 00 00
和00 6f 75 1d f6 7f 00 00
,它们指向的是对应虚表中首位虚函数指针的地址
这里其实是会产生内存对齐的,vptr
指针类型占8字节而int
类型只有4字节,但是为了直观起见,图中把它们画成一样大
因此我们需要一个“步长为一个指针大小”的指针,暂且称之为pTemp
。它以std::size_t
的类型记录虚表指针
std::size_t* pTemp = (std::size_t*)(&s);
然后我们需要解析pTemp中记录的数据,拿到虚函数表的地址,即第一个虚函数指针的地址。而因为pTemp
解析出来时std::size_t
类型,因此我们需要再将它转回指针的格式
std::size_t* pVirtualTable = (std::size_t*)*(pTemp);
因为虚函数表中记录的是函数指针,因此我们需要像访问数组一样访问虚表,然后将其转换为函数指针,最后进行调用
auto pFatherFuncOverride = (void(*)())pVirtualTable[0];
pFatherFuncOverride();
auto pSonFunc = (void(*)())pVirtualTable[1];
pSonFunc();
上文中了访问Father
和Son
的虚函数,下面来访问Mother
中的虚函数,由于Mother
的虚表并非位于对象的首部,因此第一步需要做偏移,偏移的距离是Father
的大小
std::size_t* pTemp = (std::size_t*)((char*)&s + sizeof(Father));
后面的步骤于上文相同
std::size_t* pVirtualTable = (std::size_t*)*(pTemp);
auto pMotherFunc = (void(*)())pVirtualTable[0];
pMotherFunc();
多重继承中的基类指针的偏移
Father* pFather = new Son();
Mother* pMother = new Son();
delete pFather;
delete pMother;
当实际遇到多重继承的多态语境时,它们的指向如下图所示
菱形继承
struct Animal
{
int animalData = 1;
};
struct Tiger : public Animal
{
int tigerData = 10;
};
struct Lion : public Animal
{
int lionData = 20;
};
struct Tiger_Lion : public Tiger, public Lion
{
int tigerLionData = 50;
};
对于这么一串结构,Tiger_Lion
实例的内存布局为
是的,基类中的数据存在了两份,这是不必要的也是“不正确”的,我们可以通过类名限定的方式来访问基类中的成员变量或成员函数
// 创建派生类对象
Tiger_Lion tigerLion;
// 访问基类Lion中的animalData
tigerLion.Lion::animalData;
正确的做法是采用虚继承,采用虚继承后类的布局将发生变化
struct Animal {
int animalData = 1;
};
struct Tiger : virtual public Animal {
int tigerData = 10;
};
struct Lion : virtual public Animal {
int lionData = 20;
};
struct Tiger_Lion : public Tiger, public Lion {
int tigerLionData = 50;
};
以Tiger
的实例为例
Tiger tiger;
可以看到,Tiger
的头部被添加了一个指针,而基类中的数据animalData
排在了内存的最后面。下面重点讨论这个指针
这个指针并不是虚表指针,指向的也不是虚函数表,它指向的是虚继承表,虚继承表中记录的是偏移量,而偏移量是uint32_t
类型的,占4B,与平台无关
std::size_t* pTemp = (std::size_t*)&tiger;
std::size_t* pVirtualBase = (std::size_t*)*pTemp;
std::cout << ((uint32_t*)pVirtualBase)[0] << std::endl; // 0
std::cout << ((uint32_t*)pVirtualBase)[1] << std::endl; // 16
0
代表从0
开始,16
代表走16
个字节才到达基类的部分。很明显,64位平台下指针的大小是8B
,int
类型是4B
,补齐到8B
。8 + 8 = 16B
所以在构造Tiger
的时候,调用顺序如下
- 构建虚继承表指针
- 进入基类的初始化列表(通过获取虚继承表中的数据,移动到相应的位置进行构造)
- 进入基类的构造函数
- 进入派生类的初始化列表
- 进入派生类的构造函数
所以对于Tiger_Lion
的实例来说,它的内存布局如下
对于菱形继承来说,会先构造Animal
,在构造Tiger
,再构造Lion
,最后构造Tiger_Lion
虚继承与虚函数
考虑有以下类设计
struct Animal {
int animalData = 1;
virtual void animal_func() {}
};
struct Tiger : virtual public Animal {
int tigerData = 10;
void animal_func() override {}
virtual void tiger_func() {}
};
struct Lion : virtual public Animal {
int lionData = 20;
virtual void lion_func() {}
};
struct Tiger_Lion : public Tiger, public Lion {
int tigerLionData = 50;
void animal_func() override {}
void lion_func() override {}
};
此时Tiger类的实例化对象的内存布局如下。由于虚继承的缘故,实例化对象中包含了两个虚函数表(此时虚函数表不会合并了),一个虚类表
Lion类的实例化对象的内存布局如下
Tiger_Lion类的实例化对象的内存布局如下
勘误
实际上虚表的末尾会记录一个nullptr
来作为虚表的结束标记位,值为0;虚继承表的末尾也有一个0
图片的比例也存在一些问题,但是懒得改了,下次一定