C++ 中的多态、虚函数、虚函数表、虚函数表指针和虚析构函数

以下是对 C++ 中多态、虚函数、虚函数表(vtable)、虚函数表指针(vptr)以及虚析构函数的详细介绍,使用 Markdown 格式,包含代码示例和 LaTeX 表示(若适用)。内容基于 C++ 的运行时多态机制,并结合上下文(如内存对齐、智能指针等)。


1. 多态

含义

多态(Polymorphism)是面向对象编程的核心特性,允许通过基类指针或引用调用派生类的行为。C++ 中的多态主要分为:

  • 编译时多态:通过函数重载或模板实现。
  • 运行时多态:通过虚函数和继承实现,依赖动态绑定。

运行时多态

  • 通过基类指针或引用调用派生类的虚函数,根据对象的实际类型动态决定调用哪个函数。
  • 依赖虚函数表虚函数表指针实现。

数学表示

对于基类指针 ( p ) 指向派生类对象,调用虚函数 ( f ),实际执行的函数为:
[
\text{Execute}(p \to f) = \text{Dispatch}(p.\text{vptr}, f)
]
其中,(\text{Dispatch}) 根据虚函数表解析具体函数地址。


2. 虚函数

含义

  • 使用 virtual 关键字声明的成员函数,允许在派生类中被重写(override)。
  • 实现运行时多态,调用时根据对象的实际类型选择函数实现。

规则

  • 基类函数声明为 virtual,派生类重写时无需重复 virtual(但建议使用 override 明确)。
  • 虚函数调用通过虚函数表动态解析。
  • 构造函数不能为虚函数,析构函数可以(见虚析构函数)。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

class Base {
public:
virtual void func() { std::cout << "Base::func\n"; }
};

class Derived : public Base {
public:
void func() override { std::cout << "Derived::func\n"; }
};

int main() {
Base* ptr = new Derived();
ptr->func(); // 输出: Derived::func
delete ptr;
}

3. 虚函数表(vtable)

含义

  • 虚函数表是一个编译器生成的数组,存储类中虚函数的地址。
  • 每个包含虚函数的类有一个唯一的 vtable,共享给该类的所有对象。

实现机制

  • 编译器为每个包含虚函数的类生成一个 vtable。
  • vtable 包含该类及其基类的虚函数地址,按声明顺序排列。
  • 派生类重写虚函数时,vtable 中对应的地址更新为派生类的实现。

内存布局

对于类 ( C ) 包含 ( n ) 个虚函数,vtable 是一个数组:
[
\text{vtable}_C = [\text{addr}(f_1), \text{addr}(f_2), \dots, \text{addr}(f_n)]
]
其中 (\text{addr}(f_i)) 是虚函数 ( f_i ) 的地址。


4. 虚函数表指针(vptr)

含义

  • 虚函数表指针(vptr)是对象内存中的一个隐藏指针,指向该对象的类的 vtable。
  • 每个包含虚函数的对象在构造时由编译器插入 vptr。

实现机制

  • vptr 通常位于对象内存布局的开头(与内存对齐相关,参考上下文)。
  • 构造派生类对象时,vptr 会被设置为指向派生类的 vtable。
  • 调用虚函数时,编译器通过 vptr 查找 vtable 中的函数地址。

内存影响

  • vptr 增加对象大小,通常为指针大小(32 位系统为 4 字节,64 位为 8 字节)。
  • 对齐要求(参考上下文)可能导致额外填充。

代码示例(概念性)

1
2
3
4
5
6
7
8
9
10
11
12
class Base {
public:
virtual void func1() {}
virtual void func2() {}
// vptr 隐式添加,指向 Base 的 vtable
};

class Derived : public Base {
public:
void func1() override {}
// vptr 指向 Derived 的 vtable
};

内存布局(假设 64 位系统):

  • Base 对象:8 字节(vptr)。
  • Derived 对象:8 字节(vptr,指向不同的 vtable)。

5. 虚析构函数

含义

  • 将基类的析构函数声明为 virtual,确保通过基类指针删除派生类对象时,正确调用派生类的析构函数。
  • 防止内存泄漏或未定义行为,特别是在使用智能指针(如 std::unique_ptr)时(参考上下文)。

为什么需要

  • 通过基类指针删除派生类对象时,非虚析构函数只调用基类的析构函数,导致派生类部分未清理。
  • 虚析构函数通过 vtable 动态调用正确的析构函数。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <memory>

class Base {
public:
virtual ~Base() { std::cout << "Base::~Base\n"; }
};

class Derived : public Base {
public:
~Derived() override { std::cout << "Derived::~Derived\n"; }
};

