4.面向对象的特性:多态原理

2026-01-23
3735 字 · 19 分钟

🔬 多态原理深度解析

📖 内容概览

多态是面向对象编程的核心特性之一,它允许同名函数因指向对象的不同而调用相应对象中的函数。其底层实现依赖于虚函数表的机制,操作系统为构成多态的每个类增加了一个虚函数表,其中存放的是 virtual 关键词修饰的虚函数的首地址。编译器运行时通过虚表中存储的函数首地址来调用对应的函数,从而实现多态的目的。

🎯 多态基础概念

🧩 什么是多态?

多态可以让同名函数,因为函数指向对象的不同,而去调用该对象中该名称的函数。其实底层就是因为虚表的一些神奇操作。操作系统为构成多态的每个类增加了一个虚函数表。这个虚函数表中存放的就是virtual关键词修饰的虚函数的首地址。编译器运行的时候通过虚表中存储的函数首地址去调用对应的函数。从而达到我们多态的目的。

📋 多态的实现条件

  1. 基类中必须有虚函数:使用 virtual 关键字声明
  2. 派生类重写基类虚函数:实现自己的版本
  3. 通过基类指针或引用调用虚函数:实现动态绑定

🔄 虚函数表机制

🧪 验证虚函数表的存在

通过比较普通类和含有虚函数的类的大小,可以验证虚函数表的存在。

#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位一个指针大小)

Terminal window
E:\MyGithubPro\GStudyCodes\c-cpp-tests>g++ -o 虚表 虚表.cpp
E:\MyGithubPro\GStudyCodes\c-cpp-tests>虚表
Person size: 4
Student 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;
}
}

于是发现,父子类中都有一张虚表,用来存放虚函数的地址,子类重写了父类中的虚函数时,子类的虚表会指向新的地址,该地址是存放重写的虚函数的。(函数都在代码段) 当没有重写时候,父子的虚表都指向同一个地址。

Terminal window
虚表地址>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);
};

运行结果: 先继承的类虚表在前面,后继承的类的虚表在后边。 子类中有父类没有的虚函数会默认放在第一张虚表中。

Terminal window
E:\MyGithubPro\GStudyCodes\c-cpp-tests>g++ -o 虚表 虚表.cpp
E:\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;
}

运行结果:

Terminal window
Size of Animal: 12
Size of Mammal: 24
Size of Bird: 28
Size of Bat: 40
Creating Bat object...
Animal constructor
Mammal constructor
Bird constructor
Bat constructor
Calling speak through different interfaces:
Bat squeaks
Calling specific methods:
Mammal feeds milk
Bird flies
Bat uses echolocation
Bat destructor
Bird destructor
Mammal destructor
Animal 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++面向对象编程的重要特性,理解其底层实现有助于编写更高效、更灵活的程序。

Thanks for reading!

4.面向对象的特性:多态原理

2026-01-23
3735 字 · 19 分钟

已复制链接

评论区

目录