4.面向对象的特性:多态原理
🔬 多态原理深度解析
📖 内容概览
多态是面向对象编程的核心特性之一,它允许同名函数因指向对象的不同而调用相应对象中的函数。其底层实现依赖于虚函数表的机制,操作系统为构成多态的每个类增加了一个虚函数表,其中存放的是 virtual 关键词修饰的虚函数的首地址。编译器运行时通过虚表中存储的函数首地址来调用对应的函数,从而实现多态的目的。
🎯 多态基础概念
🧩 什么是多态?
多态可以让同名函数,因为函数指向对象的不同,而去调用该对象中该名称的函数。其实底层就是因为虚表的一些神奇操作。操作系统为构成多态的每个类增加了一个虚函数表。这个虚函数表中存放的就是virtual关键词修饰的虚函数的首地址。编译器运行的时候通过虚表中存储的函数首地址去调用对应的函数。从而达到我们多态的目的。
📋 多态的实现条件
- 基类中必须有虚函数:使用
virtual关键字声明 - 派生类重写基类虚函数:实现自己的版本
- 通过基类指针或引用调用虚函数:实现动态绑定
🔄 虚函数表机制
🧪 验证虚函数表的存在
通过比较普通类和含有虚函数的类的大小,可以验证虚函数表的存在。
#include <iostream>namespace test1 { // 取消内存对齐 #pragma pack(push, 1) // 设置对齐为1字节 // 求一个正常的class大小 class Person { public: void test() {} private: int m_a; }; class Student { public: virtual void test() {} }; void mytest() { Person s1; Student s2; std::cout << "Person size: " << sizeof(s1) << std::endl; std::cout << "Student size: " << sizeof(s2) << std::endl; } // 求一个有虚表的class的大小}运行结果: 我们发现实现多态的类比没实现的大八个字节(64位一个指针大小)
E:\MyGithubPro\GStudyCodes\c-cpp-tests>g++ -o 虚表 虚表.cppE:\MyGithubPro\GStudyCodes\c-cpp-tests>虚表Person size: 4Student size: 12🔍 虚函数表内容解析
虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr。利用这个特性我们进行类型强转,于是可以打印出这个虚表中存储的各个函数指针的数值。
namespace test2 { //student 继承person 打印出指针 深入验证虚表存储的是什么 class Person { public: virtual void Example1() { std::cout << "pex1" << std::endl; } virtual void Example2() { std::cout << "pex2" << std::endl; } virtual void Example3() { std::cout << "pex3" << std::endl; } virtual void Example4() { std::cout << "pex4" << std::endl; } private: int _a; };
class Student : public Person { public: virtual void Example1() { std::cout << "sex1" << std::endl; } virtual void Example2() { std::cout << "sex2" << std::endl; } virtual void Example3() { std::cout << "sex3" << std::endl; } virtual void Example5() { std::cout << "sex5" << std::endl; } private: int _b; };
typedef void(*VFPTR) ();
void MyPrint(VFPTR vTable[]) { // 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数 std::cout << " 虚表地址>" << vTable << std::endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]); VFPTR f = vTable[i]; f(); } std::cout << std::endl; }
int mytest() { Person s1; Student s2;
//打印出虚函数表的数值 VFPTR* vTableb = (VFPTR*)(*(void**)&s1); MyPrint(vTableb);
VFPTR* vTabled = (VFPTR*)(*(void**)&s2); MyPrint(vTabled);
return 0; }}于是发现,父子类中都有一张虚表,用来存放虚函数的地址,子类重写了父类中的虚函数时,子类的虚表会指向新的地址,该地址是存放重写的虚函数的。(函数都在代码段) 当没有重写时候,父子的虚表都指向同一个地址。
虚表地址>0x7ff7d2a6a9f0 第0个虚函数地址 :0Xd2a67db0,->pex1 第1个虚函数地址 :0Xd2a67df0,->pex2 第2个虚函数地址 :0Xd2a67e30,->pex3 第3个虚函数地址 :0Xd2a67e70,->pex4 虚表地址>0x7ff7d2a6aa20 第0个虚函数地址 :0Xd2a67ef0,->sex1 第1个虚函数地址 :0Xd2a67f30,->sex2 第2个虚函数地址 :0Xd2a67f70,->sex3 第4个虚函数地址 :0Xd2a67fb0,->sex5⚙️ 继承体系中的虚函数表
📋 2.1 单继承的虚函数表
在单继承中,派生类和基类各自拥有一张虚函数表。当派生类重写基类的虚函数时,会在派生类的虚函数表中覆盖相应的函数指针。
🔄 2.2 多继承的虚函数表
在多继承中,子类中就会存在1张虚表包含了n个父类虚表的虚表,每张虚表都来自于不同的父类。 代码所示:
namespace test3 { class Base1 { public: virtual void func1() { cout << "Base1::func1" << endl; } virtual void func2() { cout << "Base1::func2" << endl; } private: int b1; }; class Base2 { virtual void func1() { cout << "Base2::func1" << endl; } virtual void func2() { cout << "Base2::func2" << endl; } int b2; class Derive : public Base1, public Base2 { virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } int d1; typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) { cout << " 虚表地址>" << vTable << endl; for (int i = 0; vTable[i] != nullptr; ++i) { printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]); VFPTR f = vTable[i]; f(); } cout << endl; } void mytest() typedef void(*VFPTR) (); Derive d; VFPTR* vTableb1 = (VFPTR*)(*(void**)&d); //PrintVTable(vTableb1); cout << " 虚表地址>" << vTableb1 << endl; printf(" 第0个虚函数地址 :0X%x,->", vTableb1[0]); VFPTR f = vTableb1[0]; f(); printf(" 第1个虚函数地址 :0X%x,->", vTableb1[1]); f = vTableb1[1]; printf(" 第2个虚函数地址 :0X%x,->", vTableb1[2]); f = vTableb1[2]; VFPTR* vTableb2 = (VFPTR*)(*(void**)((char*)&d + sizeof(Base1))); PrintVTable(vTableb2);};运行结果: 先继承的类虚表在前面,后继承的类的虚表在后边。 子类中有父类没有的虚函数会默认放在第一张虚表中。
E:\MyGithubPro\GStudyCodes\c-cpp-tests>g++ -o 虚表 虚表.cppE:\MyGithubPro\GStudyCodes\c-cpp-tests>虚表 虚表地址>0x7ff77731ac10 第0个虚函数地址 :0X77318330,->Derive::func1 第1个虚函数地址 :0X77318230,->Base1::func2 第2个虚函数地址 :0X77318370,->Derive::func3 虚表地址>0x7ff77731ac38 第0个虚函数地址 :0X77318400,->Derive::func1 第1个虚函数地址 :0X773182d0,->Base2::func2⚠️ 菱形继承与虚继承
⚠️ 4.1 菱形继承问题
菱形继承是指一个类同时继承了两个直接基类,而这两个直接基类又继承了同一个基类。 菱形继承会导致子类中存在多个基类的成员变量,从而导致二义性问题。
A (基类) / \ B C (中间层) \ / D (派生类)在D类对象中,会包含两份A类的成员变量和虚函数表指针。
🛠️ 4.2 虚继承的解决方案
为了解决菱形继承问题,C++引入了虚继承。 虚继承的实现原理是在子类中添加一个指向虚基类的指针,通过这个指针来访问虚基类的成员变量。 虚继承可以避免菱形继承中出现的二义性问题,但是也会增加内存占用和访问时间。
#include <iostream>using namespace std;// 取消内存对齐 #pragma pack(push, 1) // 设置对齐为1字节// 菱形继承示例class Animal {public: Animal() { cout << "Animal constructor" << endl; } virtual ~Animal() { cout << "Animal destructor" << endl; } virtual void speak() { cout << "Animal speaks" << endl; } virtual void move() { cout << "Animal moves" << endl;protected: int age = 0;};class Mammal : virtual public Animal { // 使用虚继承避免二义性 Mammal() { cout << "Mammal constructor" << endl; } virtual ~Mammal() { cout << "Mammal destructor" << endl; } virtual void speak() override { cout << "Mammal speaks" << endl; virtual void feedMilk() { cout << "Mammal feeds milk" << endl; int weight = 10;class Bird : virtual public Animal { // 使用虚继承避免二义性 Bird() { cout << "Bird constructor" << endl; } virtual ~Bird() { cout << "Bird destructor" << endl; } cout << "Bird chirps" << endl; virtual void fly() { cout << "Bird flies" << endl; double wingSpan = 1.0;class Bat : public Mammal, public Bird { Bat() { cout << "Bat constructor" << endl; } virtual ~Bat() { cout << "Bat destructor" << endl; } cout << "Bat squeaks" << endl; virtual void echolocate() { cout << "Bat uses echolocation" << endl;int main() { cout << "Size of Animal: " << sizeof(Animal) << endl; cout << "Size of Mammal: " << sizeof(Mammal) << endl; cout << "Size of Bird: " << sizeof(Bird) << endl; cout << "Size of Bat: " << sizeof(Bat) << endl;
cout << "\nCreating Bat object..." << endl; Bat bat; cout << "\nCalling speak through different interfaces:" << endl; Animal* animalPtr = &bat; Mammal* mammalPtr = &bat; Bird* birdPtr = &bat; animalPtr->speak(); // 调用Bat::speak() mammalPtr->speak(); // 调用Bat::speak() birdPtr->speak(); // 调用Bat::speak() cout << "\nCalling specific methods:" << endl; bat.feedMilk(); bat.fly(); bat.echolocate(); return 0;}运行结果:
Size of Animal: 12Size of Mammal: 24Size of Bird: 28Size of Bat: 40Creating Bat object...Animal constructorMammal constructorBird constructorBat constructorCalling speak through different interfaces:Bat squeaksCalling specific methods:Mammal feeds milkBird fliesBat uses echolocationBat destructorBird destructorMammal destructorAnimal destructor🏗️ 虚继承的内存机制
从内存的角度来看,虚继承(Virtual Inheritance)是如何避免二义性的呢?
❌ 5.1 普通多重继承的问题
在普通的多重继承中,如果两个基类都继承自同一个父类,那么派生类中就会包含两份父类的副本:
A (基类) / \ B C (中间层) \ / D (派生类)在D类对象中,会包含两份A类的成员变量和虚函数表指针。
🏗️ 5.2 虚继承的内存布局
当使用虚继承时,编译器采用了一种特殊的内存布局来避免二义性: a) 虚基类指针(vbptr)和虚基类表(vbtable)
- 编译器为每个包含虚继承的类添加一个特殊的指针,称为虚基类指针(vbptr)
- vbptr指向一个虚基类表(vbtable),该表包含偏移量信息
- 这些偏移量告诉运行时系统如何找到虚基类的实例 b) 唯一的虚基类实例
- 虽然D类同时继承自B和C,但通过虚继承,A类的实例在D对象中只存在一份
- B和C都通过vbtable中的偏移量来引用同一个A实例 c) 内存布局示例 在虚继承中,对象的内存布局可以用以下可视化表示:
D对象内存布局(从低地址到高地址):┌─────────────────┐ ←─ 起始地址│ D的数据 │├─────────────────┤│ C的数据 ││ C的虚函数表指针 │ ←─ *(void**)ptr_C 指向C的虚函数表│ B的数据 ││ B的虚函数表指针 │ ←─ *(void**)ptr_B 指向B的虚函数表│ D的虚函数表指针 │ ←─ *(void**)ptr_D 指向D的虚函数表│ 虚基类表指针 │ ←─ vbptr 指向vbtable│ (vbptr) ││ ...其他数据... ││ A的数据 │ ←─ 唯一的虚基类实例│ (虚基类实例) ││ A的虚函数表指针 │ ←─ *(void**)ptr_A 指向A的虚函数表└─────────────────┘虚基类表(vbtable)结构:┌─────────────────┐ ←─ vbptr指向的位置│ 到A实例的偏移量 │ ←─ 存储A实例相对于当前对象的偏移│ ...其他偏移... │└─────────────────┘访问路径说明:
- 当通过B*指针访问A的成员时:B的vbptr → vbtable → 偏移量 → A实例
- 当通过C*指针访问A的成员时:C的vbptr → vbtable → 偏移量 → 同一个A实例
- 保证无论通过哪种路径访问,都指向同一个A实例,避免二义性
🚫 5.3 避免二义性的机制
a) 统一访问路径
- 当通过B或C访问A的成员时,编译器使用vbtable中的偏移量来定位唯一的A实例
- 这确保了无论通过哪条继承路径访问A,最终都指向同一个实例 b) 运行时解析
- 在运行时,通过vbptr和vbtable,系统能够正确计算出虚基类实例相对于当前对象的偏移量
- 这消除了访问冲突和二义性
📚 5.4 实际应用示例
在我们的菱形继承示例中:
class Animal { /* ... */ };class Mammal : virtual public Animal { /* ... */ };class Bird : virtual public Animal { /* ... */ };class Bat : public Mammal, public Bird { /* ... */ };- Bat对象中只有一份Animal的成员变量
- 通过Mammal或Bird访问Animal的成员时,会通过各自的vbtable找到同一份Animal实例
- 这样就避免了二义性问题
⚡️ 5.5 性能影响
虚继承虽然解决了二义性问题,但也带来了额外的开销:
- 额外的指针(vbptr)占用内存
- 访问虚基类成员时需要额外的间接寻址
- 增加了对象构建和销毁的复杂性 这就是虚继承从内存角度避免二义性的详细机制。
📋 类内存布局总结
📍 6.1 普通类的内存布局
对于一个普通的A类 内存布局为:函数存在数据段(类成员共享)成员变量存在栈(由具体情况而定) 一般情况下,类大小计算只需要考虑成员变量
class A{void func(){}int a};🔑 6.2 虚函数类的内存布局
对于一个内部存在虚函数的B类 class B{ virtual void func(){} int b}; 内存布局为:函数存在数据段(类成员共享)成员变量存在栈(由具体情况而定),但是在类的起始位置会存在一个虚表指针(4/8字节), 指向虚函数表所在位置。 计算时需要额外考虑虚表指针。
🔄 6.3 多继承的内存布局
C继承A,又继承B,在C的内存布局中,会按照先后顺序进行数据的存放, A前B后,A开始位置是A的虚表指针, B的开始位置是B的虚表指针,C中就有多个虚表。
🛠️ 6.4 虚继承的内存布局
虚继承解决了多继承产生的菱形继承问题,底层实际是产生数据冗余和二义性的地方不存储对应数据,而是选择存储一个指向虚基表的指针,通过虚基表中的偏移量来找到对应的数据。 多态实现了,一个对象指向谁调用谁。底层是在类的起始位置存储了一个虚函数表地址,当程序运行起来时,通过在虚函数表中查找对应的函数,来实现指向谁调用谁。
🛠️ 多态实现机制对比
| 继承类型 | 内存开销 | 访问效率 | 二义性问题 | 适用场景 |
|---|---|---|---|---|
| 普通继承 | 低 | 高 | 可能存在 | 简单继承关系 |
| 多继承 | 中等 | 中等 | 存在 | 需要多重能力 |
| 虚继承 | 高 | 较低 | 解决 | 菱形继承场景 |
🎯 核心要点总结
- 虚函数表:每个包含虚函数的类都有一个虚函数表,对象中有一个指向该表的指针
- 动态绑定:运行时根据对象的实际类型调用相应函数
- 虚继承:解决菱形继承的二义性问题,通过虚基类指针和偏移量实现
- 内存布局:不同继承方式有不同的内存布局特点,影响性能和功能 多态机制是C++面向对象编程的重要特性,理解其底层实现有助于编写更高效、更灵活的程序。