int main() {
std::unique_ptr<Base> ptr = std::make_unique<Derived>();
// 删除时调用 Derived::~Derived 然后 Base::~Base
} // 输出: Derived::~Derived, Base::~Base

注意:若 Base::~Base 非虚,则只调用 Base::~Base,可能导致内存泄漏。

规则

  • 基类析构函数应始终为虚函数(除非明确不需要多态删除)。
  • 虚析构函数增加 vtable 条目,略微增加开销。
  • C++11 起,建议默认析构函数为虚:virtual ~Base() = default;

6. 总结

  • 多态:通过虚函数实现运行时动态绑定。
  • 虚函数:使用 virtual 声明,动态调用派生类实现。
  • 虚函数表(vtable):存储虚函数地址的数组,每个类一个。
  • 虚函数表指针(vptr):对象中的指针,指向类的 vtable,增加内存开销。
  • 虚析构函数:确保通过基类指针删除对象时正确清理,防止内存泄漏。
  • 注意事项
    • 虚函数和 vptr 增加性能和内存开销,需权衡使用。
    • 虚析构函数在多态场景中几乎总是必要的。
    • 与智能指针结合时,虚析构函数和 noexcept 优化至关重要。

相关问题

虚函数表和虚函数表指针是一一对应的关系

虚函数可以被重写为虚函数吗?

  • 答案:是的,虚函数可以被派生类重写(override),且重写的函数自动成为虚函数,无需再次声明 virtual 关键字。
  • 原因:C++ 中,一旦基类函数被声明为 virtual,其在派生类中的重写版本(具有相同函数签名)默认继承虚函数属性。这是 C++ 的虚函数机制确保运行时多态(参考上下文:运行时多态)。
  • 注意:派生类可以使用 override 关键字显式标记重写,以提高代码可读性并确保正确性。

示例

1
2
3
4
5
6
7
8
9
class Base {
public:
virtual void func() { std::cout << "Base::func\n"; }
};

class Derived : public Base {
public:
void func() override { std::cout << "Derived::func\n"; } // 重写,自动为虚函数
};

2. 所有包含虚函数的类的实例共享一个 vtable 吗?如果被重写了,所有实例的虚函数实现都改为调用重写的函数吗?

  • 答案
    • 共享 vtable:同一个类的所有实例共享同一个虚函数表(vtable)。vtable 是类级别的静态数据结构,存储该类的虚函数地址。
    • 重写的影响:当派生类重写基类的虚函数时,派生类会生成自己的 vtable,其中重写的函数地址覆盖基类的对应函数地址。基类实例的 vtable 不受影响,仍指向基类的函数实现。
  • 详细说明
    • 每个类(基类或派生类)有自己的 vtable,包含该类的虚函数地址。
    • 基类实例的 vptr(虚函数表指针)指向基类的 vtable,派生类实例的 vptr 指向派生类的 vtable。
    • 重写只影响派生类的 vtable,不会改变基类的 vtable。因此,基类实例仍调用基类的函数实现,派生类实例调用重写的函数实现。

3.哪些函数不能被声明为虚函数?

构造函数,静态成员函数,非成员函数,友元函数,内联函数

4.为什么不需要虚构造函数?

在 C++ 中,不需要虚构造函数的原因如下:

  1. 构造函数的调用时机和目的
    • 构造函数用于在对象创建时初始化对象,其调用发生在对象类型确定时(即 new Class() 时类型已明确)。
    • 虚函数机制依赖运行时多态,通过虚函数表(vtable)和虚函数表指针(vptr)根据对象实际类型动态调用函数。然而,在构造对象时,vtable 尚未完全初始化(派生类构造函数可能尚未运行),因此无法依赖 vtable 实现“虚”调用。
  2. 类型已知的静态绑定
    • 构造函数调用是静态绑定的,程序员在代码中明确指定创建的类型(例如 Base* p = new Derived()),编译器直接生成对应的构造函数调用。
    • 运行时多态(虚函数)解决的是通过基类指针/引用调用未知派生类函数的问题,而构造函数调用不需要这种动态分派,因为类型在创建时已知。
  3. 构造顺序的固定性
    • 在继承体系中,构造函数调用遵循严格的顺序:先基类,后派生类。这确保对象逐步构建,vptr 在每个阶段被正确设置为当前类的 vtable(参考上下文:vptr 设置)。
    • 如果构造函数是虚函数,可能导致未定义行为,因为派生类构造函数可能在基类构造未完成时调用,破坏对象初始化逻辑。
  4. 虚函数依赖对象存在
    • 虚函数调用需要一个已构造的对象(包含有效的 vptr 和 vtable)。在构造函数运行时,对象尚未完全构造,vptr 可能未指向正确的 vtable(参考上下文:vtable 和 vptr)。
    • 因此,构造函数无法通过虚机制动态分派。

