C++ 虚函数表
C++类在内存中的存储方式
C++ 内存分为 5 个区域:
- 堆 heap :由 new 分配的内存块,其释放编译器不去管,由程序员自己控制。如果程序员没有释放掉,在程序结束时系统会自动回收。涉及的问题:“缓冲区溢出”、“内存泄露”。
- 栈 stack :是那些编译器在需要时分配,在不需要时自动清除的存储区。存放局部变量、函数参数。存放在栈中的数据只在当前函数及下一层函数中有效,一旦函数返回了,这些数据也就自动释放了。
- 全局/静态存储区 (.bss段和.data段) :全局和静态变量被分配到同一块内存中。在 C 语言中,未初始化的放在.bss段中,初始化的放在.data段中;在 C++ 里则不区分了。
- 常量存储区 (.rodata段) :存放常量,不允许修改(通过非正当手段也可以修改)。
- 代码区 (.text段) :存放代码(如函数),不允许修改(类似常量存储区),但可以执行(不同于常量存储区)。
注意:静态局部变量也存储在全局/静态存储区,作用域为定义它的函数或语句块,生命周期与程序一致。
其中对象数据中存储非静态成员变量、虚函数表指针以及虚基类表指针(如果继承多个)。这里就有一个问题,既然对象里不存储类的成员函数的指针,那类的对象是怎么调用公用函数代码的呢?对象对公用函数代码的调用是在编译阶段就已经决定了的,例如有类对象a,成员函数为show(),如果有代码a.show(),那么在编译阶段会解释为 类名::show(&a)。会给show()传一个对象的指针,即this指针。
从上面的this指针可以说明一个问题:静态成员函数和非静态成员函数都是在类的定义时放在内存的代码区的,但是类为什么只能直接调用静态成员函数,而非静态成员函数(即使函数没有参数)只有类对象能够调用的问题?原因是类的非静态成员函数其实都内含了一个指向类对象的指针型参数(即this指针),因而只有类对象才能调用(此时this指针有实值)。
虚函数表
C++中虚函数是通过一张虚函数表(Virtual Table)来实现的,在这个表中,主要是一个类的虚函数表的地址表;这张表解决了继承、覆盖的问题。在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以当我们用父类的指针来操作一个子类的时候,这张虚函数表就像一张地图一样指明了实际所应该调用的函数。
C++编译器是保证虚函数表的指针存在于对象实例中最前面的位置(是为了保证取到虚函数表的最高的性能),这样我们就能通过已经实例化的对象的地址得到这张虚函数表,再遍历其中的函数指针,并调用相应的函数。
C++对象的内存布局(x86环境)
只有数据成员的对象
#include<iostream>
class Base1 {
public:
int base1_1;
int base1_2;
};
int main() {
std::cout << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
return 0;
}
运行结果
可以看到,成员变量是按照定义的顺序来保存的,最先声明的在最上边,然后依次保存!类对象的大小就是所有成员变量大小之和(严格说是成员变量内存对齐之后的大小之和)。
拥有仅一个虚函数的类对象
#include<iostream>
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {}
};
int main() {
std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
Base1 b1;
return 0;
}
运行结果:
多了4个字节?且 base1_1 和 base1_2 的偏移都各自向后多了4个字节!说明类对象的最前面被多加了4个字节的东西。
现在, 我们通过VS2022来瞧瞧类Base1的变量b1的内存布局情况:虚函数指针_vfptr位于所有的成员变量之前定义。
- 由于我没有写构造函数,所以变量的数据没有根据,但虚函数是编译器为我们构造的,数据正确!
- Debug模式下,未初始化的变量值为0xCCCCCCCC,即:-858983460。
base1_1 前面多了一个变量 _vfptr (常说的虚函数表 vtable 指针),其类型为void**,这说明它是一个void*指针(注意不是数组)。
再看看[0]元素, 其类型为void*,其值为 ConsoleApplication2.exe!Base1::base1_fun1(void),这是什么意思呢?如果对 WinDbg 比较熟悉,那么应该知道这是一种惯用表示手法,她就是指 Base1::base1_fun1() 函数的地址。
可得,__vfptr的定义伪代码大概如下:
void* __fun[1] = { &Base1::base1_fun1 };
const void** __vfptr = &__fun[0];
大家有没有留意这个__vfptr?为什么它被定义成一个 指向指针数组的指针,而不是直接定义成一个 指针数组呢?我为什么要提这样一个问题?因为如果仅是一个指针的情况,您就无法轻易地修改那个数组里面的内容,因为她并不属于类对象的一部分。属于类对象的, 仅是一个指向虚函数表的一个指针_vfptr而已,注意到_vfptr前面的const修饰,她修饰的是那个虚函数表, 而不是__vfptr。
我们来用代码调用一下:
/**
* x86
*/
#include<iostream>
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {
std::cout << "Base1::base1_fun1" << std::endl;
}
virtual void base1_fun2() {
std::cout << "Base1::base1_fun2" << std::endl;
}
};
/*
对(int*)*(int*)(&b)可以这样理解,(int*)(&b)就是对象b的地址,只不过被强制转换成了int*了,如果直接调用*(int*)(&b)则是指向对象b地址所指向的数据,但是此处是个虚函数表呀,所以指不过去,必须通过(int*)将其转换成函数指针来进行指向就不一样了,它的指向就变成了对象b中第一个函数的地址,所以(int*)*(int*)(&b)就是独享b中第一个函数的地址;
又因为pFun是由Fun这个函数声明的函数指针,所以相当于是Fun的实体,必须再将这个地址转换成pFun认识的,即加上(Fun)*进行强制转换:简要概括就是从b地址开始
读取四个字节的内容,然后将这个内容解释成一个内存地址,然后访问这个地址,然后将这个地址中存放的值再解释成一个函数的地址.
*/
typedef void(*Fn)(void);
int main() {
std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
Base1 b1;
Fn fn = nullptr;
std::cout << "虚函数表的地址为:" << (int*)(&b1) << std::endl;
std::cout << "虚函数表的第一个函数地址为:" << (int*)*(int*)(&b1) << std::endl;
fn = (Fn) * ((int*)*(int*)(&b1) + 0);
fn();
fn = (Fn) * ((int*)*(int*)(&b1) + 1);
fn();
return 0;
}
拥有多个虚函数的类对象
在上个代码调用虚函数的日子中你有没有注意到,多了一个虚函数, 类对象大小却依然是12个字节!
再看看VS形象的表现,_vfptr所指向的函数指针数组中出现了第2个元素,其值为Base1类的第2个虚函数base1_fun2()的函数地址。
现在, 虚函数指针以及虚函数表的伪定义大概如下:
void* __fun[] = { &Base1::base1_fun1, &Base1::base1_fun2 };
const void** __vfptr = __fun[0];
通过上面图表, 我们可以得到如下结论:
- 更加肯定前面我们所描述的: __vfptr只是一个指针, 她指向一个函数指针数组(即: 虚函数表)
- 增加一个虚函数, 只是简单地向该类对应的虚函数表中增加一项而已, 并不会影响到类对象的大小以及布局情况
前面已经提到过: __vfptr只是一个指针,她指向一个数组,并且:这个数组没有包含到类定义内部,那么她们之间是怎样一个关系呢?
不妨,我们再定义一个类的变量b2,现在再来看看__vfptr的指向:
通过窗口我们看到:
- b1和b2是类的两个变量,理所当然,她们的地址是不同的(见 &b1 和 &b2)
虽然b1和b2是类的两个变量, 但是她们的__vfptr的指向却是同一个虚函数表
由此我们可以总结出:同一个类的不同实例共用同一份虚函数表, 她们都通过一个所谓的虚函数表指针__vfptr(定义为void**类型)指向该虚函数表。
那么问题就来了! 这个虚函数表保存在哪里呢?
- 虚函数表是全局共享的元素,即全局仅有一个.
- 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表。即虚函数表不是函数,不是程序代码,不肯能存储在代码段。
- 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不再堆中。
根据以上特征,虚函数表类似于类中静态成员变量。静态成员变量也是全局共享,大小确定。
所以我推测虚函数表和静态成员变量一样,存放在全局数据区。其实,我们无需过分追究她位于哪里,重点是:
- 她是编译器在编译时期为我们创建好的, 只存在一份;
- 定义类对象时, 编译器自动将类对象的__vfptr指向这个虚函数表;
单继承且本身不存在虚函数的继承类的内存布局
#include<iostream>
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {}
virtual void base1_fun2() {}
};
class Derive1 : public Base1
{
public:
int derive1_1;
int derive1_2;
};
int main() {
std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
Derive1 d1;
return 0;
}
现在类的布局情况应该是下面这样:
本身不存在虚函数(不严谨)但存在基类虚函数覆盖的单继承类的内存布局
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {}
virtual void base1_fun2() {}
};
class Derive1 : public Base1
{
public:
int derive1_1;
int derive1_2;
// 覆盖基类函数
virtual void base1_fun1() {}
};
int main() {
std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
Derive1 d1;
return 0;
}
特别注意那一行:原本是Base1::base1_fun1(),但由于继承类重写了基类Base1的此方法,所以现在变成了Derive1::base1_fun1()!
那么, 无论是通过Derive1的指针还是Base1的指针来调用此方法,调用的都将是被继承类重写后的那个方法(函数),多态发生了!!!
那么新的布局图:
定义了基类没有的虚函数的单继承的类对象布局
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {}
virtual void base1_fun2() {}
};
class Derive1 : public Base1
{
public:
int derive1_1;
int derive1_2;
//和上上个类不同的是多了一个自身定义的虚函数. 和上个类不同的是没有基类虚函数的覆盖.
virtual void derive1_fun1() {}
};
为嘛呢?现在继承类明明定义了自身的虚函数,但不见了?类对象的大小,以及成员偏移情况居然没有变化!
既然表面上没办法了, 我们就只能从汇编入手了, 来看看调用derive1_fun1()时的代码:
Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun1();
要注意:我为什么使用指针的方式调用?说明一下:因为如果不使用指针调用,虚函数调用是不会发生动态绑定的哦!你若直接 d1.derive1_fun1();是不可能会发生动态绑定的,但如果使用指针:pd1->derive1_fun1(); 那么 pd1就无从知道她所指向的对象到底是Derive1 还是继承于Derive1的对象,虽然这里我们并没有对象继承于Derive1,但是她不得不这样做,毕竟继承类不管你如何继承,都不会影响到基类,对吧?
pd1->derive1_fun1();
004A2233 mov eax,dword ptr [pd1]
004A2236 mov edx,dword ptr [eax]
004A2238 mov esi,esp
004A223A mov ecx,dword ptr [pd1]
004A223D mov eax,dword ptr [edx+8]
004A2240 call eax
汇编代码解释:
- 第2行:由于pd1是指向d1的指针,所以执行此句后 eax 就是d1的地址。
- 第3行:又因为Base1::__vfptr是Base1的第1个成员,同时也是Derive1的第1个成员,那么: &__vfptr == &d1, clear?所以当执行完 mov edx, dword ptr[eax] 后,edx就得到了__vfptr的值,也就是虚函数表的地址。
- 第5行:由于是__thiscall调用,所以把this保存到ecx中。
第6行:一定要注意到那个 edx+8,由于edx是虚函数表的地址,那么 edx+8将是虚函数表的第3个元素,也就是__vftable[2]! - 第7行:调用虚函数。
结果:
- 现在我们应该知道内幕了!继承类Derive1的虚函数表被加在基类的后面!事实的确就是这样!
- 由于Base1只知道自己的两个虚函数索引 [0][1], 所以就算在后面加上了[2],Base1根本不知情,不会对她造成任何影响。
- 如果基类没有虚函数呢?
我们试着调用一下:
#include<iostream>
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {
std::cout << "base1_fun1" << std::endl;
}
virtual void base1_fun2() {
std::cout << "base1_fun2" << std::endl;
}
};
class Derive1 : public Base1
{
public:
int derive1_1;
int derive1_2;
//和上上个类不同的是多了一个自身定义的虚函数. 和上个类不同的是没有基类虚函数的覆盖.
virtual void derive1_fun1() {
std::cout << "derive1_fun1" << std::endl;
}
};
typedef void(*Fn)(void);
int main() {
std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
Base1 b1;
Fn fn = nullptr;
std::cout << "虚函数表的地址为:" << (int*)(&b1) << std::endl;
std::cout << "虚函数表的第一个函数地址为:" << (int*)*(int*)(&b1) << std::endl;
fn = (Fn) * ((int*)*(int*)(&b1) + 0);
fn();
fn = (Fn) * ((int*)*(int*)(&b1) + 1);
fn();
Derive1 d1;
fn = (Fn) * ((int*)*(int*)(&d1) + 2);
fn();
return 0;
}
内存布局
多继承且存在虚函数覆盖同时又存在自身定义的虚函数的类对象布局
#include<iostream>
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {}
virtual void base1_fun2() {}
};
class Base2
{
public:
int base2_1;
int base2_2;
virtual void base2_fun1() {}
virtual void base2_fun2() {}
};
// 多继承
class Derive1 : public Base1, public Base2
{
public:
int derive1_1;
int derive1_2;
// 基类虚函数覆盖
virtual void base1_fun1() {}
virtual void base2_fun2() {}
// 自身定义的虚函数
virtual void derive1_fun1() {}
virtual void derive1_fun2() {}
};
int main() {
std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Base2) << " " << offsetof(Base2, base2_1) << " " << offsetof(Base2, base2_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
Derive1 d1;
return 0;
}
结论:
- 按照基类的声明顺序,基类的成员依次分布在继承中。
- 已经发生了虚函数覆盖!
- 我们自己定义的虚函数呢?怎么还是看不见?
继承反汇编,这次的调用代码如下:
Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun2();
反汇编代码如下:
pd1->derive1_fun2();
0008631E mov eax,dword ptr [pd1]
00086321 mov edx,dword ptr [eax]
00086323 mov esi,esp
00086325 mov ecx,dword ptr [pd1]
00086328 mov eax,dword ptr [edx+0Ch]
0008632B call eax
解释:
- 第2行: 取d1的地址
- 第3行: 取Base1::__vfptr的值
- 第6行: 0x0C, 也就是第4个元素(下标为[3])
结论:Derive1的虚函数表依然是保存到第1个拥有虚函数表的那个基类的后面的.
类对象布局图:
如果第1个直接基类没有虚函数(表)
#include<iostream>
class Base1
{
public:
int base1_1;
int base1_2;
};
class Base2
{
public:
int base2_1;
int base2_2;
virtual void base2_fun1() {}
virtual void base2_fun2() {}
};
// 多继承
class Derive1 : public Base1, public Base2
{
public:
int derive1_1;
int derive1_2;
// 自身定义的虚函数
virtual void derive1_fun1() {}
virtual void derive1_fun2() {}
};
int main() {
std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Base2) << " " << offsetof(Base2, base2_1) << " " << offsetof(Base2, base2_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun2();
return 0;
}
Base1已经没有虚函数表了吗?重点是看虚函数的位置,进入函数调用(和前一次是一样的):
Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun2();
反汇编调用代码:
pd1->derive1_fun2();
008F667E mov eax,dword ptr [pd1]
008F6681 mov edx,dword ptr [eax]
008F6683 mov esi,esp
008F6685 mov ecx,dword ptr [pd1]
008F6688 mov eax,dword ptr [edx+0Ch]
008F668B call eax
这段汇编代码和前面一个完全一样,那么问题就来了,Base1 已经没有虚函数表了,为什么还是把Base1的第1个元素当作__vfptr呢?
不难猜测: 当前的布局已经发生了变化, 有虚函数表的基类放在对象内存前面?不过事实是否属实?需要仔细斟酌。
我们可以通过对基类成员变量求偏移来观察:
所以不难验证: 我们前面的推断是正确的, 谁有虚函数表, 谁就放在前面!
现在类的布局情况:
两个基类都没有虚函数表
#include<iostream>
class Base1
{
public:
int base1_1;
int base1_2;
};
class Base2
{
public:
int base2_1;
int base2_2;
};
// 多继承
class Derive1 : public Base1, public Base2
{
public:
int derive1_1;
int derive1_2;
// 自身定义的虚函数
virtual void derive1_fun1() {}
virtual void derive1_fun2() {}
};
int main() {
std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Base2) << " " << offsetof(Base2, base2_1) << " " << offsetof(Base2, base2_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun2();
return 0;
}
可以看到, 现在__vfptr已经独立出来了, 不再属于Base1和Base2!
再看看偏移:
&d1==&d1.__vfptr 说明虚函数始终在最前面!
内存布局:
如果有三个基类: 虚函数表分别是有,没有,有!
#include<iostream>
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {}
virtual void base1_fun2() {}
};
class Base2
{
public:
int base2_1;
int base2_2;
};
class Base3
{
public:
int base3_1;
int base3_2;
virtual void base3_fun1() {}
virtual void base3_fun2() {}
};
// 多继承
class Derive1 : public Base1, public Base2, public Base3
{
public:
int derive1_1;
int derive1_2;
// 自身定义的虚函数
virtual void derive1_fun1() {}
virtual void derive1_fun2() {}
};
int main() {
std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Base2) << " " << offsetof(Base2, base2_1) << " " << offsetof(Base2, base2_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Base3) << " " << offsetof(Base3, base3_1) << " " << offsetof(Base3, base3_2) << std::endl;
std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun2();
return 0;
}
内存布局:
只需知道: 谁有虚函数表, 谁就往前靠!