虚函数表在单继承与多继承中的实现机制
目录
一、单继承中的虚函数表
1、基础代码示例
2、问题疑惑
为什么监视窗口只显示两个虚函数?
如何查看完整的虚表内容?
3、单继承虚表生成机制
4、查看完整虚表的方法
方法一:内存监视窗口(如上演示的)
方法二:代码打印虚表(更直观)
二、多继承中的虚函数表
1、基础代码示例
2、多继承虚表生成机制
3、查看多继承虚表(同上面一样的方法)
使用内存监视窗口和使用代码打印虚表内容
预期行为与原理
验证方法:代码打印虚表
预期输出结果分析
三、菱形虚拟继承的虚函数表
当中的继承关系图
重要建议
四、总结对比(重点!!!)
五、虚函数表深度解析
1、虚函数表的基本特性
虚函数表的构成与分布
对象内存结构与虚表指针
重要说明:
2、虚函数表的实现细节
代码示例(上面已经分析过了,这里自己尝试同样的方法去分析)
虚函数表的存储特性(重点!!!)
3、虚函数表存储位置验证
内存分布测试代码
运行结果分析
关键发现
4、调试技巧与注意事项
查看完整虚表
重要总结
一、单继承中的虚函数表
1、基础代码示例
以下列单继承关系为例,我们来看看基类和派生类的虚表模型:
#include <iostream>
using namespace std;// 基类
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;
};
2、问题疑惑
问题也就是我们观察到的现象——派生类对象 b
的虚表(_vfptr)里只显示了两个虚函数(func1 和 func2)——是完全正常的。这并不是错误,而是编译器调试器(如VS的监视窗口)的一种显示优化或限制。(我使用的是VS2022IDE,它所用的编译器是MSVC)
为什么监视窗口只显示两个虚函数?
1. 显示优化(隐藏派生类独有的虚函数)
监视窗口会识别出 Derive
类继承自 Base
类。它的智能显示逻辑是:
-
覆盖(重写)的虚函数(如
func1
):会直接显示为重写后的版本(Derive::func1
)。 -
从基类继承且未重写的虚函数(如
func2
):会显示为基类的版本(Base::func2
)。 -
派生类新增的虚函数(如
func3
,func4
):默认情况下,监视窗口会选择不显示它们,因为它认为你最关心的是与基类接口相关的部分。
2. 这并不代表虚表是不完整的
对象的实际虚表在内存中绝对是完整的,包含了 func1
(已重写)、func2
(继承)以及 func3
和 func4
(新增)这四个函数的地址。只是调试器认为后两个不是“重点”,所以把它们隐藏了。
如何查看完整的虚表内容?
如果想确认 func3
和 func4
确实在虚表中,可以使用以下的方法:使用内存窗口(最直接)这是查看内存真实情况最准确的方法。
-
在调试时,打开 【调试】->【窗口】->【内存】->【内存1】。
-
在内存窗口的地址栏中,输入
&b
(派生类对象的地址),按回车。你会看到对象b
的内存布局。(此时我切换成32位(x86)机器环境,方便观察)
-
对象
b
的第一个成员就是虚表指针(_vfptr)。复制内存窗口中这4字节(32位程序)或8字节(64位程序)的值。 -
将复制到的值(虚表的地址)粘贴到内存窗口的地址栏中,按回车。
-
现在你就在直接查看虚表的内容了。虚表是一个函数指针数组,通常以
00 00 00 00
(nullptr) 结束。你会看到一连串的函数地址,这些就是func1
,func2
,func3
,func4
的地址。
所以其中,基类和派生类对象的虚表模型如下:
3、单继承虚表生成机制
在单继承关系中,派生类虚表的生成遵循以下过程:
-
继承基类虚表内容:将基类的虚函数地址复制到派生类虚表
-
覆盖重写函数:对派生类重写的虚函数地址进行替换(如
func1
) -
添加新虚函数:在虚表末尾添加派生类新增的虚函数地址(如
func3
、func4
)
4、查看完整虚表的方法
某些编译器可能会隐藏部分虚函数显示,可通过以下方法查看完整虚表:
方法一:内存监视窗口(如上演示的)
在调试器中调出内存监视窗口,输入派生类对象的虚表指针地址,即可查看虚表中存储的所有虚函数地址。
方法二:代码打印虚表(更直观)
可以编写一个小的调试函数来打印出虚表中的所有函数地址,并尝试调用它们。
#include <iostream>
using namespace std;typedef void(*VFPTR)(); // 定义一个函数指针类型// 基类
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;
};// 打印虚表函数
void PrintVTable(VFPTR* vTable)
{cout << "虚表地址: " << vTable << endl;for (int i = 0; vTable[i] != nullptr; i++) // 遍历直到遇到空指针{printf("vTable[%d]: %p -> ", i, vTable[i]);VFPTR func = vTable[i];func(); // 直接调用该函数指针,会执行对应的函数}cout << endl;
}int main()
{Base a;Derive b;// 打印派生类对象b的虚表// 1. 取对象b的地址// 2. 强转为int*(或void**),为了取对象头4/8字节的内容(即虚表指针)// 3. 解引用拿到虚表指针的值(即虚表的地址)// 4. 强转为VFPTR*(函数指针数组)并打印PrintVTable((VFPTR*)(*(int*)&b)); // 对于32位程序// PrintVTable((VFPTR*)(*(void**)&b)); // 对于64位程序更通用return 0;
}
运行这段代码,将会看到类似这样的输出:
- 直接使用函数指针也就是直接调用其函数;
- 直接通过虚函数表的内存布局来访问虚函数,每个虚函数的地址都为一个字节,+1也就是访问下一个虚函数。
二、多继承中的虚函数表
1、基础代码示例
以下列多继承关系为例,我们来看看基类和派生类的虚表模型:
// 基类1
class Base1
{
public:virtual void func1() { cout << "Base1::func1()" << endl; }virtual void func2() { cout << "Base1::func2()" << endl; }
private:int _b1;
};// 基类2
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; } // 重写两个基类的func1virtual void func3() { cout << "Derive::func3()" << endl; } // 新增虚函数
private:int _d1;
};
其中,两个基类的虚表模型如下:
而派生类的虚表模型就不那么简单了,派生类的虚表模型如下:
2、多继承虚表生成机制
在多继承关系中,派生类会维护多个虚表:
-
继承各个基类虚表:为每个基类创建独立的虚表
-
覆盖重写函数:在所有相关虚表中替换被重写的虚函数地址(如
func1,也就是所有继承过来的同名函数都会被重写
) -
添加新虚函数:通常在第一个基类的虚表中添加派生类新增的虚函数(如
func3
)
3、查看多继承虚表(同上面一样的方法)
使用内存监视窗口和使用代码打印虚表内容
Derive
类继承自 Base1
和 Base2
,并重写了 func1
,还新增了 func3
。
预期行为与原理
在多继承中,派生类对象会包含多个虚表指针(vptr),每个直接基类对应一个。对于您的代码:
-
Derive
对象会包含两个 vptr:-
Base1
的 vptr:指向一个虚表,包含Base1
接口相关的虚函数。 -
Base2
的 vptr:指向另一个虚表,包含Base2
接口相关的虚函数。
-
-
重写规则:派生类重写的虚函数(如
func1
)会覆盖到所有相关基类的虚表中。 -
新增虚函数:通常会被放入第一个基类(
Base1
)的虚表末尾。
验证方法:代码打印虚表
我们将使用一个函数来打印任意对象的虚表内容,这是最可靠的验证方法。
#include <iostream>
using namespace std;typedef void(*VFPTR)(); // 定义函数指针类型// 基类1
class Base1
{
public:virtual void func1() { cout << "Base1::func1()" << endl; }virtual void func2() { cout << "Base1::func2()" << endl; }
private:int _b1;
};// 基类2
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; } // 重写两个基类的func1virtual void func3() { cout << "Derive::func3()" << endl; } // 新增虚函数
private:int _d1;
};// 打印虚表函数
void PrintVTable(VFPTR* vTable, const char* tableName = "")
{if (tableName && tableName[0] != '\0') {cout << "=== " << tableName << " ===" << endl;}cout << "虚表地址: " << vTable << endl;for (int i = 0; vTable[i] != nullptr; i++) {printf("vTable[%d]: %p -> ", i, vTable[i]);// 尝试调用函数,如果调用失败说明不是简单函数(可能是调整后的指针)// 但对我们观察地址足够了VFPTR func = vTable[i];// 安全起见,先注释掉直接调用,专注于观察地址// func(); cout << endl;}cout << "==================" << endl << endl;
}int main()
{Base1 b1;Base2 b2;Derive d;cout << "===== 基类对象虚表 =====" << endl;// 打印基类对象的虚表 (32位系统用法)PrintVTable((VFPTR*)(*(int*)&b1), "Base1 VTable");PrintVTable((VFPTR*)(*(int*)&b2), "Base2 VTable");cout << "===== 派生类对象虚表 =====" << endl;// 打印派生类对象中Base1部分的虚表// Derive对象的起始地址就是Base1子对象的地址PrintVTable((VFPTR*)(*(int*)&d), "Derive's Base1 VTable");// 打印派生类对象中Base2部分的虚表// Base2子对象在Derive对象中的偏移量是sizeof(Base1)char* deriveAddr = (char*)&d; // 先转换为char*便于字节操作char* base2Addr = deriveAddr + sizeof(Base1); // 计算Base2子对象地址PrintVTable((VFPTR*)(*(int*)base2Addr), "Derive's Base2 VTable");return 0;
}
预期输出结果分析
运行上述代码后,应该会看到类似这样的输出:
三、菱形虚拟继承的虚函数表
以下列菱形虚拟继承关系为例,我们来看看基类和派生类的虚表模型:
class A
{
public:virtual void funcA(){cout << "A::funcA()" << endl;}
private:int _a;
};
class B : virtual public A
{
public:virtual void funcA(){cout << "B::funcA()" << endl;}virtual void funcB(){cout << "B::funcB()" << endl;}
private:int _b;
};
class C : virtual public A
{
public:virtual void funcA(){cout << "C::funcA()" << endl;}virtual void funcC(){cout << "C::funcC()" << endl;}
private:int _c;
};
class D : public B, public C
{
public:virtual void funcA(){cout << "D::funcA()" << endl;}virtual void funcD(){cout << "D::funcD()" << endl;}
private:int _d;
};
当中的继承关系图
其中,A类当中有一个虚函数funcA,B类当中有一个虚函数funcB,C类当中有一个虚函数funcC,D类当中有一个虚函数funcD。此外B类、C类和D类当中均对A类当中的funcA进行了重写。
A类对象当中的成员及其分布情况:A类对象的成员包括一个虚表指针和成员变量_a,虚表指针指向的虚表当中存储的是A类虚函数funcA的地址。
B类对象当中的成员及其分布情况:B类由于是虚拟继承的A类,所以B类对象当中将A类继承下来的成员放到了最后,除此之外,B类对象的成员还包括一个虚表指针、一个虚基表指针和成员变量_b,虚表指针指向的虚表当中存储的是B类虚函数funcB的地址。
虚基表当中存储的是两个偏移量,第一个是虚基表指针距离B虚表指针的偏移量,第二个是虚基表指针距离虚基类A的偏移量。(忘记了的话回去看虚拟继承那一篇博客)
同理,C类对象当中的成员及其分布情况:C类对象当中的成员分布情况与B类对象当中的成员分布情况相同。C类也是虚拟继承的A类,所以C类对象当中将A类继承下来的成员放到了最后,除此之外,C类对象的成员还包括一个虚表指针、一个虚基表指针和成员变量_c,虚表指针指向的虚表当中存储的是C类虚函数funcC的地址。
虚基表当中存储的是两个偏移量,第一个是虚基表指针距离C虚表指针的偏移量,第二个是虚基表指针距离虚基类A的偏移量。
重点:D类对象当中的成员及其分布情况:D类对象当中成员的分布情况较为复杂,D类的继承方式是菱形虚拟继承,在D类对象当中,将A类继承下来的成员放到了最后,除此之外,D类对象的成员还包括从B类继承下来的成员、从C类继承下来的成员和成员变量_d。
需要注意的是,D类对象当中的虚函数funcD的地址是存储到了B类的虚表(第一个被继承的类)当中。
菱形虚拟继承是最复杂的继承场景,涉及虚基表和多个虚表指针。在这种模式下:
-
虚基类共享:虚基类(如A)的成员被所有派生类共享
-
虚基表指针:每个虚拟继承的类都包含虚基表指针,用于定位共享的虚基类成员
-
多重虚表:派生类包含多个虚表,分别对应不同的继承路径
重要建议
实际开发中应避免使用菱形继承和菱形虚拟继承,原因包括:
-
实现复杂,容易出错
-
成员访问存在性能开销
-
代码可维护性差
-
可能引发难以调试的问题
四、总结对比(重点!!!)
继承类型 | 虚表数量 | 特点 | 复杂度 |
---|---|---|---|
单继承 | 1个 | 简单直观,直接扩展 | 低 |
多继承 | 多个(与基类数相同) | 每个基类对应一个虚表 | 中 |
菱形虚拟继承 | 多个虚表+虚基表 | 包含虚基表指针,共享虚基类 | 高 |
理解不同继承模式下的虚函数表机制,有助于深入掌握C++的多态实现原理和对象内存模型。在实际项目中,优先使用单继承和组合来替代多继承,以提高代码的可维护性和性能。
五、虚函数表深度解析(回顾)
1、虚函数表的基本特性
虚函数表的构成与分布
-
基类虚函数表:存放基类所有虚函数的地址。同类型对象共享同一张虚表,不同类型对象拥有独立的虚表。
-
派生类虚函数表:由三部分组成:继承的基类虚函数地址、重写的虚函数地址(覆盖基类对应项)、新增的虚函数地址
对象内存结构与虚表指针
派生类对象包含两部分:
-
继承的基类部分:包含基类成员和独立的虚表指针
-
自有成员部分:包含派生类特有成员
重要说明:
派生类中继承的基类部分的虚表指针与基类对象的虚表指针不是同一个,它们分别指向各自类型的虚表,就像基类对象成员和派生类中的基类成员也是相互独立的。
2、虚函数表的实现细节
代码示例(上面已经分析过了,这里自己尝试同样的方法去分析)
#include <iostream>
using namespace std;class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }void func5() { cout << "Base::func5" << endl; } // 普通成员函数
protected:int a = 1;
};class Derive : public Base {
public:virtual void func1() { cout << "Derive::func1" << endl; } // 重写基类虚函数virtual void func3() { cout << "Derive::func3" << endl; } // 新增虚函数void func4() { cout << "Derive::func4" << endl; } // 普通成员函数
protected:int b = 2;
};int main() {Base b;Derive d;return 0;
}
虚函数表的存储特性(重点!!!)
-
虚函数本身:与普通函数一样,编译后为一段指令代码,存储在代码段
-
虚函数地址:存储在虚函数表中
-
虚函数表结构:是一个存储虚函数指针的指针数组
-
结束标记:VS的编译器会在数组末尾添加
0x00000000(也就是NULL)
作为结束标记(g++编译器无此特性,这是MSVC(VS的编译器)这个编译器自行定义的)
3、虚函数表存储位置验证
内存分布测试代码
int main() {int i = 0;static int j = 1;int* p1 = new int;const char* p2 = "xxxxxxxx";Base b;Derive d;Base* p3 = &b;Derive* p4 = &d;// 输出各内存区域地址printf("栈区:%p\n", &i);printf("静态区:%p\n", &j);printf("堆区:%p\n", p1);printf("常量区:%p\n", p2);// 输出虚表和相关函数地址printf("Base虚表地址:%p\n", *(int*)p3);printf("Derive虚表地址:%p\n", *(int*)p4);printf("虚函数地址:%p\n", &Base::func1);printf("普通函数地址:%p\n", &Base::func5);delete p1;return 0;
}
运行结果分析
关键发现
-
虚表存储位置:虚表地址(
00BCAB34
和00BCAB74
)与常量区地址(00BCABA4
)相近,表明在VS编译器下虚表存储在代码段(常量区) -
函数存储位置:虚函数和普通函数地址(
00BC1488
和00BC14C4
)都位于代码段 -
重要提示:C++标准并未规定虚表的具体存储位置,不同编译器可能有不同实现
4、调试技巧与注意事项
查看完整虚表
在某些编译器(如VS)的监视窗口中,可能无法看到派生类新增的虚函数(如func3
)。可通过以下方法查看:
-
内存窗口:直接查看对象内存中的虚表指针指向的内容
-
代码打印:编写函数遍历虚表并输出所有函数地址
重要总结
-
虚函数表是C++实现动态多态的核心机制
-
派生类虚表通过覆盖机制实现函数重写
-
虚表存储位置由编译器决定,通常位于常量区
-
理解虚表机制有助于深入掌握C++面向对象编程