当前类没有指向自己的虚函数表的 vptr,只有指向基类的 vptr?

回答:不完全正确。

  • 派生类的 vptr 情况

    • 在非虚继承中,派生类对象通常包含多个 vptr(如果有多个基类包含虚函数),每个 vptr 指向对应基类的 vtable 分区。派生类自身的虚函数(如 Derived::g)通常被合并到某个基类的 vtable 中(通常是第一个非虚继承的基类),因此派生类可能没有独立的“指向自己的 vptr”。

    • 示例(非虚继承):

      1
      2
      3
      class Base1 { virtual void f1() {} };
      class Base2 { virtual void f2() {} };
      class Derived : public Base1, public Base2 { virtual void f3() {} };
      • Derived对象的内存布局:

        1
        | vptr_Base1 | Base1 数据 | vptr_Base2 | Base2 数据 | Derived 数据 |
      • vptr_Base1 指向 Derived 的 vtable 中与 Base1 兼容的部分(包含 Base1::f1Derived::f3)。

      • vptr_Base2 指向 Derived 的 vtable 中与 Base2 兼容的部分(包含 Base2::f2)。

      • Derived::f3 被追加到 Base1 的 vtable 分区,因此 Derived 没有独立的 vptr。

  • 虚继承中的 vptr

    • 在虚继承中,共享基类(如菱形继承中的顶层基类)只有一个实例,其 vptr 由派生类管理。派生类对象仍包含多个 vptr(对应每个有虚函数的基类和共享基类),但虚基类的 vptr 可能通过虚基类表(vbase table)或偏移量间接访问。

    • 示例(虚继承):

      1
      2
      3
      4
      class Base { virtual void f() {} };
      class Base1 : virtual public Base { virtual void f1() {} };
      class Base2 : virtual public Base { virtual void f2() {} };
      class Derived : public Base1, public Base2 { virtual void f3() {} };
      • Derived对象的内存布局(简化):

        1
        | vptr_Base1 | Base1 数据 | vptr_Base2 | Base2 数据 | vptr_Base | Base 数据 | Derived 数据 |
      • vptr_Base1 指向 Base1 的 vtable 分区(包含 Base1::f1Derived::f3)。

      • vptr_Base2 指向 Base2 的 vtable 分区(包含 Base2::f2)。

      • vptr_Base 指向共享 Base 子对象的 vtable(包含 Base::f)。

      • 虚继承可能引入虚基类指针(vbptr)来定位 Base 子对象,但这与 vptr 不同。

  • 为什么派生类没有“自己的 vptr”

    • 编译器为了优化内存和性能,通常将派生类的虚函数(如 Derived::f3)合并到某个基类的 vtable 中(通常是第一个非虚继承的基类)。
    • 派生类的 vtable 可能被分割为多个部分,分别由基类的 vptr 管理,因此派生类不需要额外的 vptr。
    • 如果派生类是单一继承且没有多重继承或虚继承,派生类对象通常只有一个 vptr,指向包含基类和派生类虚函数的 vtable。
  • 例外情况

    • 如果派生类没有继承任何基类的虚函数,且自身定义了虚函数,对象会包含一个 vptr,指向派生类自己的 vtable。

      1
      class Derived { virtual void f() {} };
      • Derived 对象只有一个 vptr,指向 Derived 的 vtable(包含 f)。

4. 虚继承与非虚继承的内存布局对比

  • 非虚继承

    • 每个基类子对象独立存在,包含自己的 vptr(如果基类有虚函数)。

    • 派生类对象的 vptr 数量等于有虚函数的基类数量。

    • 派生类的虚函数合并到某个基类的 vtable 分区,无需额外 vptr。

    • 内存布局示例(Base1 和Base2非虚继承):

      1
      | vptr_Base1 | Base1 数据 | vptr_Base2 | Base2 数据 | Derived 数据 |
  • 虚继承

    • 虚基类(如 Base)在派生类对象中只有一份实例,包含一个 vptr。

    • 派生类对象包含每个非虚基类的 vptr,以及共享虚基类的 vptr。

    • 虚基类子对象的位置可能通过虚基类指针(vbptr)或偏移量管理,增加内存开销。

    • 内存布局示例(Base1和Base2虚继承Base):

      1
      | vptr_Base1 | Base1 数据 | vptr_Base2 | Base2 数据 | Derived 数据 | vptr_Base | Base 数据 |
    • 虚基类子对象(Base)通常放在对象末尾,位置由 vbptr 间接引用