本篇文章深入分析多态、虚继承、多重继承的内存布局及虚函数表以及实现原理。编译器使用VS 2022,直接放结论,代码及内存调试信息在后文。
结论
内存布局
一个没有虚函数的类,它的大小其实就是所有成员变量的大小,此时它就是一个由诸多成员变量组成的结构体,计算大小时同样要按照字节对齐去计算。
一个没有虚函数的类派生出一个没有虚函数的派生类,那么这个派生类的内存布局就是先基类成员变量,然后派生类成员变量组成的结构体,各成员变量在内存中存储顺序按照声明时的顺序来存放。
一个有虚函数的类,类本身会生成一份虚函数表,这个虚函数表是所有类对象共享的,每个类对象都会在构造时首先生成一个虚表指针,指向这个虚函数表,然后才是各个成员变量,所以有虚函数的类对象会比没有虚函数的类多一个虚表指针。
一个派生类非虚继承于一个有虚函数的类,不论派生类是否有同样的虚函数,它的内存布局都只是在有虚函数的基类基础上增加派生类的成员变量,虚表指针是直接继承基类的,指向基类虚表指针,如果派生类有同样的虚函数,那就覆盖基类虚表中同名函数。如果是派生类独有的虚函数,那就追加在基类虚函数表后面。
一个派生类虚继承于一个有虚函数且有成员变量的基类,此时派生类会重新生成它自己的虚表指针和虚函数表,内存布局则是派生类的虚表指针和成员变量在前,基类的虚表指针和成员变量在后;
虚函数表
每个含有虚函数的类都会有一个虚函数表: 如果类定义或继承了虚函数,编译器会为该类生成一个虚函数表。这个表包含了指向类虚函数实现的指针。
派生类覆写基类的虚函数: 它会在自己的虚函数表中更新该函数的入口。这确保了使用基类指针或引用调用虚函数时,执行的是派生类中最新的函数实现。
当一个类有多个基类时,且每个基类都有自己的虚函数表: 派生类将继承所有这些虚函数表。如果派生类覆写了任何继承的虚函数,它会在继承来的表上进行修改。如果派生类增加了新的虚函数,则会追加到现有的虚函数表。
虚基类表
只有在使用虚继承时,类才会有虚基类表: 虚基类表用于存储从派生类到虚基类的偏移量信息,这样无论虚基类在继承层次结构中被继承多少次,派生类中都只有一个实例。
每个使用虚继承的类(直接或间接继承虚基类的类)会有自己的虚基类表: 用于正确定位其虚基类的实例。
如果一个类继承多个类,且这些类通过虚继承自同一个基类,派生类会有一个虚基类表来管理对那个共享基类的访问。
没有虚函数
单一类
一个类没有虚函数的时候,其实就是结构体,它的内存布局就是按照成员变量的顺序来的。
#include <iostream>
using namespace std;
class Base
{
double x;
int y;
char z;
public:
Base() {}
~Base() {}
};
int main()
{
Base test;
return 0;
}
内存布局如下所示:
此时没有虚函数,类就是一个结构体,计算大小按照8个字节对齐。
派生类
相当于结构体的嵌套,内存布局按照声明顺序来。
#include <iostream>
using namespace std;
class Base1
{
double x;
int y;
char z;
public:
Base1() {}
~Base1() {}
};
class Base2
{
int x2;
int y2;
public:
Base2() {};
~Base2() {};
};
class Derive:public Base1,Base2
{
private:
int x1;
public:
Derive() {};
~Derive() {};
};
int main()
{
Derive test;
return 0;
}
存在虚函数
单一类
先看一个包含虚函数的单类,代码如下:
#include <iostream>
using namespace std;
class Base
{
double x;
int y;
char z;
public:
virtual void print() {}; //增加的虚函数
Base() {}
~Base() {}
};
int main()
{
Base test;
return 0;
}
可以看到,有了虚函数以后,在之前基础上增加了vfptr,大小为8字节,正好是一个指针的大小(64位系统)。所以有了虚函数,单一的类就会相应的增加一个虚指针。
凡是存在虚函数的类,生成的对象都会生成一个虚表指针,并且这个虚表指针存储于对象所占用内存的最开始,也就是首先生成了虚表指针,然后再给成员变量分配的空间,虚表指针占用大小与操作系统有关。
不实现虚函数的派生类
代码如下:
#include <iostream>
using namespace std;
class Base
{
double x;
int y;
char z;
public:
virtual void print() {};
Base() {}
~Base() {}
};
class Derive:public Base
{
private:
int x1;
public:
Derive() {};
~Derive() {};
};
int main()
{
Base test;
return 0;
}
对于派生类对象而言,跟之前没有虚函数的时候没啥区别,一样的只是在基类基础上增加了派生类的成员变量而已,直接使用的是父类的虚表指针,虚函数表中也是父类的函数。
实现虚函数的派生类
派生类中实现基类同样的虚函数,其实就是多态的基本操作。代码如下:
#include <iostream>
using namespace std;
class Base
{
double x;
int y;
char z;
public:
virtual void print() { cout << "Base\n"; };
Base() {}
~Base() {}
};
class Derive:public Base
{
private:
int x1;
public:
virtual void print() { cout << "Derive\n"; };
Derive() {};
~Derive() {};
};
int main()
{
Derive test;
return 0;
}
看起来内存布局其实跟之前没有区别,派生类并没有重新生成虚表指针,直接继承了基类的虚表指针,但是虚表中的函数变成了派生类实现的函数。
其实在普通继承(非虚继承)的时候派生类并不会重新生成虚表指针,只是会使用它自身的虚函数地址去覆盖基类的相同虚函数,如果是派生类独有的虚函数,则直接追加到虚函数表的最后面。
继承多个基类并实现虚函数的派生类
如果有一个类继承了两个基类的虚函数,并实现呢?
#include <iostream>
using namespace std;
class Base1
{
private:
int x1;
public:
virtual void print() { cout << "Base1\n"; };
Base1() {}
~Base1() {}
};
class Base2
{
private:
int x2;
public:
virtual void print() { cout << "Base2\n"; };
Base2() {}
~Base2() {}
};
class Derive :public Base1,Base2
{
private:
int x1;
public:
virtual void print() { cout << "Derive\n"; };
Derive() {};
~Derive() {};
};
int main()
{
Derive test;
return 0;
}
Derive::$vftable@Base1@:这是派生自Base1的虚函数表。它包含了指向Derive::print函数的指针,意味着Derive重写了Base1的虚函数print。
Derive::$vftable@Base2@:这是派生自Base2的虚函数表。由于Derive::print也应用于Base2,表中包含了一个特殊的条目&thunk: this-=16; goto Derive::print。
这是一个调整器(thunk),用于调整this指针,以便Derive::print函数能够正确地访问Base2的成员。-16意味着在调用print前,需要将this指针向后调整16字节,这是因为Base2在Derive对象中的起始位置偏移了16字节。
由此可得:你的父类如果存在虚函数,会一并继承其虚函数表,有几个父类,就有几个虚函数表。
虚继承
单继承
在没有虚函数的时候是不是虚继承影响不大,但存在虚函数的时候虚继承和非虚继承是不一样的。
#include <iostream>
using namespace std;
class Base
{
double x;
int y;
char z;
public:
virtual void print() { cout << "Base\n"; };
Base() {}
~Base() {}
};
class Derive:virtual public Base
{
private:
int x1;
public:
virtual void print() { cout << "Derive\n"; };
Derive() {};
~Derive() {};
};
int main()
{
Derive test;
return 0;
}
vbtable: 存储有关虚基类Base在派生类对象中偏移量的信息。它表明基类Base位于派生类对象起始地址之后的24字节处。
vftable: 包含了虚函数print的地址。vtordisp用于在调用虚函数时调整this指针,以便正确访问虚基类Base的成员。vtordisp的值为-24,表明需要调整的偏移量。
虚继承不只是实现了派生类自己的虚表指针,还重新生成了属于它自己的虚函数表,等于虚继承就比非虚继承多了很多开销。
再说回内存布局,在非虚继承的时候是按照顺序存储,但虚继承情况下,派生类的虚表指针和成员变量在前面,基类的虚表指针和成员变量在后面。
多重继承和二义性问题
在多重继承的情境下,如果两个或多个基类继承自同一个更远的基类,而一个派生类又从这些基类继承,则最远处的基类会在派生类中有多个实例。这会导致访问最远处基类的成员时出现二义性,因为编译器无法确定使用哪个实例。
虚继承通过确保在继承层次结构中只创建基类的单一实例来解决这个问题。当一个类通过虚继承继承另一个类时,它不会创建基类的新实例,而是使用现有的实例(如果已经存在)。这意味着无论基类被继承多少次,派生类中都只会有一个共享的基类实例。
以下代码为例,查看内存布局:
#include <iostream>
using namespace std;
class A
{
public:
int a;
A() {}
virtual ~A() {}
};
class B : virtual public A
{
public:
int b;
B() {}
~B() {}
};
class C : virtual public A
{
public:
int c;
C() {}
~C() {}
};
class D :public B, public C
{
public:
int d;
};
int main()
{
D d;
return 0;
}
总共有三个虚表:两个是虚基类表(每个基类B和C各一个),用于虚继承的偏移量管理;一个是虚函数表,用于支持类D的虚函数,如其析构函数的动态绑定。
对于类B、类C、类D这三个,它是按照顺序来存储的,对于类A与上一节虚继承得出的结果一样,虚基类的虚表指针和成员变量是放在一块内存的最后面的。
个人理解: 虚基类之所以放在对象所属内存的后面,跟虚继承的机制有关,用了虚继承以后,能保证虚基类在对象内存中永远只有一份拷贝,如果还是按照顺序存储,虚基类只有一份,但是派生类却有多个,那编译器到底该把虚基类放在哪个派生类前面呢,那干脆放在最后面,让大家共享,这样就不存在冲突行为,同时这也解释了为什么虚继承能解决二义性问题。