C++ Virtual的背后
编译期多态和运行期多态
又或者可以称之为静态多态和动态多态
通俗的来讲这两者的区别就是:应该调用哪一个重载?和 应该绑定哪一个虚函数?
编译期多态是指不同的模板参数具现化导致调用不同的重载函数,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
函数。但inline与virtual同时声明并不会导致编译错误,编译器会根据函数是否为virtual call来决定是否将该方法inline -
operator new/delete
系列函数:关键词static
对这些函数声明是可选的:不管是否使用该关键词,这些函数都始终是静态成员函数
虚表指针与虚表概述
首先虚函数表是属于类的,每个带有虚函数的类都会有一张虚函数表,这张表可以看作是存放虚函数指针的数组,这张虚表存储在常量区(.rdata)只读数据段;虚表指针是属于每个类的实例化对象的,同理如果类中有虚函数的话,虚表指针将存在于对象内存的首部
虚函数表在编译时生成,其中记录的虚函数也是在编译期时确定;因为类成员的初始化顺序按照声明的顺序排列,而虚表指针位于内存首部,因此虚表指针是在类构造函数的初始化列表之前被创建的
虚函数的调用
虚函数实现的机理是每个类实例的虚表指针。以武器攻击的多态场景为例,首先拿到对象的this
指针,然后通过访问首部得到虚表指针,进而通过指针指向的数据访问到虚函数表,再通过查表找到对应的虚函数进行调用
pWeapon->attack();
虚表的建立
如果是基类 ,那么虚表在编译阶段被创建,虚函数指针以虚函数声明的顺序依次添加到虚表中(这其实取决于不同编译器的虚函数表实现,取决于Main VTable以及Sub VTable等)
如果是派生类,以多重继承为例(为了简单起见,不设置虚析构函数)
class Father
{
public:
std::int64_t fatherData = 1;
virtual void father_func() {}
};
class Mother
{
public:
std::int64_t motherData = 2;
virtual void mother_func() {}
};
class Son : public Father, public Mother
{
public:
std::int64_t 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位环境下。s
的内存结构为
int main() {
Son s;
}
因此我们需要一个“步长为一个指针大小”的指针,暂且称之为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
排在了内存的最后面。下面重点讨论这个指针
这个指针并不是虚表指针,指向的也不是虚函数表,它指向的是虚继承表,虚继承表中记录的是偏移量。在MSVC64位环境下,它占4B且可以被int
类型解析
std::size_t* pTemp = (std::size_t*)&tiger;
std::size_t* pVirtualBase = (std::size_t*)*pTemp;
std::cout << ((int*)pVirtualBase)[0] << std::endl; // 0
std::cout << ((int*)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类的实例化对象的内存布局如下
虚表实现杂谈
虚表中的“额外“信息
对于不同的编译器,例如MSVC和GCC,它们对虚函数表以及RTTI都有不同的实现。上文中讨论的都是虚函数表的基础知识,事实上虚函数表还需要有一个区域用来记录offset-to-top
;另一个区域用于记录type_info
。下面以GCC为编译器作为例子
offset-to-top
中记录了一个偏移量,这个偏移量就是虚表指针指向的内存位置与对象实际类型的起始位置之间的距离,单位为B
class Father
{
public:
int fatherData_int = 1;
double fatherData_double = 3.14;
void* fatherData_ptr = nullptr;
virtual void father_func() {}
};
class Mother
{
public:
int motherData_int = 2;
double motherData_double = 6.18;
virtual void mother_func() {}
};
class Sister
{
public:
int sisterData_int = 3;
virtual void sister_func() {}
};
class Son : public Father, public Mother, public Sister
{
public:
int sonData_int = 4;
void father_func() override {}
void mother_func() override {}
virtual void son_func() {}
};
假设有如上的代码,那么Son
类的内存布局以及虚函数表结构为
可以看到虚函数表指针其实并不是指向虚函数表的首部,在其之上还有offset-to-head
和rtti的信息。offset-to-head
的值将会在dynamic_cast
中访问到
虚表中的-32代表:从Son
内存布局中的Mother
部分开始,需要向上偏移32个字节才能打到Son
的起始位置,Son
在这里充当“最终派生对象”。-56同理,在-32的基础上需要在计算上一个一个虚表指针,一个int
类型和一个double
类型的值,由于内存对齐共有24B
cpp标准要求dynamic_cast
在运行期检查表达式所指向的最终派生对象,并且dynamic_cast
具有兄弟转(sidecast
)的功能(sidecast
同样也要求dynamic_cast
先将指针转换为最终派生对象再转换为指定的类型),这是static_cast
无法实现的。正是因为有dynamic_cast<void*>
和sidecast
的存在,这也就导致了虚表一定需要维护一个偏移数据来让多重继承下已经偏移过的指针们在运行时找到回家的路,而不是像static_cast
一般在做downcast
的时候利用到了编译期计算的偏移数据
虚表中记录的rtti typeinfo的数据正是给typeid
运算符使用的。当typeid用于多态类型的表达式时,就可能涉及到虚表查找这一项运行时开销;其他情况下typeid
都是在编译期就给出表达式的静态类型的
其他
What is the first (int (*)(...))0 vtable entry in the output of g++ -fdump-class-hierarchy?
template<std::size_t Size>
decltype(auto) get_actual_vtable(void* class_address)
{
void* vtable_func = *static_cast<void**>(class_address);
// 需要偏移两项 即offset-to-head和rtti info
void** vtable_start = static_cast<void**>(vtable_func) - 2;
// 返回大小为Size的数组的地址
return reinterpret_cast<void*(*)[Size]>(vtable_start);
}
template<typename T, typename = void>
struct is_polymorphic_type : std::false_type
{
};
template<typename T>
struct is_polymorphic_type<T, std::void_t<decltype(dynamic_cast<void*>((T*)nullptr))>> : std::true_type
{
};
delete operator
虽然operator delete是静态函数,无法被标记为virtual,但是标准要求在执行delete operator的时候需要在最终派生类的作用域查找operator delete,进而使得delete operator整个操作时多态的