27.面向对象-多态-虚函数表
27.1 知识点
知识回顾
在程序运行时,系统中会有不同的内存存储区域。
目前我们学习过的存储区域包括:
代码段(Code Segment / 代码存储区):
- 存储函数的机器码(执行指令)等信息的区域。
- 通常是只读的,防止程序意外修改自己的代码。
- 在程序加载时被加载到内存中。
- 生命周期较长,与程序同生共死。
数据段(Data Segment / 数据存储区):
存储全局变量、静态变量。
分为两个子区域:
- 初始化数据段:存储已初始化的全局和静态变量。
- 未初始化数据段(BSS Segment):存储未初始化的全局和静态变量,通常初始化为零。
生命周期较长,与程序同生共死。
常量段(Constant Segment / 常量存储区):
- 存储字符串字面量和其他只读数据。
- 通常是只读的,以防止程序修改常量的值。
- 比如
cout << "Hello, World" << endl
中的"Hello, World"
就存储在常量区。 - 生命周期由程序控制。
- 注意:我们声明的常量变量不一定存储在常量段,而是根据声明的位置决定存储区域。
栈(Stack):
- 存储局部变量、函数参数、返回值。
- 由编译器自动管理,随着函数的调用和返回而分配和释放。
- 生命周期与函数调用相同,具有快速分配和释放的特点。
堆(Heap):
- 存储动态分配的内存数据。
- 由程序员手动管理,需要主动分配和释放。
- 生命周期由程序员决定。
虚函数表的基本概念
- 虚函数表(简称:V-Table)是 C++ 实现多态的底层原理。
- 编译器会为每一个包含虚函数的类创建至少一个虚函数表。
- 虚函数表是一个数组,表中存储的是该类虚函数的函数指针地址。
- 虚函数表是被类的所有对象共享的(所有实例化的该类对象共用该类的虚函数表)。
- 虚函数表通常被存储在数据段或常量段(更常见)。
- 不管对象是在堆上、栈上还是作为静态对象,只会有一份对应类的虚函数表。
- 每个对象中隐藏了至少一个虚函数表指针(vptr)。
- vptr 在对象构造时自动赋值,指向类的虚函数表。
虚函数表的工作原理
类定义时:
- 编译器为每个包含虚函数的类生成一张虚函数表,通常存储在数据段或常量段。
- 虚函数表的本质是一个指针数组。
- 指针数组中每个元素存储着虚函数的函数指针,指向该类虚函数的代码实现。
对象实例化时:
- 所有包含虚函数的对象都包含至少一个隐藏的虚函数表指针(VPtr)。
- VPtr 指向该类的虚函数表。
- 对于同一个类的不同对象,VPtr 是属于对象的,虚函数表是属于类的(共享唯一)。
调用虚函数时:
- 程序运行时通过对象中的 VPtr 查找对应的虚函数表(VTable),再找到函数地址并调用。
实例分析:
注意:
- 只有包含虚函数的类才会有虚函数表。
- 由于包含虚函数的类对象中存在隐藏的虚函数指针,因此
sizeof
时得到的字节数会多出一个指针大小(64位下为8字节,32位为4字节)。 - 例如
Father2
没有虚函数,就会比Father
少一个虚函数指针的内存分配。
```cpp
cout << sizeof(Father) << endl; // 16
cout << sizeof(Father2) << endl; // 8
```
多继承和单继承的区别
单继承中,子类的虚函数表可以理解为是复制父类的虚函数表后再修改:
- 修改其中指向,或添加子类的虚函数。
多重继承中:
- 有多少个父类,就有多少张虚函数表。
- 子类对象中会有多个虚函数表指针指向各自的虚函数表。
- 子类自己的虚函数通常放入第一个虚函数表中(主流编译器的实现方式)。
实例分析:
cout << sizeof(Son) << endl; // 24
// Father 有 double 成员,占用 8 字节,加上两个虚函数指针。
Father* f = new Son();
f->Fun1();
// 多态的实现过程是:程序会到子类对象的虚函数表中,找到实际调用的函数的地址并执行。
27.2 知识点代码
Father.h
#pragma once
class Father
{
public:
double x = 0;
virtual void Fun1()
{
}
virtual void Fun2()
{
}
virtual void Fun3()
{
}
void Fun4()
{
}
};
Father2.h
#pragma once
class Father2
{
public:
double x = 0;
};
Mother.h
#pragma once
class Mother
{
public:
virtual void MFun1() {}
virtual void MFun2() {}
virtual void MFun3() {}
};
Son.h
#pragma once
#include "Father.h"
#include "Mother.h"
class Son : public Father, public Mother
{
public:
void Fun1() override
{
}
void MFun1() override
{
}
virtual void Fun5()
{
}
};
Lesson27_面向对象_多态_虚函数表.cpp
#include <iostream>
#include "Father.h"
#include "Father2.h"
#include "Son.h"
using namespace std;
int main()
{
#pragma region 知识回顾
//在程序运行时,系统中会有不同的内存存储区域
//
//我们目前学习过的知识,涉及的存储区域有:
//1.代码段(Code Segment,或称代码存储区):
// 存储 函数的机器码(执行指令)等信息的区域
// 通常是只读的,防止程序意外修改自己的代码
// 在程序加载时就被加载到内存中
// 生命周期较长,与程序同生共死
//
//2.数据段(Data Segment,或称数据存储区):存储 全局变量、静态变量 等信息的区域
// 一般分成两个子区域
// 初始化数据段:存储已初始化的全局和静态变量
// 未初始化数据段(BSS Segment):存储未初始化的全局和静态变量,通常初始化为零
// 生命周期较长,与程序同生共死
//
//3.常量段(Constant Segment,或称常量存储区):存储 字符串字面量和其他只读数据 信息的区域
// 通常是只读的,以防止程序修改常量的值
// 比如 cout << "Hello, World" << endl 中的 "Hello, World" 就存储在常量区
// 生命周期由程序控制
// 注意:我们声明的常量变量不存储在常量段,而是根据在哪里声明决定存储在哪里
//
//4.栈(Stack):存储 局部变量、函数参数、返回值 等信息的区域
// 由编译器自动管理,随着函数的调用和返回而分配和释放
// 生命周期与函数调用相同,快速分配和释放
//
//5.堆(Heap):存储 动态分配的内存数据 等信息的区域
// 由程序员手动管理,需要主动分配,主动释放
// 生命周期不固定,由程序员决定生死
#pragma endregion
#pragma region 知识点一 虚函数表的基本概念
//虚函数表(简称:V-Table)是C++中多态实现的底层原理
//编译器会为每一个有虚函数的类创建至少一个虚函数表
//虚函数表是一个数组,表中存储的是该类虚函数的函数指针地址
//虚函数表被类的所有对象共享(所有实例化的某类对象都共用该类的虚函数表)
//虚函数表通常是存储在数据段或常量段(更常见)的
//不管实例化的对象在堆、栈、静态等哪个存储区,对应类的虚函数表永远只有一份
//每个对象中隐藏了至少一个虚函数表指针(vptr)
//该指针在对象构造时自动赋值,指向对应类的虚函数表
#pragma endregion
#pragma region 知识点二 虚函数表的工作原理
//类定义时:
//编译器为每个有虚函数的类生成一张虚函数表,存储在数据段或常量段(更常见)
//虚函数表的本质是一个指针数组信息
//指针数组中每个元素存储着虚函数的函数指针(指向该类对应虚函数的代码实现)
//实例化对象时:
//存在虚函数的对象内部都会至少有一个隐藏的虚函数表指针(简称:V-Ptr)
//该指针指向虚函数表
//对于同一类的不同对象来说 虚函数表指针是属于对象的 虚函数表是属于类的(唯一共享的)
//调用虚函数时:
//运行时会通过VPtr指向的VTable查表调用相应的函数
//注意:
//1.只有有虚函数的类才会有虚函数表
//2.由于存在虚函数的类对象中存在隐藏的指针
// 因此sizeof时得到的字节数会至少多一个指针(64位8字节,32位4字节)的大小
cout << sizeof(Father) << endl;//16
cout << sizeof(Father2) << endl;//8
#pragma endregion
#pragma region 知识点三 多继承和单继承的区别ss
//单继承中,子类的虚函数表可以理解为是复制父类的虚函数表后再修改
//比如修改其中的指向,添加新的自己的虚函数
//而在多重继承中,有多少个父类就会有多少张虚函数表
//声明子类对象时,对象中会有多个虚函数表指针指向对象的虚函数表
//对于子类自己的虚函数,将会放入第一个虚函数表中(主流编辑器的实现方式)
cout << sizeof(Son) << endl;//24 Father有double成员8字节 加上两个虚函数指针
//多态的实现其实就是去子类找虚函数表找到实际调用的函数的内存地址进行调用
Father* f = new Son();
f->Fun1();
#pragma endregion
}
27.3 练习题
下列哪种情况会导致类中产生虚函数表?
正确答案:B. 类中存在虚函数
解析:
只有当类中声明了虚函数(virtual function),编译器才会为该类生成虚函数表(vtable)。虚函数表中记录了类中所有虚函数的函数指针,用于支持运行时的动态多态。
- A. 类中存在 static 函数 → ❌ 静态函数属于类而不是对象,不参与虚函数机制。
- B. 类中存在虚函数 → ✅ 这是触发生成虚函数表的条件。
- C. 类中存在普通成员函数 → ❌ 普通成员函数不会被放入虚函数表。
- D. 类中存在构造函数 → ❌ 构造函数不会被虚拟化,也不会出现在虚函数表中。
如果一个类继承了两个都有虚函数的基类,会发生什么?
正确答案:C. 会为每个基类生成一个虚函数表指针
解析:
当一个类多重继承两个含有虚函数的基类时,每个基类都有自己的 vtable 指针(vptr)。派生类会为每个基类维护一个 vptr,确保每条继承链上的虚函数机制都能正常工作。
- A. 只会生成一个虚函数表 → ❌ 错,多个有虚函数的基类需要多个 vptr。
- B. 不会生成虚函数表 → ❌ 错,只要有虚函数就一定会生成。
- C. 会为每个基类生成一个虚函数表指针 → ✅ 正确。
- D. 子类无法继承 → ❌ 错,C++允许这样的继承结构。
虚函数表中存储的主要是什么?
正确答案:C. 指向虚函数的指针
解析:
虚函数表(vtable)中存储的是指向虚函数的函数指针。每个类的虚函数表是一个函数指针数组,用于动态绑定虚函数的实现。
- A. 虚函数的名称 → ❌ 名称是编译时的概念,不存储在表中。
- B. 虚函数的源代码 → ❌ 源代码不存储在内存中。
- C. 指向虚函数的指针 → ✅ 正确。
- D. 成员变量的偏移地址 → ❌ 虚函数表与成员变量无关。
子类重写父类某个虚函数后,其虚函数表会发生什么?
正确答案:C. 替换父类表中对应函数的指针
解析:
子类重写(override)父类的虚函数后,它自己的虚函数表会用子类的函数地址替换父类相应的条目,以确保通过基类指针调用时能动态绑定到子类实现。
- A. 完全继承父类虚函数表不变 → ❌ 错,会进行替换操作。
- B. 删除父类所有虚函数 → ❌ 错,只替换被重写的。
- C. 替换父类表中对应函数的指针 → ✅ 正确。
- D. 添加新虚函数表 → ❌ 错,不会新增 vtable,只会修改已有表的内容。
简述虚函数表(vtable)和虚函数指针(vptr)之间的关系
参考答案:
- vptr 是每个对象中隐藏的一个指针,指向类的虚函数表(vtable)。
- vtable 是类级别的,包含指向当前类中所有虚函数实现的函数指针数组。
- 每个对象在构造时都会初始化其 vptr,使其指向正确的 vtable。
- 借助 vptr 在运行时查找 vtable 中对应函数地址,C++ 实现了动态多态。
虚函数在性能上比普通函数慢,为什么?
参考答案:
是的,虚函数相较于普通函数在性能上确实更慢,原因如下:
查找跳转成本高:
虚函数需要先通过对象的 vptr 找到 vtable,再从 vtable 中查找函数地址,然后再跳转调用,这比普通函数直接跳转调用慢。内存开销更大:
每个含虚函数的对象需要一个 vptr,占用额外的内存。继承链复杂影响性能:
当继承链较深、虚函数表较大时,虚函数的查找可能涉及多个表的访问,这会进一步增加 CPU 缓存 miss 率,影响执行效率。
相比之下,普通函数在编译时就已绑定地址,执行时无需查表,效率更高。虚函数的优势在于灵活性和扩展性,而非性能。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com