C++ 的多态性分为运行时多态与编译时多态,其中前者的实现原理就是虚函数表(vtable)与虚函数指针(vptr)。

1. 基本概念

首先看一下二者的定义:

  • 虚函数表:每个包含虚函数的类都有一个虚函数表,它是一个函数指针数组,存储该类所有虚函数的地址。
  • 虚函数指针:每个对象实例内部隐含的一个指针,指向其所属类的虚函数表。

从定义上的角度需要关注的是,虚函数表每个是类所拥有的,而虚函数指针是每个实例化的对象说拥有的。
因此虚函数表在编译阶段就已经由编译器自动生成,每个累都有且仅有一个虚表,所有该类的对象共享同一份虚表。 而与之不同的是,虚指针是在对象构造时即运行时,由构造函数隐式初始化,指向当前类的虚表。

只从定义和概念的角度是很难搞清楚其背后的运行机制的,所以接下来,我将结合代码以及它们的内存分布进行深入讲解。

2. 单继承中的虚函数机制

虚函数表布局

先来看一下最简单的单继承类中的虚函数表的布局:

1
2
3
4
5
6
7
8
9
10
11
class Base {
public:
virtual void func1() {}
virtual void func2() {}
};

class Derived : public Base {
public:
void func1() override {} // 重写 Base::func1
virtual void func3() {} // 新增虚函数
};
  • Base 的虚表:[ &Base::func1, &Base::func2 ]
  • Derived 的虚表: [ &Derived::func1, &Base::func2, &Derived::func3 ]

可以看到,由于在派生类 Derived 中对基类的 func1 进行重写,相应的,在虚表中也将 func1的地址替换成了派生类的地址,而没有进行重写的func2函数的地址则没有变化直接进行继承。

对象内存布局

对象的内存布局如下:

1
2
3
4
5
6
Derived对象: 
+------------------+
| VPTR → Derived虚表 |
| Base数据成员 |
| Derived数据成员 |
+------------------+

动态绑定过程

1
2
Base* obj = new Derived();
obj->func1(); // 调用 Derived::func1

通过 obj 的虚指针找到 Derived 的虚表,然后根据虚表的索引找到 Derived::func1的地址并调用。

3. 多继承中的虚函数机制

多继承与单继承的相同点在于,每个类也是独立维护虚表,不同的是派生类的对象包含多个虚指针。此外,派生类新增的虚函数会被追加到第一个基类的虚表(在单继承中不会)中。

1
2
3
4
5
6
7
8
class Base1 { virtual void f1() {} };
class Base2 { virtual void f2() {} };

class Derived : public Base1, public Base2 {
public:
void f1() override {} // 覆盖 Base1::f1
virtual void f3() {} // 新增虚函数,追加到Base1的虚表
};

虚表布局如下:

  • Base1 虚表:[ &Derived::f1, &Derived::f3 ]
  • Base2 虚表:[ &Base2::f2 ]

这里有个看似比较矛盾的现象,即虽然 f3 存在于 Base1 的虚表中,但 Base1 类型的指针无法直接调用 f3,因为 Base1 类没有声明 f3。这是C++的语法规则限制。
实际上,**只有通过 Derived 类型的指针或引用,才能直接调用 f3**:

1
2
Derived* d = new Derived();
d->f3(); // 合法

其内存布局如下:

1
2
3
4
5
6
7
8
Derived对象: 
+------------------+
| VPTR1 → Base1虚表 |
| Base1数据成员 |
| VPTR2 → Base2虚表 |
| Base2数据成员 |
| Derived数据成员 |
+------------------+

4. 菱形继承与虚继承的虚函数机制

菱形继承的定义在这里不做介绍,它的问题是, 非虚继承时,顶层基类(Base)会在派生类中存在多个副本,导致数据冗余和二义性。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Base {
public:
virtual void func() { cout << "Base::func" << endl; }
int base_data = 10;
};

class Mid1 : public Base {
public:
void func() override { cout << "Mid1::func" << endl; }
int mid1_data = 20;
};

class Mid2 : public Base {
public:
void func() override { cout << "Mid2::func" << endl; }
int mid2_data = 30;
};

class Derived : public Mid1, public Mid2 {
public:
void func() override { cout << "Derived::func" << endl; }
int derived_data = 40;
};
  • 数据冗余Derived 对象包含两个独立的 Base 子对象(通过 Mid1 和 Mid2 继承)。
  • 二义性:调用 func() 时需明确路径(如 Mid1::func() 或 Mid2::func()),否则编译报错。

image.png

解决方案也很简单,将中间的派生类定义为虚继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base { ... }; // 同上

class Mid1 : virtual public Base { // 虚继承Base
public:
void func() override { cout << "Mid1::func" << endl; }
int mid1_data = 20;
};

class Mid2 : virtual public Base { // 虚继承Base
public:
void func() override { cout << "Mid2::func" << endl; }
int mid2_data = 30;
};

class Derived : public Mid1, public Mid2 {
public:
void func() override { cout << "Derived::func" << endl; }
int derived_data = 40;
};

使用虚继承后:

  • 共享 Base 子对象Mid1 和 Mid2 虚继承 Base,使得 Derived 只保留一个 Base 实例。
  • 虚基类表(VBTABLE):虚继承的类会包含指向虚基类表的指针(VBPTR),用于定位共享的 Base 子对象。
    image.png

这里要关注一下,虚基类表与虚函数表是两个不同的概念,上面示例中虚基类表的结构如下:
image.png

虚表中存储的 offset_to_Base 是一个整数值,表示从当前子对象(如 Mid1 或 Mid2)到共享 Base 子对象的内存偏移量。 例如,Mid1 的虚表中 offset_to_Base 可能为 16(假设 Mid1 子对象到 Base 的偏移为 16 字节)。

5. 虚函数指针的工作流程

1. 构造与析构过程

  • 构造顺序:基类 → 派生类,VPTR 逐步更新为当前类的虚表。
  • 析构顺序:派生类 → 基类,VPTR 逆向切换回基类的虚表。

2. 动态绑定的实现

  • 通过对象的 VPTR 找到虚表,再通过虚表索引定位函数地址。

注意事项:

  • 必须使用虚析构函数:- 若基类析构函数非虚,通过基类指针删除派生类对象会导致内存泄漏

© 2024 Montee | Powered by Hexo | Theme stellar


Static Badge